From e2890fd3ab0e6857e3b6f73bac645f35fcf394b9 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Wed, 10 Jun 2026 15:17:56 +0200 Subject: [PATCH 1/2] fix(well-known-did-nostr): probe profile/card.jsonld for root-path WebIDs (#451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit profilePathCandidates() derives candidates from the WebID's pathname. A root-path WebID like https://melvin.solid.social/#me has pathname '/', so pathnameRel is '' and every candidate resolves to a DIRECTORY (dataRoot itself, or the pod dir via the subdomain gate) — the indexer then logs NO_CANDIDATE_MATCHED / not-a-regular-file on every TTL rebuild and never publishes the account's DID document. Fix is option 2 from the issue: when the pathname is a pod root ('' or trailing '/'), additionally probe the conventional profile document location, profile/card.jsonld, under each directory candidate. The trailing-slash sibling (e.g. https://example.com/alice/#me, pathname '/alice/') is the same bug class and is covered by the same isPodRoot check. Deliberate narrowing vs the issue's sketch: only profile/card.jsonld is probed — not profile/card or profile/card.ttl — because the rebuild loop parses candidates with JSON.parse, so Turtle-shaped files could never match; probing them would only add dead candidates and failure-log noise. Cross-account safety: unchanged. The rebuild loop accepts a candidate only when its declared @id equals account.webId exactly, and the subdomain gate (host's first DNS label must match podName) applies to the fallback candidate the same as the primary. Non-root WebIDs produce byte-identical candidate lists (locked by a deepStrictEqual regression test). Tests: 5 new cases in the existing #411 describe block — root pod, the exact melvin.solid.social subdomain scenario from the issue, trailing-slash path mode, gate preservation under non-matching podName, and the no-change guarantee for document-shaped WebIDs. Full suite: 925/925 passing. Closes #451. --- src/idp/well-known-did-nostr.js | 24 +++++++++++++++ test/well-known-did-nostr.test.js | 49 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/idp/well-known-did-nostr.js b/src/idp/well-known-did-nostr.js index 4a44beac..bf66ecd3 100644 --- a/src/idp/well-known-did-nostr.js +++ b/src/idp/well-known-did-nostr.js @@ -310,6 +310,10 @@ export function profilePathFromWebId(dataRoot, webId, accountId = 'unknown') { * → `/profile/card.jsonld` * 3. Subdomain-mode pod (host=`alice.example.com`, path=`/profile/card.jsonld`) * → `/alice/profile/card.jsonld` + * 4. Root-path WebID (path=`/` or `/alice/`, e.g. `https://melvin.solid.social/#me`) + * → additionally probes `profile/card.jsonld` under the + * otherwise-directory candidate(s), e.g. + * `/melvin/profile/card.jsonld` (#451) * * The subdomain candidate (3) is gated on `podName` matching the * WebID host's first DNS label — without that gate, a root-pod @@ -345,6 +349,23 @@ export function profilePathCandidates(dataRoot, webId, podName = null) { }; // Path-mode named pod OR root pod. consider(pathnameRel); + // Root-path / pod-root WebID (#451): a WebID like + // `https://melvin.solid.social/#me` (pathname `/`) or + // `https://example.com/alice/#me` (pathname `/alice/`) makes the + // candidate above resolve to a DIRECTORY (dataRoot itself, or the + // pod dir) — never a profile document. Probe the conventional + // profile location underneath it. Only `profile/card.jsonld`: the + // rebuild loop reads candidates with JSON.parse, so the Turtle + // conventions (`profile/card`, `profile/card.ttl`) could never + // match anyway. Cross-account safety is preserved — the rebuild + // loop accepts a candidate only when the document's declared `@id` + // equals account.webId exactly. + const isPodRoot = pathnameRel === '' || pathnameRel.endsWith('/'); + if (isPodRoot) { + // path.resolve skips empty segments, so pathnameRel === '' lands + // on `/profile/card.jsonld` (root pod) as intended. + consider(pathnameRel, 'profile/card.jsonld'); + } // Subdomain mode: only when the WebID host's first DNS label // matches the account's podName (case-insensitive — DNS is). if (typeof podName === 'string' && podName.length > 0) { @@ -352,6 +373,9 @@ export function profilePathCandidates(dataRoot, webId, podName = null) { const expected = podName.toLowerCase() + '.'; if (host.startsWith(expected)) { consider(podName, pathnameRel); + if (isPodRoot) { + consider(podName, pathnameRel, 'profile/card.jsonld'); + } } } return { paths, skipped }; diff --git a/test/well-known-did-nostr.test.js b/test/well-known-did-nostr.test.js index dbcf7d5d..3374f6f4 100644 --- a/test/well-known-did-nostr.test.js +++ b/test/well-known-did-nostr.test.js @@ -806,6 +806,55 @@ describe('profilePathCandidates — deployment-shape coverage (#411)', () => { assert.deepStrictEqual(paths, [path.join(DATA_ROOT, 'profile', 'card.jsonld')]); }); + // Root-path WebIDs (#451): pathname `/` yields an empty pathnameRel, + // so the plain candidates resolve to directories (dataRoot itself / + // the pod dir) and the indexer ENOENT/not-a-regular-file'd every + // account shaped like `https://melvin.solid.social/#me`. The fix + // additionally probes the conventional `profile/card.jsonld` + // location under each directory candidate. + + it('root-path WebID probes /profile/card.jsonld (#451)', () => { + const { paths } = profilePathCandidates(DATA_ROOT, 'https://example.com/#me'); + const expected = path.join(DATA_ROOT, 'profile', 'card.jsonld'); + assert.ok(paths.includes(expected), + `expected ${expected}; got ${paths.join(', ')}`); + }); + + it('root-path WebID in subdomain mode probes //profile/card.jsonld (#451)', () => { + // The exact solid.social scenario from the issue: account `melvin` + // with WebID https://melvin.solid.social/#me, profile on disk at + // /melvin/profile/card.jsonld. + const { paths } = profilePathCandidates(DATA_ROOT, 'https://melvin.solid.social/#me', 'melvin'); + const expected = path.join(DATA_ROOT, 'melvin', 'profile', 'card.jsonld'); + assert.ok(paths.includes(expected), + `expected ${expected}; got ${paths.join(', ')}`); + }); + + it('pod-root WebID with trailing slash probes /profile/card.jsonld (#451)', () => { + // Path-mode sibling of the root-path case: pathname `/alice/` + // also resolves to a directory without the fallback. + const { paths } = profilePathCandidates(DATA_ROOT, 'https://example.com/alice/#me'); + const expected = path.join(DATA_ROOT, 'alice', 'profile', 'card.jsonld'); + assert.ok(paths.includes(expected), + `expected ${expected}; got ${paths.join(', ')}`); + }); + + it('root-path WebID does NOT probe a podName dir when the host label does not match (#451)', () => { + // The subdomain gate must keep applying to the fallback candidate; + // otherwise a root-pod WebID could probe another account's pod dir. + const { paths } = profilePathCandidates(DATA_ROOT, 'https://melvin.solid.social/#me', 'other'); + const leaked = path.join(DATA_ROOT, 'other', 'profile', 'card.jsonld'); + assert.ok(!paths.includes(leaked), + `gate bypassed: ${leaked} should not be a candidate; got ${paths.join(', ')}`); + }); + + it('non-root WebIDs gain NO extra fallback candidates (#451)', () => { + // A document-shaped pathname must produce exactly the same + // candidate list as before the #451 fix. + const { paths } = profilePathCandidates(DATA_ROOT, 'https://example.com/alice/profile/card.jsonld#me'); + assert.deepStrictEqual(paths, [path.join(DATA_ROOT, 'alice', 'profile', 'card.jsonld')]); + }); + it('returns empty paths for an unparseable webId', () => { assert.deepStrictEqual(profilePathCandidates(DATA_ROOT, 'not a url'), { paths: [], skipped: [] }); assert.deepStrictEqual(profilePathCandidates(DATA_ROOT, null), { paths: [], skipped: [] }); From bb89e471fe73df7008294f8c4292e9fe4cc7cdb4 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Wed, 10 Jun 2026 15:37:07 +0200 Subject: [PATCH 2/2] review fix (#546): suppress root-level fallback when the subdomain gate matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot found a real cross-account window in the new fallback: for a subdomain root-path WebID (https://melvin.solid.social/#me, podName melvin), the root-level fallback emitted /profile/card.jsonld — the ROOT pod's document — BEFORE the pod-specific candidate. The rebuild loop's @id check absolutizes a relative subject ("@id": "#me", a supported shape, see collectAuthenticationIds) against the PROBING account's WebID, so the root pod's profile could pass the check and bind the root pod's pubkeys to the subdomain account. Fix per the review suggestion: compute the subdomain gate up front and emit the root-level fallback only when the gate does NOT match. When it matches, the pod-dir fallback is the only correct location — no legitimate deployment serves a subdomain account's profile from the dataRoot root. New regression test asserts /profile/card.jsonld is NOT a candidate for the subdomain root-path case. Full suite: 926/926 passing. --- src/idp/well-known-did-nostr.js | 43 ++++++++++++++++++++----------- test/well-known-did-nostr.test.js | 14 ++++++++++ 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/idp/well-known-did-nostr.js b/src/idp/well-known-did-nostr.js index bf66ecd3..dfd7a9db 100644 --- a/src/idp/well-known-did-nostr.js +++ b/src/idp/well-known-did-nostr.js @@ -347,6 +347,16 @@ export function profilePathCandidates(dataRoot, webId, podName = null) { } if (!paths.includes(r)) paths.push(r); }; + // Subdomain gate: the WebID host's first DNS label must match the + // account's podName (case-insensitive — DNS is). Computed up front + // because the root-path fallback below keys off it too. + const subdomainMatch = + typeof podName === 'string' && podName.length > 0 && + url.hostname.toLowerCase().startsWith(podName.toLowerCase() + '.'); + // Pod-root WebID shape (#451): pathname `/` (→ pathnameRel '') or a + // trailing slash like `/alice/`. + const isPodRoot = pathnameRel === '' || pathnameRel.endsWith('/'); + // Path-mode named pod OR root pod. consider(pathnameRel); // Root-path / pod-root WebID (#451): a WebID like @@ -357,25 +367,28 @@ export function profilePathCandidates(dataRoot, webId, podName = null) { // profile location underneath it. Only `profile/card.jsonld`: the // rebuild loop reads candidates with JSON.parse, so the Turtle // conventions (`profile/card`, `profile/card.ttl`) could never - // match anyway. Cross-account safety is preserved — the rebuild - // loop accepts a candidate only when the document's declared `@id` - // equals account.webId exactly. - const isPodRoot = pathnameRel === '' || pathnameRel.endsWith('/'); - if (isPodRoot) { + // match anyway. + // + // Gated on !subdomainMatch: when the host carries the account's + // podName label, the profile lives under the pod dir (the + // subdomain fallback below) and `/profile/card.jsonld` + // is a DIFFERENT account's document — the root pod's. The rebuild + // loop's @id check absolutizes a relative subject (`"@id": "#me"`, + // a supported shape — see collectAuthenticationIds) against the + // PROBING account's WebID, so the root pod's profile could pass + // the check and bind the root pod's pubkeys to the subdomain + // account. Suppressing the root-level fallback here closes that + // cross-account window; no legitimate deployment serves a + // subdomain account's profile from the dataRoot root. + if (isPodRoot && !subdomainMatch) { // path.resolve skips empty segments, so pathnameRel === '' lands // on `/profile/card.jsonld` (root pod) as intended. consider(pathnameRel, 'profile/card.jsonld'); } - // Subdomain mode: only when the WebID host's first DNS label - // matches the account's podName (case-insensitive — DNS is). - if (typeof podName === 'string' && podName.length > 0) { - const host = url.hostname.toLowerCase(); - const expected = podName.toLowerCase() + '.'; - if (host.startsWith(expected)) { - consider(podName, pathnameRel); - if (isPodRoot) { - consider(podName, pathnameRel, 'profile/card.jsonld'); - } + if (subdomainMatch) { + consider(podName, pathnameRel); + if (isPodRoot) { + consider(podName, pathnameRel, 'profile/card.jsonld'); } } return { paths, skipped }; diff --git a/test/well-known-did-nostr.test.js b/test/well-known-did-nostr.test.js index 3374f6f4..3b0bb64a 100644 --- a/test/well-known-did-nostr.test.js +++ b/test/well-known-did-nostr.test.js @@ -830,6 +830,20 @@ describe('profilePathCandidates — deployment-shape coverage (#411)', () => { `expected ${expected}; got ${paths.join(', ')}`); }); + it('root-path WebID in subdomain mode does NOT probe the root pod profile (#451 cross-account guard)', () => { + // When the subdomain gate matches, /profile/card.jsonld + // is the ROOT pod's document — a different account. A relative + // subject there ("@id": "#me") would absolutize against the + // probing account's WebID and pass the rebuild loop's @id check, + // binding the root pod's pubkeys to the subdomain account. The + // root-level fallback must therefore be suppressed when the gate + // matches. + const { paths } = profilePathCandidates(DATA_ROOT, 'https://melvin.solid.social/#me', 'melvin'); + const rootPodProfile = path.join(DATA_ROOT, 'profile', 'card.jsonld'); + assert.ok(!paths.includes(rootPodProfile), + `cross-account window: ${rootPodProfile} must not be probed for a subdomain account; got ${paths.join(', ')}`); + }); + it('pod-root WebID with trailing slash probes /profile/card.jsonld (#451)', () => { // Path-mode sibling of the root-path case: pathname `/alice/` // also resolves to a directory without the fallback.