Skip to content

Commit ddb3a49

Browse files
committed
fix(registry): adversarial review round 6 findings
Addresses 7 findings from the round-6 adversarial review and documents the eighth. #1 (high) Capability consent bypass [registry.ts, RegistryPluginDetail.tsx] The drift check was gated on the client sending acknowledgedDeclaredAccess. If the publisher's release record had no extension, the admin saw an empty permission dialog, omitted the acknowledgement, and the server skipped the check entirely -- letting a bundle whose manifest declares real capabilities slip through behind an empty consent UI. Server now extracts capabilities from the bundle manifest after download and refuses with DECLARED_ACCESS_REQUIRED if the bundle declares any capabilities and no acknowledgement was sent. Client always sends the list (empty when no extension) so the new server check is always armed. #2 (high) Concurrent install bundle deletion [registry.ts] Two parallel installs of the same (did, slug, version) both passed the pre-existing-row check, both uploaded to the same deterministic R2 prefix, and one then won the state-row PK race. The loser's catch block deleted the R2 bundle the winner had just written. On state-write failure we now re-query the state row: if a winner exists, we lost the race and must not touch the R2 bundle. Cleanup runs only when the failure is a real DB error, not a lost concurrent install. #3 (high) SSRF via DNS-resolving public hostnames [registry.ts, ssrf.ts moved] Literal-IP blocklist alone left a DNS-rebinding gap: any public DNS service resolving an attacker-chosen hostname to loopback / RFC1918 / 169.254.169.254 passed the URL check. The import pipeline already shipped resolveAndValidateExternalUrl which does Cloudflare DoH resolution and rejects on any forbidden resolved address; reuse it for artifact downloads. Move src/import/ssrf.ts to src/security/ssrf.ts to reflect that it's not import-specific. Leave a re-export shim at the old path so 13 existing callers keep working unchanged. Add #security/* path alias. #5 (high) Aggregator-supplied handles treated as verified [PublisherHandle.tsx] usePublisherHandle returned status: 'ok' with the aggregator-supplied handle whenever one was present, skipping local DID->handle round-trip. A compromised aggregator could label an attacker DID as e.g. 'stripe.com' and the UI would render it as verified. Always run LocalActorResolver via resolveDidToHandle; use the aggregator handle only for a cross-check. If the aggregator's claim differs from the verified handle, mark the publisher invalid. #6 (medium) Postgres migration 038 schema-qualification [038_registry_plugin_state.ts] The columns probe queried information_schema.columns without filtering by table_schema. A _plugin_state table in another schema (multi-tenant Postgres, per-test schemas) could make the migration skip the column adds. Filter by table_schema = current_schema(). #7 (medium) Install errors leak full artifact URLs [registry.ts] fetchArtifact recorded each full URL in the joined error message that bubbled up to the admin client. Artifacts hosted on storage backends often carry presigned tokens in the query string; failed installs were leaking those into HTTP responses and logs. Strip query and fragment when building client-visible errors (origin + path only); log the full URL server-side for debugging. #8 (medium) Credentialed aggregator URLs accepted [config.ts] validateAggregatorUrl accepted https://user:pass@example.com. The normalized URL ends up in the admin manifest and is shipped to every admin browser; browser fetch() also rejects credentialed URLs outright. Reject them at config-validation time. #4 (high, documented not fixed) Aggregator-trust-root scope [types.ts] Full MST proof / publisher signature verification is not in this PR; the server still trusts the aggregator-supplied (did, slug, checksum, artifact URL). Expand the JSDoc on EmDashConfig.experimental.registry to spell out exactly what the v1 trust contract is, what EmDash does verify independently (checksum, manifest id/version/capabilities), and what it doesn't (release-record signatures, replay). Recommendation: point aggregatorUrl only at an aggregator you operate or trust at centralized-source level until signature verification lands.
1 parent f450514 commit ddb3a49

