~ 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](limits.md)).
- **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:

| gap | where |
|---|---|
| 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](env.md) | Next: [docs index](../README.md)