Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 44 additions & 7 deletions src/idp/well-known-did-nostr.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,10 @@ export function profilePathFromWebId(dataRoot, webId, accountId = 'unknown') {
* → `<dataRoot>/profile/card.jsonld`
* 3. Subdomain-mode pod (host=`alice.example.com`, path=`/profile/card.jsonld`)
* → `<dataRoot>/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.
* `<dataRoot>/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
Expand Down Expand Up @@ -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 `<dataRoot>/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 `<dataRoot>/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 };
Expand Down
63 changes: 63 additions & 0 deletions test/well-known-did-nostr.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dataRoot>/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 <dataRoot>/<podName>/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
// <dataRoot>/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, <dataRoot>/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 <pod>/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: [] });
Expand Down