diff --git a/src/handlers/container.js b/src/handlers/container.js index c143e01..e7908f2 100644 --- a/src/handlers/container.js +++ b/src/handlers/container.js @@ -193,34 +193,33 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex.jsonld`, { listed: false }); await storage.write(`${podPath}settings/privateTypeIndex.jsonld`, serialize(privateTypeIndex)); - // Create default ACL files - // Pod root: owner full control, public read - const rootAcl = generateOwnerAcl(podUri, webId, true); + // Create default ACL files. Each .acl is written inside the container it + // protects, so the resource it refers to is always './' (resolved against + // the .acl's own URL by the parser — see #428). This keeps pods portable + // across hostnames; the absolute podUri is no longer baked in. + const rootAcl = generateOwnerAcl('./', webId, true); await storage.write(`${podPath}.acl`, serializeAcl(rootAcl)); - // Private folder: owner only (no public) - const privateAcl = generatePrivateAcl(`${podUri}private/`, webId); + const privateAcl = generatePrivateAcl('./', webId); await storage.write(`${podPath}private/.acl`, serializeAcl(privateAcl)); - // settings folder: owner only (contains private preferences) - const settingsAcl = generatePrivateAcl(`${podUri}settings/`, webId); + const settingsAcl = generatePrivateAcl('./', webId); await storage.write(`${podPath}settings/.acl`, serializeAcl(settingsAcl)); - // publicTypeIndex: public read, overrides the private default inherited from /settings/ - const publicTypeIndexAcl = generateOwnerAcl(`${podUri}settings/publicTypeIndex.jsonld`, webId, false); + // publicTypeIndex: public read, overrides the private default inherited + // from /settings/. This is a resource ACL (lives at .../publicTypeIndex.jsonld.acl), + // so the resource is './publicTypeIndex.jsonld' relative to the parent. + const publicTypeIndexAcl = generateOwnerAcl('./publicTypeIndex.jsonld', webId, false); await storage.write(`${podPath}settings/publicTypeIndex.jsonld.acl`, serializeAcl(publicTypeIndexAcl)); - // Inbox: owner full, public append - const inboxAcl = generateInboxAcl(`${podUri}inbox/`, webId); + const inboxAcl = generateInboxAcl('./', webId); await storage.write(`${podPath}inbox/.acl`, serializeAcl(inboxAcl)); - // Public folder: owner full, public read (with inheritance) - const publicAcl = generatePublicFolderAcl(`${podUri}public/`, webId); + const publicAcl = generatePublicFolderAcl('./', webId); await storage.write(`${podPath}public/.acl`, serializeAcl(publicAcl)); - // Profile folder: owner full, public read (with inheritance) // Profile documents must be publicly readable for WebID verification - const profileAcl = generatePublicFolderAcl(`${podUri}profile/`, webId); + const profileAcl = generatePublicFolderAcl('./', webId); await storage.write(`${podPath}profile/.acl`, serializeAcl(profileAcl)); // Initialize storage quota if configured diff --git a/src/server.js b/src/server.js index f23768f..fa38702 100644 --- a/src/server.js +++ b/src/server.js @@ -964,27 +964,30 @@ export function createServer(options = {}) { const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex.jsonld`, { listed: false }); await storage.write('/settings/privateTypeIndex.jsonld', serialize(privateTypeIndex)); - // ACL files - const rootAcl = generateOwnerAcl(podUri, webId, true); + // ACL files. Each .acl uses './' (or a relative basename for resource + // ACLs) so the pod isn't host-locked to whichever interface the server + // happened to bind on first start. The parser resolves these against + // the .acl's own request URL — see #428. + const rootAcl = generateOwnerAcl('./', webId, true); await storage.write('/.acl', serializeAcl(rootAcl)); - const privateAcl = generatePrivateAcl(`${podUri}private/`, webId); + const privateAcl = generatePrivateAcl('./', webId); await storage.write('/private/.acl', serializeAcl(privateAcl)); - const settingsAcl = generatePrivateAcl(`${podUri}settings/`, webId); + const settingsAcl = generatePrivateAcl('./', webId); await storage.write('/settings/.acl', serializeAcl(settingsAcl)); // publicTypeIndex: public read, overrides the private default inherited from /settings/ - const publicTypeIndexAcl = generateOwnerAcl(`${podUri}settings/publicTypeIndex.jsonld`, webId, false); + const publicTypeIndexAcl = generateOwnerAcl('./publicTypeIndex.jsonld', webId, false); await storage.write('/settings/publicTypeIndex.jsonld.acl', serializeAcl(publicTypeIndexAcl)); - const inboxAcl = generateInboxAcl(`${podUri}inbox/`, webId); + const inboxAcl = generateInboxAcl('./', webId); await storage.write('/inbox/.acl', serializeAcl(inboxAcl)); - const publicAcl = generatePublicFolderAcl(`${podUri}public/`, webId); + const publicAcl = generatePublicFolderAcl('./', webId); await storage.write('/public/.acl', serializeAcl(publicAcl)); - const profileAcl = generatePublicFolderAcl(`${podUri}profile/`, webId); + const profileAcl = generatePublicFolderAcl('./', webId); await storage.write('/profile/.acl', serializeAcl(profileAcl)); // Note: Quota not initialized for root-level pods (no user directory) diff --git a/src/wac/parser.js b/src/wac/parser.js index ed8f1ff..d2ede5c 100644 --- a/src/wac/parser.js +++ b/src/wac/parser.js @@ -212,7 +212,10 @@ function normalizeMode(mode) { /** * Generate a default public read ACL - * @param {string} resourceUrl - URL of the resource + * @param {string} resourceUrl - URL of the resource. May be relative (e.g. + * './' for the .acl's own container) — the parser resolves it against + * the .acl's URL at check time, which keeps the document portable across + * hostnames. See #428. * @returns {object} JSON-LD ACL document */ export function generatePublicReadAcl(resourceUrl) { @@ -237,8 +240,10 @@ export function generatePublicReadAcl(resourceUrl) { /** * Generate a full owner ACL (owner has full control, public read) - * @param {string} resourceUrl - URL of the resource - * @param {string} ownerWebId - WebID of the owner + * @param {string} resourceUrl - URL of the resource. May be relative (see + * `generatePublicReadAcl`). + * @param {string} ownerWebId - WebID of the owner. Currently absolute; + * relative agent WebIDs are tracked in #427 Phase 2. * @param {boolean} isContainer - Whether this is a container * @returns {object} JSON-LD ACL document */ diff --git a/test/wac.test.js b/test/wac.test.js index 010cfa5..b0fa77c 100644 --- a/test/wac.test.js +++ b/test/wac.test.js @@ -13,7 +13,16 @@ import { assertHeader, getBaseUrl } from './helpers.js'; -import { parseAcl, AccessMode, generateOwnerAcl, serializeAcl } from '../src/wac/parser.js'; +import { + parseAcl, + AccessMode, + generateOwnerAcl, + generatePrivateAcl, + generateInboxAcl, + generatePublicFolderAcl, + generatePublicReadAcl, + serializeAcl +} from '../src/wac/parser.js'; import { checkAccess, getRequiredMode } from '../src/wac/checker.js'; describe('WAC Parser', () => { @@ -238,6 +247,73 @@ describe('WAC Parser', () => { assert.ok(publicAuth); }); }); + + // Phase 1 of #427 (#428): generators should preserve relative resourceUrls + // verbatim so callers can emit host-portable ACLs. The parser already + // resolves them at check time against the .acl's URL. + describe('relative resourceUrl portability (#428)', () => { + const webId = 'https://alice.example/profile/card.jsonld#me'; + + it('generateOwnerAcl preserves "./" in accessTo and default', () => { + const acl = generateOwnerAcl('./', webId, true); + const owner = acl['@graph'].find(a => a['@id'] === '#owner'); + const pub = acl['@graph'].find(a => a['@id'] === '#public'); + assert.strictEqual(owner['acl:accessTo']['@id'], './'); + assert.strictEqual(owner['acl:default']['@id'], './'); + assert.strictEqual(pub['acl:accessTo']['@id'], './'); + // #public intentionally has no default — child resources require auth + assert.strictEqual(pub['acl:default'], undefined); + }); + + it('generatePrivateAcl preserves "./"', () => { + const acl = generatePrivateAcl('./', webId); + const owner = acl['@graph'][0]; + assert.strictEqual(owner['acl:accessTo']['@id'], './'); + assert.strictEqual(owner['acl:default']['@id'], './'); + }); + + it('generateInboxAcl preserves "./"', () => { + const acl = generateInboxAcl('./', webId); + for (const auth of acl['@graph']) { + assert.strictEqual(auth['acl:accessTo']['@id'], './'); + assert.strictEqual(auth['acl:default']['@id'], './'); + } + }); + + it('generatePublicFolderAcl preserves "./"', () => { + const acl = generatePublicFolderAcl('./', webId); + for (const auth of acl['@graph']) { + assert.strictEqual(auth['acl:accessTo']['@id'], './'); + assert.strictEqual(auth['acl:default']['@id'], './'); + } + }); + + it('generatePublicReadAcl preserves a relative resource basename', () => { + const acl = generatePublicReadAcl('./publicTypeIndex.jsonld'); + assert.strictEqual( + acl['@graph'][0]['acl:accessTo']['@id'], + './publicTypeIndex.jsonld' + ); + }); + + it('round-trip: relative "./" resolves to the .acl base URL on parse', async () => { + const generated = generateOwnerAcl('./', webId, true); + const wire = serializeAcl(generated); + + // Parse the same .acl document under two different host URLs and + // assert accessTo resolves to whichever host asked. This is what + // makes the on-disk pod portable across interfaces. + const auths1 = await parseAcl(wire, 'http://localhost:4444/.acl'); + const auths2 = await parseAcl(wire, 'http://0.0.0.0:4444/.acl'); + + const pub1 = auths1.find(a => a.agentClasses.includes('foaf:Agent')); + const pub2 = auths2.find(a => a.agentClasses.includes('foaf:Agent')); + assert.ok(pub1.accessTo.includes('http://localhost:4444/'), + `Expected localhost resolution, got: ${JSON.stringify(pub1.accessTo)}`); + assert.ok(pub2.accessTo.includes('http://0.0.0.0:4444/'), + `Expected 0.0.0.0 resolution, got: ${JSON.stringify(pub2.accessTo)}`); + }); + }); }); describe('WAC Checker', () => { @@ -325,6 +401,27 @@ describe('WAC Integration', () => { }); }); + describe('Cross-host ACL portability (#428)', () => { + // The .acl is written with a relative `./` so the public-read rule + // matches whichever host the request comes in on. Before #428, the + // .acl baked the bind-time host into accessTo and any other host + // returned 401. We exercise this by varying the Host: header. + it('serves public-read resources regardless of Host header', async () => { + // Profile is public-read by default (#427 Phase 1). + const baseHost = new URL(getBaseUrl()).host; + const profileUrl = `${getBaseUrl()}/wactest/profile/`; + const hostsToTry = [baseHost, 'localhost:9999', 'pod.example:443', 'pod.invalid']; + + for (const host of hostsToTry) { + const res = await fetch(profileUrl, { headers: { Host: host } }); + assert.strictEqual( + res.status, 200, + `Public-read should succeed for Host: ${host} (got ${res.status})` + ); + } + }); + }); + describe('WAC-Allow Header', () => { it('should return WAC-Allow header for public container', async () => { const res = await request('/wactest/public/');