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
41 changes: 40 additions & 1 deletion src/handlers/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { initializeQuota, checkQuota, updateQuotaUsage } from '../storage/quota.
import { getAllHeaders } from '../ldp/headers.js';
import { isContainer, getEffectiveUrlPath, getPodName } from '../utils/url.js';
import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js';
import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl, relativizeOwnerWebId } from '../wac/parser.js';
import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl, relativizeOwnerWebId, AccessMode } from '../wac/parser.js';
import { checkAccess } from '../wac/checker.js';
import { buildResourceUrl } from '../auth/middleware.js';
import { provisionOwnerKey, assertProvisionKeysCompatible } from '../keys/provision.js';
import { createToken } from '../auth/token.js';
import { canAcceptInput, toJsonLd, RDF_TYPES } from '../rdf/conneg.js';
Expand Down Expand Up @@ -88,6 +90,43 @@ export async function handlePost(request, reply) {
const newStoragePath = storagePath + filename + (isCreatingContainer ? '/' : '');
const resourceUrl = `${request.protocol}://${request.hostname}${newUrlPath}`;

// Security: a Slug that resolves to an `.acl` sidecar governs ANOTHER
// resource's permissions — the WAC checker searches for `*.acl`, so an
// `.acl` written here becomes the authorization policy for its sibling.
// The authorize() preHandler only checked Append/Write on the *container*
// (the request path), and its dedicated `.acl` Control guard
// (authorizeAclAccess) never fires here because the request path is the
// container, not the resolved sidecar. Without this an agent with mere
// Append rights on a container could POST `Slug: victim.acl` and self-grant
// Control on a sibling resource — privilege escalation. `.meta` is not
// consulted for WAC, but it is a protected Solid sidecar dotfile, so we gate
// it the same way (defense in depth) rather than let it be minted by Append.
// Mirror authorizeAclAccess: require acl:Control on the protected resource
// before minting a sidecar via POST. Build the resource URL with the same
// buildResourceUrl() the auth middleware uses so this Control decision is
// evaluated against the identical origin (host+port, subdomain-normalized).
// noDebit: this is a secondary WAC check on a request the authorize() hook
// already evaluated (and possibly billed) — pass noDebit so a payment-gated
// Control grant can't be charged here (no double debit, no silent charge).
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,
noDebit: true
});
Comment on lines +111 to +121

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

if (!allowed) {
return reply.code(403).send({
error: 'Forbidden',
message: 'Creating an ACL/meta sidecar via POST requires Control on the protected resource'
});
}
}

