Skip to content

Host-signed integration-proxy calls for installations, with a loopback exception for exactly the proxy (#1700) - #1702

Closed
michielbdejong wants to merge 1 commit into
claude/atomic-signature-v2from
claude/installation-proxy-signing
Closed

michielbdejong wants to merge 1 commit into
claude/atomic-signature-v2from
claude/installation-proxy-signing

Conversation

@michielbdejong

Copy link
Copy Markdown
Contributor

Pieces 1 and 3 of #1700, the atomic-server host side of phase 2 of ontola/atomic-plugins#54 (decisions 2, 8 and 12).

Base: #1696 (claude/atomic-signature-v2), not #1697. This PR only needs get_authentication_headers_v2 from #1696. It touches nothing in #1697's frame capabilities or page code, and nothing in #1689's catalog installs. Stacking on #1697 would add its browser diff to this review without making anything here work better. Once #1696 merges, this retargets to feat/plugin-debug.

Piece 1: an app identity for installed catalog plugins

Reading the code, a JS Installation already gets one. Activating it mints an app agent on that node (ensure_js_identity, Tree::AppAgent keyed by drive and installation), and every node that activates it mints its own. So it is already per (installation, node), which is the shape decision 10 asks for. What was missing was proof that the page can read it, and a use for it. This PR adds both:

  • a test that GET /app-agent?drive&app=<installation> reports that agent. That's the agent a page delegates to or registers with POST /runtimes;
  • the host signing with it (piece 3).

#1697's refusal ("only apps with their own agent") only hits drive apps without a key. Catalog drive apps from #1689 go through createApp and get one.

The proxy needs app ≠ runtime, so which agent counts as the installation's app, and where that's recorded, is not settled by #54. It's open question 1 in #1700, so this PR doesn't pick one.

Piece 3: the host signs ctx.http to the proxy

  • New option --integration-proxy-url / ATOMIC_INTEGRATION_PROXY_URL: the proxy's origin, exactly its BASE_URL. It is validated at boot: http(s), no path, query, fragment or credentials. It's held on the Db, next to the node key, so every HostCore sees it without passing a new field through the ~20 StoreHost construction sites.
  • Signing (HostCore::fetch): a request to exactly that origin is signed with a v2 signature (method, full URL with query, SHA-256 of the body). It signs as this node's app agent for the installation, using get_authentication_headers_v2 inside with_app_agent, so the key never leaves the store.
    • The plugin's own x-atomic-* headers are dropped first, so a plugin can't pick its signer.
    • An installation with no app agent on this node is refused with a clear error before any connection, rather than sent unsigned.
    • The manifest policy check still applies.
  • SSRF exception (egress::ProxyOrigin, destination_addresses): only when the configured origin is on loopback (localhost, 127.x, ::1), and only for exactly that scheme, host and port.
    • The host connects to the loopback address itself. localhost is never resolved, so DNS can't redirect it.
    • A proxy configured on a public name gets no exception, just the ordinary checks.
  • Not done: telling the plugin which connection it was delegated (open question 3 in Plugins phase 2 (atomic-plugins#54): installation identities, host-signed proxy calls, connection requests #1700: the node never sees delegations), and a manifest placeholder for the proxy origin (open question 4).

Tests (written first, cheapest layer: Rust unit tests)

  • plugins::host_core:
    • a_request_to_the_configured_proxy_is_signed_v2_as_the_installations_node_agent: a real request to a one-shot loopback server standing in for the proxy. It checks the agent header and the single v2 signature. The signature is verified with check_auth_signature over method, full URL and body, and fails for another body;
    • an_installation_without_an_agent_on_this_node_cannot_reach_the_proxy;
    • the_proxy_exception_does_not_open_other_loopback_origins: another port, the other scheme, localhost when 127.0.0.1 is configured, and 127.0.0.2. All are refused even when the manifest declares them.
  • plugins::egress:
    • a_proxy_origin_is_an_origin_and_nothing_more;
    • the_configured_loopback_proxy_is_reached_at_loopback_without_a_lookup;
    • every_other_loopback_target_is_still_refused (also covers [::1], credentials in the URL, and no proxy configured);
    • a_proxy_elsewhere_gets_no_exception.
  • handlers::app_endpoints_test::an_active_installation_reports_its_agent_on_this_node.
  • cargo test -p atomic-server --lib: 274 passed. cargo test -p atomic_lib --features db-redb --lib: 657 passed. cargo clippy -p atomic-server -p atomic_lib --tests is clean.
  • Not tested: against the real proxy (atomic-plugins#122), and a second node.

No UI changes.

Refs #1700, ontola/atomic-plugins#54, ontola/atomic-plugins#122.

🤖 Generated with Claude Code

…ode agent (#1700)

Server-side plugins reach the integration proxy through ctx.http, signed by
the host (ontola/atomic-plugins#54, decisions 8 and 12):

- --integration-proxy-url / ATOMIC_INTEGRATION_PROXY_URL: the proxy origin,
  validated at boot and held on the Db.
- HostCore::fetch signs requests to exactly that origin with a v2 request
  signature as this node's app agent for the installation
  (get_authentication_headers_v2 inside with_app_agent). Plugin-supplied
  x-atomic-* headers are dropped; an installation without an agent on this
  node is refused before connecting.
- egress::ProxyOrigin / destination_addresses: a loopback exception for
  exactly the configured scheme, host and port, connecting to loopback
  without a lookup. Every other loopback or private target stays refused.
- A test that GET /app-agent reports the identity activation mints for a JS
  Installation (decision 2): catalog installations already get one per node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@michielbdejong

Copy link
Copy Markdown
Contributor Author

Merge-risk triage (2026-09-28). This is a read-only review of this PR's own diff. The rule it applies: nothing that works today may break for existing users or clients, and new behaviour is added alongside the old. The stack is being rebased onto develop as one chain before merging, so line references are against the current stacked base.

Verdict: SAFE

Signing, and the loopback exception for exactly the proxy origin, only apply when --integration-proxy-url is set; without it nothing changes. The origin is validated at boot, and the exception matches only the exact scheme, host and port (egress.rs ProxyOrigin::parse / destination_addresses).

Note: 1702 → 1725 → 1731 → 1831 is a clean, flag-gated server chain and can land first.

🤖 Generated with Claude Code

@michielbdejong

Copy link
Copy Markdown
Contributor Author

Closing as part of the plugin-host stack cleanup (Q-101): not needed by atomic-plugins now. See the inventory at #1726 (comment). The branch is kept, and pin candidates are unaffected. Closed by Claude (VPS coordinator) on behalf of Michiel.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request plugin Should probably be an Atomic Plugin security server atomic-server

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant