WebAssembly / wasi-http

Other
155 stars 24 forks source link

[v0.3] Request metadata #4

Open lann opened 1 year ago

lann commented 1 year ago

Original context: https://github.com/WebAssembly/wasi-http/pull/3#discussion_r1122091615

There are certain pieces of metadata that are commonly associated with requests but aren't part of HTTP itself, e.g.:

This kind of metadata can be inserted into requests as headers, but this has historically been a source of security and name collision issues and I hope we can offer a better alternative.

A few options to consider:

lukewagner commented 7 months ago

This is probably a good idea to return to now that 0.2 is done. Just sketching some thoughts based on a discussion with @lann today:

I think there are two cases:

  1. Common metadata that everyone will want, so it would be valuable to standardize in WASI HTTP
  2. Middleware- or vendor-defined metadata that we want to be able to "tack on" to a request or response

Case (1) is the easier case to sketch in WIT and I'm thinking looks roughly like:

package wasi:http;
interface types {
  ...
  resource request {
    ...
    /// request-metadata is a child resource and follows the same child rules as headers and body
    metadata: func() -> request-metadata
  }
  resource request-metadata {
    peer-IP-address: func() -> option<...>
    set-peer-IP-address: func(...)
    ...
  }
}

and similarly for response.

For case (2), the middleware/vendor can leverage the Component Model's parametric polymorphism (and WIT's use) to, effectively, extend request-metadata:

package my-auth-middleware:api;
interface metadata {
  use wasi:http/types.{request-metadata};
  get-user: func(req: borrow<request-metadata>) -> ...
}

Thus, from, let's say JS, you could write:

import { * as auth } from 'my-auth-middleware:api/metadata';
export func handle(request) {
  let user = auth.getUser(request.metadata);
  ...
}

If we wanted to make this syntactically nicer so that getUser showed up as a method on the prototype chain of request.metadata, we could implement CM/#296 (which incidentally mentions exactly this use case) which gives the bindings generator the info it needs.

For reference, a component using my-auth-middleware could target a world looking like:

world auth-guest {
  import wasi:http/types;
  import my-auth-middleware:api/metadata;
  import wasi:http/outgoing-handler;
  export wasi:http/incoming-handler;
}

