From 9c8622161fe5344a88f4a57f6ca667e961b093d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 05:54:12 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(runtime):=20endpoint=20policy=20keys?= =?UTF-8?q?=20=E2=80=94=20authRequired=20/=20rateLimit=20/=20cacheTtl=20(#?= =?UTF-8?q?5091)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the three policy keys `ApiEndpointSchema` declares, in the order #5040 §3 fixes (rateLimit → authRequired → cacheTtl), reusing existing primitives only: - `authRequired` → `shouldDenyAnonymous` + the `ANONYMOUS_DENY_*` constants, so a declared endpoint answers the same 401 as `/meta`, `/ai` and `/security`. The key arrives materialized (schema default `true`), so there is no "omitted" state a consumer could read differently. - `rateLimit` → #5006's `deriveBucketConfig` / `resolveRateLimitKey` / `SharedTokenBucketLimiter` over the shared counter store, keyed `apiep::` so the endpoint budget and the server-level budget are independent rather than one budget counted twice. Over limit answers the server limiter's own 429 body plus `Retry-After`. - `cacheTtl` → response-header semantics only (#5091 ruled out a server-side cache): `private, max-age=` for a positive ttl, `no-store` for 0 or negative, nothing when absent, nothing + a warn on a non-GET endpoint. Metering runs BEFORE the auth gate on purpose: credential stuffing is anonymous traffic against an `authRequired` endpoint, and gating first would let a scanner make unlimited attempts none of which ever reach the meter. The dispatch step runs the chain between the match and its 501, and target execution can only land on the far side of the chain — the branch is unreachable without a policy context, so wiring an executor without wiring policies is not something a later change can do by forgetting. Structurally unreachable and zero live behavior change: a non-empty `apis:` is still rejected at publish until the #5040 E7 flip, and the step's answer without a policy context is byte-identical to today's. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --- .changeset/endpoint-policy-keys.md | 15 + .../runtime/src/api-endpoint-step.test.ts | 123 ++++++ packages/runtime/src/api-endpoint-step.ts | 90 ++++- packages/runtime/src/endpoint-policy.test.ts | 357 ++++++++++++++++++ packages/runtime/src/endpoint-policy.ts | Bin 0 -> 17804 bytes .../src/error-envelope.conformance.test.ts | 7 + 6 files changed, 583 insertions(+), 9 deletions(-) create mode 100644 .changeset/endpoint-policy-keys.md create mode 100644 packages/runtime/src/endpoint-policy.test.ts create mode 100644 packages/runtime/src/endpoint-policy.ts diff --git a/.changeset/endpoint-policy-keys.md b/.changeset/endpoint-policy-keys.md new file mode 100644 index 0000000000..d596ffe9a9 --- /dev/null +++ b/.changeset/endpoint-policy-keys.md @@ -0,0 +1,15 @@ +--- +"@objectstack/runtime": minor +--- + +声明式端点的策略键接线:`authRequired` / `rateLimit` / `cacheTtl`(#5040 E4) + +新增 `packages/runtime/src/endpoint-policy.ts` —— `ApiEndpointSchema` 三个策略键的唯一读取方,并接入端点派发步(匹配命中 → 策略链 → 答复)。三个键全部复用既有原语,零发明: + +- `authRequired`:复用 `shouldDenyAnonymous` 与 `ANONYMOUS_DENY_*` 常量,未认证得到与 `/meta`、`/ai`、`/security` 完全相同的 401 包络。默认值由 schema 物化(缺省即 `true`),执行器读不到「未声明」这个中间态;`authRequired: false` 是唯一的开门方式,且在 diff 中可见。 +- `rateLimit`:复用 #5006 的 `deriveBucketConfig` / `resolveRateLimitKey` / `SharedTokenBucketLimiter`,桶键为 `apiep:<端点名>:<主体或 IP>` —— 与 server 级预算各自独立计量,互不侵蚀。超限答与 server 级限流器逐字节一致的 429 + `Retry-After`。 +- `cacheTtl`:仅响应头语义(不实现服务端缓存,#5091 已裁)。正值 → `Cache-Control: private, max-age=`(`private` 是安全规则:任何响应都可能是按主体裁剪过的);`0`/负值 → `no-store`;缺省 → 不发头;非 GET → 不发头并 warn 点名。 + +链序按 #5040 §3:**先限流、后鉴权**、再算缓存头 —— 凭据爆破本就是匿名流量,先答 401 会让扫号者完全绕开计量。 + +**结构性不可达、零现网行为变更**:非空 `apis:` 在 publish/validate 仍被硬拒(E7 翻转前),且派发步在未获得策略上下文时的答复与此前逐字节相同。 diff --git a/packages/runtime/src/api-endpoint-step.test.ts b/packages/runtime/src/api-endpoint-step.test.ts index efcf67ca32..73c99d3a5a 100644 --- a/packages/runtime/src/api-endpoint-step.test.ts +++ b/packages/runtime/src/api-endpoint-step.test.ts @@ -18,6 +18,7 @@ import { describe, it, expect } from 'vitest'; import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api'; import type { ApiEndpointMatch } from '@objectstack/spec/contracts'; +import type { CounterStore } from '@objectstack/plugin-auth'; import { APP_ENDPOINT_SEGMENT, @@ -25,6 +26,11 @@ import { isAppEndpointPath, runAppEndpointStep, } from './api-endpoint-step.js'; +import { + createEndpointRateLimiterRegistry, + endpointBucketKey, + type EndpointPolicyContext, +} from './endpoint-policy.js'; /** A declared endpoint in the ADR-0121 D1 shape, defaults materialized. */ const TASKS: ApiEndpoint = ApiEndpointSchema.parse({ @@ -142,4 +148,121 @@ describe('a match answers 501 until the executor lands (#5040 E5)', () => { // is how two spellings of "the same path" start to disagree. expect(calls).toEqual([{ path: '/api/v1/apps/showcase/tasks', method: 'GET' }]); }); + + it('names the keys it did NOT evaluate when no policy context was threaded', async () => { + // Truthfulness of the report is the point: this seam's whole job today + // is telling an operator what did and did not happen. + const { service } = matcherFor([TASKS]); + const answer = await step('/api/v1/apps/showcase/tasks', 'GET', service); + const hint = String((answer!.body as { error: { hint: unknown } }).error.hint); + expect(hint).toContain('not evaluated'); + expect(hint).toContain('authRequired'); + expect(answer!.headers).toBeUndefined(); + }); +}); + +/** + * The policy chain, seen from the step (#5040 E4 / #5091). + * + * The module-level cases live in `endpoint-policy.test.ts`; what is asserted + * here is the WIRING — that the chain runs between the match and the answer, + * that a denial short-circuits (no 501, no execution slot reached), and that a + * pass still ends in the 501 until E5 lands. + */ +describe('the policy chain runs between the match and the answer', () => { + /** An endpoint that is open to anonymous callers unless a case says otherwise. */ + const OPEN: ApiEndpoint = ApiEndpointSchema.parse({ + ...TASKS, name: 'showcase_open', authRequired: false, + }); + + function policyContext(overrides: Partial = {}): EndpointPolicyContext { + const entries = new Map(); + const store: CounterStore = { + get: async (key: string) => entries.get(key) as T | undefined, + set: async (key: string, value: unknown) => { entries.set(key, value); }, + }; + return { + limiters: createEndpointRateLimiterRegistry({ resolveCache: async () => store }), + ...overrides, + }; + } + + const policedStep = (endpoints: ApiEndpoint[], policy: EndpointPolicyContext, method = 'GET') => + runAppEndpointStep({ + method, + path: endpoints[0]!.path, + prefix: '/api/v1', + metadataService: matcherFor(endpoints).service as never, + policy, + }); + + it('answers 401 instead of 501 when the endpoint requires auth and the caller has none', async () => { + const answer = await policedStep([TASKS], policyContext()); + expect(answer?.status).toBe(401); + const body = answer!.body as { success: boolean; error: Record }; + expect(body.error.code).toBe('UNAUTHENTICATED'); + // The 501 is NOT also emitted: a denial is the answer, not a stage. + expect(JSON.stringify(body)).not.toContain('NOT_IMPLEMENTED'); + }); + + it('answers 429 with the Retry-After header once the endpoint budget is spent', async () => { + const limited = ApiEndpointSchema.parse({ + ...OPEN, name: 'showcase_limited', rateLimit: { enabled: true, windowMs: 1_000, maxRequests: 1 }, + }); + const policy = policyContext(); + + expect((await policedStep([limited], policy))?.status).toBe(501); // within budget + const over = await policedStep([limited], policy); + + expect(over?.status).toBe(429); + // The header rides on the ANSWER, so the transport writes it with the + // body — a 429 whose Retry-After got lost tells a client nothing. + expect(over?.headers).toEqual({ 'Retry-After': '1' }); + expect((over!.body as { error: { code: string } }).error.code).toBe('RATE_LIMIT_EXCEEDED'); + }); + + it('reaches the 501 only after the chain passed, and says so', async () => { + const answer = await policedStep([OPEN], policyContext()); + expect(answer?.status).toBe(501); + const hint = String((answer!.body as { error: { hint: unknown } }).error.hint); + expect(hint).toContain('enforced'); + expect(hint).toContain('#5040'); + }); + + it('never puts the cacheTtl header on the 501 — but the verdict still carries it', async () => { + // Exposure, not application: `Cache-Control` describes a successful body + // that does not exist yet (execution is E5), and telling a client to + // cache a 501 for 30s would be worse than saying nothing. The header + // lives on the policy verdict, which is what the executor will read — + // asserted directly in `endpoint-policy.test.ts`. + const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtl: 30 }); + const answer = await policedStep([cached], policyContext()); + expect(answer?.status).toBe(501); + expect(answer?.headers).toBeUndefined(); + }); + + it('resolves the caller once and keys the endpoint bucket with it', async () => { + const seen: Array> = []; + const entries = new Map(); + const store: CounterStore = { + get: async (k: string) => entries.get(k) as T | undefined, + set: async (k: string, v: unknown) => { entries.set(k, v); }, + }; + const limited = ApiEndpointSchema.parse({ + ...TASKS, name: 'showcase_tasks', rateLimit: { enabled: true, windowMs: 1_000, maxRequests: 5 }, + }); + + const answer = await policedStep([limited], { + limiters: createEndpointRateLimiterRegistry({ resolveCache: async () => store }), + headers: { cookie: 'session=abc' }, + remoteAddress: '203.0.113.9', + resolvePrincipalId: async (headers) => { seen.push(headers); return 'usr_7'; }, + }); + + // Authenticated, so the 401 gate passes and the bucket keys by principal + // rather than by address — one lookup serving both. + expect(answer?.status).toBe(501); + expect(seen).toEqual([{ cookie: 'session=abc' }]); + expect([...entries.keys()]).toEqual([endpointBucketKey('showcase_tasks', 'principal:usr_7')]); + }); }); diff --git a/packages/runtime/src/api-endpoint-step.ts b/packages/runtime/src/api-endpoint-step.ts index f47890e24c..c98a2cecf3 100644 --- a/packages/runtime/src/api-endpoint-step.ts +++ b/packages/runtime/src/api-endpoint-step.ts @@ -27,16 +27,28 @@ * drive `matchEndpoint` through a stub, exactly as #5040 §5 prescribes for * every E-series unit that lands before the flip. * + * ## The policy chain (#5040 E4) now runs between the match and the answer + * + * `authRequired` / `rateLimit` / `cacheTtl` are enforced by + * {@link applyEndpointPolicies}, in the order #5040 §3 fixes, whenever the + * caller supplies a {@link EndpointPolicyContext}. A denial (401 / 429) is the + * answer; a pass still ends in the 501 below, because the thing that would run + * the endpoint is E5. + * + * That ordering is structural, not stylistic: execution lands INSIDE the + * post-policy branch, which is unreachable without a policy context. Wiring an + * executor without wiring policies is therefore not something a future change + * can do by forgetting — it would have nowhere to put the call. + * * What it does NOT do yet, so nobody reads more into it than is here: - * `rateLimit`, `authRequired`, `cacheTtl`, `inputMapping` / `outputMapping` - * (E4) and target execution — `object_operation` via `callData`, `flow` via the - * automation service (E5). Those insert BETWEEN the match and the answer, in - * the order #5040 §3 fixes. + * `inputMapping` / `outputMapping` and target execution — `object_operation` + * via `callData`, `flow` via the automation service (E5). */ import { DispatcherErrorCode } from '@objectstack/spec/api'; import type { ApiEndpointMatch, IMetadataService } from '@objectstack/spec/contracts'; import { apiErrorResponse } from './error-envelope.js'; +import { applyEndpointPolicies, type EndpointPolicyContext } from './endpoint-policy.js'; /** * The platform's single reserved carve-out segment for app-declared endpoints @@ -81,6 +93,17 @@ export function isAppEndpointPath(path: string, runtimePrefix: string): boolean export interface AppEndpointStepAnswer { status: number; body: unknown; + /** + * Headers that are part of THIS answer and must be written with it — today + * only `Retry-After` on a rate-limit denial, where the header carries the + * one piece of information the client needs to behave. + * + * Note what is NOT here: the `Cache-Control` computed from `cacheTtl`. It + * describes a successful response body that does not exist yet, and telling + * a client to cache a 501 would be worse than saying nothing. It stays on + * the policy verdict until execution lands (#5040 E5). + */ + headers?: Record; } export interface AppEndpointStepInput { @@ -98,6 +121,19 @@ export interface AppEndpointStepInput { * depend on its landing). */ metadataService: Pick | undefined; + /** + * Request context + services for the policy chain (#5040 E4): the caller's + * headers and peer address, the principal lookup, the endpoint limiter + * registry, `trustProxy`. + * + * Optional ONLY because the dispatch seam that calls this step does not + * thread it yet — that plumbing lands with the executor wiring (#5040 E5), + * which is the same change that needs the request body and the environment + * anyway. Omitting it does not open anything: the terminal answer without a + * policy context is the 501 below, so no request can be SERVED unpoliced, + * and execution can only be added on the far side of the chain. + */ + policy?: EndpointPolicyContext; } /** @@ -130,16 +166,52 @@ export async function runAppEndpointStep( const match: ApiEndpointMatch | undefined = await metadataService.matchEndpoint({ path, method }); if (!match) return undefined; + if (!input.policy) { + // No policy context threaded yet (see `AppEndpointStepInput.policy`). + // The answer is the same 501 this seam has always given, and the hint + // says which keys were NOT evaluated — a report that is wrong about + // what ran is worse than no report. + return notImplemented(match, method, path, + 'The mounting seam is in place; execution (target dispatch, mappings) lands with #5040 E5. This ' + + 'request reached the step without a policy context, so authRequired / rateLimit / cacheTtl were ' + + 'not evaluated — nothing was served either. Until the E7 flip a non-empty `apis:` is rejected at ' + + 'publish, so no reachable deployment can produce this answer.'); + } + + const verdict = await applyEndpointPolicies({ ...input.policy, endpoint: match.endpoint, method }); + if (verdict.verdict === 'deny') { + return { + status: verdict.status, + body: verdict.body, + ...(verdict.headers ? { headers: verdict.headers } : {}), + }; + } + + // ── Everything past this line has been through the policy chain ────── + // This is where target execution lands (#5040 E5), and it is the ONLY place + // it can land: the branch is unreachable without a policy context, and the + // deny above short-circuits before it. `verdict.responseHeaders` carries the + // `Cache-Control` that the executor's success answer should apply — it is + // deliberately not applied to the 501 (see `AppEndpointStepAnswer.headers`). + return notImplemented(match, method, path, + 'Policies (authRequired / rateLimit / cacheTtl) were enforced and this request passed them; target ' + + 'execution lands with #5040 E5. Until the E7 flip a non-empty `apis:` is rejected at publish, so no ' + + 'reachable deployment can produce this answer.'); +} + +/** The one 501 body this step answers with, whichever branch produced it. */ +function notImplemented( + match: ApiEndpointMatch, + method: string, + path: string, + hint: string, +): AppEndpointStepAnswer { return apiErrorResponse({ code: DispatcherErrorCode.enum.NOT_IMPLEMENTED, httpStatus: 501, message: `Declarative endpoint '${match.endpoint.name}' claims ${method} ${path}, but the endpoint ` + 'executor is not enabled in this build. It lands in 17.x (#5040).', - extra: { - hint: 'The mounting seam is in place; execution (target dispatch, authRequired / rateLimit / ' - + 'cacheTtl / mappings) lands with #5040 E4–E5. Until then a non-empty `apis:` is rejected ' - + 'at publish, so no reachable deployment can produce this answer.', - }, + extra: { hint }, }); } diff --git a/packages/runtime/src/endpoint-policy.test.ts b/packages/runtime/src/endpoint-policy.test.ts new file mode 100644 index 0000000000..f3bd052a3b --- /dev/null +++ b/packages/runtime/src/endpoint-policy.test.ts @@ -0,0 +1,357 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The endpoint policy keys, one key at a time (#5040 E4 / #5091). + * + * Three keys, and for each of them the three cases that matter: declared and + * hit, declared and not hit, and the boundary (`authRequired: false`, a budget + * that is present but disarmed, `cacheTtl: 0`). A policy key that is only ever + * tested in its "allow" direction is indistinguishable from a key nobody read. + * + * Everything is driven with stubs — a counter store, a principal resolver, a + * clock. That is not a convenience: the module takes its dependencies + * explicitly precisely so the decision can be tested without booting a kernel, + * and so the bucket KEYS (the part a security review cares about) are + * observable rather than inferred. + */ + +import { describe, it, expect } from 'vitest'; +import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api'; +import type { CounterStore } from '@objectstack/plugin-auth'; + +import { + applyEndpointPolicies, + computeCacheControl, + createEndpointRateLimiterRegistry, + endpointBucketKey, + ENDPOINT_BUCKET_PREFIX, + type EndpointPolicyContext, + type EndpointPolicyVerdict, +} from './endpoint-policy.js'; + +/** A declared endpoint with defaults materialized, exactly as the matcher hands it over. */ +function declare(overrides: Record = {}): ApiEndpoint { + return ApiEndpointSchema.parse({ + name: 'showcase_tasks', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, + ...overrides, + }); +} + +/** An observable counter store — the bucket keyspace is the thing under test. */ +function fakeCache() { + const entries = new Map(); + const store: CounterStore = { + get: async (key: string) => entries.get(key) as T | undefined, + set: async (key: string, value: unknown) => { entries.set(key, value); }, + }; + return { store, entries, keys: () => [...entries.keys()] }; +} + +interface Harness { + context: EndpointPolicyContext; + keys: () => string[]; + tick: (ms: number) => void; +} + +function harness(options: { principalId?: string; remoteAddress?: string; headers?: Record } = {}): Harness { + const cache = fakeCache(); + let now = 1_700_000_000_000; + const limiters = createEndpointRateLimiterRegistry({ + resolveCache: async () => cache.store, + now: () => now, + }); + return { + context: { + limiters, + ...(options.headers ? { headers: options.headers } : {}), + ...(options.remoteAddress ? { remoteAddress: options.remoteAddress } : {}), + resolvePrincipalId: async () => options.principalId, + }, + keys: cache.keys, + tick: (ms: number) => { now += ms; }, + }; +} + +const run = (endpoint: ApiEndpoint, h: Harness, method = 'GET'): Promise => + applyEndpointPolicies({ ...h.context, endpoint, method }); + +// ───────────────────────────────────────────────────────────────────────────── +describe('authRequired — the default is the safe one, and it is materialized', () => { + it('denies an anonymous caller with the platform 401, when the key is omitted', async () => { + // Omitted in the source, `true` after parse: the executor never sees an + // "unspecified" state, so forgetting the key cannot open an endpoint. + const endpoint = declare(); + expect(endpoint.authRequired).toBe(true); + + const verdict = await run(endpoint, harness({})); + expect(verdict.verdict).toBe('deny'); + if (verdict.verdict !== 'deny') return; + expect(verdict.status).toBe(401); + // The SAME 401 every other seam answers (#2567) — not a second dialect. + expect(verdict.body.error.code).toBe('UNAUTHENTICATED'); + expect(verdict.body.error.message).toBe('Authentication is required to access this endpoint.'); + expect(verdict.body.error.httpStatus).toBe(401); + expect(verdict.body.success).toBe(false); + }); + + it('lets an authenticated caller through, and reports who they are', async () => { + const verdict = await run(declare({ authRequired: true }), harness({ principalId: 'usr_7' })); + expect(verdict).toEqual({ verdict: 'pass', principalId: 'usr_7', responseHeaders: {} }); + }); + + it('lets an anonymous caller through when the endpoint declares `authRequired: false`', async () => { + const verdict = await run(declare({ authRequired: false }), harness({})); + expect(verdict).toEqual({ verdict: 'pass', responseHeaders: {} }); + }); + + it('treats an auth lookup that throws as anonymous — a hiccup is a 401, never an outage', async () => { + const h = harness({}); + h.context.resolvePrincipalId = async () => { throw new Error('auth service down'); }; + const verdict = await run(declare(), h); + expect(verdict.verdict).toBe('deny'); + if (verdict.verdict !== 'deny') return; + expect(verdict.status).toBe(401); + }); + + it('never lets a declared path exempt itself through the control-plane allowlist', async () => { + // `isAuthGateAllowlisted` treats any path containing an `/auth/` segment + // as control plane. If the request path were passed to + // `shouldDenyAnonymous`, an app could open its own endpoint by NAMING it + // `.../auth/...` — an authoring-time bypass of the platform default + // deny. The path is not passed; this pins that it stays that way. + const endpoint = declare({ name: 'sneaky', path: '/api/v1/apps/showcase/auth/callback' }); + const verdict = await run(endpoint, harness({})); + expect(verdict.verdict).toBe('deny'); + if (verdict.verdict !== 'deny') return; + expect(verdict.status).toBe(401); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +describe('rateLimit — #5006 primitives, an endpoint-scoped keyspace', () => { + /** Two requests per second, so the boundary is reachable in a test. */ + const limited = (overrides: Record = {}) => declare({ + authRequired: false, + rateLimit: { enabled: true, windowMs: 1_000, maxRequests: 2 }, + ...overrides, + }); + + it('does nothing at all when the key is absent', async () => { + const h = harness({ principalId: 'usr_7' }); + for (let i = 0; i < 20; i++) { + expect((await run(declare(), h)).verdict).toBe('pass'); + } + expect(h.keys(), 'an endpoint with no `rateLimit` touched the counter store').toEqual([]); + }); + + it('does nothing when the budget is present but not armed (`enabled` defaults to false)', async () => { + // The schema default is `enabled: false`, so a block written without it + // is a DISARMED budget. Honouring that literally is the declared = + // enforced reading; #5111 carries the publish gate that stops an author + // from writing one by accident. + const endpoint = declare({ authRequired: false, rateLimit: { windowMs: 1_000, maxRequests: 1 } }); + expect(endpoint.rateLimit?.enabled).toBe(false); + + const h = harness({}); + for (let i = 0; i < 5; i++) expect((await run(endpoint, h)).verdict).toBe('pass'); + expect(h.keys()).toEqual([]); + }); + + it('admits up to the budget, then answers the 429 with a usable Retry-After', async () => { + const h = harness({ principalId: 'usr_7' }); + const endpoint = limited(); + + expect((await run(endpoint, h)).verdict).toBe('pass'); // 1st — at budget + expect((await run(endpoint, h)).verdict).toBe('pass'); // 2nd — at budget + const over = await run(endpoint, h); // 3rd — over + + expect(over.verdict).toBe('deny'); + if (over.verdict !== 'deny') return; + expect(over.status).toBe(429); + // Byte-identical to the server-level limiter's 429 (#5006): one 429 on + // this wire surface, whichever budget produced it. + expect(over.body.error.code).toBe('RATE_LIMIT_EXCEEDED'); + expect(over.body.error.message).toBe('Rate limit exceeded. Retry after the interval in the Retry-After header.'); + expect(over.headers?.['Retry-After']).toBe('1'); + expect((over.body.error.details as { retryAfterSeconds: number }).retryAfterSeconds).toBe(1); + expect(typeof (over.body.error.details as { resetAt: string }).resetAt).toBe('string'); + + // And the budget really is a budget: it refills. + h.tick(1_000); + expect((await run(endpoint, h)).verdict).toBe('pass'); + }); + + it('keys the bucket by endpoint × caller, under its own namespace', async () => { + const h = harness({ principalId: 'usr_7' }); + await run(limited(), h); + expect(h.keys()).toEqual([endpointBucketKey('showcase_tasks', 'principal:usr_7')]); + expect(h.keys()[0]).toBe(`${ENDPOINT_BUCKET_PREFIX}showcase_tasks:principal:usr_7`); + // NOT the bare `principal:usr_7` the server-level limiter counts in — + // the two budgets are independent, and sharing a keyspace would make the + // smaller one silently smaller still. + expect(h.keys()).not.toContain('principal:usr_7'); + }); + + it('gives two endpoints separate budgets', async () => { + const h = harness({ principalId: 'usr_7' }); + const a = limited(); + const b = limited({ name: 'showcase_reports', path: '/api/v1/apps/showcase/reports' }); + + expect((await run(a, h)).verdict).toBe('pass'); + expect((await run(a, h)).verdict).toBe('pass'); + expect((await run(a, h)).verdict).toBe('deny'); + // `b` is untouched by `a` exhausting itself. + expect((await run(b, h)).verdict).toBe('pass'); + expect((await run(b, h)).verdict).toBe('pass'); + expect((await run(b, h)).verdict).toBe('deny'); + expect(h.keys().sort()).toEqual([ + endpointBucketKey('showcase_reports', 'principal:usr_7'), + endpointBucketKey('showcase_tasks', 'principal:usr_7'), + ]); + }); + + it('gives two callers separate budgets, and keys anonymous traffic by peer address', async () => { + const endpoint = limited(); + const cache = fakeCache(); + let now = 1_700_000_000_000; + const limiters = createEndpointRateLimiterRegistry({ resolveCache: async () => cache.store, now: () => now }); + const as = (ctx: Partial) => + applyEndpointPolicies({ limiters, ...ctx, endpoint, method: 'GET' }); + + const alice = { resolvePrincipalId: async () => 'usr_alice' }; + const anon = { remoteAddress: '203.0.113.9' }; + + expect((await as(alice)).verdict).toBe('pass'); + expect((await as(alice)).verdict).toBe('pass'); + expect((await as(alice)).verdict).toBe('deny'); + // A different session is not throttled by Alice's spending… + expect((await as({ resolvePrincipalId: async () => 'usr_bob' })).verdict).toBe('pass'); + // …and neither is an anonymous caller, who keys by address. + expect((await as(anon)).verdict).toBe('pass'); + expect(cache.keys()).toContain(endpointBucketKey('showcase_tasks', 'ip:203.0.113.9')); + }); + + it('never keys off a forwarded header unless `trustProxy` was declared', async () => { + // Untrusted, `X-Forwarded-For` is attacker input: honouring it lets + // anyone mint a fresh bucket per request. Q3=C, inherited whole from + // `resolveRateLimitKey` rather than re-decided here. + const endpoint = limited(); + const forged = { 'x-forwarded-for': '198.51.100.1' }; + + const untrusted = harness({ headers: forged, remoteAddress: '203.0.113.9' }); + await run(endpoint, untrusted); + expect(untrusted.keys()).toEqual([endpointBucketKey('showcase_tasks', 'ip:203.0.113.9')]); + + const trusted = harness({ headers: forged, remoteAddress: '203.0.113.9' }); + trusted.context.trustProxy = true; + await run(endpoint, trusted); + expect(trusted.keys()).toEqual([endpointBucketKey('showcase_tasks', 'ip:198.51.100.1')]); + }); + + it('meters BEFORE it gates, so a 401 storm still spends the budget (#5040 §3)', async () => { + // The order that matters most and is easiest to get wrong: credential + // stuffing is anonymous traffic against an `authRequired` endpoint. If + // the 401 came first, none of it would ever reach the meter. + const endpoint = limited({ authRequired: true }); + const h = harness({ remoteAddress: '203.0.113.9' }); + + const first = await run(endpoint, h); + expect(first.verdict).toBe('deny'); + if (first.verdict === 'deny') expect(first.status).toBe(401); + expect(h.keys()).toEqual([endpointBucketKey('showcase_tasks', 'ip:203.0.113.9')]); + + await run(endpoint, h); + const third = await run(endpoint, h); + expect(third.verdict).toBe('deny'); + if (third.verdict !== 'deny') return; + expect(third.status, 'the anonymous caller was never metered').toBe(429); + }); + + it('serves the request when the counter store itself is broken — bounded fail-open', async () => { + const limiters = createEndpointRateLimiterRegistry({ + resolveCache: async () => ({ + get: async () => { throw new Error('cache backend down'); }, + set: async () => { throw new Error('cache backend down'); }, + }), + }); + const verdict = await applyEndpointPolicies({ + limiters, endpoint: limited(), method: 'GET', + }); + // Same trade #5006 makes: a cache outage is a louder problem than an + // unmetered request, and it must not take the API down. + expect(verdict.verdict).toBe('pass'); + }); + + it('refuses to serve an armed budget it cannot honour, naming the endpoint', async () => { + const h = harness({}); + const zeroBudget = declare({ authRequired: false, rateLimit: { enabled: true, maxRequests: 0 } }); + await expect(run(zeroBudget, h)).rejects.toThrow(/showcase_tasks/); + // Fail CLOSED: the alternative is quietly serving traffic the author + // asked to be metered. E7's publish gate is where this should be met. + await expect(run(zeroBudget, h)).rejects.toThrow(/rateLimit/); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +describe('cacheTtl — response-header semantics only', () => { + it('says nothing when the key is absent', async () => { + expect(computeCacheControl(declare(), 'GET')).toBeUndefined(); + const verdict = await run(declare({ authRequired: false }), harness({})); + expect(verdict).toEqual({ verdict: 'pass', responseHeaders: {} }); + }); + + it('sets `private, max-age=` for a positive ttl', async () => { + const verdict = await run(declare({ authRequired: false, cacheTtl: 30 }), harness({})); + expect(verdict.verdict).toBe('pass'); + if (verdict.verdict !== 'pass') return; + expect(verdict.responseHeaders).toEqual({ 'Cache-Control': 'private, max-age=30' }); + }); + + it('is `private` even on an anonymous endpoint — a shared cache must never hold a per-caller answer', () => { + expect(computeCacheControl({ name: 'e', cacheTtl: 60 }, 'GET')).toBe('private, max-age=60'); + }); + + it('reads 0 as "do not cache" rather than as silence', async () => { + // The boundary #5091 asks for. `cacheTtl: 0` is a sentence the author + // wrote; answering it identically to an absent key would make writing it + // a no-op, which is the failure mode this program exists to remove. + expect(computeCacheControl({ name: 'e', cacheTtl: 0 }, 'GET')).toBe('no-store'); + const verdict = await run(declare({ authRequired: false, cacheTtl: 0 }), harness({})); + expect(verdict.verdict).toBe('pass'); + if (verdict.verdict !== 'pass') return; + expect(verdict.responseHeaders).toEqual({ 'Cache-Control': 'no-store' }); + }); + + it('truncates a fractional ttl rather than emitting a fractional max-age', () => { + expect(computeCacheControl({ name: 'e', cacheTtl: 30.7 }, 'GET')).toBe('private, max-age=30'); + }); + + it('refuses to invent a meaning for a negative ttl', () => { + expect(computeCacheControl({ name: 'e', cacheTtl: -5 }, 'GET')).toBe('no-store'); + }); + + it('sends no header on a non-GET endpoint, and says so out loud', () => { + const warnings: string[] = []; + const header = computeCacheControl({ name: 'purge', cacheTtl: 30 }, 'POST', { + warn: (m: string) => { warnings.push(m); }, + }); + expect(header).toBeUndefined(); + expect(warnings.join('\n')).toContain("endpoint 'purge'"); + expect(warnings.join('\n')).toContain('GET-only'); + }); + + it('is not computed for a denied request', async () => { + // Nothing to cache, nothing to say: the denial carries its own headers + // (`Retry-After`) and no cache directive at all. + const denied = await run(declare({ cacheTtl: 30 }), harness({})); + expect(denied.verdict).toBe('deny'); + if (denied.verdict !== 'deny') return; + expect(denied.headers).toBeUndefined(); + expect(JSON.stringify(denied.body)).not.toContain('max-age'); + }); +}); diff --git a/packages/runtime/src/endpoint-policy.ts b/packages/runtime/src/endpoint-policy.ts new file mode 100644 index 0000000000000000000000000000000000000000..200a69cdcaef7b58388c6d2fd5d9237d3e2b07ad GIT binary patch literal 17804 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 { // the first that answers a request no route matched. Listed the day it // was written, for the same reason the line above was. './api-endpoint-step.ts', + // [#5091] The endpoint POLICY keys write the 401 and the 429 that a + // declared endpoint answers — a seventh way onto this wire surface. + // Both bodies deliberately restate an existing seam's (the platform + // anonymous-deny, the server-level limiter's 429), which is exactly the + // situation where a hand-rolled copy is tempting; the scan is what makes + // "restate" mean "call the same builder". + './endpoint-policy.ts', ]; for (const file of MODULES) { From 8c4e9d03c0e580ac0e89b05697eed6eca15d7aa4 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Tue, 4 Aug 2026 05:57:49 +0000 Subject: [PATCH 2/2] fix(runtime): strip two NUL bytes from endpoint-policy.ts cacheKey literal (#5091) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分隔符误写为 \x00:git 判文件为二进制,ESLint / check:nul-bytes 红。 替换为空格,registry 内部键行为等价。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --- packages/runtime/src/endpoint-policy.ts | Bin 17804 -> 17804 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/runtime/src/endpoint-policy.ts b/packages/runtime/src/endpoint-policy.ts index 200a69cdcaef7b58388c6d2fd5d9237d3e2b07ad..7c0225a48c65340e002e0b57a1cf2abe4106c9b6 100644 GIT binary patch delta 35 qcmeC_X6)%^+%Qc}L_wuGIX^EgGhHt^u^=%yv!t?CVe=}vM`{4*Yz=w< delta 35 qcmeC_X6)%^+%Qc}gh8b`IX^EgGhHt^u^=%yv!t??Ve=}vM`{4%j14*f