let success;
if (isCreatingContainer) {
success = await storage.createContainer(newStoragePath);
Expand Down
32 changes: 28 additions & 4 deletions src/wac/checker.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,32 @@ import { readLedger, getBalance, debit } from '../webledger.js';
* @param {boolean} options.isContainer - Whether resource is a container
* @param {string|null} options.agentWebId - WebID of the agent (null for unauthenticated)
* @param {string} options.requiredMode - Required access mode (from AccessMode)
* @returns {Promise<{allowed: boolean, wacAllow: string}>}
* @param {boolean} [options.noDebit=false] - When true, evaluate a
* PaymentCondition without charging the ledger. A positive-cost paid grant
* is treated as not-satisfied (returns paymentRequired) rather than debited.
* 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,
* paymentRequired?: object|null,
* paid?: number,
* balance?: number,
* currency?: string
* }>}
* `paymentRequired` carries the unmet PaymentCondition (present when a paid
* grant is denied, including every `noDebit` denial). `paid`/`balance`/
* `currency` are set only when a debit actually occurred. The no-ACL deny
* path returns just `{allowed, wacAllow}`.
*/
export async function checkAccess({
resourceUrl,
resourcePath,
isContainer,
agentWebId,
requiredMode
requiredMode,
noDebit = false
}) {
// Find applicable ACL
const aclResult = await findApplicableAcl(resourceUrl, resourcePath, isContainer);
Expand All @@ -43,7 +61,8 @@ export async function checkAccess({
resourceUrl, // Use actual resource URL, not the ACL container URL
agentWebId,
requiredMode,
isDefault
isDefault,
noDebit
);

// Calculate WAC-Allow header
Expand Down Expand Up @@ -129,7 +148,7 @@ function getParentPath(path) {
// Supported condition types
const SUPPORTED_CONDITIONS = ['PaymentCondition', 'https://webacl.org/ns#PaymentCondition'];

async function checkAuthorizations(authorizations, targetUrl, agentWebId, requiredMode, isDefault) {
async function checkAuthorizations(authorizations, targetUrl, agentWebId, requiredMode, isDefault, noDebit = false) {
for (const auth of authorizations) {
// For default ACLs, check if auth has default rules and matches target
// For direct ACLs, check if accessTo matches target
Expand Down Expand Up @@ -183,6 +202,11 @@ async function checkAuthorizations(authorizations, targetUrl, agentWebId, requir
// Paid access: check balance and deduct
const balance = getBalance(ledger, agentWebId, currency);
if (cost > 0 && balance >= cost) {
// Guard checks must not charge: a paid grant is left unsatisfied
// here so billing happens once, in the primary authorize() path.
if (noDebit) {
return { allowed: false, paymentRequired: paymentCondition };
}
const result = debit(ledger, agentWebId, cost, currency);
const { writeLedger } = await import('../webledger.js');
await writeLedger(ledger);
Expand Down
54 changes: 54 additions & 0 deletions test/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,60 @@ describe('Authentication', () => {
const res3 = await request('/authuser1/authenticated-only/test.txt', { auth: 'authuser2' });
assertStatus(res3, 200);
});

it('should deny POST-created .acl/.meta sidecars without Control on the protected resource', async () => {
// 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 an .acl sidecar, which the WAC checker would then treat
// as the authorization policy for the sibling resource. .meta is not a
// WAC input, but is gated the same way as a protected Solid sidecar.
await createTestPod('sidecarvictim');

const aclBody = JSON.stringify({
'@context': { acl: 'http://www.w3.org/ns/auth/acl#' },
'@graph': []
});

// Append-only (unauthenticated public append) agent tries to plant victim.acl
const attackAcl = await request('/sidecarvictim/inbox/', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Slug': 'victim.acl' },
body: aclBody
});
assertStatus(attackAcl, 403);

// The same trick with a .meta sidecar must also be blocked
const attackMeta = await request('/sidecarvictim/inbox/', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Slug': 'victim.meta' },
body: aclBody
});
assertStatus(attackMeta, 403);

// A normal (non-sidecar) POST to the public inbox still works
const legit = await request('/sidecarvictim/inbox/', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Slug': 'note' },
body: JSON.stringify({ type: 'note' })
});
assertStatus(legit, 201);
});

it('should allow the owner (Control) to POST an .acl sidecar', async () => {
// Owners hold acl:Control, so the sidecar guard must not block them.
await createTestPod('sidecarowner');

const res = await request('/sidecarowner/', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Slug': 'owned.acl' },
body: JSON.stringify({
'@context': { acl: 'http://www.w3.org/ns/auth/acl#' },
'@graph': []
}),
auth: 'sidecarowner'
});
assertStatus(res, 201);
});
});

describe('WAC-Allow Header', () => {
Expand Down
75 changes: 75 additions & 0 deletions test/wac.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
relativizeOwnerWebId
} from '../src/wac/parser.js';
import { checkAccess, getRequiredMode } from '../src/wac/checker.js';
import * as storage from '../src/storage/filesystem.js';
import { createLedger, setBalance, getBalance, LEDGER_PATH } from '../src/webledger.js';

describe('WAC Parser', () => {
describe('parseAcl', () => {
Expand Down Expand Up @@ -713,3 +715,76 @@ describe('WAC Conditions', () => {
});
});
});

describe('WAC PaymentCondition noDebit (secondary/guard checks must not charge)', () => {
let baseUrl;
const AGENT = 'https://payer.example/profile/card#me';
const COST = 5;
const RESOURCE_PATH = '/paygate/resource';

before(async () => {
const result = await startTestServer();
baseUrl = result.baseUrl;
});

after(async () => {
await stopTestServer();
});

// Seed a ledger balance for AGENT and an ACL granting AGENT Control on the
// protected resource, gated behind a positive-cost PaymentCondition.
async function seed(balance) {
const ledger = createLedger();
setBalance(ledger, AGENT, balance, 'sat');
await storage.write(LEDGER_PATH, Buffer.from(JSON.stringify(ledger)));

const resourceUrl = `${baseUrl}${RESOURCE_PATH}`;
const acl = {
'@context': { acl: 'http://www.w3.org/ns/auth/acl#' },
'@graph': [{
'@id': '#paid',
'@type': 'acl:Authorization',
'acl:agent': { '@id': AGENT },
'acl:accessTo': { '@id': resourceUrl },
'acl:mode': [{ '@id': 'acl:Control' }],
'acl:condition': { '@type': 'PaymentCondition', amount: String(COST), currency: 'sat' }
}]
};
await storage.write(`${RESOURCE_PATH}.acl`, Buffer.from(JSON.stringify(acl)));
return resourceUrl;
}

async function balanceNow() {
const raw = await storage.read(LEDGER_PATH);
return getBalance(JSON.parse(raw.toString()), AGENT, 'sat');
}

it('debits the ledger on a normal (primary) Control check', async () => {
const resourceUrl = await seed(100);
const res = await checkAccess({
resourceUrl,
resourcePath: RESOURCE_PATH,
isContainer: false,
agentWebId: AGENT,
requiredMode: AccessMode.CONTROL
});
assert.strictEqual(res.allowed, true);
assert.strictEqual(res.paid, COST);
assert.strictEqual(await balanceNow(), 100 - COST, 'primary check should debit');
});

it('does NOT debit when noDebit is set (guard/secondary check)', async () => {
const resourceUrl = await seed(100);
const res = await checkAccess({
resourceUrl,
resourcePath: RESOURCE_PATH,
isContainer: false,
agentWebId: AGENT,
requiredMode: AccessMode.CONTROL,
noDebit: true
});
assert.strictEqual(res.allowed, false, 'paid grant is not satisfied without charging');
assert.ok(res.paymentRequired, 'should surface paymentRequired instead of debiting');
assert.strictEqual(await balanceNow(), 100, 'balance must be unchanged');
});
});