Note that outgoing-handler/incoming-handler (whose handle functions take and return WASI HTTP requests and responses and don't know anything about my-auth-middleware) don't have to change nor is there any dynamic cast needed anywhere. And if two components targeting my-auth-world are linked together (linking one's exported handler to the other's imported handler, assuming host magic or 0.3 when we can collapse the two handler interfaces into wasi:http/handler), it also just works (still without a downcasts). (Parametric polymorphism FTW!)

An alternative approach to Case (2) is to extend WASI HTTP with some totally dynamic fields/values:

package wasi:http;
interface types {
  resource request-metadata {
    set-dynamic: func(key: string, value: list<u8>);
    get-dynamic: func(key: string) -> option<list<u8>>;
}

I expect this is what some HTTP standard libraries do and maybe there are some good use cases for it (it's better than smuggling the info through headers), so maybe we should do it too, but it seems worse from a performance and declarative-interfaces perspective.

lann commented 7 months ago

As discussed, I really like the interface that this parametric polymorphism provides. My main concern is with the implementation of composition. For example, if I want to implement auth "middleware" by wrapping a wasi-http handler component in an auth component, I believe this design either requires host magic or it requires the auth component to implement "donut wrapping" of its parent's wasi-http interface(s)/types. The former (host magic) would probably be fine for certain "standard-ish" metadata but it isn't clear to me how it would be extensible. The latter (donut wrapping) seems plausible in principle but I'm not sure that the details of its implementation have been explored enough to be comfortable with depending on it for 0.3.

lukewagner commented 7 months ago

Yeah, that's a fair point, virtualization is definitely important. For this particular scenario, I think we might be able to get away with something that isn't optimal (it creates 1 more component instance than if we had full donut-wrapping producer toolchain support), but is at least is expressible in WAC/WIT today (I think?).

So the overall composed component could look like:

import types: wasi:http/types;
import outgoing: wasi:http/handler;
let auth-imports = new my-auth-middleware:import-adapter { types, outgoing };
let guest = new my:guest { auth-imports, outgoing }
let auth-exports = new my-auth-middleware:export-adapter { auth-imports, guest };
export auth-exports as wasi:http/handler;

where my-auth-middleware:import-adapter had type:

world {
  import wasi:http/types;
  import wasi:http/handler;
  export wasi:http/types;
  export my-auth-middleware:api/metadata;
  import my-auth-middleware:private-api/private-methods;
  export wasi:http/handler;
}

and my-auth-middleware:export-adapter had type:

world {
  import wasi:http/types;
  import my-auth-middleware:api/metadata;
  import my-auth-middleware:private-api/private-methods;
  import wasi:http/handler;
  export wasi:http/handler;
}

and my:guest targeted the auth-guest world above. The private-methods interface gives the export-adapter component the ability to potentially call trusted exports on the import-adapter component that you wouldn't want the guest to be able to do (to maintain whatever auth invariants).

It's not optimal, but perhaps we could use this use case to push forward producer tooling to support donut wrapping directly so that import-adapter and export-adapter can be fused into one component with one memory, allowing the shared-nothing call through private-methods to be a shared-everything call through core wasm.

WDYT?

lann commented 7 months ago

I'll have to let others chime in on the composition implementation but the approach looks straightforward enough to me in the near term (0.3). Thanks!

lann commented 6 months ago

Going back to https://github.com/WebAssembly/wasi-http/issues/4#issuecomment-2086877906's "case (1)", I wonder if it could/should be unified with (2) by defining "first-party" metadata in that same form as "third-party" metadata, e.g.

package wasi:http;
interface peer-metadata {
  use types.{request-metadata};
  peer-IP-address: func(req: borrow<request-metadata>) -> option<...>;
  ...
}

This would produce less-nice bindings (absent new bindgen features) for these particular functions but it would be consistent with - and establish the pattern for - "third-party" metadata. It could also dovetail nicely with optional imports to statically express whether an implementer provides the given metadata.

Edit: somewhat of an aside, peer-IP-address is a perfect candidate for "middleware" virtualization: it is very common for a service to get the "real" peer IP from a trusted header like X-Forwarded-For.

lukewagner commented 6 months ago

Ah, good point! It also replaces dynamic option<...> result type in my original sample with more static "this is what I actually need" declaration.

lukewagner commented 6 months ago

@dicej pointed out that we'll run into a current WIT limitation when trying to write that private-methods interface, since we have a single function that needs to refer to both the imported wasi:http/types.request and the exported wasi:http/types.request. In particular, what we need to write is:

package my-auth-middleware:private-api;
interface private-methods {
  use host-http.{request as host-request};
  use virtual-http.{request as virtual-request};
  wrap: func(r: host-request, auth-data: ...) -> virtual-request;
}

Normally, this would fail validation because host-http and virtual-http are not interface names defined in this package (intentionally). What the WIT extension would allow us to do is satisfy them via with in the world targeted by the import adapter:

package my-auth-middleware:private-api;
world import-adapter {
  import wasi:http/types;
  import wasi:http/handler;
  export wasi:http/types;
  export my-auth-middleware:api/metadata;
  export private-methods with { host-http = import wasi:http/types, virtual-http = export wasi:http/types };
  export wasi:http/handler;
}

The above with { foo = import bar } syntax was discussed as part of CM/#308 originally in the spirit of overriding the default resolution, but here there's nothing to override; if we didn't have the with, the export private-methods; would fail to validate.


I realized there's another approach in which import-adapter doesn't export wasi:http/types but instead whatever made up name it wants, using WAC to do the renaming:

package my-auth-middleware:private-api;
interface virtual-http-types {
  ... copy-paste wasi:http/types
}
interface private-methods {
  use wasi:http/types.{request as host-request};
  use virtual-http-types.{request as virtual-request};
  wrap: func(r: host-request, auth-data: ...) -> virtual-request;
}
world import-adapter {
  import wasi:http/types;
  import wasi:http/handler;
  export virtual-http-types;
  export my-auth-middleware:api/metadata;
  export private-methods;
  export wasi:http/handler;
}

and the WAC would be updated to do the renaming from virtual-http-types to wasi:http/types:

import types: wasi:http/types;
import outgoing: wasi:http/handler;
let auth-imports = new my-auth-middleware:import-adapter { types, outgoing };
let guest = new my:guest { wasi:http/types = auth-imports[my-auth-middleware:private-api/virtual-http-types], outgoing }
let auth-exports = new my-auth-middleware:export-adapter { auth-imports, guest };
export auth-exports as wasi:http/handler;

which is allowed because ultimately "wasi:http/types" is just an import name and the types match. It does feel silly to have to copy-paste wasi:http/types into virtual-http-types, but I think this could be avoided by (yet another) WIT feature we should add: allow include inside interface { ... }, so that we could write interface virtual-http-types { include wasi:http/types; }.

So, altogether, I think we have multiple options for how to express this.