9 files changed

Lines changed: 800 additions & 618 deletions

File tree

packages/admin/src/components/PublisherHandle.tsx

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,30 @@
11
/**
22
* Renders an atproto publisher's identity, with three branches:
33
*
4-
* - **Verified handle**: shows `@handle`. Either the aggregator
5-
* already resolved the handle at ingest (we trust that), or our
6-
* local `LocalActorResolver` round-tripped the DID document's
7-
* `alsoKnownAs` back to the same DID.
4+
* - **Verified handle**: shows `@handle`. Our local
5+
* `LocalActorResolver` round-tripped the DID document's
6+
* `alsoKnownAs` back to the same DID (verified by DNS TXT or
7+
* `.well-known`, not by the aggregator).
88
* - **Unverified publisher**: DID document claims a handle but the
9-
* handle's domain doesn't point back to the same DID. Treat as
10-
* untrusted -- the publisher might be impersonating someone else.
11-
* Surface as `Unverified publisher` in error styling. Callers
12-
* should also disable destructive actions (install, etc.).
13-
* - **Missing handle**: no claimed handle at all (or DID document
14-
* resolution failed entirely). Fall back to the raw DID.
9+
* handle's domain doesn't point back to the same DID, OR the
10+
* aggregator's claimed handle doesn't match the bidirectionally
11+
* verified one. Treat as untrusted -- the publisher might be
12+
* impersonating someone else, or the aggregator might be lying
13+
* about a handle. Surface as `Unverified publisher` in error
14+
* styling. Callers should also disable destructive actions
15+
* (install, etc.).
16+
* - **Missing handle**: no handle claimed in the DID document (no
17+
* `alsoKnownAs`), or the DID document couldn't be fetched
18+
* (network error, unsupported DID method).
1519
*
1620
* `aggregatorHandle` is what the registry's `searchPackages` /
17-
* `resolvePackage` endpoint returned for this DID -- best-effort, may
18-
* be `null`. When absent, this component falls back to a per-DID
19-
* `LocalActorResolver` lookup via `resolveDidToHandle`, cached in
20-
* localStorage for 24h so repeat renders don't refetch.
21+
* `resolvePackage` endpoint returned for this DID. It is NEVER trusted
22+
* on its own -- the aggregator is an untrusted indexer that could be
23+
* compromised or buggy. We always run our own DID->handle round-trip
24+
* via `LocalActorResolver` (cached in localStorage for 24h) and use
25+
* the aggregator's value only to *cross-check*: if the aggregator
26+
* claims a handle that differs from what the DID document
27+
* bidirectionally verifies, the publisher is marked invalid.
2128
*/
2229

2330
import { useLingui } from "@lingui/react/macro";
@@ -26,6 +33,9 @@ import * as React from "react";
2633

2734
import { resolveDidToHandle } from "../lib/api/registry.js";
2835

36+
/** Trailing dot(s) on an FQDN, stripped before handle comparison. */
37+
const TRAILING_DOT = /\.+$/;
38+
2939
export type PublisherHandleStatus = "ok" | "invalid" | "missing";
3040

