diff --git a/src/idp/well-known-did-nostr.js b/src/idp/well-known-did-nostr.js index 4a44beac..dfd7a9db 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 @@ -343,15 +347,48 @@ 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); - // 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); + // 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. + // + // 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'); + } + 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 dbcf7d5d..3b0bb64a 100644 --- a/test/well-known-did-nostr.test.js +++ b/test/well-known-did-nostr.test.js @@ -806,6 +806,69 @@ 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('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. + 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: [] });