Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ See [STATUS.md](server/STATUS.md) to learn more about which features will remain

## UNRELEASED

- wasip2 class extenders do not reach the integration proxy (#1700, answer
5). They were already refused, because the host has no installation agent
to sign as for them; the refusal now comes first, whether the extender uses
the proxy's URL or `atomic-proxy:`, and says why. Documented under
"Class extenders and the integration proxy" in the plugin docs.
- Plugins reach the integration proxy without naming its origin (#1700,
answer 4). A manifest declares `proxy: ["clockify"]`, and the plugin calls
`ctx.http` with `atomic-proxy:/clockify/...`, as its operations declare it.
Expand Down
4 changes: 4 additions & 0 deletions TESTING_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ its v2 signature. They also check refusals, before any connection, for an
undeclared platform, no delegated connection, no configured proxy, no
matching operation and a dot segment.

A wasip2 class extender is refused at the integration proxy, by URL and by
`atomic-proxy:`, even when its manifest lists the proxy origin (#1700, answer
5): `host_core::tests::a_class_extender_is_refused_at_the_proxy_and_told_why`.

Issues view: `TablePage/Issues/issueStatus.test.ts` covers reading open/closed
status tags and booleans, picking close/reopen targets, and title/`#number`
filtering; `browser/e2e/tests/issues-view.spec.ts` covers the Issues view for
Expand Down
11 changes: 10 additions & 1 deletion docs/src/plugins/creating-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ They can also access a few functions provided by the server:
These functions are documented and typed in the [class-extender.wit](https://github.com/ontola/atomic-server/blob/c2a1aaf814e73381e597fc6472bf0dca9689084c/server/wit/class-extender.wit) file.
You can use this file to generate bindings for your programming language of choice.

### Class extenders and the integration proxy

A wasip2 class extender cannot reach the integration proxy (`--integration-proxy-url`), whether it calls it by its URL or with an `atomic-proxy:` URL, even when its manifest lists the proxy's origin in `network.origins`.
The server refuses the request before it connects, with an error that says so.

The proxy only accepts requests signed as an installation's agent on this node, which the page registers with the proxy as a runtime of the installation, and which the connections delegated to the installation name.
A class extender has no such agent: it signs as its own plugin agent, which no installation or delegated connection names.
An integration that needs the proxy belongs in a JS plugin (`runtime: atomic-js/1`) installed from the catalog, which gets an agent on every node that activates it.

## Namespaces

A plugin is identified by a namespace and a name.
Expand Down Expand Up @@ -126,7 +135,7 @@ Every field except `schemaVersion` is optional; unknown fields and malformed dec
- `capabilities`: `storage`, `full-drive-access`, `extended-fuel`, `extended-memory`, `custom-view`, each either as a name or as `{name, reason}`. The reason is shown at review. Network access is not a capability: declare destinations instead.
- `secrets`, `operations`, `actions`: as in schema version 1. Secrets name an exact origin a credential may be sent to; operations are exact endpoints with an `effect` of `read` or `write`; actions reference operations.
- `network.origins`: exact origins (no wildcards, paths or ports beyond the origin) for packages that call the host `fetch` without an operation id. It never widens what `operations` grant.
- `proxy`: integration-proxy platforms the plugin uses, for example `["clockify"]`, also accepted in schema version 1. The plugin calls `ctx.http` with a proxy-relative URL such as `atomic-proxy:/clockify/api/v1/user`, and the operation that admits it is declared with that URL too. The server resolves it to `{--integration-proxy-url}/proxy/{connection_id}/clockify/api/v1/user`, taking the connection id from the Installation's `integrationConnections` (also passed to the plugin as `ctx.connections`), and signs it as the node's agent for the installation. A request is refused when the platform is not declared, when no connection is delegated for it, or when the node has no proxy configured. Calling the proxy by its absolute URL still works but is deprecated.
- `proxy`: integration-proxy platforms the plugin uses, for example `["clockify"]`, also accepted in schema version 1. The plugin calls `ctx.http` with a proxy-relative URL such as `atomic-proxy:/clockify/api/v1/user`, and the operation that admits it is declared with that URL too. The server resolves it to `{--integration-proxy-url}/proxy/{connection_id}/clockify/api/v1/user`, taking the connection id from the Installation's `integrationConnections` (also passed to the plugin as `ctx.connections`), and signs it as the node's agent for the installation. A request is refused when the platform is not declared, when no connection is delegated for it, or when the node has no proxy configured. Calling the proxy by its absolute URL still works but is deprecated. Only JS installations reach the proxy; see [class extenders and the integration proxy](#class-extenders-and-the-integration-proxy).
- `configSchema`, `defaultConfig`: objects, as in `plugin.json`.
- `name`, `namespace`, `version`, `description`, `author`: metadata. `name` and `namespace` must be safe path segments.

Expand Down
73 changes: 73 additions & 0 deletions server/src/plugins/host_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,10 @@ pub async fn installation_grants(
// Grant, world and fetch policy
// ---------------------------------------------------------------------------

/// Why a wasip2 class extender's request to the integration proxy is refused.
/// Documented in `docs/src/plugins/creating-plugins.md`.
pub const CLASS_EXTENDER_PROXY_REFUSAL: &str = "wasip2 class extenders cannot reach the integration proxy: the proxy only accepts requests signed as an installation's agent on this node, and a class extender signs as its own plugin agent, which no installation or delegated connection names. Move the integration into a JS plugin installed from the catalog.";

/// The trust boundary an installation lives in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum World {
Expand Down Expand Up @@ -769,6 +773,7 @@ impl HostCore {
effect: &str,
) -> Result<FetchResponse, String> {
let proxy = self.integration_proxy()?;
self.refuse_class_extender_at_proxy(&request.url, proxy.as_ref())?;
let (request, relative) = self.resolve_proxy_relative(request, proxy.as_ref()).await?;
let authorized = self.authorize(request, effect, relative.as_ref())?;
if relative.is_none()
Expand Down Expand Up @@ -911,6 +916,33 @@ impl HostCore {
Ok((request, Some(relative)))
}

/// wasip2 class extenders do not reach the integration proxy (#1700,
/// answer 5), whether by `atomic-proxy:` or by the proxy's absolute URL.
///
/// The proxy accepts only requests signed as an installation's node agent,
/// registered as a runtime of the installation's app. A class extender
/// has no such agent: its identity is the plugin's own agent, kept in
/// `PluginMeta`, which no installation names and no delegation reaches.
/// Checked first, so the refusal says this rather than whichever later
/// check (the manifest's platforms, the missing app agent) trips.
fn refuse_class_extender_at_proxy(
&self,
url: &str,
proxy: Option<&egress::ProxyOrigin>,
) -> Result<(), String> {
if self.world != World::ServerExtension {
return Ok(());
}
let to_proxy = ProxyRelative::parse(url).is_some()
|| proxy.is_some_and(|proxy| {
url::Url::parse(url).is_ok_and(|url| proxy.is_target_of(&url))
});
if to_proxy {
return Err(CLASS_EXTENDER_PROXY_REFUSAL.into());
}
Ok(())
}

/// The integration proxy this node is configured with, if any.
fn integration_proxy(&self) -> Result<Option<egress::ProxyOrigin>, String> {
self.db
Expand Down Expand Up @@ -1610,6 +1642,47 @@ mod tests {
assert!(err.contains("no app agent on this node"), "{err}");
}

/// #1700, answer 5: a wasip2 class extender does not reach the proxy, even
/// when its manifest declares the proxy's origin and the node has a proxy
/// configured, and whether it names the proxy by URL or `atomic-proxy:`.
#[actix_rt::test]
async fn a_class_extender_is_refused_at_the_proxy_and_told_why() {
// Nothing listens here: the refusal has to come before any connection.
let origin = "http://127.0.0.1:9";
let mut fixture = crate::plugins::test_fixture::fixture("host_core_proxy_wasip2").await;
crate::plugins::test_fixture::write_plugin(&mut fixture, "probe").await;
let db = Arc::new(fixture.appstate.store.clone());
db.set_integration_proxy(Some(origin.to_string()));
let host = HostCore::for_class_extender(
db.clone(),
&ClassExtenderScope::Drive(fixture.drive.clone()),
Some(fixture.plugin.clone()),
Some(db.get_default_agent().unwrap()),
Some(&class_extender_manifest(&[origin])),
)
.unwrap();

for url in [
format!("{origin}/proxy/conn-1/github/search"),
"atomic-proxy:/github/search".to_string(),
] {
let err = host
.fetch(
FetchRequest {
operation: None,
method: "GET".into(),
url: url.clone(),
headers: vec![],
body: None,
},
"read",
)
.await
.unwrap_err();
assert_eq!(err, CLASS_EXTENDER_PROXY_REFUSAL, "{url}");
}
}

#[actix_rt::test]
async fn the_proxy_exception_does_not_open_other_loopback_origins() {
let node_agent = Agent::new(None).unwrap();
Expand Down
Loading