fix(wac): require Control on protected resource for POST-created .acl/.meta sidecars - #580
Conversation
…/.meta sidecars
A POST whose Slug resolves to an `.acl`/`.meta` sidecar is currently
authorized only against the *container* (the request path), because the
dedicated ACL Control guard in `auth/middleware.js` (`authorizeAclAccess`)
keys on `urlPath.endsWith('.acl')` — which never matches a container POST.
The sidecar filename is only produced *inside* `handlePost` via
`generateUniqueFilename`, after authorization has run.
Impact: an agent holding only `acl:Append` on a container (e.g. a
public-append inbox/upload directory created by `generateInboxAcl`) can
`POST` with `Slug: victim.acl` and write a resource ACL that grants itself
`acl:Control`/`acl:Read` on a sibling — privilege escalation to full
control of a resource it had no access to. The slug validator permits `.`,
so `victim.acl` passes.
Fix: in `handlePost`, when the resolved child filename ends in `.acl`/`.meta`,
require `acl:Control` on the protected resource (the sidecar path minus the
suffix) before writing, mirroring `authorizeAclAccess`. Owners (who hold
Control) are unaffected; Append-only agents get 403.
Found during a cross-implementation audit against the solid-pod-rs Rust port,
which shared the same gap and is fixed in lockstep. Reproduction and a
proposed regression test are in the PR description; the full integration
harness could not be exercised in the contributor's environment (missing
optional `@simplewebauthn/server` dep used by the passkey path at server
bootstrap), so CI validation is requested.
Co-Authored-By: jjohare <github@thedreamlab.uk>
There was a problem hiding this comment.
Pull request overview
This PR closes a WAC authorization gap where POST to a container could mint an .acl/.meta sidecar (via Slug) without requiring acl:Control on the protected resource, enabling privilege escalation. It adds a targeted authorization check in handlePost once the resolved child filename is known.
Changes:
- Add a post-slug-resolution guard in
handlePostto requireacl:Controlon the protected resource when creating*.acl/*.metaviaPOST. - Wire in WAC primitives (
checkAccess,AccessMode) needed to perform that additional authorization check.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const protectedUrlPath = newUrlPath.replace(/\.(acl|meta)$/, ''); | ||
| const protectedStoragePath = newStoragePath.replace(/\.(acl|meta)$/, ''); | ||
| const { allowed } = await checkAccess({ | ||
| resourceUrl: `${request.protocol}://${request.hostname}${protectedUrlPath}`, | ||
| resourcePath: protectedStoragePath, | ||
| isContainer: protectedUrlPath.endsWith('/'), | ||
| agentWebId: request.webId, | ||
| requiredMode: AccessMode.CONTROL | ||
| }); |
There was a problem hiding this comment.
Fixed in 7deed84: the Control check now builds the protected-resource URL with buildResourceUrl(request, protectedUrlPath) — the same helper authorize()/authorizeAclAccess() use — so it evaluates against the identical origin (host+port, subdomain-normalized) as the rest of WAC.
| if (!isCreatingContainer && /\.(acl|meta)$/.test(filename)) { | ||
| const protectedUrlPath = newUrlPath.replace(/\.(acl|meta)$/, ''); | ||
| const protectedStoragePath = newStoragePath.replace(/\.(acl|meta)$/, ''); | ||
| const { allowed } = await checkAccess({ | ||
| resourceUrl: `${request.protocol}://${request.hostname}${protectedUrlPath}`, |
There was a problem hiding this comment.
A regression test was added in the same commit under review (5eeed9e), in test/auth.test.js: 'should deny POST-created .acl/.meta sidecars without Control on the protected resource' (asserts 403 for Slug victim.acl/victim.meta via the public-append inbox, 201 for a normal POST) plus 'should allow the owner (Control) to POST an .acl sidecar'. Verified it fails against the unpatched handler and passes with the guard.
Cover the privilege-escalation path fixed in this PR: an append-only agent (public inbox) POSTing Slug: victim.acl / victim.meta must get 403, a normal non-sidecar POST still gets 201, and the owner (Control) can still POST an .acl sidecar. The deny test fails against the unpatched handler and passes with the Control guard.
| const protectedUrlPath = newUrlPath.replace(/\.(acl|meta)$/, ''); | ||
| const protectedStoragePath = newStoragePath.replace(/\.(acl|meta)$/, ''); | ||
| const { allowed } = await checkAccess({ | ||
| resourceUrl: `${request.protocol}://${request.hostname}${protectedUrlPath}`, | ||
| resourcePath: protectedStoragePath, | ||
| isContainer: protectedUrlPath.endsWith('/'), |
There was a problem hiding this comment.
Fixed in 7deed84 by switching to buildResourceUrl(), which prefers the Host header (preserving the port) and applies subdomain normalization, matching the auth middleware.
| // Security: a Slug that resolves to an `.acl`/`.meta` sidecar governs | ||
| // ANOTHER resource's permissions. The authorize() preHandler only checked | ||
| // Append/Write on the *container* (the request path), and its dedicated |
There was a problem hiding this comment.
Fixed in 7deed84: reworded to make clear only .acl is consulted by the WAC checker; .meta is gated as defense-in-depth (protected Solid sidecar dotfile), not because it governs permissions.
| // Regression for the POST .acl sidecar injection: an agent holding only | ||
| // acl:Append on a container (here, the public-append inbox) must not be | ||
| // able to plant a sidecar that governs a sibling resource's permissions. |
There was a problem hiding this comment.
Fixed in 7deed84 — the test comment no longer implies .meta affects WAC; it notes .meta is not a WAC input and is gated as a protected sidecar.
…ta rationale Address review feedback on the POST .acl/.meta sidecar guard: - Build the protected-resource URL with buildResourceUrl() (the same helper authorize()/authorizeAclAccess() use) instead of a hand-rolled request.hostname string, so the Control decision is evaluated against the identical origin (host+port, subdomain-normalized) as the rest of WAC. - Reword the code and test comments: only .acl is consulted for WAC; .meta is gated as defense-in-depth (protected Solid sidecar), not because it governs permissions.
| if (!isCreatingContainer && /\.(acl|meta)$/.test(filename)) { | ||
| const protectedUrlPath = newUrlPath.replace(/\.(acl|meta)$/, ''); | ||
| const protectedStoragePath = newStoragePath.replace(/\.(acl|meta)$/, ''); | ||
| const { allowed } = await checkAccess({ | ||
| resourceUrl: buildResourceUrl(request, protectedUrlPath), | ||
| resourcePath: protectedStoragePath, | ||
| isContainer: protectedUrlPath.endsWith('/'), | ||
| agentWebId: request.webId, | ||
| requiredMode: AccessMode.CONTROL | ||
| }); |
There was a problem hiding this comment.
Fixed in cfdf385. Added a noDebit option to checkAccess() (threaded into checkAuthorizations()): when set, a matching positive-cost PaymentCondition is treated as not-satisfied (returns paymentRequired) instead of debiting the ledger. The sidecar Control guard now passes noDebit: true, so this secondary check can't charge — no double debit and no silent charge; the authoritative debit stays in the primary authorize() hook. Owners hold unconditioned Control and are unaffected. Added test/wac.test.js coverage asserting the primary check debits while the noDebit check leaves the balance unchanged.
The POST .acl/.meta guard in handlePost runs a second checkAccess() on a request the global authorize() hook already evaluated (and possibly billed). Since checkAccess() debits the web ledger for a matching positive-cost PaymentCondition, a payment-gated Control grant could be charged inside the guard — a double debit, and a silent one (the guard ignores paid/ paymentRequired, so no X-Cost/X-Balance headers and 403 instead of 402). Add a noDebit option to checkAccess()/checkAuthorizations(): when set, a positive-cost paid grant is treated as not-satisfied (returns paymentRequired) rather than debited. Owners hold unconditioned Control and are unaffected; the authoritative debit stays in the primary authorize() path. The sidecar guard passes noDebit: true. Adds test/wac.test.js coverage asserting the primary check debits while the noDebit check leaves the balance unchanged.
| * Used by secondary/guard checks (e.g. the POST sidecar Control gate in | ||
| * handlePost) so a single request cannot debit twice or charge silently; | ||
| * the authoritative debit stays in the primary authorize() hook. | ||
| * @returns {Promise<{allowed: boolean, wacAllow: string}>} |
There was a problem hiding this comment.
Fixed in aab92cd — the @returns for checkAccess() now documents the full shape (paymentRequired/paid/balance/currency alongside allowed/wacAllow), and notes which fields are present in the deny/debit/noDebit paths.
The @returns for checkAccess() listed only {allowed, wacAllow}, but the function also returns paymentRequired/paid/balance/currency, which callers (and the noDebit test) rely on. Document the full shape.
Security: require acl:Control on the protected resource for POST-created .acl/.meta sidecars (#580). Closes a WAC privilege-escalation gap where an agent with only acl:Append on a container (e.g. a public inbox) could POST Slug: victim.acl and self-grant Control over a sibling resource. The guard mirrors authorizeAclAccess, uses buildResourceUrl for origin-consistent WAC evaluation, and runs with noDebit so the secondary check never charges the web ledger.
Summary
A
POSTwhoseSlugresolves to an.acl/.metasidecar is currently authorized only against the container (the request path), not against the sidecar's protected resource. The dedicated ACL Control guard inauth/middleware.js(authorizeAclAccess) keys onurlPath.endsWith('.acl'), which never matches a containerPOST— the sidecar filename is only produced insidehandlePostviagenerateUniqueFilename, after authorization has already run.Impact — privilege escalation
An agent holding only
acl:Appendon a container (e.g. a public-append inbox/upload directory produced bygenerateInboxAcl) can:POSTto the container with headerSlug: victim.aclacl:Control+acl:ReadThe slug validator permits
.(/^[a-zA-Z0-9._-]+$/), sovictim.aclpasses. The resulting/container/victim.aclthen governs/container/victim, giving the attacker full control of a sibling resource it had no prior access to. The same trick works for.meta.Fix
In
handlePost, when the resolved child filename ends in.acl/.meta, requireacl:Controlon the protected resource (the sidecar path minus the suffix) via the existingcheckAccess, before writing. This mirrorsauthorizeAclAccess. Owners (who hold Control) are unaffected; an Append-only agent now gets403.Testing note (please validate in CI)
I could not run the full integration harness in my environment (the server bootstrap eagerly imports the passkey path, and
@simplewebauthn/serveris not installed there). The change is syntax- and import-verified and mirrors the existingauthorizeAclAccesslogic. A proposed regression test (integration style, usingstartTestServer+createTestPod): create a container whose ACL grants a second agent onlyacl:Append; as that agentPOSTSlug: x.acl; assert403; assert as the owner (Control) the same POST succeeds.Context
Found during a cross-implementation audit against the Rust port
solid-pod-rs, which shared the same latent gap and is fixed in lockstep. Happy to adjust the approach (e.g. reject sidecar-via-POST outright vs. the Control-gate here) to whatever the maintainers prefer.🤖 Generated by Claude Code