From dcecbe7ed44e7fadf6f4f6fbc182de41f8b5b711 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 5 Oct 2026 12:59:39 -0400 Subject: [PATCH 01/10] WIP admit anonymous reads --- packages/realm-server/tests/helpers/index.ts | 7 + .../tests/realm-policy-anonymous-read-test.ts | 483 ++++++++++++++++++ .../runtime-common/card-operations/explain.ts | 23 +- .../runtime-common/card-operations/gate.ts | 13 + .../card-operations/telemetry.ts | 63 +++ .../runtime-common/card-operations/types.ts | 4 + packages/runtime-common/realm.ts | 313 +++++++++++- packages/runtime-common/router.ts | 14 + 8 files changed, 904 insertions(+), 16 deletions(-) create mode 100644 packages/realm-server/tests/realm-policy-anonymous-read-test.ts diff --git a/packages/realm-server/tests/helpers/index.ts b/packages/realm-server/tests/helpers/index.ts index 0850aba99d0..3eb34c59ff4 100644 --- a/packages/realm-server/tests/helpers/index.ts +++ b/packages/realm-server/tests/helpers/index.ts @@ -1733,6 +1733,7 @@ export async function runTestRealmServerWithRealms({ liveSearchCache, linkShapePolicy, mediaCacheAdapter, + clientAddress, }: { realmsRootPath: string; realms: { @@ -1762,6 +1763,11 @@ export async function runTestRealmServerWithRealms({ // The store every capture surface persists to: the worker's capture task, // each realm's `_screenshot/` route, and the server's `_screenshot-card`. mediaCacheAdapter?: MediaCacheAdapter; + // How the server works out a caller's address. Omit for none in front of + // it, which makes every supertest request the same loopback caller; a test + // that tells callers apart trusts one hop and names each in + // `X-Forwarded-For`. + clientAddress?: ConstructorParameters[0]['clientAddress']; }) { stripTlsEnvVars(); ensureDirSync(realmsRootPath); @@ -1867,6 +1873,7 @@ export async function runTestRealmServerWithRealms({ liveSearchCache, linkShapePolicy, mediaCacheAdapter, + ...(clientAddress ? { clientAddress } : {}), }); let testRealmHttpServer = await awaitListening( testRealmServer.listen(parseInt(serverURL.port)), diff --git a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts new file mode 100644 index 00000000000..934e67a28cc --- /dev/null +++ b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts @@ -0,0 +1,483 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; +import supertest from 'supertest'; +import type { Test, SuperTest, Response } from 'supertest'; +import { basename, join } from 'path'; +import { dirSync } from 'tmp'; +import { + parseAddressRanges, + rri, + SupportedMimeType, +} from '@cardstack/runtime-common'; +import type { + QueuePublisher, + QueueRunner, + Realm, +} from '@cardstack/runtime-common'; +import { + setAnonymousRequestSink, + type AnonymousRequestEvent, +} from '@cardstack/runtime-common/card-operations/telemetry'; +import type { PgAdapter } from '@cardstack/postgres'; +import { resetCatalogRealms } from '../handlers/handle-fetch-catalog-realms.ts'; +import type { RealmHttpServer as Server } from '../server.ts'; +import { + closeServer, + createJWT, + createVirtualNetwork, + matrixURL, + realmConfigCardJSON, + runTestRealmServerWithRealms, + setupDB, + setupTestDatabaseTemplate, +} from './helpers/index.ts'; +import { setupCatalogTestSubset } from './helpers/catalog-test-subset.ts'; + +// A newsroom realm nobody may read without signing in, governed by a policy +// card in an Org realm. Its policy opens published articles to callers who +// aren't signed in, and the stored bytes of the published ones too. A Library +// realm's policy opens nothing to such callers. +const NEWSROOM = 'http://127.0.0.1:4444/newsroom/'; +const LIBRARY = 'http://127.0.0.1:4444/library/'; +const ORG = 'http://127.0.0.1:4444/org/'; +const NEWSROOM_POLICY = `${ORG}policies/newsroom`; +const LIBRARY_POLICY = `${ORG}policies/library`; +const EDITOR = '@editor:localhost'; +const READER = '@reader:localhost'; +const ORG_ADMIN = '@org-admin:localhost'; + +// Callers' addresses come from the ranges reserved for documentation. +const VISITOR = '192.0.2.10'; +const OTHER_VISITOR = '192.0.2.11'; +const BLOCKED_RANGE = '198.51.100.0/24'; +const BLOCKED_VISITOR = '198.51.100.7'; +const OUR_SERVICES = '203.0.113.5'; + +const REALM_POLICY = { + module: rri('@cardstack/catalog/realm-policy/realm-policy'), + name: 'RealmPolicy', +}; + +const ARTICLE_MODULE = ` + import { contains, containsMany, field, CardDef } from "@cardstack/base/card-api"; + import StringField from "@cardstack/base/string"; + export class Article extends CardDef { + @field headline = contains(StringField); + @field status = contains(StringField); + @field authorIds = containsMany(StringField); + } +`; + +const ARTICLE = { module: `${NEWSROOM}article`, name: 'Article' }; +const BOOK = { module: `${LIBRARY}article`, name: 'Article' }; + +function article(headline: string, status: string, authorIds: string[] = []) { + return JSON.stringify({ + data: { + type: 'card', + attributes: { headline, status, authorIds }, + meta: { adoptsFrom: { module: '../article', name: 'Article' } }, + }, + }); +} + +function policyCard( + targetType: { module: string; name: string }, + grants: Record[], +) { + return JSON.stringify({ + data: { + type: 'card', + attributes: { rules: [{ targetType, grants }] }, + meta: { adoptsFrom: REALM_POLICY }, + }, + }); +} + +const PUBLISHED = `${NEWSROOM}articles/published`; +const DRAFT = `${NEWSROOM}articles/draft`; +const MISSING = `${NEWSROOM}articles/nowhere`; + +module(basename(import.meta.filename), function (hooks) { + let newsroom: Realm; + let library: Realm; + let org: Realm; + let request: SuperTest; + let server: Server; + let records: AnonymousRequestEvent[]; + + setupCatalogTestSubset(hooks); + + async function start({ + dbAdapter, + publisher, + runner, + }: { + dbAdapter: PgAdapter; + publisher: QueuePublisher; + runner: QueueRunner; + }) { + let result = await runTestRealmServerWithRealms({ + virtualNetwork: createVirtualNetwork(), + realmsRootPath: join(dirSync().name, 'realm_server_1'), + clientAddress: { + trustedProxyHops: 1, + infraAddresses: parseAddressRanges([OUR_SERVICES]).ranges, + }, + realms: [ + { + realmURL: new URL(NEWSROOM), + fileSystem: { + 'realm.json': realmConfigCardJSON({ + name: 'Newsroom', + policy: NEWSROOM_POLICY, + }), + 'article.gts': ARTICLE_MODULE, + 'articles/published.json': article('Polls open', 'published'), + 'articles/draft.json': article('Polls close', 'draft', [EDITOR]), + }, + permissions: { + [EDITOR]: ['read', 'write', 'realm-owner'], + [READER]: ['read'], + }, + }, + { + realmURL: new URL(LIBRARY), + fileSystem: { + 'realm.json': realmConfigCardJSON({ + name: 'Library', + policy: LIBRARY_POLICY, + }), + 'article.gts': ARTICLE_MODULE, + 'articles/published.json': article('Shelved', 'published'), + }, + permissions: { [EDITOR]: ['read', 'write', 'realm-owner'] }, + }, + { + realmURL: new URL(ORG), + fileSystem: { + 'realm.json': realmConfigCardJSON({ name: 'Org' }), + 'policies/newsroom.json': policyCard(ARTICLE, [ + { + operation: 'read', + anonymous: true, + where: '.status == "published"', + }, + // Reads the caller, so it never admits one who isn't signed in, + // even to an article they would otherwise be named on. + { + operation: 'read', + anonymous: true, + where: '.authorIds | any(. == actor())', + }, + { + operation: 'readSource', + anonymous: true, + where: '.status == "published"', + }, + ]), + // Grants only signed-in callers. + 'policies/library.json': policyCard(BOOK, [{ operation: 'read' }]), + }, + permissions: { [ORG_ADMIN]: ['read', 'write', 'realm-owner'] }, + }, + ], + dbAdapter, + publisher, + runner, + matrixURL, + }); + server = result.testRealmHttpServer; + request = supertest(server); + newsroom = result.realms.find((realm) => realm.url === NEWSROOM)!; + library = result.realms.find((realm) => realm.url === LIBRARY)!; + org = result.realms.find((realm) => realm.url === ORG)!; + } + + async function stop() { + for (let realm of [newsroom, library, org]) { + realm.__testOnlyClearCaches(); + realm.unsubscribe(); + } + await closeServer(server); + resetCatalogRealms(); + } + + let templateDatabase = setupTestDatabaseTemplate(hooks, { + key: import.meta.filename, + build: async (args) => { + await start(args); + return stop; + }, + }); + + setupDB(hooks, { + templateDatabase, + beforeEach: async (dbAdapter, publisher, runner) => { + await start({ dbAdapter, publisher, runner }); + records = []; + setAnonymousRequestSink((record) => records.push(record)); + }, + afterEach: async () => { + setAnonymousRequestSink(undefined); + await stop(); + }, + }); + + function readCard( + url: string, + from: string | undefined = VISITOR, + accept: string = SupportedMimeType.CardJson, + ) { + let req = request.get(new URL(url).pathname).set('Accept', accept); + return from === undefined ? req : req.set('X-Forwarded-For', from); + } + + function readSource(url: string, from: string = VISITOR) { + return readCard(`${url}.json`, from, SupportedMimeType.CardSource); + } + + async function setNewsroomConfig(fields: { + anonymousRateLimit?: unknown; + anonymousBlocklist?: unknown; + }) { + await newsroom.write( + 'realm.json', + realmConfigCardJSON({ + name: 'Newsroom', + policy: NEWSROOM_POLICY, + ...fields, + }), + ); + await newsroom.indexing(); + } + + function unauthenticated(response: Response, label: string, assert: Assert) { + assert.strictEqual(response.status, 401, `${label}: 401`); + assert.strictEqual( + response.body?.errors?.[0]?.code, + 'actor-required', + `${label}: told to authenticate`, + ); + } + + test('a published article is read by a caller who is not signed in, in a realm nobody may read without signing in', async function (assert) { + let response = await readCard(PUBLISHED); + assert.strictEqual(response.status, 200, response.text); + assert.strictEqual( + response.body.data.attributes.headline, + 'Polls open', + 'the article is served', + ); + let source = await readSource(PUBLISHED); + assert.strictEqual(source.status, 200, 'and its stored bytes'); + assert.true(source.text.includes('Polls open')); + }); + + test('a draft, a missing article, and an article in a realm that opens nothing get the same 401', async function (assert) { + let draft = await readCard(DRAFT); + let missing = await readCard(MISSING); + let library = await readCard(`${LIBRARY}articles/published`); + let draftSource = await readSource(DRAFT); + let missingSource = await readSource(MISSING); + for (let [label, response] of [ + ['a draft', draft], + ['a missing article', missing], + ['a realm that opens nothing', library], + ["a draft's bytes", draftSource], + ["a missing article's bytes", missingSource], + ] as const) { + unauthenticated(response, label, assert); + } + assert.strictEqual(draft.text, missing.text, 'byte-identical bodies'); + assert.strictEqual(draft.text, library.text); + assert.strictEqual(draftSource.text, missingSource.text); + }); + + test("a grant that reads the caller admits nobody who isn't signed in, and is not a fault", async function (assert) { + let before = newsroom.__testOnlyPolicyGateStats().predicateEvaluations; + let response = await readCard(DRAFT); + unauthenticated(response, 'a draft its author is named on', assert); + let after = newsroom.__testOnlyPolicyGateStats().predicateEvaluations; + assert.strictEqual( + after - before, + 1, + 'only the grant that reads the card was evaluated', + ); + }); + + test('a realm whose policy opens nothing answers as it always has, loading nothing', async function (assert) { + let before = library.__testOnlyPolicyGateStats(); + let response = await readCard(`${LIBRARY}articles/published`); + unauthenticated(response, 'library', assert); + let after = library.__testOnlyPolicyGateStats(); + assert.strictEqual(after.policyLoads, before.policyLoads, 'no policy load'); + assert.strictEqual( + after.predicateEvaluations, + before.predicateEvaluations, + 'no predicate', + ); + assert.deepEqual(records, [], 'and records nothing'); + }); + + test('signed-in callers read as they always have', async function (assert) { + let response = await request + .get(new URL(DRAFT).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR) + .set('Authorization', `Bearer ${createJWT(newsroom, READER, ['read'])}`); + assert.strictEqual(response.status, 200, 'a reader reads the draft'); + assert.deepEqual(records, [], 'and is no anonymous caller'); + }); + + test('an address that uses up the limit gets 429 until the window turns, and nobody else is affected', async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 2, windowSeconds: 600 }, + }); + assert.strictEqual((await readCard(PUBLISHED)).status, 200); + assert.strictEqual((await readSource(PUBLISHED)).status, 200); + let over = await readCard(PUBLISHED); + assert.strictEqual(over.status, 429, 'the third read is refused'); + assert.strictEqual(over.body.errors[0].code, 'rate-limited'); + let retryAfter = Number(over.headers['retry-after']); + assert.true( + retryAfter >= 1 && retryAfter <= 600, + `Retry-After says when: ${retryAfter}`, + ); + assert.strictEqual( + over.body.errors[0].meta.retryAfterSeconds, + retryAfter, + 'and the body says the same', + ); + + assert.strictEqual( + (await readCard(PUBLISHED, OTHER_VISITOR)).status, + 200, + 'another address has its own budget', + ); + let reader = await request + .get(new URL(PUBLISHED).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR) + .set('Authorization', `Bearer ${createJWT(newsroom, READER, ['read'])}`); + assert.strictEqual( + reader.status, + 200, + 'a signed-in caller is never limited', + ); + + let limited = records.filter((r) => r.outcome === 'rate-limited'); + assert.strictEqual(limited.length, 1, 'the refusal is recorded'); + assert.deepEqual( + { + clientIP: limited[0].clientIP, + limit: limited[0].limit, + operation: limited[0].operation, + }, + { + clientIP: VISITOR, + limit: { requests: 2, windowSeconds: 600, from: 'realm' }, + operation: 'read', + }, + ); + }); + + test('a request no grant admits costs the caller nothing', async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + }); + for (let i = 0; i < 3; i++) { + unauthenticated(await readCard(DRAFT), `refused probe ${i}`, assert); + } + assert.strictEqual( + (await readCard(PUBLISHED)).status, + 200, + 'the budget is untouched', + ); + assert.deepEqual( + records.map((r) => r.outcome), + ['refused', 'refused', 'refused', 'admitted'], + ); + }); + + test('a limit the realm sets nothing for is the platform default', async function (assert) { + await readCard(PUBLISHED); + let [admitted] = records; + assert.strictEqual(admitted?.outcome, 'admitted'); + assert.strictEqual(admitted?.limit?.from, 'platform'); + assert.strictEqual(admitted?.count, 1); + }); + + test('a blocked address or range gets the 401 an unadmitted caller gets, whatever the grants say', async function (assert) { + await setNewsroomConfig({ anonymousBlocklist: [BLOCKED_RANGE] }); + let blocked = await readCard(PUBLISHED, BLOCKED_VISITOR); + unauthenticated(blocked, 'a blocked range', assert); + assert.strictEqual( + blocked.text, + (await readCard(MISSING, VISITOR)).text, + 'indistinguishable from a refusal', + ); + assert.strictEqual( + (await readCard(PUBLISHED, VISITOR)).status, + 200, + 'an address outside the range reads', + ); + let block = records.find((r) => r.outcome === 'blocked'); + assert.strictEqual(block?.blockReason, 'blocklist'); + assert.strictEqual(block?.clientIP, BLOCKED_VISITOR); + }); + + test('a blocklist entry that is not an address closes the realm to callers who are not signed in', async function (assert) { + await setNewsroomConfig({ anonymousBlocklist: ['the spammer'] }); + unauthenticated(await readCard(PUBLISHED), 'any address', assert); + assert.strictEqual( + records.find((r) => r.outcome === 'blocked')?.blockReason, + 'blocklist-invalid', + ); + }); + + test('a caller whose address cannot be worked out is treated as blocked', async function (assert) { + unauthenticated(await readCard(PUBLISHED, undefined), 'no address', assert); + let block = records.find((r) => r.outcome === 'blocked'); + assert.strictEqual(block?.blockReason, 'ip-undetermined'); + assert.strictEqual(block?.clientIP, null); + }); + + test('the address is the one the load balancer appended, not one the caller wrote', async function (assert) { + await setNewsroomConfig({ + anonymousBlocklist: [BLOCKED_RANGE], + anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + }); + let response = await readCard(PUBLISHED, `${VISITOR}, ${BLOCKED_VISITOR}`); + unauthenticated(response, 'a forged earlier entry', assert); + assert.strictEqual((await readCard(PUBLISHED, VISITOR)).status, 200); + assert.strictEqual( + (await readCard(PUBLISHED, `${BLOCKED_VISITOR}, ${VISITOR}`)).status, + 429, + 'naming another address first does not buy another budget', + ); + }); + + test('our own services are measured and never limited or blocked', async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + anonymousBlocklist: [`${OUR_SERVICES}/32`], + }); + for (let i = 0; i < 3; i++) { + assert.strictEqual( + (await readCard(PUBLISHED, OUR_SERVICES)).status, + 200, + `read ${i}`, + ); + } + unauthenticated( + await readCard(DRAFT, OUR_SERVICES), + 'still only what a grant admits', + assert, + ); + assert.deepEqual( + records.map((r) => r.outcome), + ['infra', 'infra', 'infra', 'refused'], + ); + }); +}); diff --git a/packages/runtime-common/card-operations/explain.ts b/packages/runtime-common/card-operations/explain.ts index 9dcf574203c..4eb316b5f76 100644 --- a/packages/runtime-common/card-operations/explain.ts +++ b/packages/runtime-common/card-operations/explain.ts @@ -628,6 +628,19 @@ async function listedCards( }; } +// Whether the realm's policy opens `operation` to callers who aren't signed in, +// as the realm reads it to admit one. +async function opensToAnonymous( + core: OperationCore, + operation: string, +): Promise { + let policy = await core.policy?.compiledPolicy(); + return ( + !policy?.uncompilable && + (policy?.anonymous?.operations.includes(operation) ?? false) + ); +} + // What an explain's gate decisions are recorded as having arrived on. const EXPLAIN_ROUTE: PolicyRoute = Object.freeze({ transport: 'explain' as const, @@ -660,8 +673,14 @@ async function explain( ); // A realm that names a policy answers a caller who presented no credentials // with a 401 before the request is routed, for every request its ACL - // declines them, so nothing about the target is read. - if (actor.kind !== 'user' && coarseDeclined !== 'none') { + // declines them that its policy opens nothing to, so nothing about the + // target is read. One its policy opens the operation to is judged by the + // gate as such a caller, against the grants that opt in to them. + if ( + actor.kind !== 'user' && + coarseDeclined !== 'none' && + !(await opensToAnonymous(core, question.operation)) + ) { return refused(base, 'actor-required', { status: 401, code: 'actor-required', diff --git a/packages/runtime-common/card-operations/gate.ts b/packages/runtime-common/card-operations/gate.ts index 28adf2fcace..deaac8124c0 100644 --- a/packages/runtime-common/card-operations/gate.ts +++ b/packages/runtime-common/card-operations/gate.ts @@ -621,6 +621,12 @@ async function decide( core.policy, notes ? notes.alongside(trace) : trace, ); + // A caller who isn't signed in is admitted only by a grant that opts in to + // them. Every other grant was written for signed-in callers, and admitting + // anyone at all through one would widen it past what its author wrote. + if (scope.caller.kind === 'anonymous') { + matched = matched.filter(({ grant }) => grant.anonymous); + } if (matched.length === 0) { return refuse('no-grant'); } @@ -1369,6 +1375,13 @@ async function firstHolding( if (!where) { return candidate; } + // A predicate that reads the caller never holds for a caller who has no + // actor, so it is not evaluated for one: asked, it would throw for want of + // an actor, and turn a grant that doesn't apply into a fault. + if (actor === undefined && where.readsActor) { + scope.trace?.evaluated(candidate.grant, 'did-not-hold'); + continue; + } stats.predicateEvaluations++; let started = performance.now(); // A predicate judged against the snapshot does not hold for a target with diff --git a/packages/runtime-common/card-operations/telemetry.ts b/packages/runtime-common/card-operations/telemetry.ts index f422daf54c4..db173613ca1 100644 --- a/packages/runtime-common/card-operations/telemetry.ts +++ b/packages/runtime-common/card-operations/telemetry.ts @@ -501,6 +501,69 @@ export function emitPolicyCompile(event: PolicyCompileEvent): void { ); } +// ============================================================================ +// Per-request telemetry for callers a realm's policy admits without a session. +// +// One line for each such request the realm judges against its anonymous +// controls: one its policy opens the operation to, from a caller who isn't +// signed in. A realm whose policy opens nothing to such callers writes none, +// and answers them as a realm with no anonymous grants always has. The client +// address is recorded as the realm server worked it out, so an operator can +// find the addresses to block and follow one visitor's requests through +// `realm:requests` by the correlation id. +// ============================================================================ + +export interface AnonymousRequestEvent { + kind: 'anonymous-request'; + realmURL: string; + operation: string; + route: string; + // - `admitted`: a grant admitted it and it was counted against the limit. + // - `infra`: a grant admitted it, from one of the platform's own egress + // addresses, which is counted here but never limited or blocked. + // - `refused`: no grant admitted it, and it was answered as an + // unauthenticated request is. + // - `blocked`: the realm refused the caller's address before any grant was + // asked (see `blockReason`). + // - `rate-limited`: a grant admitted it, but the address had used up its + // limit, so nothing was done. + outcome: 'admitted' | 'infra' | 'refused' | 'blocked' | 'rate-limited'; + blockReason?: 'blocklist' | 'ip-undetermined' | 'blocklist-invalid'; + clientIP: string | null; + // What the caller is counted under: the address, or the /64 an IPv6 + // address is in. + rateLimitKey?: string; + limit?: { + requests: number; + windowSeconds: number; + from: 'realm' | 'platform'; + }; + // The window's count once this request was counted. + count?: number; + retryAfterSeconds?: number; + correlationId: string | null; +} + +let anonymousRequestSink: ((event: AnonymousRequestEvent) => void) | undefined; + +export function setAnonymousRequestSink( + sink: ((event: AnonymousRequestEvent) => void) | undefined, +): void { + anonymousRequestSink = sink; +} + +let anonymousRequestLog: ReturnType | undefined; + +export function emitAnonymousRequest(event: AnonymousRequestEvent): void { + if (anonymousRequestSink) { + anonymousRequestSink(event); + return; + } + (anonymousRequestLog ??= logger(OPERATIONS_CHANNEL)).info( + JSON.stringify({ channel: OPERATIONS_CHANNEL, ...event }), + ); +} + // Build and write one record, logging and swallowing anything that throws on // the way. A record describes a decision and never changes one: a sink or a // log transport that throws must not turn an admitted invocation into a 500, diff --git a/packages/runtime-common/card-operations/types.ts b/packages/runtime-common/card-operations/types.ts index 83124269588..23d0cca1ace 100644 --- a/packages/runtime-common/card-operations/types.ts +++ b/packages/runtime-common/card-operations/types.ts @@ -1429,6 +1429,10 @@ export type OperationErrorCode = // of them could land. Serial order is how a batch says that two entries // touch the same target. | 'conflicting-targets' + // A caller who isn't signed in, admitted by a grant, has used up the + // invocations the realm allows their address in the current window. Nothing + // was done. Carries a 429 with `Retry-After`, and `meta.retryAfterSeconds`. + | 'rate-limited' // The operation could not be carried out for a reason that is not the // caller's — an unreadable definition, an errored index row, a failure // inside the executor. diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 862816d105c..5b6f7337a3a 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -1,10 +1,21 @@ import { Deferred } from './deferred.ts'; import { + CLIENT_CLASS_HEADER, + CLIENT_IP_HEADER, + INFRA_CLIENT_CLASS, + formatIP, isUnsetLimit, + parseIP, + rangesContain, + rateLimitKey, resolveAnonymousAccess, type AnonymousAccessSettings, type AnonymousRateLimit, } from './anonymous-access.ts'; +import { + DBAnonymousRateLimiter, + type AnonymousRateLimiter, +} from './anonymous-rate-limiter.ts'; import { resolveRangeHeader } from './http-range.ts'; import { awaitRealmIndexSettled, @@ -281,7 +292,10 @@ import { type CapabilityCaller, } from './card-operations/capabilities.ts'; import { + emitAnonymousRequest, emitCapabilityCheck, + recordSafely, + type AnonymousRequestEvent, type CapabilityCheckEvent, type PolicyRoute, } from './card-operations/telemetry.ts'; @@ -2286,6 +2300,12 @@ export type RequestContext = { // all of which take the conservative bounded hold. Identity, not // authority, like `authenticatedUser`. anonymous?: true; + // Set when the realm's policy admitted a request that authenticated nobody + // (see `#admitsAnonymous`): the operation it was admitted to, and the + // caller's address as the realm server worked it out. Such a caller is + // counted against the realm's anonymous rate limit once what it asked for + // succeeds, and is told to authenticate wherever no grant admits it. + anonymousCaller?: AnonymousCaller; // The user this request's session vouches for as themselves, not just as // an identity: a token `checkPermission` verified end to end, whose session // is not revoked, which is not delegated to one realm, and which stands for @@ -2348,9 +2368,60 @@ interface RequestDispatch { // passes the seal as an operational endpoint, but meets the realm ACL like // any other read. answersWithoutCredentials?: boolean; + // What the dispatch would run for a caller who isn't signed in, where a + // grant could admit one: the operations, any one of which the realm's + // policy has to open to such callers for it to admit them here (empty + // means any at all), and whether a successful answer counts against the + // realm's anonymous rate limit. A dispatch without it never admits a + // caller who isn't signed in. + anonymous?: AnonymousDispatch; handle: () => Promise; } +interface AnonymousDispatch { + operations: readonly string[]; + charged: boolean; +} + +// A request the realm's policy admitted though it authenticated nobody. +interface AnonymousCaller { + operation: string; + // The caller's address in canonical form, or null where the realm server + // could not work one out (only a caller of ours can be admitted then). + clientIP: string | null; + // What the caller is counted under (see `rateLimitKey`). + rateLimitKey: string | undefined; + // The caller is one of the platform's own services: counted in telemetry, + // never limited or blocked. + infra: boolean; + charged: boolean; +} + +// The card+json read: what an anonymous grant on `read` opens. +const ANONYMOUS_CARD_READ: AnonymousDispatch = { + operations: ['read'], + charged: true, +}; +// A data file's or a card's stored bytes: what a `readSource` grant opens. +const ANONYMOUS_BYTES_READ: AnonymousDispatch = { + operations: ['readSource'], + charged: true, +}; +// A stylesheet goes with the markup that references it, so it is served to an +// anonymous caller a policy would serve any card's markup to, and counts for +// nothing on its own: the read or search that drew the markup was counted. +const ANONYMOUS_STYLESHEET: AnonymousDispatch = { + operations: ['read', 'query'], + charged: false, +}; +// A capability check asks what the caller could invoke and invokes nothing, so +// it answers an anonymous caller in any realm whose policy opens anything to +// one, and is never counted. +const ANONYMOUS_CAPABILITY_CHECK: AnonymousDispatch = { + operations: [], + charged: false, +}; + // A route as `Realm.routeDescriptions` lists it: a router route, the hashed // stylesheet serve under its path prefix, or the fallback file and module // serve for one method, which answers whatever path and media type no router @@ -2576,6 +2647,7 @@ export class Realm { // `#cachedRealmConfig`, like the policy pointer. #cachedAnonymousAccess: AnonymousAccessSettings | undefined; #platformAnonymousRateLimit: AnonymousRateLimit | undefined; + #anonymousRateLimiter: AnonymousRateLimiter; // Bumped by every invalidation, and captured by a parse before it starts. // A parse that reads the realm's state and then has an index swap land // underneath it is holding values the realm has already moved past, so it @@ -2724,6 +2796,7 @@ export class Realm { mediaCacheAdapter, cardDocumentCache, realmFor, + anonymousRateLimiter, }: { url: string; adapter: RealmAdapter; @@ -2759,6 +2832,10 @@ export class Realm { // that realm only for a caller who may read it. Without it, an explain // reaches only this realm's own targets. realmFor?: (url: URL) => Promise; + // Where the realm counts the invocations of callers its policy admits + // without a session. Unset, the realm's database, which every process + // serving the realm shares. + anonymousRateLimiter?: AnonymousRateLimiter; }, opts?: Options, ) { @@ -2798,6 +2875,8 @@ export class Realm { this.#readIndexDrainBudgetMs = opts?.readIndexDrainBudgetMs ?? READ_INDEX_DRAIN_BUDGET_MS; this.#platformAnonymousRateLimit = opts?.anonymousRateLimit; + this.#anonymousRateLimiter = + anonymousRateLimiter ?? new DBAnonymousRateLimiter(dbAdapter); let owner: string | undefined; let _fetch = fetcher( virtualNetwork.fetch, @@ -3006,7 +3085,7 @@ export class Realm { '/_capabilities', SupportedMimeType.JSON, this.handleCapabilities.bind(this), - APPLIES_ARCHIVED_SEAL, + { ...APPLIES_ARCHIVED_SEAL, anonymous: ANONYMOUS_CAPABILITY_CHECK }, ) .post( '/_cancel-indexing-job', @@ -3038,12 +3117,10 @@ export class Realm { this.createCard.bind(this), APPLIES_ARCHIVED_SEAL, ) - .get( - '/.*', - SupportedMimeType.CardJson, - this.getCard.bind(this), - APPLIES_ARCHIVED_SEAL, - ) + .get('/.*', SupportedMimeType.CardJson, this.getCard.bind(this), { + ...APPLIES_ARCHIVED_SEAL, + anonymous: ANONYMOUS_CARD_READ, + }) // The card+html, file-meta+html and markdown reads serve what the index // holds for a path without running an operation, so they are answered // on the realm ACL alone, as the file-meta read below is: a caller a @@ -7586,7 +7663,11 @@ export class Realm { if ( !dispatch.consumesCoarseOutcome || requiredPermission === 'realm-owner' || - !(await this.#admitsDespiteCoarseRefusal(request, requestContext)) + !(await this.#admitsDespiteCoarseRefusal( + request, + requestContext, + dispatch.anonymous, + )) ) { let answer = await this.#refusalUnderPolicy( request, @@ -7634,7 +7715,11 @@ export class Realm { message: 'search index is not available', }); } - return await dispatch.handle(); + return await this.#chargeAnonymous( + request, + requestContext, + await dispatch.handle(), + ); } catch (e) { if (e instanceof AuthenticationError) { return createResponse({ @@ -7748,6 +7833,7 @@ export class Realm { ) { return { ...APPLIES_ARCHIVED_SEAL, + anonymous: ANONYMOUS_STYLESHEET, handle: () => this.serveHashedScopedCSS(request, requestContext), }; } @@ -7792,6 +7878,7 @@ export class Realm { appliesArchivedSeal: matched.appliesArchivedSeal, operationalEndpoint: matched.operationalEndpoint, answersWithoutCredentials: matched.operationalEndpoint, + ...(matched.anonymous ? { anonymous: matched.anonymous } : {}), handle: serve, }; } @@ -7858,6 +7945,7 @@ export class Realm { ): RequestDispatch { return { consumesCoarseOutcome: true, + anonymous: ANONYMOUS_BYTES_READ, handle: async () => { if (request.method === 'HEAD' && requestContext.coarseAllowed) { let probe = await this.#readProbe(request, requestContext); @@ -7869,7 +7957,11 @@ export class Realm { requestContext.coarseAllowed = false; requestContext.coarseRefusal = refusal; if ( - !(await this.#admitsDespiteCoarseRefusal(request, requestContext)) + !(await this.#admitsDespiteCoarseRefusal( + request, + requestContext, + ANONYMOUS_BYTES_READ, + )) ) { return this.realmIdentityResponse(requestContext); } @@ -7955,13 +8047,190 @@ export class Realm { async #admitsDespiteCoarseRefusal( request: Request, requestContext: RequestContext, + anonymous?: AnonymousDispatch, ): Promise { if (this.#testOnlyCoarseAdmission) { return await this.#testOnlyCoarseAdmission(request, requestContext); } - return await this.#policyJudges( - requestContext.coarseRefusal, + if ( + await this.#policyJudges(requestContext.coarseRefusal, requestContext) + ) { + return true; + } + return anonymous + ? await this.#admitsAnonymous(request, requestContext, anonymous) + : false; + } + + // Whether the realm's policy admits a request that authenticated nobody to + // what its route would run. Only where the policy opens one of the route's + // operations to callers who aren't signed in, and only from an address the + // realm doesn't refuse. Everything else gets the answer an unauthenticated + // request has always had, before anything about its path is resolved: the + // question reads the compiled policy's summary and the realm's own + // `realm.json`, and loads no target and evaluates no predicate. A realm + // whose policy opens nothing to such callers records nothing. + // + // The address is read only from the headers the realm server sets (see + // `CLIENT_IP_HEADER`). A request without one, which is a request whose + // address the server could not work out, is refused as a blocked one, + // unless it is from the platform's own infrastructure, which is never + // blocked. + async #admitsAnonymous( + request: Request, + requestContext: RequestContext, + anonymous: AnonymousDispatch, + ): Promise { + if ( + !(requestContext.coarseRefusal instanceof CoarseAuthenticationRequired) || + requestContext.authenticatedUser || + request.headers.has('Authorization') || + (await this.getRealmPolicy()) === undefined + ) { + return false; + } + let opened = await this.getAnonymousAdmission(); + let operation = + anonymous.operations.length === 0 + ? [...opened][0] + : anonymous.operations.find((name) => opened.has(name)); + if (operation === undefined) { + return false; + } + let ipText = request.headers.get(CLIENT_IP_HEADER); + let address = ipText ? parseIP(ipText) : undefined; + let caller: AnonymousCaller = { + operation, + clientIP: address ? formatIP(address) : null, + rateLimitKey: address ? rateLimitKey(address) : undefined, + infra: request.headers.get(CLIENT_CLASS_HEADER) === INFRA_CLIENT_CLASS, + charged: anonymous.charged, + }; + if (!caller.infra) { + let access = await this.getAnonymousAccess(); + let blockReason: AnonymousRequestEvent['blockReason'] = + access.invalidBlocklistEntries.length > 0 + ? 'blocklist-invalid' + : !address + ? 'ip-undetermined' + : rangesContain(access.blocklist, address) + ? 'blocklist' + : undefined; + if (blockReason) { + this.#recordAnonymous(request, caller, { + outcome: 'blocked', + blockReason, + }); + return false; + } + } + requestContext.anonymousCaller = caller; + return true; + } + + // Counts a request the realm's policy admitted though it authenticated + // nobody, once what it asked for has succeeded, and answers in its place + // when the caller's address has used up the realm's limit. Only once it has + // succeeded, so a request no grant admits costs the caller nothing. A read + // is answered before it is counted, but has done nothing a refusal would + // have to undo. A caller of ours is recorded and never counted. + async #chargeAnonymous( + request: Request, + requestContext: RequestContext, + response: ResponseWithNodeStream, + ): Promise { + let caller = requestContext.anonymousCaller; + if (!caller) { + return response; + } + if (response.status === 401) { + this.#recordAnonymous(request, caller, { outcome: 'refused' }); + return response; + } + if (!caller.charged || response.status >= 400) { + return response; + } + if (caller.infra) { + this.#recordAnonymous(request, caller, { outcome: 'infra' }); + return response; + } + let { limit, limitFrom } = await this.getAnonymousAccess(); + let outcome = await this.#anonymousRateLimiter.charge({ + realmURL: this.url, + clientIP: caller.rateLimitKey!, + limit, + cost: 1, + }); + let recordedLimit = { ...limit, from: limitFrom }; + if (outcome.admitted) { + this.#recordAnonymous(request, caller, { + outcome: 'admitted', + limit: recordedLimit, + count: outcome.count, + }); + return response; + } + this.#recordAnonymous(request, caller, { + outcome: 'rate-limited', + limit: recordedLimit, + retryAfterSeconds: outcome.retryAfterSeconds, + }); + response.nodeStream?.destroy(); + await response.body?.cancel().catch(() => {}); + return this.#rateLimitedResponse(requestContext, outcome.retryAfterSeconds); + } + + // The answer to a request whose caller's address has used up the realm's + // anonymous rate limit: nothing was done, and when to try again. + #rateLimitedResponse( + requestContext: RequestContext, + retryAfterSeconds: number, + ): ResponseWithNodeStream { + return createResponse({ + body: JSON.stringify( + errorsDocument({ + status: 429, + code: 'rate-limited', + title: 'Too many requests', + detail: `This address has made as many requests to this realm without signing in as it allows for now. Try again in ${retryAfterSeconds} second${retryAfterSeconds === 1 ? '' : 's'}.`, + meta: { retryAfterSeconds }, + }), + null, + 2, + ), + init: { + status: 429, + headers: { + 'content-type': SupportedMimeType.JSONAPI, + 'Retry-After': String(retryAfterSeconds), + 'X-Boxel-Realm-Url': requestContext.realm.url, + }, + }, requestContext, + }); + } + + #recordAnonymous( + request: Request, + caller: AnonymousCaller, + detail: Pick< + AnonymousRequestEvent, + 'outcome' | 'blockReason' | 'limit' | 'count' | 'retryAfterSeconds' + >, + ): void { + recordSafely('anonymous-request', () => + emitAnonymousRequest({ + kind: 'anonymous-request', + realmURL: this.url, + operation: caller.operation, + route: `${request.method} ${new URL(request.url).pathname}`, + clientIP: caller.clientIP, + ...(caller.rateLimitKey ? { rateLimitKey: caller.rateLimitKey } : {}), + correlationId: request.headers.get( + X_BOXEL_LOGGING_CORRELATION_ID_HEADER, + ), + ...detail, + }), ); } @@ -8019,6 +8288,16 @@ export class Realm { if (!unauthenticated) { return notFound(request, requestContext); } + return this.#authenticationRequired(requestContext); + } + + // What a realm with a policy tells a request that authenticated nobody and + // that nothing admitted: to authenticate. A caller the policy admitted + // without a session is told the same wherever no grant admits what it asked + // for, including a target that isn't there, so the answer is the same for a + // path that names a card and one that names nothing, and the same as for a + // caller no grant admitted at all. + #authenticationRequired(requestContext: RequestContext): Response { return createResponse({ body: JSON.stringify( errorsDocument({ @@ -8070,8 +8349,11 @@ export class Realm { // this once, ahead of the checks that could each find nothing, rather than // at whichever check did. Built at each, the stack would name the check. #bytesNotThere(request: Request, requestContext: RequestContext): Response { - return request.method === 'HEAD' - ? this.realmIdentityResponse(requestContext) + if (request.method === 'HEAD') { + return this.realmIdentityResponse(requestContext); + } + return requestContext.anonymousCaller + ? this.#authenticationRequired(requestContext) : notFound(request, requestContext); } @@ -13366,6 +13648,9 @@ export class Realm { let cacheOutcomeHeader: Record = cacheOutcome ? { [CARD_DOCUMENT_CACHE_HEADER]: cacheOutcome } : {}; + if (assembly.kind === 'not-found' && requestContext.anonymousCaller) { + return this.#authenticationRequired(requestContext); + } if (assembly.kind !== 'document') { let response = await this.#respondToCardJsonOutcome( assembly, diff --git a/packages/runtime-common/router.ts b/packages/runtime-common/router.ts index 0b419a6755e..f876c44b7e6 100644 --- a/packages/runtime-common/router.ts +++ b/packages/runtime-common/router.ts @@ -308,6 +308,18 @@ export interface RouteOptions { // anyway. It records that the route was weighed and left to the ACL, so // that a route that says nothing about the policy is one nobody decided. aclOnly?: true; + // What the route would run for a caller who isn't signed in, where a grant + // could admit one: the operations any one of which the realm's policy has to + // open to such callers (empty for any at all), and whether a successful + // answer counts against the realm's anonymous rate limit. Only a route that + // consumes the ACL's outcome can take this. A route without it answers such + // a caller as the ACL does. + anonymous?: AnonymousRouteOptions; +} + +export interface AnonymousRouteOptions { + operations: readonly string[]; + charged: boolean; } export interface Route { @@ -318,6 +330,7 @@ export interface Route { grantableBytes: boolean; operationalEndpoint: boolean; aclOnly: boolean; + anonymous?: AnonymousRouteOptions; } export interface RouteDescription { @@ -423,6 +436,7 @@ export class Router { grantableBytes: opts.grantableBytes === true, operationalEndpoint: opts.operationalEndpoint === true, aclOnly: opts.aclOnly === true, + ...(opts.anonymous ? { anonymous: opts.anonymous } : {}), }); } From a0f83acd3694209f97231512c17c5d2e3f2c23bb Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 04:53:15 -0400 Subject: [PATCH 02/10] WIP anonymous reads: skip compiling for a policy that opens nothing; tests --- packages/catalog/test-subset.json | 1 + .../tests/realm-policy-anonymous-read-test.ts | 134 +++++++++++++++--- .../runtime-common/card-operations/policy.ts | 41 ++++++ 3 files changed, 159 insertions(+), 17 deletions(-) diff --git a/packages/catalog/test-subset.json b/packages/catalog/test-subset.json index 36245c494c5..c6b465c69a6 100644 --- a/packages/catalog/test-subset.json +++ b/packages/catalog/test-subset.json @@ -21,6 +21,7 @@ "realmServer": [ "catalog-test-subset-test", "realm-policy-activation-test", + "realm-policy-anonymous-read-test", "realm-policy-archived-seal-test", "realm-policy-batch-test", "realm-policy-capabilities-test", diff --git a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts index 934e67a28cc..e30ec919aab 100644 --- a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts +++ b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts @@ -136,9 +136,12 @@ module(basename(import.meta.filename), function (hooks) { 'articles/published.json': article('Polls open', 'published'), 'articles/draft.json': article('Polls close', 'draft', [EDITOR]), }, + // The org admin reads the realms the org's policies govern, which + // is what lets them ask a policy what it decides there. permissions: { [EDITOR]: ['read', 'write', 'realm-owner'], [READER]: ['read'], + [ORG_ADMIN]: ['read'], }, }, { @@ -151,7 +154,10 @@ module(basename(import.meta.filename), function (hooks) { 'article.gts': ARTICLE_MODULE, 'articles/published.json': article('Shelved', 'published'), }, - permissions: { [EDITOR]: ['read', 'write', 'realm-owner'] }, + permissions: { + [EDITOR]: ['read', 'write', 'realm-owner'], + [ORG_ADMIN]: ['read'], + }, }, { realmURL: new URL(ORG), @@ -224,13 +230,22 @@ module(basename(import.meta.filename), function (hooks) { }, }); + // `from` is the caller's address, or null for a request whose address the + // server can't work out. function readCard( url: string, - from: string | undefined = VISITOR, + from: string | null = VISITOR, accept: string = SupportedMimeType.CardJson, ) { let req = request.get(new URL(url).pathname).set('Accept', accept); - return from === undefined ? req : req.set('X-Forwarded-For', from); + return from === null ? req : req.set('X-Forwarded-For', from); + } + + // The records of this test's own requests for articles. The realm's own + // renders also reach it without credentials while it indexes, and are + // recorded as any such request is. + function articleRecords() { + return records.filter((record) => record.route.includes('/articles/')); } function readSource(url: string, from: string = VISITOR) { @@ -317,7 +332,7 @@ module(basename(import.meta.filename), function (hooks) { before.predicateEvaluations, 'no predicate', ); - assert.deepEqual(records, [], 'and records nothing'); + assert.deepEqual(articleRecords(), [], 'and records nothing'); }); test('signed-in callers read as they always have', async function (assert) { @@ -327,7 +342,7 @@ module(basename(import.meta.filename), function (hooks) { .set('X-Forwarded-For', VISITOR) .set('Authorization', `Bearer ${createJWT(newsroom, READER, ['read'])}`); assert.strictEqual(response.status, 200, 'a reader reads the draft'); - assert.deepEqual(records, [], 'and is no anonymous caller'); + assert.deepEqual(articleRecords(), [], 'and is no anonymous caller'); }); test('an address that uses up the limit gets 429 until the window turns, and nobody else is affected', async function (assert) { @@ -340,10 +355,8 @@ module(basename(import.meta.filename), function (hooks) { assert.strictEqual(over.status, 429, 'the third read is refused'); assert.strictEqual(over.body.errors[0].code, 'rate-limited'); let retryAfter = Number(over.headers['retry-after']); - assert.true( - retryAfter >= 1 && retryAfter <= 600, - `Retry-After says when: ${retryAfter}`, - ); + assert.true(retryAfter >= 1, `Retry-After says when: ${retryAfter}`); + assert.true(retryAfter <= 600, 'no later than the window turns'); assert.strictEqual( over.body.errors[0].meta.retryAfterSeconds, retryAfter, @@ -366,7 +379,7 @@ module(basename(import.meta.filename), function (hooks) { 'a signed-in caller is never limited', ); - let limited = records.filter((r) => r.outcome === 'rate-limited'); + let limited = articleRecords().filter((r) => r.outcome === 'rate-limited'); assert.strictEqual(limited.length, 1, 'the refusal is recorded'); assert.deepEqual( { @@ -395,14 +408,14 @@ module(basename(import.meta.filename), function (hooks) { 'the budget is untouched', ); assert.deepEqual( - records.map((r) => r.outcome), + articleRecords().map((r) => r.outcome), ['refused', 'refused', 'refused', 'admitted'], ); }); test('a limit the realm sets nothing for is the platform default', async function (assert) { await readCard(PUBLISHED); - let [admitted] = records; + let [admitted] = articleRecords(); assert.strictEqual(admitted?.outcome, 'admitted'); assert.strictEqual(admitted?.limit?.from, 'platform'); assert.strictEqual(admitted?.count, 1); @@ -422,7 +435,7 @@ module(basename(import.meta.filename), function (hooks) { 200, 'an address outside the range reads', ); - let block = records.find((r) => r.outcome === 'blocked'); + let block = articleRecords().find((r) => r.outcome === 'blocked'); assert.strictEqual(block?.blockReason, 'blocklist'); assert.strictEqual(block?.clientIP, BLOCKED_VISITOR); }); @@ -431,14 +444,14 @@ module(basename(import.meta.filename), function (hooks) { await setNewsroomConfig({ anonymousBlocklist: ['the spammer'] }); unauthenticated(await readCard(PUBLISHED), 'any address', assert); assert.strictEqual( - records.find((r) => r.outcome === 'blocked')?.blockReason, + articleRecords().find((r) => r.outcome === 'blocked')?.blockReason, 'blocklist-invalid', ); }); test('a caller whose address cannot be worked out is treated as blocked', async function (assert) { - unauthenticated(await readCard(PUBLISHED, undefined), 'no address', assert); - let block = records.find((r) => r.outcome === 'blocked'); + unauthenticated(await readCard(PUBLISHED, null), 'no address', assert); + let block = articleRecords().find((r) => r.outcome === 'blocked'); assert.strictEqual(block?.blockReason, 'ip-undetermined'); assert.strictEqual(block?.clientIP, null); }); @@ -458,6 +471,93 @@ module(basename(import.meta.filename), function (hooks) { ); }); + // The org admin asks the policy card what it decides for a caller who isn't + // signed in. + async function explainAnonymous(policy: string, target: string) { + let response = await request + .post(`${new URL(ORG).pathname}_operations`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', SupportedMimeType.BoxelOperations) + .set('Content-Type', SupportedMimeType.BoxelOperations) + .set( + 'Authorization', + `Bearer ${createJWT(org, ORG_ADMIN, ['read', 'write', 'realm-owner'])}`, + ) + .send( + JSON.stringify({ + 'boxel:operations': [ + { + op: 'invoke', + 'boxel:name': 'explain', + href: policy, + data: { actor: '', target, operation: 'read' }, + }, + ], + }), + ); + assertOk(response); + return response.body['atomic:results'][0]; + } + + function assertOk(response: Response) { + if (response.status !== 200) { + throw new Error(`explain answered ${response.status}: ${response.text}`); + } + } + + test('explain answers for a caller who is not signed in where the policy opens the operation to one', async function (assert) { + let published = await explainAnonymous(NEWSROOM_POLICY, PUBLISHED); + assert.strictEqual(published.actor, null); + assert.strictEqual(published.decision, 'allowed', 'a published article'); + assert.strictEqual(published.reason, 'granted'); + let draft = await explainAnonymous(NEWSROOM_POLICY, DRAFT); + assert.strictEqual(draft.decision, 'denied', 'a draft'); + assert.notStrictEqual( + draft.reason, + 'actor-required', + 'judged against the grants rather than refused for want of an actor', + ); + let library = await explainAnonymous( + LIBRARY_POLICY, + `${LIBRARY}articles/published`, + ); + assert.strictEqual( + library.reason, + 'actor-required', + 'a policy that opens nothing tells such a caller to authenticate', + ); + }); + + test('a capability check answers a caller who is not signed in, and counts for nothing', async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + }); + let ask = (realm: string, target: string) => + request + .post(`${new URL(realm).pathname}_capabilities`) + .set('Accept', SupportedMimeType.JSON) + .set('Content-Type', SupportedMimeType.JSON) + .set('X-Forwarded-For', VISITOR) + .send({ checks: [{ target, operation: 'read' }] }); + for (let i = 0; i < 3; i++) { + let response = await ask(NEWSROOM, PUBLISHED); + assert.strictEqual(response.status, 200, `check ${i}`); + assert.true(response.body.checks[0].allowed, 'a published article'); + } + let draft = await ask(NEWSROOM, DRAFT); + assert.false(draft.body.checks[0].allowed, 'a draft'); + unauthenticated( + await ask(LIBRARY, `${LIBRARY}articles/published`), + 'a realm whose policy opens nothing', + assert, + ); + assert.strictEqual( + (await readCard(PUBLISHED)).status, + 200, + 'none of the checks used up the budget', + ); + }); + test('our own services are measured and never limited or blocked', async function (assert) { await setNewsroomConfig({ anonymousRateLimit: { requests: 1, windowSeconds: 600 }, @@ -476,7 +576,7 @@ module(basename(import.meta.filename), function (hooks) { assert, ); assert.deepEqual( - records.map((r) => r.outcome), + articleRecords().map((r) => r.outcome), ['infra', 'infra', 'infra', 'refused'], ); }); diff --git a/packages/runtime-common/card-operations/policy.ts b/packages/runtime-common/card-operations/policy.ts index 328970cf320..357026f1dcb 100644 --- a/packages/runtime-common/card-operations/policy.ts +++ b/packages/runtime-common/card-operations/policy.ts @@ -258,6 +258,25 @@ export interface RealmPolicyCacheEnvironment extends PolicyCompileEnvironment { // holds now. const NO_OPERATIONS: ReadonlySet = new Set(); +// Whether any grant in a policy card's attributes, as stored, opts in to +// callers who aren't signed in. A card where none does opens nothing to them +// however it compiles, so this answers for it without compiling. One where +// some grant does is compiled to find out what that grant opens. +function opensAnythingToAnonymous(attributes: unknown): boolean { + let rules = (attributes as { rules?: unknown } | undefined)?.rules; + return ( + Array.isArray(rules) && + rules.some( + (rule) => + Array.isArray(rule?.grants) && + rule.grants.some( + (grant: unknown) => + (grant as { anonymous?: unknown } | null)?.anonymous === true, + ), + ) + ); +} + export class RealmPolicyCache { #env: RealmPolicyCacheEnvironment; #current: Compilation | undefined; @@ -313,9 +332,31 @@ export class RealmPolicyCache { // be compiled opens nothing, which leaves that caller with the answer every // other caller who isn't signed in gets, rather than a failure that would // tell them the realm names a policy. + // + // A policy no grant of which opts in to such callers is answered from the + // card as the index holds it, without compiling: the realm asks this of + // every request that authenticated nobody, including the ones its own + // renders make, and a realm whose policy opens nothing to such callers pays + // for no compile on their account. async anonymousAdmission(): Promise> { let compiled: CompiledRealmPolicy | undefined; try { + let card = await this.#env.policyCard(); + if (!card) { + return NO_OPERATIONS; + } + let current = this.#current; + let warm = + current && + current.compiled.card === card && + !this.#stale && + now() - this.#validatedAt < MAX_UNVALIDATED_MS; + if (!warm) { + let row = await this.#env.readCard(new URL(card)); + if (!opensAnythingToAnonymous(row?.instance?.attributes)) { + return NO_OPERATIONS; + } + } compiled = await this.get(); } catch (e: unknown) { log.warn( From 7367fd602b83a2a0f9ce75e71b43b206e52607b2 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 11:16:50 -0400 Subject: [PATCH 03/10] Turn an exhausted address away before running, count only served reads, admit anonymous card HEADs Checks the address's budget before a request runs, so a caller over the limit costs the realm one query. Charges only once a read has served a document, headers or bytes, so redirects and HEAD refusals are free. A limiter failure answers 503 rate-limit-unavailable and releases the response stream. The anonymous policy peek is memoized like the compiled policy. Card+json HEAD admits anonymous callers as GET does. Co-Authored-By: Claude Opus 5.5 --- .../tests/realm-policy-anonymous-read-test.ts | 237 +++++++++++++++++- .../runtime-common/anonymous-rate-limiter.ts | 45 +++- .../runtime-common/card-operations/policy.ts | 45 +++- .../card-operations/telemetry.ts | 14 +- .../runtime-common/card-operations/types.ts | 11 +- packages/runtime-common/realm.ts | 214 ++++++++++++++-- 6 files changed, 525 insertions(+), 41 deletions(-) diff --git a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts index e30ec919aab..047ce6e01de 100644 --- a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts +++ b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts @@ -5,9 +5,11 @@ import type { Test, SuperTest, Response } from 'supertest'; import { basename, join } from 'path'; import { dirSync } from 'tmp'; import { + archiveRealm, parseAddressRanges, rri, SupportedMimeType, + unarchiveRealm, } from '@cardstack/runtime-common'; import type { QueuePublisher, @@ -59,12 +61,20 @@ const REALM_POLICY = { }; const ARTICLE_MODULE = ` - import { contains, containsMany, field, CardDef } from "@cardstack/base/card-api"; + import { contains, containsMany, field, CardDef, Component } from "@cardstack/base/card-api"; import StringField from "@cardstack/base/string"; export class Article extends CardDef { @field headline = contains(StringField); @field status = contains(StringField); @field authorIds = containsMany(StringField); + static isolated = class Isolated extends Component { + + }; } `; @@ -104,6 +114,7 @@ module(basename(import.meta.filename), function (hooks) { let org: Realm; let request: SuperTest; let server: Server; + let db: PgAdapter; let records: AnonymousRequestEvent[]; setupCatalogTestSubset(hooks); @@ -220,6 +231,7 @@ module(basename(import.meta.filename), function (hooks) { setupDB(hooks, { templateDatabase, beforeEach: async (dbAdapter, publisher, runner) => { + db = dbAdapter; await start({ dbAdapter, publisher, runner }); records = []; setAnonymousRequestSink((record) => records.push(record)); @@ -580,4 +592,227 @@ module(basename(import.meta.filename), function (hooks) { ['infra', 'infra', 'infra', 'refused'], ); }); + test('an address over its limit is turned away before anything it asks for runs, whatever it names', async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + }); + assert.strictEqual((await readCard(PUBLISHED)).status, 200); + let before = newsroom.__testOnlyPolicyGateStats().predicateEvaluations; + let published = await readCard(PUBLISHED); + let draft = await readCard(DRAFT); + let missing = await readCard(MISSING); + for (let [label, response] of [ + ['a published article', published], + ['a draft', draft], + ['a missing article', missing], + ] as const) { + assert.strictEqual(response.status, 429, `${label}: 429`); + assert.strictEqual(response.body.errors[0].code, 'rate-limited'); + } + assert.strictEqual(published.text, draft.text, 'the same answer for each'); + assert.strictEqual(published.text, missing.text); + assert.strictEqual( + newsroom.__testOnlyPolicyGateStats().predicateEvaluations, + before, + 'no grant was evaluated for any of them', + ); + }); + + test('a redirect, and a HEAD that finds nothing a grant opens, cost nothing', async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + }); + let redirect = await readCard(`${PUBLISHED}.json`); + assert.strictEqual(redirect.status, 302, 'the .json spelling redirects'); + for (let [label, url, accept] of [ + ['a draft', DRAFT, SupportedMimeType.CardJson], + ['a missing article', MISSING, SupportedMimeType.CardJson], + ["a draft's bytes", `${DRAFT}.json`, SupportedMimeType.CardSource], + [ + "a missing article's bytes", + `${MISSING}.json`, + SupportedMimeType.CardSource, + ], + ] as const) { + let head = await request + .head(new URL(url).pathname) + .set('Accept', accept) + .set('X-Forwarded-For', VISITOR); + assert.notStrictEqual(head.status, 429, `${label}: not counted`); + assert.notOk(head.headers['etag'], `${label}: describes no card`); + } + assert.strictEqual( + (await readCard(PUBLISHED)).status, + 200, + 'the budget is untouched', + ); + assert.deepEqual( + articleRecords().map((r) => r.outcome), + ['refused', 'refused', 'refused', 'refused', 'admitted'], + 'the HEADs are refusals and the redirect is nothing', + ); + }); + + test('a HEAD of a card a grant opens answers as its GET does, and is counted', async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 2, windowSeconds: 600 }, + }); + let head = await request + .head(new URL(PUBLISHED).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR); + assert.strictEqual(head.status, 200, 'the HEAD is answered'); + assert.true( + String(head.headers['content-type']).startsWith( + SupportedMimeType.CardJson, + ), + 'with the headers of the card it names', + ); + let get = await readCard(PUBLISHED); + assert.strictEqual(get.status, 200); + assert.strictEqual( + head.headers['etag'], + get.headers['etag'], + 'the same validator as the GET', + ); + assert.strictEqual( + (await readCard(PUBLISHED)).status, + 429, + 'the HEAD and the GET each used a unit', + ); + }); + + test('a caller cannot name its own address or claim to be one of our services', async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + }); + let forged = () => + readCard(PUBLISHED) + .set('X-Boxel-Client-Class', 'infra') + .set('X-Boxel-Client-IP', OTHER_VISITOR); + assert.strictEqual((await forged()).status, 200); + assert.strictEqual( + (await forged()).status, + 429, + 'still counted, under its own address', + ); + let recorded = articleRecords(); + assert.deepEqual( + recorded.map((r) => [r.outcome, r.clientIP]), + [ + ['admitted', VISITOR], + ['rate-limited', VISITOR], + ], + ); + }); + + test("a stylesheet that goes with a card's markup is served, and counts for nothing", async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + }); + let interned = (await db.execute( + `SELECT hash FROM scoped_css WHERE realm_url = $1 LIMIT 1`, + { bind: [NEWSROOM] }, + )) as { hash: string }[]; + assert.strictEqual( + interned.length, + 1, + 'precondition: indexing interned the article stylesheet', + ); + let path = `${new URL(NEWSROOM).pathname}_scoped-css/article.gts.md5-${interned[0].hash}.glimmer-scoped.css`; + for (let i = 0; i < 3; i++) { + let response = await request + .get(path) + .set('Accept', SupportedMimeType.All) + .set('X-Forwarded-For', VISITOR); + assert.strictEqual(response.status, 200, `stylesheet ${i}`); + } + assert.strictEqual( + (await readCard(PUBLISHED)).status, + 200, + 'the budget is untouched', + ); + }); + + test('an archived realm answers a caller a grant admits with its seal, and anyone else as when it was active', async function (assert) { + let draftActive = await readCard(DRAFT); + await archiveRealm(db, new URL(NEWSROOM)); + try { + let published = await readCard(PUBLISHED); + assert.strictEqual(published.status, 403, 'a published article: 403'); + assert.strictEqual(published.body.errors[0].code, 'archived'); + let draft = await readCard(DRAFT); + unauthenticated(draft, 'a draft', assert); + assert.strictEqual(draft.text, draftActive.text, 'as when it was active'); + unauthenticated(await readCard(MISSING), 'a missing article', assert); + } finally { + await unarchiveRealm(db, new URL(NEWSROOM)); + } + }); + + test('what no anonymous grant can open stays closed to a caller who is not signed in', async function (assert) { + let path = new URL(NEWSROOM).pathname; + let document = article('Breaking', 'published'); + let closed: [string, () => Test][] = [ + ['a card+html read', () => readCard(PUBLISHED, VISITOR, 'text/html')], + [ + 'a search', + () => + request + .post(`${path}_search`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', SupportedMimeType.CardJson) + .set('Content-Type', 'application/json') + .set('X-Forwarded-For', VISITOR) + .send(JSON.stringify({ filter: { type: ARTICLE } })), + ], + [ + 'an operations batch', + () => + request + .post(`${path}_operations`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', SupportedMimeType.BoxelOperations) + .set('Content-Type', SupportedMimeType.BoxelOperations) + .set('X-Forwarded-For', VISITOR) + .send( + JSON.stringify({ + 'boxel:operations': [ + { op: 'invoke', 'boxel:name': 'read', href: PUBLISHED }, + ], + }), + ), + ], + [ + 'a card+json create', + () => + request + .post(path) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR) + .send(document), + ], + [ + 'a card+json update', + () => + request + .patch(new URL(PUBLISHED).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR) + .send(document), + ], + [ + 'a card+json delete', + () => + request + .delete(new URL(PUBLISHED).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR), + ], + ]; + for (let [label, send] of closed) { + assert.strictEqual((await send()).status, 401, `${label}: 401`); + } + assert.deepEqual(articleRecords(), [], 'and none of them is admitted'); + }); }); diff --git a/packages/runtime-common/anonymous-rate-limiter.ts b/packages/runtime-common/anonymous-rate-limiter.ts index 7fb008ba484..76a2239deb7 100644 --- a/packages/runtime-common/anonymous-rate-limiter.ts +++ b/packages/runtime-common/anonymous-rate-limiter.ts @@ -28,6 +28,13 @@ export type AnonymousRateOutcome = // in another, and a federated search charges each realm it reaches on its own. export interface AnonymousRateLimiter { charge(charge: AnonymousRateCharge): Promise; + // Whether the caller has already used up the current window, without + // counting anything. Asked before an invocation runs, so a caller over the + // limit is turned away before the realm does the work, and charged only + // once the invocation has served them. + remaining( + probe: Omit, + ): Promise; } // Kept in the database so every realm-server process, and a restarted one, @@ -50,17 +57,45 @@ export class DBAnonymousRateLimiter implements AnonymousRateLimiter { this.#now = opts?.now ?? Date.now; } + #window(limit: AnonymousRateLimit) { + let nowSeconds = Math.floor(this.#now() / 1000); + let windowStart = + Math.floor(nowSeconds / limit.windowSeconds) * limit.windowSeconds; + let expiresAt = windowStart + limit.windowSeconds; + let retryAfterSeconds = Math.max(1, expiresAt - nowSeconds); + return { nowSeconds, windowStart, expiresAt, retryAfterSeconds }; + } + + async remaining({ + realmURL, + clientIP, + limit, + }: Omit): Promise { + let { windowStart, retryAfterSeconds } = this.#window(limit); + let rows = await query(this.#dbAdapter, [ + `SELECT count FROM anonymous_rate_limits WHERE realm_url =`, + param(realmURL), + 'AND client_ip =', + param(clientIP), + 'AND window_start =', + param(windowStart), + 'AND window_seconds =', + param(limit.windowSeconds), + ] as Expression); + let count = rows.length === 0 ? 0 : Number(rows[0].count); + return count < limit.requests + ? { admitted: true, count } + : { admitted: false, retryAfterSeconds }; + } + async charge({ realmURL, clientIP, limit, cost, }: AnonymousRateCharge): Promise { - let nowSeconds = Math.floor(this.#now() / 1000); - let windowStart = - Math.floor(nowSeconds / limit.windowSeconds) * limit.windowSeconds; - let expiresAt = windowStart + limit.windowSeconds; - let retryAfterSeconds = Math.max(1, expiresAt - nowSeconds); + let { nowSeconds, windowStart, expiresAt, retryAfterSeconds } = + this.#window(limit); if (!Number.isInteger(cost) || cost < 1) { throw new Error( diff --git a/packages/runtime-common/card-operations/policy.ts b/packages/runtime-common/card-operations/policy.ts index 2f164f8b537..d57012c55de 100644 --- a/packages/runtime-common/card-operations/policy.ts +++ b/packages/runtime-common/card-operations/policy.ts @@ -300,7 +300,16 @@ export class RealmPolicyCache { #revisit: { card: string; settledAt?: number } | undefined; // How often compiling, revalidating and asking for a card's visit actually // happen, for tests that assert on it rather than on the result alone. - readonly stats = { compiles: 0, revalidations: 0, revisits: 0 }; + readonly stats = { compiles: 0, revalidations: 0, revisits: 0, peeks: 0 }; + // Whether the policy card, as the index last held it, opts any grant in to + // callers who aren't signed in (see `anonymousAdmission`). Kept as long as + // a compiled policy is, and dropped when the card's realm is indexed. + #anonymousPeek: + | { card: string; opensAnything: boolean; readAt: number } + | undefined; + // Bumped whenever an index moves, so a read that began before then doesn't + // keep what it read. + #peekGeneration = 0; constructor(env: RealmPolicyCacheEnvironment) { this.#env = env; @@ -351,11 +360,8 @@ export class RealmPolicyCache { current.compiled.card === card && !this.#stale && now() - this.#validatedAt < MAX_UNVALIDATED_MS; - if (!warm) { - let row = await this.#env.readCard(new URL(card)); - if (!opensAnythingToAnonymous(row?.instance?.attributes)) { - return NO_OPERATIONS; - } + if (!warm && !(await this.#opensAnythingToAnonymous(card))) { + return NO_OPERATIONS; } compiled = await this.get(); } catch (e: unknown) { @@ -371,8 +377,32 @@ export class RealmPolicyCache { : new Set(compiled.anonymous.operations); } + async #opensAnythingToAnonymous(card: string): Promise { + let peek = this.#anonymousPeek; + if ( + peek && + peek.card === card && + now() - peek.readAt < MAX_UNVALIDATED_MS + ) { + return peek.opensAnything; + } + this.stats.peeks++; + let readAt = now(); + let generation = this.#peekGeneration; + let row = await this.#env.readCard(new URL(card)); + let opensAnything = opensAnythingToAnonymous(row?.instance?.attributes); + if (generation === this.#peekGeneration) { + this.#anonymousPeek = { card, opensAnything, readAt }; + } + return opensAnything; + } + // The index of the realm at `realmURL` has moved. indexMoved(realmURL: string): void { + this.#peekGeneration++; + if (this.#anonymousPeek?.card.startsWith(realmURL)) { + this.#anonymousPeek = undefined; + } for (let refresh of this.#inFlight) { refresh.moved.push(realmURL); } @@ -395,6 +425,8 @@ export class RealmPolicyCache { // Drops the cached compilation and zeroes the counts, so a test starts from // a cold cache. clear(): void { + this.#anonymousPeek = undefined; + this.#peekGeneration++; this.#current = undefined; this.#stale = false; this.#joinable = undefined; @@ -402,6 +434,7 @@ export class RealmPolicyCache { this.stats.compiles = 0; this.stats.revalidations = 0; this.stats.revisits = 0; + this.stats.peeks = 0; } #refresh(card: string): Promise { diff --git a/packages/runtime-common/card-operations/telemetry.ts b/packages/runtime-common/card-operations/telemetry.ts index db173613ca1..2643715567b 100644 --- a/packages/runtime-common/card-operations/telemetry.ts +++ b/packages/runtime-common/card-operations/telemetry.ts @@ -525,9 +525,17 @@ export interface AnonymousRequestEvent { // unauthenticated request is. // - `blocked`: the realm refused the caller's address before any grant was // asked (see `blockReason`). - // - `rate-limited`: a grant admitted it, but the address had used up its - // limit, so nothing was done. - outcome: 'admitted' | 'infra' | 'refused' | 'blocked' | 'rate-limited'; + // - `rate-limited`: the address had used up its limit, so it was turned + // away before anything ran, whatever it asked for. + // - `unavailable`: the realm couldn't read or update the address's count, + // so it was turned away rather than let through uncounted. + outcome: + | 'admitted' + | 'infra' + | 'refused' + | 'blocked' + | 'rate-limited' + | 'unavailable'; blockReason?: 'blocklist' | 'ip-undetermined' | 'blocklist-invalid'; clientIP: string | null; // What the caller is counted under: the address, or the /64 an IPv6 diff --git a/packages/runtime-common/card-operations/types.ts b/packages/runtime-common/card-operations/types.ts index 36ce5d399d7..9f605193474 100644 --- a/packages/runtime-common/card-operations/types.ts +++ b/packages/runtime-common/card-operations/types.ts @@ -1436,10 +1436,15 @@ export type OperationErrorCode = // of them could land. Serial order is how a batch says that two entries // touch the same target. | 'conflicting-targets' - // A caller who isn't signed in, admitted by a grant, has used up the - // invocations the realm allows their address in the current window. Nothing - // was done. Carries a 429 with `Retry-After`, and `meta.retryAfterSeconds`. + // A caller who isn't signed in, admitted by the realm's policy, has used up + // the invocations the realm allows their address in the current window. + // Nothing was done. Carries a 429 with `Retry-After`, and + // `meta.retryAfterSeconds`. | 'rate-limited' + // The realm couldn't count an invocation by a caller who isn't signed in, so + // it turned the caller away rather than let it through uncounted. Nothing + // was done. Carries a 503 with `Retry-After`. + | 'rate-limit-unavailable' // The operation could not be carried out for a reason that is not the // caller's — an unreadable definition, an errored index row, a failure // inside the executor. diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 59b87d55a78..2493fa6c0e7 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -15,6 +15,7 @@ import { import { DBAnonymousRateLimiter, type AnonymousRateLimiter, + type AnonymousRateOutcome, } from './anonymous-rate-limiter.ts'; import { resolveRangeHeader } from './http-range.ts'; import { @@ -2400,6 +2401,26 @@ interface AnonymousCaller { // never limited or blocked. infra: boolean; charged: boolean; + // Set where the request serves what it asked for: a card's document or + // headers, or a file's bytes. Only such a request is counted, so a redirect + // or a refusal costs the caller nothing whatever its status. + served?: true; + // What the caller is answered with in place of running the request, when + // the realm can't count it: its address has used up the realm's limit, or + // the count couldn't be read. Settled at admission, before anything runs. + turnedAway?: + | { kind: 'rate-limited'; retryAfterSeconds: number } + | { kind: 'unavailable' }; +} + +// How long a caller the realm couldn't count is told to wait. +const COUNT_UNAVAILABLE_RETRY_SECONDS = 5; + +// Releases a response that is answered in place of, so a file stream it holds +// is closed rather than left open. +async function discardBody(response: ResponseWithNodeStream): Promise { + response.nodeStream?.destroy(); + await response.body?.cancel().catch(() => {}); } // The card+json read: what an anonymous grant on `read` opens. @@ -7721,6 +7742,12 @@ export class Realm { message: 'search index is not available', }); } + // A caller admitted without a session whose address has no budget left + // is answered before the request runs (see `#checkAnonymousBudget`). + let turnedAway = this.#anonymousTurnedAway(requestContext); + if (turnedAway) { + return turnedAway; + } return await this.#chargeAnonymous( request, requestContext, @@ -7971,6 +7998,10 @@ export class Realm { ) { return this.realmIdentityResponse(requestContext); } + let turnedAway = this.#anonymousTurnedAway(requestContext); + if (turnedAway) { + return turnedAway; + } } } if ( @@ -8047,9 +8078,11 @@ export class Realm { // // - The realm names a policy. A realm with none answers every refusal // exactly as the ACL gave it. - // - The caller is someone. A policy grants by who is asking, and a request - // that authenticated nobody is told to authenticate, whatever its path - // names and whatever the policy holds. + // - The caller is someone, or the route runs an operation the policy opens + // to callers who aren't signed in and the realm doesn't refuse the + // caller's address (see `#admitsAnonymous`). Any other request that + // authenticated nobody is told to authenticate, whatever its path names + // and whatever the policy holds. async #admitsDespiteCoarseRefusal( request: Request, requestContext: RequestContext, @@ -8086,9 +8119,10 @@ export class Realm { request: Request, requestContext: RequestContext, anonymous: AnonymousDispatch, + refusal: unknown = requestContext.coarseRefusal, ): Promise { if ( - !(requestContext.coarseRefusal instanceof CoarseAuthenticationRequired) || + !(refusal instanceof CoarseAuthenticationRequired) || requestContext.authenticatedUser || request.headers.has('Authorization') || (await this.getRealmPolicy()) === undefined @@ -8129,31 +8163,113 @@ export class Realm { }); return false; } + if (caller.charged) { + caller.turnedAway = await this.#checkAnonymousBudget( + request, + caller, + access, + ); + } } requestContext.anonymousCaller = caller; return true; } + // Whether the caller's address has budget left for one more invocation, + // asked before the invocation runs so that one over the limit costs the + // realm nothing more than this question. The answer is the address's, not + // the target's, so it says nothing about what the request names. A count + // that can't be read turns the caller away rather than letting an + // invocation through uncounted. + async #checkAnonymousBudget( + request: Request, + caller: AnonymousCaller, + { limit, limitFrom }: AnonymousAccessSettings, + ): Promise { + let outcome: AnonymousRateOutcome; + try { + outcome = await this.#anonymousRateLimiter.remaining({ + realmURL: this.url, + clientIP: caller.rateLimitKey!, + limit, + }); + } catch (e: unknown) { + this.#recordAnonymousCountFailure(request, caller, e); + return { kind: 'unavailable' }; + } + if (outcome.admitted) { + return undefined; + } + this.#recordAnonymous(request, caller, { + outcome: 'rate-limited', + limit: { ...limit, from: limitFrom }, + retryAfterSeconds: outcome.retryAfterSeconds, + }); + return { + kind: 'rate-limited', + retryAfterSeconds: outcome.retryAfterSeconds, + }; + } + + #recordAnonymousCountFailure( + request: Request, + caller: AnonymousCaller, + e: unknown, + ): void { + this.#log.warn( + `could not count a request to ${this.url} from a caller who isn't signed in, so it was turned away: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + this.#recordAnonymous(request, caller, { outcome: 'unavailable' }); + } + + // The answer a caller the realm admitted without a session gets in place of + // what it asked for, when its admission settled that it is turned away. + #anonymousTurnedAway( + requestContext: RequestContext, + ): ResponseWithNodeStream | undefined { + let turnedAway = requestContext.anonymousCaller?.turnedAway; + if (!turnedAway) { + return undefined; + } + return turnedAway.kind === 'rate-limited' + ? this.#rateLimitedResponse(requestContext, turnedAway.retryAfterSeconds) + : this.#countUnavailableResponse(requestContext); + } + + // Marks the request as having served what its caller asked for (see + // `AnonymousCaller.served`). + #servedAnonymous(requestContext: RequestContext): void { + if (requestContext.anonymousCaller) { + requestContext.anonymousCaller.served = true; + } + } + // Counts a request the realm's policy admitted though it authenticated - // nobody, once what it asked for has succeeded, and answers in its place - // when the caller's address has used up the realm's limit. Only once it has - // succeeded, so a request no grant admits costs the caller nothing. A read - // is answered before it is counted, but has done nothing a refusal would - // have to undo. A caller of ours is recorded and never counted. + // nobody, once it has served what it asked for (see + // `AnonymousCaller.served`), so a request no grant admits, a redirect, and a + // target that isn't there cost the caller nothing. An address with no + // budget left was turned away at admission, before the request ran; one + // that spends its last unit concurrently with another request is answered + // here in the served response's place. A caller of ours is recorded and + // never counted. async #chargeAnonymous( request: Request, requestContext: RequestContext, response: ResponseWithNodeStream, ): Promise { let caller = requestContext.anonymousCaller; - if (!caller) { + if (!caller || caller.turnedAway) { return response; } - if (response.status === 401) { - this.#recordAnonymous(request, caller, { outcome: 'refused' }); + if (!caller.served) { + if (response.status === 401 || request.method === 'HEAD') { + this.#recordAnonymous(request, caller, { outcome: 'refused' }); + } return response; } - if (!caller.charged || response.status >= 400) { + if (!caller.charged) { return response; } if (caller.infra) { @@ -8161,12 +8277,19 @@ export class Realm { return response; } let { limit, limitFrom } = await this.getAnonymousAccess(); - let outcome = await this.#anonymousRateLimiter.charge({ - realmURL: this.url, - clientIP: caller.rateLimitKey!, - limit, - cost: 1, - }); + let outcome: AnonymousRateOutcome; + try { + outcome = await this.#anonymousRateLimiter.charge({ + realmURL: this.url, + clientIP: caller.rateLimitKey!, + limit, + cost: 1, + }); + } catch (e: unknown) { + this.#recordAnonymousCountFailure(request, caller, e); + await discardBody(response); + return this.#countUnavailableResponse(requestContext); + } let recordedLimit = { ...limit, from: limitFrom }; if (outcome.admitted) { this.#recordAnonymous(request, caller, { @@ -8181,8 +8304,7 @@ export class Realm { limit: recordedLimit, retryAfterSeconds: outcome.retryAfterSeconds, }); - response.nodeStream?.destroy(); - await response.body?.cancel().catch(() => {}); + await discardBody(response); return this.#rateLimitedResponse(requestContext, outcome.retryAfterSeconds); } @@ -8216,6 +8338,35 @@ export class Realm { }); } + // The answer to a request from a caller who isn't signed in that the realm + // couldn't count: nothing was done, and the caller may try again shortly. + #countUnavailableResponse( + requestContext: RequestContext, + ): ResponseWithNodeStream { + return createResponse({ + body: JSON.stringify( + errorsDocument({ + status: 503, + code: 'rate-limit-unavailable', + title: 'Service unavailable', + detail: `This realm couldn't count this request, so it wasn't carried out. Try again in ${COUNT_UNAVAILABLE_RETRY_SECONDS} seconds.`, + meta: { retryAfterSeconds: COUNT_UNAVAILABLE_RETRY_SECONDS }, + }), + null, + 2, + ), + init: { + status: 503, + headers: { + 'content-type': SupportedMimeType.JSONAPI, + 'Retry-After': String(COUNT_UNAVAILABLE_RETRY_SECONDS), + 'X-Boxel-Realm-Url': requestContext.realm.url, + }, + }, + requestContext, + }); + } + #recordAnonymous( request: Request, caller: AnonymousCaller, @@ -10389,6 +10540,7 @@ export class Realm { createdAt?: number | null; }, ): Promise { + this.#servedAnonymous(requestContext); let contentType = options?.defaultHeaders?.['content-type']; // Only advertise `public` caching when the realm is world-readable; // otherwise the response is auth-gated and must not be stored by shared @@ -13277,13 +13429,26 @@ export class Realm { // A `HEAD` passes the realm's permission check whoever sends it, so it // asks the read question itself. A caller the ACL would not let read is // answered by the realm's policy, as their `GET` is, where the policy has - // them to judge. Everyone else it refuses gets the discovery answer. + // them to judge, including a caller who isn't signed in where the policy + // opens `read` to one. Everyone else it refuses gets the discovery answer. let probe = await this.#readProbe(request, requestContext); let coarseDeclined: { coarseDeclined?: true } = {}; if (!probe.allowed) { - if (!(await this.#policyJudges(probe.refusal, requestContext))) { + if ( + !(await this.#policyJudges(probe.refusal, requestContext)) && + !(await this.#admitsAnonymous( + request, + requestContext, + ANONYMOUS_CARD_READ, + probe.refusal, + )) + ) { return this.realmIdentityResponse(requestContext); } + let turnedAway = this.#anonymousTurnedAway(requestContext); + if (turnedAway) { + return turnedAway; + } coarseDeclined = { coarseDeclined: true }; } // Read-your-writes, as on the `GET`: a validator computed off an index the @@ -13359,6 +13524,7 @@ export class Realm { `the headers-only read of ${url.href} answered with something other than headers`, ); } + this.#servedAnonymous(requestContext); if (result.type === 'file-meta') { // A `GET` of a path that holds bytes answers with the file's metadata // document, which is derived from those bytes and has no index row @@ -13708,6 +13874,7 @@ export class Realm { } return response; } + this.#servedAnonymous(requestContext); return createResponse({ body: assembly.body, varyOn: LINK_SHAPE_VARY, @@ -13887,6 +14054,7 @@ export class Realm { // document — valid JSON the caller discriminates via // `data.type === 'file-meta'`, instead of raw bytes that crash a // downstream `response.json()`. + this.#servedAnonymous(requestContext); return createResponse({ body: assembly.body, init: { headers: { 'content-type': SupportedMimeType.CardJson } }, From 57eb2c0781ce06edfd9c84eaf4ddf076d36e6399 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 11:38:41 -0400 Subject: [PATCH 04/10] Count an anonymous capability check once per pair before it runs; test card+html with its own media type A capability check loads each pair's target and runs its grants, so a caller admitted without a session is charged one unit per pair against the realm's per-address budget before any is checked. A request that doesn't fit gets the 429 whole. Charging is shared with the served-read path through #countAnonymous. Co-Authored-By: Claude Opus 5.5 --- .../tests/realm-policy-anonymous-read-test.ts | 51 ++++++++++++----- .../card-operations/telemetry.ts | 3 + packages/runtime-common/realm.ts | 55 ++++++++++++++++--- 3 files changed, 88 insertions(+), 21 deletions(-) diff --git a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts index 047ce6e01de..8dc5108704e 100644 --- a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts +++ b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts @@ -540,33 +540,53 @@ module(basename(import.meta.filename), function (hooks) { ); }); - test('a capability check answers a caller who is not signed in, and counts for nothing', async function (assert) { + test('a capability check answers a caller who is not signed in, counted once for each pair it asks about', async function (assert) { await setNewsroomConfig({ - anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + anonymousRateLimit: { requests: 4, windowSeconds: 600 }, }); - let ask = (realm: string, target: string) => + let ask = (realm: string, targets: string[]) => request .post(`${new URL(realm).pathname}_capabilities`) .set('Accept', SupportedMimeType.JSON) .set('Content-Type', SupportedMimeType.JSON) .set('X-Forwarded-For', VISITOR) - .send({ checks: [{ target, operation: 'read' }] }); - for (let i = 0; i < 3; i++) { - let response = await ask(NEWSROOM, PUBLISHED); - assert.strictEqual(response.status, 200, `check ${i}`); - assert.true(response.body.checks[0].allowed, 'a published article'); - } - let draft = await ask(NEWSROOM, DRAFT); - assert.false(draft.body.checks[0].allowed, 'a draft'); + .send({ + checks: targets.map((target) => ({ target, operation: 'read' })), + }); + let both = await ask(NEWSROOM, [PUBLISHED, DRAFT]); + assert.strictEqual(both.status, 200, both.text); + assert.true(both.body.checks[0].allowed, 'a published article'); + assert.false(both.body.checks[1].allowed, 'a draft'); unauthenticated( - await ask(LIBRARY, `${LIBRARY}articles/published`), + await ask(LIBRARY, [`${LIBRARY}articles/published`]), 'a realm whose policy opens nothing', assert, ); + let before = newsroom.__testOnlyPolicyGateStats().predicateEvaluations; + let tooMany = await ask(NEWSROOM, [PUBLISHED, PUBLISHED, PUBLISHED]); + assert.strictEqual(tooMany.status, 429, 'three pairs do not fit in two'); + assert.strictEqual(tooMany.body.errors[0].code, 'rate-limited'); + assert.strictEqual( + newsroom.__testOnlyPolicyGateStats().predicateEvaluations, + before, + 'and none of them was checked', + ); + assert.strictEqual((await ask(NEWSROOM, [PUBLISHED])).status, 200); assert.strictEqual( (await readCard(PUBLISHED)).status, 200, - 'none of the checks used up the budget', + 'checks and reads draw on one budget', + ); + assert.strictEqual((await readCard(PUBLISHED)).status, 429); + let checks = records.filter((r) => r.route.endsWith('/_capabilities')); + assert.deepEqual( + checks.map((r) => [r.outcome, r.cost]), + [ + ['admitted', 2], + ['rate-limited', 3], + ['admitted', undefined], + ], + 'each check is recorded with what it cost', ); }); @@ -754,7 +774,10 @@ module(basename(import.meta.filename), function (hooks) { let path = new URL(NEWSROOM).pathname; let document = article('Breaking', 'published'); let closed: [string, () => Test][] = [ - ['a card+html read', () => readCard(PUBLISHED, VISITOR, 'text/html')], + [ + 'a card+html read', + () => readCard(PUBLISHED, VISITOR, SupportedMimeType.CardHtml), + ], [ 'a search', () => diff --git a/packages/runtime-common/card-operations/telemetry.ts b/packages/runtime-common/card-operations/telemetry.ts index 2643715567b..c4c6cb17f9f 100644 --- a/packages/runtime-common/card-operations/telemetry.ts +++ b/packages/runtime-common/card-operations/telemetry.ts @@ -548,6 +548,9 @@ export interface AnonymousRequestEvent { }; // The window's count once this request was counted. count?: number; + // How many units the request was counted as, where it was more than one: + // a capability check counts one for each pair it asks about. + cost?: number; retryAfterSeconds?: number; correlationId: string | null; } diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 2493fa6c0e7..91ba524e538 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -2442,7 +2442,8 @@ const ANONYMOUS_STYLESHEET: AnonymousDispatch = { }; // A capability check asks what the caller could invoke and invokes nothing, so // it answers an anonymous caller in any realm whose policy opens anything to -// one, and is never counted. +// one. It is counted by the handler, one unit for each pair it asks about, +// before any is checked (see `handleCapabilities`). const ANONYMOUS_CAPABILITY_CHECK: AnonymousDispatch = { operations: [], charged: false, @@ -6606,6 +6607,17 @@ export class Realm { }); } let checks = parseCapabilityChecks(body); + // A caller admitted without a session is counted for every pair before + // any is checked: each one loads its target and runs its grants as a + // read would. + let turnedAway = await this.#countAnonymous( + request, + requestContext, + checks.length, + ); + if (turnedAway) { + return turnedAway; + } let { actor } = this.#callerOf(request, requestContext); let lanes = await this.#capabilityLanes(request, requestContext); let { coarseDeclined } = lanes; @@ -8272,9 +8284,32 @@ export class Realm { if (!caller.charged) { return response; } + let answer = await this.#countAnonymous(request, requestContext, 1); + if (answer) { + await discardBody(response); + return answer; + } + return response; + } + + // Counts `cost` invocations by a caller the realm admitted without a + // session against its address's budget, and answers in their place when + // they don't fit or can't be counted. A request whose cost is known before + // it runs, such as a capability check asking about several pairs, is + // counted here first, so one that doesn't fit runs nothing. A caller of + // ours is recorded and never counted. + async #countAnonymous( + request: Request, + requestContext: RequestContext, + cost: number, + ): Promise { + let caller = requestContext.anonymousCaller; + if (!caller || cost < 1) { + return undefined; + } if (caller.infra) { this.#recordAnonymous(request, caller, { outcome: 'infra' }); - return response; + return undefined; } let { limit, limitFrom } = await this.getAnonymousAccess(); let outcome: AnonymousRateOutcome; @@ -8283,28 +8318,29 @@ export class Realm { realmURL: this.url, clientIP: caller.rateLimitKey!, limit, - cost: 1, + cost, }); } catch (e: unknown) { this.#recordAnonymousCountFailure(request, caller, e); - await discardBody(response); return this.#countUnavailableResponse(requestContext); } let recordedLimit = { ...limit, from: limitFrom }; + let costDetail = cost > 1 ? { cost } : {}; if (outcome.admitted) { this.#recordAnonymous(request, caller, { outcome: 'admitted', limit: recordedLimit, count: outcome.count, + ...costDetail, }); - return response; + return undefined; } this.#recordAnonymous(request, caller, { outcome: 'rate-limited', limit: recordedLimit, retryAfterSeconds: outcome.retryAfterSeconds, + ...costDetail, }); - await discardBody(response); return this.#rateLimitedResponse(requestContext, outcome.retryAfterSeconds); } @@ -8372,7 +8408,12 @@ export class Realm { caller: AnonymousCaller, detail: Pick< AnonymousRequestEvent, - 'outcome' | 'blockReason' | 'limit' | 'count' | 'retryAfterSeconds' + | 'outcome' + | 'blockReason' + | 'limit' + | 'count' + | 'retryAfterSeconds' + | 'cost' >, ): void { recordSafely('anonymous-request', () => From 325e840614f77ccf5b6c1690341b471a4fd99b56 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 13:17:44 -0400 Subject: [PATCH 05/10] Explain answers an anonymous caller's write as its route does Co-Authored-By: Claude Opus 5.5 --- .../tests/realm-policy-anonymous-read-test.ts | 22 +++++++++++++++++-- .../runtime-common/card-operations/explain.ts | 6 +++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts index 8dc5108704e..f4414d5ed89 100644 --- a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts +++ b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts @@ -192,6 +192,14 @@ module(basename(import.meta.filename), function (hooks) { anonymous: true, where: '.status == "published"', }, + // Opts a write in to such callers, which no route admits them + // to, so it never admits one. + { + operation: 'update', + anonymous: true, + actingUser: 'submitter', + where: '.status == "published"', + }, ]), // Grants only signed-in callers. 'policies/library.json': policyCard(BOOK, [{ operation: 'read' }]), @@ -485,7 +493,11 @@ module(basename(import.meta.filename), function (hooks) { // The org admin asks the policy card what it decides for a caller who isn't // signed in. - async function explainAnonymous(policy: string, target: string) { + async function explainAnonymous( + policy: string, + target: string, + operation = 'read', + ) { let response = await request .post(`${new URL(ORG).pathname}_operations`) .set('X-HTTP-Method-Override', 'QUERY') @@ -502,7 +514,7 @@ module(basename(import.meta.filename), function (hooks) { op: 'invoke', 'boxel:name': 'explain', href: policy, - data: { actor: '', target, operation: 'read' }, + data: { actor: '', target, operation }, }, ], }), @@ -538,6 +550,12 @@ module(basename(import.meta.filename), function (hooks) { 'actor-required', 'a policy that opens nothing tells such a caller to authenticate', ); + let update = await explainAnonymous(NEWSROOM_POLICY, PUBLISHED, 'update'); + assert.strictEqual( + update.reason, + 'actor-required', + 'a write is answered as its route answers it, whatever the policy opens', + ); }); test('a capability check answers a caller who is not signed in, counted once for each pair it asks about', async function (assert) { diff --git a/packages/runtime-common/card-operations/explain.ts b/packages/runtime-common/card-operations/explain.ts index 6f024bd9cb9..279155a5046 100644 --- a/packages/runtime-common/card-operations/explain.ts +++ b/packages/runtime-common/card-operations/explain.ts @@ -43,6 +43,7 @@ import { isOperationFailure, isWrite, refusalForNonReader, + type BaseOperation, type ExplainedGrantOutcome, type ExplainedIndexLag, type ExplainedRule, @@ -638,6 +639,11 @@ async function opensToAnonymous( core: OperationCore, operation: string, ): Promise { + // No route admits a caller who isn't signed in to a write, whatever the + // policy opens, so explain answers such a caller's write as the route does. + if (isWrite(operation as BaseOperation)) { + return false; + } let policy = await core.policy?.compiledPolicy(); return ( !policy?.uncompilable && From dec5b3154f9a55c1771f10f755e86c6dfb45bbd9 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 14:15:43 -0400 Subject: [PATCH 06/10] Rewrite the anonymous admission comment in plain words Co-Authored-By: Claude Opus 5.5 --- .../runtime-common/card-operations/policy.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/runtime-common/card-operations/policy.ts b/packages/runtime-common/card-operations/policy.ts index d57012c55de..9b496c1a40b 100644 --- a/packages/runtime-common/card-operations/policy.ts +++ b/packages/runtime-common/card-operations/policy.ts @@ -335,18 +335,19 @@ export class RealmPolicyCache { return await this.#refresh(card); } - // The operations the realm's policy opens to callers who aren't signed in, - // or none. Read from the compiled policy and nothing else, so answering such - // a caller loads no target and evaluates no predicate. A policy that can't - // be compiled opens nothing, which leaves that caller with the answer every - // other caller who isn't signed in gets, rather than a failure that would - // tell them the realm names a policy. + // Which operations this realm's policy lets unauthenticated callers run. + // Empty if none. Reading this is cheap: it never loads a card or runs a + // predicate. If the policy fails to compile, the answer is "none", so an + // unauthenticated caller gets the same 401 they'd get from any realm and + // can't tell that this one has a policy at all. // - // A policy no grant of which opts in to such callers is answered from the - // card as the index holds it, without compiling: the realm asks this of - // every request that authenticated nobody, including the ones its own - // renders make, and a realm whose policy opens nothing to such callers pays - // for no compile on their account. + // Cost matters here because this runs for every request the ACL rejects + // for missing credentials, and the realm's own renders send such requests + // while it indexes. So before compiling anything, we read the raw policy + // card from the index and look for a grant with `anonymous: true`, and + // remember the answer as long as a compile is kept. If there isn't one, we + // return "none" without compiling. If a fresh compile is already cached, we + // skip that check and use it directly. async anonymousAdmission(): Promise> { let compiled: CompiledRealmPolicy | undefined; try { From 567d707e2ecb22834deaa7fa27f8e699a77a64a1 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 14:16:50 -0400 Subject: [PATCH 07/10] Record a route that runs whatever its request names as operation * Co-Authored-By: Claude Opus 5.5 --- packages/runtime-common/card-operations/telemetry.ts | 3 +++ packages/runtime-common/realm.ts | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/runtime-common/card-operations/telemetry.ts b/packages/runtime-common/card-operations/telemetry.ts index c4c6cb17f9f..d631482d6ca 100644 --- a/packages/runtime-common/card-operations/telemetry.ts +++ b/packages/runtime-common/card-operations/telemetry.ts @@ -516,6 +516,9 @@ export function emitPolicyCompile(event: PolicyCompileEvent): void { export interface AnonymousRequestEvent { kind: 'anonymous-request'; realmURL: string; + // The operation the route was admitted to, or `*` for a route that runs + // whatever its request names: the operations envelope and the capability + // check. operation: string; route: string; // - `admitted`: a grant admitted it and it was counted against the limit. diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 91ba524e538..24ab523dfad 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -8142,9 +8142,13 @@ export class Realm { return false; } let opened = await this.getAnonymousAdmission(); + // A route that runs whatever its request names is admitted wherever the + // policy opens anything to such callers, and recorded as `*`. let operation = anonymous.operations.length === 0 - ? [...opened][0] + ? opened.size > 0 + ? '*' + : undefined : anonymous.operations.find((name) => opened.has(name)); if (operation === undefined) { return false; From 1d2307812831b016541a905785ba230ddf82c604 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 14:24:08 -0400 Subject: [PATCH 08/10] Move anonymous admission and counting out of realm.ts into AnonymousAdmission Co-Authored-By: Claude Opus 5.5 --- .../runtime-common/anonymous-admission.ts | 437 ++++++++++++++++++ packages/runtime-common/realm.ts | 434 ++--------------- 2 files changed, 475 insertions(+), 396 deletions(-) create mode 100644 packages/runtime-common/anonymous-admission.ts diff --git a/packages/runtime-common/anonymous-admission.ts b/packages/runtime-common/anonymous-admission.ts new file mode 100644 index 00000000000..07328e40ae1 --- /dev/null +++ b/packages/runtime-common/anonymous-admission.ts @@ -0,0 +1,437 @@ +import { + CLIENT_CLASS_HEADER, + CLIENT_IP_HEADER, + INFRA_CLIENT_CLASS, + formatIP, + parseIP, + rangesContain, + rateLimitKey, + type AnonymousAccessSettings, +} from './anonymous-access.ts'; +import type { + AnonymousRateLimiter, + AnonymousRateOutcome, +} from './anonymous-rate-limiter.ts'; +import { errorsDocument } from './card-operations/envelope.ts'; +import { + emitAnonymousRequest, + recordSafely, + type AnonymousRequestEvent, +} from './card-operations/telemetry.ts'; +import type { OperationError } from './card-operations/types.ts'; +import { createResponse } from './create-response.ts'; +import type { logger } from './log.ts'; +import { X_BOXEL_LOGGING_CORRELATION_ID_HEADER } from './prerender-headers.ts'; +import type { RequestContext } from './realm.ts'; +import { SupportedMimeType } from './router.ts'; +import type { ResponseWithNodeStream } from './virtual-network.ts'; + +// ============================================================================ +// Callers who aren't signed in. +// +// A realm's policy can admit a request that authenticated nobody, through the +// grants that opt in to such callers (`OperationGrant.anonymous`), and the +// governed realm limits and blocks those callers by address +// (`anonymousRateLimit`, `anonymousBlocklist` in its `realm.json`). This is +// that half of the realm: deciding whether such a request is admitted to what +// its route would run, before anything about its target is resolved, and +// counting what it costs against its address's budget once it has run. +// +// The realm hands a request here only once its ACL refused it for want of +// credentials, on a route tagged with what it would run (`AnonymousDispatch`). +// Everything else about the request is the realm's: the gate decides what a +// grant admits, exactly as it does for a signed-in caller. +// ============================================================================ + +// What a route would run for a caller who isn't signed in: the operations, any +// one of which the realm's policy has to open to such callers for it to admit +// one there (empty means any at all), and whether a successful answer counts +// against the realm's anonymous rate limit. +export interface AnonymousDispatch { + operations: readonly string[]; + charged: boolean; +} + +// A request the realm's policy admitted though it authenticated nobody. +export interface AnonymousCaller { + // What the route was admitted to, or `*` for a route that runs whatever its + // request names. + operation: string; + // The caller's address in canonical form, or null where the realm server + // could not work one out (only a caller of ours can be admitted then). + clientIP: string | null; + // What the caller is counted under (see `rateLimitKey`). + rateLimitKey: string | undefined; + // The caller is one of the platform's own services: counted in telemetry, + // never limited or blocked. + infra: boolean; + charged: boolean; + // Set where the request serves what it asked for: a card's document or + // headers, or a file's bytes. Only such a request is counted, so a redirect + // or a refusal costs the caller nothing whatever its status. + served?: true; + // What the caller is answered with in place of running the request, when + // the realm can't count it: its address has used up the realm's limit, or + // the count couldn't be read. Settled at admission, before anything runs. + turnedAway?: + | { kind: 'rate-limited'; retryAfterSeconds: number } + | { kind: 'unavailable' }; +} + +// The card+json read: what an anonymous grant on `read` opens. +export const ANONYMOUS_CARD_READ: AnonymousDispatch = { + operations: ['read'], + charged: true, +}; +// A data file's or a card's stored bytes: what a `readSource` grant opens. +export const ANONYMOUS_BYTES_READ: AnonymousDispatch = { + operations: ['readSource'], + charged: true, +}; +// A stylesheet goes with the markup that references it, so it is served to an +// anonymous caller a policy would serve any card's markup to, and counts for +// nothing on its own: the read or search that drew the markup was counted. +export const ANONYMOUS_STYLESHEET: AnonymousDispatch = { + operations: ['read', 'query'], + charged: false, +}; +// A capability check asks what the caller could invoke and invokes nothing, so +// it answers an anonymous caller in any realm whose policy opens anything to +// one. It is counted by the handler, one unit for each pair it asks about, +// before any is checked (see `AnonymousAdmission.count`). +export const ANONYMOUS_CAPABILITY_CHECK: AnonymousDispatch = { + operations: [], + charged: false, +}; + +// How long a caller the realm couldn't count is told to wait. +const COUNT_UNAVAILABLE_RETRY_SECONDS = 5; + +// What a realm supplies to decide about and count its callers who aren't +// signed in, bound to its own `realm.json` and policy. +export interface AnonymousAdmissionEnvironment { + realmURL: string; + log: ReturnType; + limiter: AnonymousRateLimiter; + // Whether the realm names a policy at all. + hasPolicy(): Promise; + // The operations the realm's policy opens to such callers. + openedOperations(): Promise>; + // The realm's limit and blocklist for such callers. + access(): Promise; +} + +export class AnonymousAdmission { + #env: AnonymousAdmissionEnvironment; + + constructor(env: AnonymousAdmissionEnvironment) { + this.#env = env; + } + + // The caller a request that authenticated nobody is, where the realm's + // policy opens one of `dispatch`'s operations to such callers and the realm + // doesn't refuse the caller's address: what it is admitted to, the address + // it is counted under, and whether that address has budget left. Undefined + // for every other request, which is refused as an unauthenticated request + // always has been, before anything about its path is resolved: the question + // reads the compiled policy's summary and the realm's own `realm.json`, and + // loads no target and evaluates no predicate. A realm whose policy opens + // nothing to such callers records nothing. + // + // The address is read only from the headers the realm server sets (see + // `CLIENT_IP_HEADER`). A request without one, which is a request whose + // address the server could not work out, is refused as a blocked one, + // unless it is from the platform's own infrastructure, which is never + // blocked. + async callerFor( + request: Request, + dispatch: AnonymousDispatch, + ): Promise { + if ( + request.headers.has('Authorization') || + !(await this.#env.hasPolicy()) + ) { + return undefined; + } + let opened = await this.#env.openedOperations(); + // A route that runs whatever its request names is admitted wherever the + // policy opens anything to such callers, and recorded as `*`. + let operation = + dispatch.operations.length === 0 + ? opened.size > 0 + ? '*' + : undefined + : dispatch.operations.find((name) => opened.has(name)); + if (operation === undefined) { + return undefined; + } + let ipText = request.headers.get(CLIENT_IP_HEADER); + let address = ipText ? parseIP(ipText) : undefined; + let caller: AnonymousCaller = { + operation, + clientIP: address ? formatIP(address) : null, + rateLimitKey: address ? rateLimitKey(address) : undefined, + infra: request.headers.get(CLIENT_CLASS_HEADER) === INFRA_CLIENT_CLASS, + charged: dispatch.charged, + }; + if (!caller.infra) { + let access = await this.#env.access(); + let blockReason: AnonymousRequestEvent['blockReason'] = + access.invalidBlocklistEntries.length > 0 + ? 'blocklist-invalid' + : !address + ? 'ip-undetermined' + : rangesContain(access.blocklist, address) + ? 'blocklist' + : undefined; + if (blockReason) { + this.#record(request, caller, { outcome: 'blocked', blockReason }); + return undefined; + } + if (caller.charged) { + caller.turnedAway = await this.#checkBudget(request, caller, access); + } + } + return caller; + } + + // The answer a caller the realm admitted without a session gets in place of + // what it asked for, when its admission settled that it is turned away. + turnedAway( + requestContext: RequestContext, + ): ResponseWithNodeStream | undefined { + let turnedAway = requestContext.anonymousCaller?.turnedAway; + if (!turnedAway) { + return undefined; + } + return turnedAway.kind === 'rate-limited' + ? this.#rateLimitedResponse(requestContext, turnedAway.retryAfterSeconds) + : this.#countUnavailableResponse(requestContext); + } + + // Counts a request the realm's policy admitted though it authenticated + // nobody, once it has served what it asked for (see + // `AnonymousCaller.served`), so a request no grant admits, a redirect, and a + // target that isn't there cost the caller nothing. An address with no + // budget left was turned away at admission, before the request ran; one + // that spends its last unit concurrently with another request is answered + // here in the served response's place. A caller of ours is recorded and + // never counted. + async charge( + request: Request, + requestContext: RequestContext, + response: ResponseWithNodeStream, + ): Promise { + let caller = requestContext.anonymousCaller; + if (!caller || caller.turnedAway) { + return response; + } + if (!caller.served) { + if (response.status === 401 || request.method === 'HEAD') { + this.#record(request, caller, { outcome: 'refused' }); + } + return response; + } + if (!caller.charged) { + return response; + } + let answer = await this.count(request, requestContext, 1); + if (answer) { + await discardBody(response); + return answer; + } + return response; + } + + // Counts `cost` invocations by a caller the realm admitted without a + // session against its address's budget, and answers in their place when + // they don't fit or can't be counted. A request whose cost is known before + // it runs, such as a capability check asking about several pairs, is + // counted here first, so one that doesn't fit runs nothing. A caller of + // ours is recorded and never counted. + async count( + request: Request, + requestContext: RequestContext, + cost: number, + ): Promise { + let caller = requestContext.anonymousCaller; + if (!caller || cost < 1) { + return undefined; + } + if (caller.infra) { + this.#record(request, caller, { outcome: 'infra' }); + return undefined; + } + let { limit, limitFrom } = await this.#env.access(); + let outcome: AnonymousRateOutcome; + try { + outcome = await this.#env.limiter.charge({ + realmURL: this.#env.realmURL, + clientIP: caller.rateLimitKey!, + limit, + cost, + }); + } catch (e: unknown) { + this.#recordCountFailure(request, caller, e); + return this.#countUnavailableResponse(requestContext); + } + let recordedLimit = { ...limit, from: limitFrom }; + let costDetail = cost > 1 ? { cost } : {}; + if (outcome.admitted) { + this.#record(request, caller, { + outcome: 'admitted', + limit: recordedLimit, + count: outcome.count, + ...costDetail, + }); + return undefined; + } + this.#record(request, caller, { + outcome: 'rate-limited', + limit: recordedLimit, + retryAfterSeconds: outcome.retryAfterSeconds, + ...costDetail, + }); + return this.#rateLimitedResponse(requestContext, outcome.retryAfterSeconds); + } + + // Whether the caller's address has budget left for one more invocation, + // asked before the invocation runs so that one over the limit costs the + // realm nothing more than this question. The answer is the address's, not + // the target's, so it says nothing about what the request names. A count + // that can't be read turns the caller away rather than letting an + // invocation through uncounted. + async #checkBudget( + request: Request, + caller: AnonymousCaller, + { limit, limitFrom }: AnonymousAccessSettings, + ): Promise { + let outcome: AnonymousRateOutcome; + try { + outcome = await this.#env.limiter.remaining({ + realmURL: this.#env.realmURL, + clientIP: caller.rateLimitKey!, + limit, + }); + } catch (e: unknown) { + this.#recordCountFailure(request, caller, e); + return { kind: 'unavailable' }; + } + if (outcome.admitted) { + return undefined; + } + this.#record(request, caller, { + outcome: 'rate-limited', + limit: { ...limit, from: limitFrom }, + retryAfterSeconds: outcome.retryAfterSeconds, + }); + return { + kind: 'rate-limited', + retryAfterSeconds: outcome.retryAfterSeconds, + }; + } + + #recordCountFailure( + request: Request, + caller: AnonymousCaller, + e: unknown, + ): void { + this.#env.log.warn( + `could not count a request to ${this.#env.realmURL} from a caller who isn't signed in, so it was turned away: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + this.#record(request, caller, { outcome: 'unavailable' }); + } + + // The answer to a request whose caller's address has used up the realm's + // anonymous rate limit: nothing was done, and when to try again. + #rateLimitedResponse( + requestContext: RequestContext, + retryAfterSeconds: number, + ): ResponseWithNodeStream { + return countRefusalResponse(requestContext, { + status: 429, + code: 'rate-limited', + title: 'Too many requests', + detail: `This address has made as many requests to this realm without signing in as it allows for now. Try again in ${retryAfterSeconds} second${retryAfterSeconds === 1 ? '' : 's'}.`, + meta: { retryAfterSeconds }, + }); + } + + // The answer to a request from a caller who isn't signed in that the realm + // couldn't count: nothing was done, and the caller may try again shortly. + #countUnavailableResponse( + requestContext: RequestContext, + ): ResponseWithNodeStream { + return countRefusalResponse(requestContext, { + status: 503, + code: 'rate-limit-unavailable', + title: 'Service unavailable', + detail: `This realm couldn't count this request, so it wasn't carried out. Try again in ${COUNT_UNAVAILABLE_RETRY_SECONDS} seconds.`, + meta: { retryAfterSeconds: COUNT_UNAVAILABLE_RETRY_SECONDS }, + }); + } + + #record( + request: Request, + caller: AnonymousCaller, + detail: Pick< + AnonymousRequestEvent, + | 'outcome' + | 'blockReason' + | 'limit' + | 'count' + | 'retryAfterSeconds' + | 'cost' + >, + ): void { + recordSafely('anonymous-request', () => + emitAnonymousRequest({ + kind: 'anonymous-request', + realmURL: this.#env.realmURL, + operation: caller.operation, + route: `${request.method} ${new URL(request.url).pathname}`, + clientIP: caller.clientIP, + ...(caller.rateLimitKey ? { rateLimitKey: caller.rateLimitKey } : {}), + correlationId: request.headers.get( + X_BOXEL_LOGGING_CORRELATION_ID_HEADER, + ), + ...detail, + }), + ); + } +} + +// Marks the request as having served what its caller asked for (see +// `AnonymousCaller.served`). +export function servedAnonymous(requestContext: RequestContext): void { + if (requestContext.anonymousCaller) { + requestContext.anonymousCaller.served = true; + } +} + +function countRefusalResponse( + requestContext: RequestContext, + error: OperationError, +): ResponseWithNodeStream { + return createResponse({ + body: JSON.stringify(errorsDocument(error), null, 2), + init: { + status: error.status, + headers: { + 'content-type': SupportedMimeType.JSONAPI, + 'Retry-After': String(error.meta!.retryAfterSeconds), + 'X-Boxel-Realm-Url': requestContext.realm.url, + }, + }, + requestContext, + }); +} + +// Releases a response that is answered in place of, so a file stream it holds +// is closed rather than left open. +async function discardBody(response: ResponseWithNodeStream): Promise { + response.nodeStream?.destroy(); + await response.body?.cancel().catch(() => {}); +} diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 24ab523dfad..2e58e4f08cb 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -1,21 +1,23 @@ import { Deferred } from './deferred.ts'; import { - CLIENT_CLASS_HEADER, - CLIENT_IP_HEADER, - INFRA_CLIENT_CLASS, - formatIP, isUnsetLimit, - parseIP, - rangesContain, - rateLimitKey, resolveAnonymousAccess, type AnonymousAccessSettings, type AnonymousRateLimit, } from './anonymous-access.ts'; +import { + ANONYMOUS_BYTES_READ, + ANONYMOUS_CAPABILITY_CHECK, + ANONYMOUS_CARD_READ, + ANONYMOUS_STYLESHEET, + AnonymousAdmission, + servedAnonymous, + type AnonymousCaller, + type AnonymousDispatch, +} from './anonymous-admission.ts'; import { DBAnonymousRateLimiter, type AnonymousRateLimiter, - type AnonymousRateOutcome, } from './anonymous-rate-limiter.ts'; import { resolveRangeHeader } from './http-range.ts'; import { @@ -293,10 +295,7 @@ import { type CapabilityCaller, } from './card-operations/capabilities.ts'; import { - emitAnonymousRequest, emitCapabilityCheck, - recordSafely, - type AnonymousRequestEvent, type CapabilityCheckEvent, type PolicyRoute, } from './card-operations/telemetry.ts'; @@ -2384,71 +2383,6 @@ interface RequestDispatch { handle: () => Promise; } -interface AnonymousDispatch { - operations: readonly string[]; - charged: boolean; -} - -// A request the realm's policy admitted though it authenticated nobody. -interface AnonymousCaller { - operation: string; - // The caller's address in canonical form, or null where the realm server - // could not work one out (only a caller of ours can be admitted then). - clientIP: string | null; - // What the caller is counted under (see `rateLimitKey`). - rateLimitKey: string | undefined; - // The caller is one of the platform's own services: counted in telemetry, - // never limited or blocked. - infra: boolean; - charged: boolean; - // Set where the request serves what it asked for: a card's document or - // headers, or a file's bytes. Only such a request is counted, so a redirect - // or a refusal costs the caller nothing whatever its status. - served?: true; - // What the caller is answered with in place of running the request, when - // the realm can't count it: its address has used up the realm's limit, or - // the count couldn't be read. Settled at admission, before anything runs. - turnedAway?: - | { kind: 'rate-limited'; retryAfterSeconds: number } - | { kind: 'unavailable' }; -} - -// How long a caller the realm couldn't count is told to wait. -const COUNT_UNAVAILABLE_RETRY_SECONDS = 5; - -// Releases a response that is answered in place of, so a file stream it holds -// is closed rather than left open. -async function discardBody(response: ResponseWithNodeStream): Promise { - response.nodeStream?.destroy(); - await response.body?.cancel().catch(() => {}); -} - -// The card+json read: what an anonymous grant on `read` opens. -const ANONYMOUS_CARD_READ: AnonymousDispatch = { - operations: ['read'], - charged: true, -}; -// A data file's or a card's stored bytes: what a `readSource` grant opens. -const ANONYMOUS_BYTES_READ: AnonymousDispatch = { - operations: ['readSource'], - charged: true, -}; -// A stylesheet goes with the markup that references it, so it is served to an -// anonymous caller a policy would serve any card's markup to, and counts for -// nothing on its own: the read or search that drew the markup was counted. -const ANONYMOUS_STYLESHEET: AnonymousDispatch = { - operations: ['read', 'query'], - charged: false, -}; -// A capability check asks what the caller could invoke and invokes nothing, so -// it answers an anonymous caller in any realm whose policy opens anything to -// one. It is counted by the handler, one unit for each pair it asks about, -// before any is checked (see `handleCapabilities`). -const ANONYMOUS_CAPABILITY_CHECK: AnonymousDispatch = { - operations: [], - charged: false, -}; - // A route as `Realm.routeDescriptions` lists it: a router route, the hashed // stylesheet serve under its path prefix, or the fallback file and module // serve for one method, which answers whatever path and media type no router @@ -2675,7 +2609,9 @@ export class Realm { // `#cachedRealmConfig`, like the policy pointer. #cachedAnonymousAccess: AnonymousAccessSettings | undefined; #platformAnonymousRateLimit: AnonymousRateLimit | undefined; - #anonymousRateLimiter: AnonymousRateLimiter; + // Decides about, and counts, the callers who aren't signed in that this + // realm's policy admits. + #anonymous: AnonymousAdmission; // Bumped by every invalidation, and captured by a parse before it starts. // A parse that reads the realm's state and then has an index swap land // underneath it is holding values the realm has already moved past, so it @@ -2903,8 +2839,14 @@ export class Realm { this.#readIndexDrainBudgetMs = opts?.readIndexDrainBudgetMs ?? READ_INDEX_DRAIN_BUDGET_MS; this.#platformAnonymousRateLimit = opts?.anonymousRateLimit; - this.#anonymousRateLimiter = - anonymousRateLimiter ?? new DBAnonymousRateLimiter(dbAdapter); + this.#anonymous = new AnonymousAdmission({ + realmURL: this.url, + log: this.#log, + limiter: anonymousRateLimiter ?? new DBAnonymousRateLimiter(dbAdapter), + hasPolicy: async () => (await this.getRealmPolicy()) !== undefined, + openedOperations: () => this.getAnonymousAdmission(), + access: () => this.getAnonymousAccess(), + }); let owner: string | undefined; let _fetch = fetcher( virtualNetwork.fetch, @@ -6610,7 +6552,7 @@ export class Realm { // A caller admitted without a session is counted for every pair before // any is checked: each one loads its target and runs its grants as a // read would. - let turnedAway = await this.#countAnonymous( + let turnedAway = await this.#anonymous.count( request, requestContext, checks.length, @@ -7755,12 +7697,12 @@ export class Realm { }); } // A caller admitted without a session whose address has no budget left - // is answered before the request runs (see `#checkAnonymousBudget`). - let turnedAway = this.#anonymousTurnedAway(requestContext); + // is answered before the request runs (see `AnonymousAdmission.callerFor`). + let turnedAway = this.#anonymous.turnedAway(requestContext); if (turnedAway) { return turnedAway; } - return await this.#chargeAnonymous( + return await this.#anonymous.charge( request, requestContext, await dispatch.handle(), @@ -8010,7 +7952,7 @@ export class Realm { ) { return this.realmIdentityResponse(requestContext); } - let turnedAway = this.#anonymousTurnedAway(requestContext); + let turnedAway = this.#anonymous.turnedAway(requestContext); if (turnedAway) { return turnedAway; } @@ -8113,329 +8055,29 @@ export class Realm { : false; } - // Whether the realm's policy admits a request that authenticated nobody to - // what its route would run. Only where the policy opens one of the route's - // operations to callers who aren't signed in, and only from an address the - // realm doesn't refuse. Everything else gets the answer an unauthenticated - // request has always had, before anything about its path is resolved: the - // question reads the compiled policy's summary and the realm's own - // `realm.json`, and loads no target and evaluates no predicate. A realm - // whose policy opens nothing to such callers records nothing. - // - // The address is read only from the headers the realm server sets (see - // `CLIENT_IP_HEADER`). A request without one, which is a request whose - // address the server could not work out, is refused as a blocked one, - // unless it is from the platform's own infrastructure, which is never - // blocked. + // Whether the realm's policy admits a request that authenticated nobody, and + // that its ACL refused for want of credentials, to what its route would run + // (see `AnonymousAdmission.callerFor`). async #admitsAnonymous( request: Request, requestContext: RequestContext, - anonymous: AnonymousDispatch, + dispatch: AnonymousDispatch, refusal: unknown = requestContext.coarseRefusal, ): Promise { if ( !(refusal instanceof CoarseAuthenticationRequired) || - requestContext.authenticatedUser || - request.headers.has('Authorization') || - (await this.getRealmPolicy()) === undefined + requestContext.authenticatedUser ) { return false; } - let opened = await this.getAnonymousAdmission(); - // A route that runs whatever its request names is admitted wherever the - // policy opens anything to such callers, and recorded as `*`. - let operation = - anonymous.operations.length === 0 - ? opened.size > 0 - ? '*' - : undefined - : anonymous.operations.find((name) => opened.has(name)); - if (operation === undefined) { + let caller = await this.#anonymous.callerFor(request, dispatch); + if (!caller) { return false; } - let ipText = request.headers.get(CLIENT_IP_HEADER); - let address = ipText ? parseIP(ipText) : undefined; - let caller: AnonymousCaller = { - operation, - clientIP: address ? formatIP(address) : null, - rateLimitKey: address ? rateLimitKey(address) : undefined, - infra: request.headers.get(CLIENT_CLASS_HEADER) === INFRA_CLIENT_CLASS, - charged: anonymous.charged, - }; - if (!caller.infra) { - let access = await this.getAnonymousAccess(); - let blockReason: AnonymousRequestEvent['blockReason'] = - access.invalidBlocklistEntries.length > 0 - ? 'blocklist-invalid' - : !address - ? 'ip-undetermined' - : rangesContain(access.blocklist, address) - ? 'blocklist' - : undefined; - if (blockReason) { - this.#recordAnonymous(request, caller, { - outcome: 'blocked', - blockReason, - }); - return false; - } - if (caller.charged) { - caller.turnedAway = await this.#checkAnonymousBudget( - request, - caller, - access, - ); - } - } requestContext.anonymousCaller = caller; return true; } - // Whether the caller's address has budget left for one more invocation, - // asked before the invocation runs so that one over the limit costs the - // realm nothing more than this question. The answer is the address's, not - // the target's, so it says nothing about what the request names. A count - // that can't be read turns the caller away rather than letting an - // invocation through uncounted. - async #checkAnonymousBudget( - request: Request, - caller: AnonymousCaller, - { limit, limitFrom }: AnonymousAccessSettings, - ): Promise { - let outcome: AnonymousRateOutcome; - try { - outcome = await this.#anonymousRateLimiter.remaining({ - realmURL: this.url, - clientIP: caller.rateLimitKey!, - limit, - }); - } catch (e: unknown) { - this.#recordAnonymousCountFailure(request, caller, e); - return { kind: 'unavailable' }; - } - if (outcome.admitted) { - return undefined; - } - this.#recordAnonymous(request, caller, { - outcome: 'rate-limited', - limit: { ...limit, from: limitFrom }, - retryAfterSeconds: outcome.retryAfterSeconds, - }); - return { - kind: 'rate-limited', - retryAfterSeconds: outcome.retryAfterSeconds, - }; - } - - #recordAnonymousCountFailure( - request: Request, - caller: AnonymousCaller, - e: unknown, - ): void { - this.#log.warn( - `could not count a request to ${this.url} from a caller who isn't signed in, so it was turned away: ${ - e instanceof Error ? e.message : String(e) - }`, - ); - this.#recordAnonymous(request, caller, { outcome: 'unavailable' }); - } - - // The answer a caller the realm admitted without a session gets in place of - // what it asked for, when its admission settled that it is turned away. - #anonymousTurnedAway( - requestContext: RequestContext, - ): ResponseWithNodeStream | undefined { - let turnedAway = requestContext.anonymousCaller?.turnedAway; - if (!turnedAway) { - return undefined; - } - return turnedAway.kind === 'rate-limited' - ? this.#rateLimitedResponse(requestContext, turnedAway.retryAfterSeconds) - : this.#countUnavailableResponse(requestContext); - } - - // Marks the request as having served what its caller asked for (see - // `AnonymousCaller.served`). - #servedAnonymous(requestContext: RequestContext): void { - if (requestContext.anonymousCaller) { - requestContext.anonymousCaller.served = true; - } - } - - // Counts a request the realm's policy admitted though it authenticated - // nobody, once it has served what it asked for (see - // `AnonymousCaller.served`), so a request no grant admits, a redirect, and a - // target that isn't there cost the caller nothing. An address with no - // budget left was turned away at admission, before the request ran; one - // that spends its last unit concurrently with another request is answered - // here in the served response's place. A caller of ours is recorded and - // never counted. - async #chargeAnonymous( - request: Request, - requestContext: RequestContext, - response: ResponseWithNodeStream, - ): Promise { - let caller = requestContext.anonymousCaller; - if (!caller || caller.turnedAway) { - return response; - } - if (!caller.served) { - if (response.status === 401 || request.method === 'HEAD') { - this.#recordAnonymous(request, caller, { outcome: 'refused' }); - } - return response; - } - if (!caller.charged) { - return response; - } - let answer = await this.#countAnonymous(request, requestContext, 1); - if (answer) { - await discardBody(response); - return answer; - } - return response; - } - - // Counts `cost` invocations by a caller the realm admitted without a - // session against its address's budget, and answers in their place when - // they don't fit or can't be counted. A request whose cost is known before - // it runs, such as a capability check asking about several pairs, is - // counted here first, so one that doesn't fit runs nothing. A caller of - // ours is recorded and never counted. - async #countAnonymous( - request: Request, - requestContext: RequestContext, - cost: number, - ): Promise { - let caller = requestContext.anonymousCaller; - if (!caller || cost < 1) { - return undefined; - } - if (caller.infra) { - this.#recordAnonymous(request, caller, { outcome: 'infra' }); - return undefined; - } - let { limit, limitFrom } = await this.getAnonymousAccess(); - let outcome: AnonymousRateOutcome; - try { - outcome = await this.#anonymousRateLimiter.charge({ - realmURL: this.url, - clientIP: caller.rateLimitKey!, - limit, - cost, - }); - } catch (e: unknown) { - this.#recordAnonymousCountFailure(request, caller, e); - return this.#countUnavailableResponse(requestContext); - } - let recordedLimit = { ...limit, from: limitFrom }; - let costDetail = cost > 1 ? { cost } : {}; - if (outcome.admitted) { - this.#recordAnonymous(request, caller, { - outcome: 'admitted', - limit: recordedLimit, - count: outcome.count, - ...costDetail, - }); - return undefined; - } - this.#recordAnonymous(request, caller, { - outcome: 'rate-limited', - limit: recordedLimit, - retryAfterSeconds: outcome.retryAfterSeconds, - ...costDetail, - }); - return this.#rateLimitedResponse(requestContext, outcome.retryAfterSeconds); - } - - // The answer to a request whose caller's address has used up the realm's - // anonymous rate limit: nothing was done, and when to try again. - #rateLimitedResponse( - requestContext: RequestContext, - retryAfterSeconds: number, - ): ResponseWithNodeStream { - return createResponse({ - body: JSON.stringify( - errorsDocument({ - status: 429, - code: 'rate-limited', - title: 'Too many requests', - detail: `This address has made as many requests to this realm without signing in as it allows for now. Try again in ${retryAfterSeconds} second${retryAfterSeconds === 1 ? '' : 's'}.`, - meta: { retryAfterSeconds }, - }), - null, - 2, - ), - init: { - status: 429, - headers: { - 'content-type': SupportedMimeType.JSONAPI, - 'Retry-After': String(retryAfterSeconds), - 'X-Boxel-Realm-Url': requestContext.realm.url, - }, - }, - requestContext, - }); - } - - // The answer to a request from a caller who isn't signed in that the realm - // couldn't count: nothing was done, and the caller may try again shortly. - #countUnavailableResponse( - requestContext: RequestContext, - ): ResponseWithNodeStream { - return createResponse({ - body: JSON.stringify( - errorsDocument({ - status: 503, - code: 'rate-limit-unavailable', - title: 'Service unavailable', - detail: `This realm couldn't count this request, so it wasn't carried out. Try again in ${COUNT_UNAVAILABLE_RETRY_SECONDS} seconds.`, - meta: { retryAfterSeconds: COUNT_UNAVAILABLE_RETRY_SECONDS }, - }), - null, - 2, - ), - init: { - status: 503, - headers: { - 'content-type': SupportedMimeType.JSONAPI, - 'Retry-After': String(COUNT_UNAVAILABLE_RETRY_SECONDS), - 'X-Boxel-Realm-Url': requestContext.realm.url, - }, - }, - requestContext, - }); - } - - #recordAnonymous( - request: Request, - caller: AnonymousCaller, - detail: Pick< - AnonymousRequestEvent, - | 'outcome' - | 'blockReason' - | 'limit' - | 'count' - | 'retryAfterSeconds' - | 'cost' - >, - ): void { - recordSafely('anonymous-request', () => - emitAnonymousRequest({ - kind: 'anonymous-request', - realmURL: this.url, - operation: caller.operation, - route: `${request.method} ${new URL(request.url).pathname}`, - clientIP: caller.clientIP, - ...(caller.rateLimitKey ? { rateLimitKey: caller.rateLimitKey } : {}), - correlationId: request.headers.get( - X_BOXEL_LOGGING_CORRELATION_ID_HEADER, - ), - ...detail, - }), - ); - } - // Whether the realm's policy is the one to judge a caller the ACL refused // with `refusal`: the realm names a policy, and the refusal was of a verified // user's permission rather than of a request that authenticated nobody. @@ -10585,7 +10227,7 @@ export class Realm { createdAt?: number | null; }, ): Promise { - this.#servedAnonymous(requestContext); + servedAnonymous(requestContext); let contentType = options?.defaultHeaders?.['content-type']; // Only advertise `public` caching when the realm is world-readable; // otherwise the response is auth-gated and must not be stored by shared @@ -13490,7 +13132,7 @@ export class Realm { ) { return this.realmIdentityResponse(requestContext); } - let turnedAway = this.#anonymousTurnedAway(requestContext); + let turnedAway = this.#anonymous.turnedAway(requestContext); if (turnedAway) { return turnedAway; } @@ -13569,7 +13211,7 @@ export class Realm { `the headers-only read of ${url.href} answered with something other than headers`, ); } - this.#servedAnonymous(requestContext); + servedAnonymous(requestContext); if (result.type === 'file-meta') { // A `GET` of a path that holds bytes answers with the file's metadata // document, which is derived from those bytes and has no index row @@ -13919,7 +13561,7 @@ export class Realm { } return response; } - this.#servedAnonymous(requestContext); + servedAnonymous(requestContext); return createResponse({ body: assembly.body, varyOn: LINK_SHAPE_VARY, @@ -14099,7 +13741,7 @@ export class Realm { // document — valid JSON the caller discriminates via // `data.type === 'file-meta'`, instead of raw bytes that crash a // downstream `response.json()`. - this.#servedAnonymous(requestContext); + servedAnonymous(requestContext); return createResponse({ body: assembly.body, init: { headers: { 'content-type': SupportedMimeType.CardJson } }, From 5b8cb0a6f802eefac8dfa743ed550ef03180a361 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 16:37:48 -0400 Subject: [PATCH 09/10] Keep a rate-limited test's requests inside one window The limiter counts in fixed windows, so requests either side of a boundary were counted apart and a test expecting a refusal saw an admission. Co-Authored-By: Claude Opus 5.5 --- packages/realm-server/tests/helpers/index.ts | 21 +++++++++++++++++++ .../tests/realm-policy-anonymous-read-test.ts | 2 ++ 2 files changed, 23 insertions(+) diff --git a/packages/realm-server/tests/helpers/index.ts b/packages/realm-server/tests/helpers/index.ts index 3eb34c59ff4..cc9b5e41c1d 100644 --- a/packages/realm-server/tests/helpers/index.ts +++ b/packages/realm-server/tests/helpers/index.ts @@ -3447,6 +3447,27 @@ export const cardInfo = { // `name` under cardInfo.name (matching the CardDef slot); other fields land // on attributes directly. Mirrors the host helper so realm-server tests can // build the same shape without depending on host. +// Waits, where needed, until the current anonymous rate-limit window has +// enough of itself left for a test's requests to land in it together. The +// limiter counts in fixed windows aligned to multiples of `windowSeconds`, so +// two requests either side of a boundary are counted in different windows, +// and a test that expects the second to be refused would see it admitted. +export async function clearOfRateLimitWindowEdge( + limit: unknown, + marginSeconds = 30, +): Promise { + let windowSeconds = (limit as { windowSeconds?: unknown } | undefined) + ?.windowSeconds; + if (typeof windowSeconds !== 'number' || windowSeconds <= 0) { + return; + } + let margin = Math.min(marginSeconds, windowSeconds / 2); + let left = windowSeconds - ((Date.now() / 1000) % windowSeconds); + if (left < margin) { + await new Promise((resolve) => setTimeout(resolve, left * 1000 + 50)); + } +} + export function realmConfigCardJSON( config: { name?: string; diff --git a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts index f4414d5ed89..35b7dfc1bbb 100644 --- a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts +++ b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts @@ -24,6 +24,7 @@ import type { PgAdapter } from '@cardstack/postgres'; import { resetCatalogRealms } from '../handlers/handle-fetch-catalog-realms.ts'; import type { RealmHttpServer as Server } from '../server.ts'; import { + clearOfRateLimitWindowEdge, closeServer, createJWT, createVirtualNetwork, @@ -285,6 +286,7 @@ module(basename(import.meta.filename), function (hooks) { }), ); await newsroom.indexing(); + await clearOfRateLimitWindowEdge(fields.anonymousRateLimit); } function unauthenticated(response: Response, label: string, assert: Assert) { From 7bb9988552ab50cfabf038258bda0668ce494d6b Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 16:47:46 -0400 Subject: [PATCH 10/10] Compare rate-limited answers without the wait each names The wait counts down by the second, so two refusals a moment apart could name different waits and fail a byte-for-byte comparison. Co-Authored-By: Claude Opus 5.5 --- .../tests/realm-policy-anonymous-read-test.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts index 35b7dfc1bbb..df3eba46bdf 100644 --- a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts +++ b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts @@ -649,8 +649,23 @@ module(basename(import.meta.filename), function (hooks) { assert.strictEqual(response.status, 429, `${label}: 429`); assert.strictEqual(response.body.errors[0].code, 'rate-limited'); } - assert.strictEqual(published.text, draft.text, 'the same answer for each'); - assert.strictEqual(published.text, missing.text); + // The wait an answer names counts down by the second, so answers a moment + // apart can name different waits. Everything else is the same. + let answer = (response: Response) => + response.text + .replace(/Try again in \d+ seconds/, 'Try again in N seconds') + .replace(/"retryAfterSeconds": \d+/, '"retryAfterSeconds": N'); + assert.strictEqual( + answer(published), + answer(draft), + 'the same answer for each', + ); + assert.strictEqual(answer(published), answer(missing)); + assert.notStrictEqual( + answer(published), + published.text, + 'the wait was taken out of the comparison', + ); assert.strictEqual( newsroom.__testOnlyPolicyGateStats().predicateEvaluations, before,