3141
export interface PublisherHandleResult {
@@ -57,19 +67,48 @@ export function usePublisherHandle(
5767
did: string,
5868
aggregatorHandle?: string | null,
5969
): PublisherHandleResult {
60-
const { data: didHandleResolution } = useQuery({
70+
// Always run the local DID->handle round-trip. We never trust the
71+
// aggregator's `aggregatorHandle` on its own: a compromised
72+
// aggregator could label an attacker DID as `stripe.com` and any
73+
// shortcut that returns the aggregator's value as verified would
74+
// let the impersonation through unchecked.
75+
const { data: didHandleResolution, isPending } = useQuery({
6176
queryKey: ["registry", "did-handle", did],
6277
queryFn: () => resolveDidToHandle(did),
63-
enabled: Boolean(did) && !aggregatorHandle,
78+
enabled: Boolean(did),
6479
staleTime: 5 * 60 * 1000,
6580
});
6681

67-
if (aggregatorHandle) return { status: "ok", handle: aggregatorHandle };
68-
if (!didHandleResolution) return { status: "missing" };
69-
if (didHandleResolution.status === "ok") {
70-
return { status: "ok", handle: didHandleResolution.handle };
82+
if (isPending || !didHandleResolution) return { status: "missing" };
83+
84+
// DID document didn't claim a handle (or the document was
85+
// unreachable). The aggregator might have one, but without our own
86+
// verification we can't display it.
87+
if (didHandleResolution.status === "missing") {
88+
return { status: "missing" };
89+
}
90+
91+
// DID document claims a handle but it doesn't round-trip.
92+
// `invalid` always wins over an aggregator-supplied handle.
93+
if (didHandleResolution.status === "invalid") {
94+
return { status: "invalid" };
7195
}
72-
return { status: didHandleResolution.status };
96+
97+
// Bidirectionally verified handle. Cross-check against the
98+
// aggregator's claim: if they differ, flag the publisher as
99+
// invalid. The aggregator may simply be stale, but we shouldn't
100+
// silently disagree with our own verification by showing the
101+
// aggregator's value -- the conservative read is "something is
102+
// off, surface it to the admin".
103+
const verifiedHandle = didHandleResolution.handle.toLowerCase();
104+
if (aggregatorHandle) {
105+
const claimed = aggregatorHandle.toLowerCase().replace(TRAILING_DOT, "");
106+
if (claimed !== verifiedHandle) {
107+
return { status: "invalid" };
108+
}
109+
}
110+
111+
return { status: "ok", handle: didHandleResolution.handle };
73112
}
74113

75114
export function PublisherHandle({

packages/admin/src/components/RegistryPluginDetail.tsx

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -147,17 +147,24 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
147147
did: pkg.did,
148148
slug,
149149
version: release?.version,
150-
// Only send the acknowledgement when the dialog had real
151-
// capability data to display. The server's drift check is
152-
// gated on `acknowledgedDeclaredAccess !== undefined`, so
153-
// omitting the field opts out of the check entirely --
154-
// correct behaviour for the (currently common) case where
155-
// the publisher's release record doesn't yet carry an
156-
// extension block. The bundle's actual capabilities are
157-
// still bound to the checksum-verified bytes; the drift
158-
// check is a UX sanity belt for already-displayed
159-
// consent, not an authorization gate.
160-
acknowledgedDeclaredAccess: capabilities.length > 0 ? capabilities : undefined,
150+
// Always send the acknowledgement, even when the dialog
151+
// showed no permissions. The server compares this list
152+
// against the bundle's actual `manifest.capabilities`
153+
// after download:
154+
//
155+
// - If the bundle has capabilities, the server
156+
// requires us to send a matching list (the consent
157+
// dialog is the only place the admin sees what
158+
// they're agreeing to).
159+
// - If the bundle has no capabilities, no consent is
160+
// required and the server ignores this field.
161+
//
162+
// Sending the empty list when the release extension was
163+
// missing means a publisher who ships a bundle with
164+
// permissions but no extension block can't sneak the
165+
// permissions past an empty consent dialog -- the
166+
// server will refuse with `DECLARED_ACCESS_REQUIRED`.
167+
acknowledgedDeclaredAccess: capabilities,
161168
});
162169
},
163170
onSuccess: () => {

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@
143143
"#menus/*": "./src/menus/*",
144144
"#widgets/*": "./src/widgets/*",
145145
"#import/*": "./src/import/*",
146+
"#security/*": "./src/security/*",
146147
"#utils/*": "./src/utils/*",
147148
"#preview/*": "./src/preview/*",
148149
"#seed/*": "./src/seed/*",

0 commit comments

Comments
 (0)