~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
82 lines · 5.2 KB Raw History

Security

What protects a user from a module, a module from another, and a server from its visitors; and the known gap that is still open.

The model

  • Modules are pure. No imports, no clock, no network: a module can only ask the host to make calls, and gets JSON back. Each event has a time, memory and size limit (limits).
  • Allow-lists. A module makes only the NSIDs in its calls; a composition routes only declared hooks and edges, to modules that implement them.
  • An app identity signs nothing. The read-only repo a host serves for app declarations has no createAccount and no getServiceAuth (either would let anyone act as the app's DID), no writes and no sync; its key is state to back up, since a new key is a new identity to anyone who cached the document.
  • Plain HTTP is allow-listed twice. com.fedfork.exec.http.fetch reaches only an origin both the module's http and the host's allowlist name. Credentials are the host's: it adds them per origin and the wasm never sees them; redirects are not followed, so they never travel to another origin. The session scope does not cover these calls, and in a composition neither do the instance's calls or policies: the host's allowlist is the bound. An origin the host gives serviceAuth gets a token minted from the run's own session per request, and that mint is held to the session's scope (rpc:<lxm>?aud=<aud>), so a module whose session lacks it cannot spend the user's identity there.
  • One scope per module. A signed-in run may only make calls its own session record's scope allows, in pages and on servers, even when the user granted more for other modules.
  • Writes are owned. A module updates or deletes only records the host created for it, unless the collection is in its anyRecords.
  • Pinned code. atlibs add checks every blob against its record's CID and every vendored module against its lockfile pin (the wasm and the record's grant fields). Anyone can rebuild a published module from its source records with atlibs verify.
  • Sessions per user. A server keeps one OAuth session per signed-in user and runs each request with that user's alone. An app password is used only when configured with a flag; secrets never go in argv, and exec: handlers run with an empty environment.
  • Serving. atlibs serve and node-hono bind 127.0.0.1, answer only loopback, IP or --origin Hosts (no DNS rebinding), refuse a foreign Origin, and run only application/json bodies (no cross-site form posts).
  • Service auth. verifyServiceAuth / atlibs_host::serviceauth check audience, method, expiry, the issuer's key and replays; DID documents are cached.
  • Capabilities. Callbacks passed between modules are unforgeable per run, and a policy never receives the caller's.

Known gap: SSRF

Status: open.

atproto keeps outbound fetching in one hardened place, the PDS: clients call their own PDS, calls to other services go through it with atproto-proxy, and paths are always /xrpc/<nsid>. atlibs hosts also fetch on a module's behalf:

  • a DID service read (service: "did:…", or did:…#id; a proxy query signed out is one too) makes the host resolve any DID the module names, did:web included, and fetch that account's PDS (or the named service's endpoint) itself, with no credentials;
  • an https service call (service: "https://…") makes the host call any origin the module names, with a service-auth token.

Mitigations in place: DID-service reads require https and refuse literal loopback, private, link-local, shared, multicast and unspecified addresses and localhost (unless ATLIBS_ALLOW_PRIVATE=1), and the path is fixed to /xrpc/<nsid>.

Open:

gapwhere
Redirects are followed without re-checking each hop, so a did:web document or PDS answering 302 to a private address reaches the host's network.JS pdsOf; the Rust host's HTTP client
A DNS name that resolves to a private address passes the literal-address check.both hosts
JS percent-decodes the whole did:web, Rust only %3A; the hosts can fetch different paths for one DID.javascript/src/run-module/index.js pdsOf, rust/host/src/host.rs
Direct https service calls are not address-checked (they need a session).both hosts

Intended direction, rather than a longer blocklist:

  • Calls to other services go through the user's PDS (proxy); the direct https service route goes away or is limited to an operator allowlist.
  • Public reads go through a relay or AppView, or the user's PDS, instead of each host dereferencing every DID's PDS.
  • DID documents are resolved only where needed (verifying a signature), by one hardened resolver: did:plc through the directory; did:web over https with a validated host and only %3A decoded; a timeout, a body cap, no redirects.
  • The Rust host takes XRPC, DID resolution and service auth from atproto-crates once they accept a pluggable fetch, so there is one fetch path to harden.

Until then, run hosts where outbound requests cannot reach anything sensitive (no cloud metadata endpoint, no private services), and treat module traffic as able to make the host fetch public URLs.

Previous: Environment | Next: docs index