From 23b98a669658333f59d83517cfea899ee5ee714e Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sat, 1 Aug 2026 01:59:47 +0900 Subject: [PATCH 1/2] fix: keep checked-in examples secure by default --- examples/quick-start-lambda/README.md | 2 + examples/quick-start-lambda/package.json | 1 + .../quick-start-lambda/src/app/bootstrap.ts | 43 ++++- examples/saas-billing-golden-path/README.md | 2 + .../saas-billing-golden-path/package.json | 1 + .../src/app/bootstrap.ts | 40 +++- pnpm-lock.yaml | 6 + scripts/first-success-verify.mts | 172 +++++++++++++++++- scripts/tests/first-success-verify.spec.ts | 109 ++++++++++- 9 files changed, 367 insertions(+), 9 deletions(-) diff --git a/examples/quick-start-lambda/README.md b/examples/quick-start-lambda/README.md index c73bdc7ad..88fc1063e 100644 --- a/examples/quick-start-lambda/README.md +++ b/examples/quick-start-lambda/README.md @@ -43,6 +43,8 @@ src/ `TestAuthProvider` can be replaced with Clerk, Auth0, or custom auth without changing `UserController` or `UserService`. The in-memory metering setup can be replaced with provider-backed storage without changing the controller or domain service. `createApp().lambdaHandler()` is the transport boundary for Lambda; the protocol and domain code remain transport-neutral. +The HTTP bootstrap uses security headers, an explicit CORS origin, a 1 MB body limit, and an in-memory sliding-window rate limiter. These middlewares satisfy Croco's default security validation without cloud credentials. Disabling security validation is reserved for temporary local migration or test fixtures, not the normal example path. + ## Run Locally ```bash diff --git a/examples/quick-start-lambda/package.json b/examples/quick-start-lambda/package.json index dd7e2af20..6d4e47a45 100644 --- a/examples/quick-start-lambda/package.json +++ b/examples/quick-start-lambda/package.json @@ -12,6 +12,7 @@ "@croco/framework-context": "workspace:*", "@croco/metering-core": "workspace:*", "@croco/protocols-rest": "workspace:*", + "@croco/ratelimit-core": "workspace:*", "@croco/telemetry-sdk-node": "workspace:*", "@croco/transports-http": "workspace:*", "reflect-metadata": "^0.2.2" diff --git a/examples/quick-start-lambda/src/app/bootstrap.ts b/examples/quick-start-lambda/src/app/bootstrap.ts index 4590f9c02..03cea4711 100644 --- a/examples/quick-start-lambda/src/app/bootstrap.ts +++ b/examples/quick-start-lambda/src/app/bootstrap.ts @@ -1,14 +1,32 @@ import { AUTH_PROVIDER_TOKEN, AuthGuard } from "@croco/auth-core"; -import { Container, type ILogger, LOGGER_TOKEN } from "@croco/framework-context"; +import { Container, LOGGER_TOKEN } from "@croco/framework-context"; import { setMeteringService } from "@croco/metering-core"; -import { createApp } from "@croco/transports-http"; +import { + createSlidingWindowPolicy, + RateLimiter, + RateLimitKeyBuilder, + SlidingWindowInMemoryStore, +} from "@croco/ratelimit-core"; +import { + bodyLimitMiddleware, + corsMiddleware, + createApp, + createRuntimeAwareRateLimitClientIdentityPolicy, + mb, + rateLimitHttpMiddleware, + securityHeadersMiddleware, +} from "@croco/transports-http"; import { createMeteringService } from "../integrations/inMemoryMetering"; import { TestAuthProvider } from "../integrations/TestAuthProvider"; import { HealthController } from "../protocols/HealthController"; import { UserController } from "../protocols/UserController"; +import type { ILogger } from "@croco/framework-context"; +import type { MiddlewareFunction } from "@croco/transports-http"; type LambdaExampleApp = ReturnType; +const RATE_LIMIT_BYPASS_PATHS = new Set(["/api/health"]); + const demoLogger: ILogger = { debug: (message, context) => { if (context === undefined) { @@ -46,7 +64,26 @@ export function createLambdaExampleApp(): LambdaExampleApp { return createApp({ controllers: [HealthController, UserController], - securityValidation: "off", + middlewares: [ + securityHeadersMiddleware(), + corsMiddleware({ origins: [process.env.WEB_ORIGIN ?? "http://localhost:5173"] }), + bodyLimitMiddleware({ limit: mb(1) }), + createApiRateLimitMiddleware(), + ], + }); +} + +function createApiRateLimitMiddleware(): MiddlewareFunction { + const rateLimiter = new RateLimiter( + new SlidingWindowInMemoryStore(), + new RateLimitKeyBuilder(["ip"]), + ); + + return rateLimitHttpMiddleware({ + rateLimiter, + policy: createSlidingWindowPolicy("api", 100, 60_000), + clientIdentity: createRuntimeAwareRateLimitClientIdentityPolicy(), + skip: (ctx) => RATE_LIMIT_BYPASS_PATHS.has(ctx.req.path), }); } diff --git a/examples/saas-billing-golden-path/README.md b/examples/saas-billing-golden-path/README.md index 07ddad7e4..9312472be 100644 --- a/examples/saas-billing-golden-path/README.md +++ b/examples/saas-billing-golden-path/README.md @@ -24,6 +24,8 @@ after-commit audit entry. Failure states: invalid checkout input returns `golden-path/checkout-validation`; terminal card decline returns `golden-path/payment-declined` without retrying or persisting an order; missing orders return `golden-path/order-not-found`. +The HTTP bootstrap uses security headers, an explicit CORS origin, a 1 MB body limit, and an in-memory sliding-window rate limiter. These middlewares satisfy Croco's default security validation without external credentials. Disabling security validation is reserved for temporary local migration or test fixtures, not the normal example path. + ## Run Locally From the repository root: diff --git a/examples/saas-billing-golden-path/package.json b/examples/saas-billing-golden-path/package.json index c2b7a0c78..014846165 100644 --- a/examples/saas-billing-golden-path/package.json +++ b/examples/saas-billing-golden-path/package.json @@ -15,6 +15,7 @@ "@croco/framework-context": "workspace:*", "@croco/problems-core": "workspace:*", "@croco/protocols-rest": "workspace:*", + "@croco/ratelimit-core": "workspace:*", "@croco/retry-core": "workspace:*", "@croco/telemetry-api": "workspace:*", "@croco/transports-http": "workspace:*", diff --git a/examples/saas-billing-golden-path/src/app/bootstrap.ts b/examples/saas-billing-golden-path/src/app/bootstrap.ts index 9905b893a..693d88899 100644 --- a/examples/saas-billing-golden-path/src/app/bootstrap.ts +++ b/examples/saas-billing-golden-path/src/app/bootstrap.ts @@ -1,7 +1,21 @@ import { EventBusConfig } from "@croco/events-core"; import { InMemoryEventBus } from "@croco/events-inmemory"; -import { Container, type ILogger, LOGGER_TOKEN } from "@croco/framework-context"; -import { createApp, type CrocoApp } from "@croco/transports-http"; +import { Container, LOGGER_TOKEN } from "@croco/framework-context"; +import { + createSlidingWindowPolicy, + RateLimiter, + RateLimitKeyBuilder, + SlidingWindowInMemoryStore, +} from "@croco/ratelimit-core"; +import { + bodyLimitMiddleware, + corsMiddleware, + createApp, + createRuntimeAwareRateLimitClientIdentityPolicy, + mb, + rateLimitHttpMiddleware, + securityHeadersMiddleware, +} from "@croco/transports-http"; import { TxManager, TxManagerRegistry } from "@croco/tx-core"; import { CheckoutService } from "../domain/CheckoutService"; import { InMemoryOrderRepository, ORDER_REPOSITORY_TOKEN } from "../domain/InMemoryOrderRepository"; @@ -14,6 +28,8 @@ import { ScriptedPaymentGateway, } from "../integrations/ScriptedPaymentGateway"; import { BillingController } from "../protocols/BillingController"; +import type { ILogger } from "@croco/framework-context"; +import type { CrocoApp, MiddlewareFunction } from "@croco/transports-http"; export type GoldenPathRuntime = { readonly app: CrocoApp; @@ -65,7 +81,12 @@ export async function createGoldenPathRuntime(): Promise { const app = createApp({ controllers: [BillingController], - securityValidation: "off", + middlewares: [ + securityHeadersMiddleware(), + corsMiddleware({ origins: [process.env.WEB_ORIGIN ?? "http://localhost:5173"] }), + bodyLimitMiddleware({ limit: mb(1) }), + createApiRateLimitMiddleware(), + ], }); return { @@ -82,6 +103,19 @@ export async function createGoldenPathRuntime(): Promise { }; } +function createApiRateLimitMiddleware(): MiddlewareFunction { + const rateLimiter = new RateLimiter( + new SlidingWindowInMemoryStore(), + new RateLimitKeyBuilder(["ip"]), + ); + + return rateLimitHttpMiddleware({ + rateLimiter, + policy: createSlidingWindowPolicy("api", 100, 60_000), + clientIdentity: createRuntimeAwareRateLimitClientIdentityPolicy(), + }); +} + export function startLocalServer(app: CrocoApp): void { const port = parseLocalPort(process.env.PORT); if (port === undefined) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f1d0ebf7..b238fb6f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,9 @@ importers: '@croco/protocols-rest': specifier: workspace:* version: link:../../packages/protocols-rest + '@croco/ratelimit-core': + specifier: workspace:* + version: link:../../packages/ratelimit-core '@croco/telemetry-sdk-node': specifier: workspace:* version: link:../../packages/telemetry-sdk-node @@ -167,6 +170,9 @@ importers: '@croco/protocols-rest': specifier: workspace:* version: link:../../packages/protocols-rest + '@croco/ratelimit-core': + specifier: workspace:* + version: link:../../packages/ratelimit-core '@croco/retry-core': specifier: workspace:* version: link:../../packages/retry-core diff --git a/scripts/first-success-verify.mts b/scripts/first-success-verify.mts index c90af205e..fd1d95b80 100644 --- a/scripts/first-success-verify.mts +++ b/scripts/first-success-verify.mts @@ -8,9 +8,9 @@ * Exit: 0 = all contracts pass, 1 = any contract fails */ -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, extname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import type * as CreateCrocoAppVerification from "../packages/create-croco-app/src/verification.ts"; import { validateGeneratedSaasDocsContract } from "./first-success-generated-contract.mts"; @@ -631,6 +631,55 @@ function formatSpineStatusSummary(status: SpineStatusCounts): string { const ROOT = readRootArg(); const QUICK_START_DIR = join(ROOT, "examples", "quick-start-lambda"); const SAAS_BILLING_DIR = join(ROOT, "examples", "saas-billing-golden-path"); +const REQUIRED_SECURITY_MIDDLEWARES = [ + "securityHeadersMiddleware", + "corsMiddleware", + "bodyLimitMiddleware", + "rateLimitHttpMiddleware", +] as const; +const REQUIRED_SECURITY_DOC_SNIPPETS = [ + "security headers", + "CORS", + "body limit", + "rate limiter", + "credentials", + "Disabling security validation", +] as const; +const SECURITY_VALIDATION_SCAN_FILE_EXTENSIONS = new Set([ + ".js", + ".jsx", + ".mjs", + ".cjs", + ".ts", + ".tsx", + ".mts", + ".cts", + ".json", + ".toml", + ".yaml", + ".yml", +]); +const SECURITY_VALIDATION_SCAN_FILE_NAMES = new Set([ + ".env", + ".env.example", + ".env.local", + ".env.development", + ".env.production", +]); +const SECURITY_VALIDATION_SCAN_IGNORED_DIRECTORIES = new Set([ + ".git", + ".output", + ".turbo", + ".wrangler", + "coverage", + "dist", + "node_modules", +]); +const UNSAFE_SECURITY_VALIDATION_PATTERNS = [ + /\bsecurityValidation\b/, + /unsafeSkipSecurityValidation\s*:\s*true/, + /\bCROCO_HTTP_SECURITY_VALIDATION\b/, +]; const paths = { rootReadme: join(ROOT, "README.md"), @@ -638,9 +687,11 @@ const paths = { healthController: join(QUICK_START_DIR, "src", "protocols", "HealthController.ts"), userController: join(QUICK_START_DIR, "src", "protocols", "UserController.ts"), authProvider: join(QUICK_START_DIR, "src", "integrations", "TestAuthProvider.ts"), + quickStartBootstrap: join(QUICK_START_DIR, "src", "app", "bootstrap.ts"), examplePkg: join(QUICK_START_DIR, "package.json"), saasReadme: join(SAAS_BILLING_DIR, "README.md"), saasPkg: join(SAAS_BILLING_DIR, "package.json"), + saasBootstrap: join(SAAS_BILLING_DIR, "src", "app", "bootstrap.ts"), saasBillingController: join(SAAS_BILLING_DIR, "src", "protocols", "BillingController.ts"), saasCheckoutService: join(SAAS_BILLING_DIR, "src", "domain", "CheckoutService.ts"), saasGoldenPathSpec: join(SAAS_BILLING_DIR, "src", "tests", "golden-path.spec.ts"), @@ -671,6 +722,7 @@ console.log("\nšŸ“‹ A. Quick-start-lambda endpoint contract\n"); { const readme = read(paths.readme); const examplePkg = read(paths.examplePkg); + const bootstrap = read(paths.quickStartBootstrap); const rootPkg = read(join(ROOT, "package.json")); // README documents pnpm install + pnpm dev @@ -714,6 +766,7 @@ console.log("\nšŸ“‹ A. Quick-start-lambda endpoint contract\n"); } const rootPackageJson = parsePackageJson(rootPkg); + const examplePackageJson = parsePackageJson(examplePkg); const smokeScript: string | undefined = rootPackageJson.scripts?.["quick-start-lambda:smoke"]; if (!smokeScript) { fail("A1e", "root package.json missing `quick-start-lambda:smoke` script"); @@ -728,6 +781,49 @@ console.log("\nšŸ“‹ A. Quick-start-lambda endpoint contract\n"); } else { pass("A1e", "root package.json exposes `quick-start-lambda:smoke`"); } + + const unsafeSecurityValidationFiles = findUnsafeSecurityValidationFiles(QUICK_START_DIR); + if (unsafeSecurityValidationFiles.length > 0) { + fail( + "A1g", + `quick-start-lambda must not bypass default security validation: ${unsafeSecurityValidationFiles.join(", ")}`, + ); + } else { + pass("A1g", "quick-start-lambda uses default security validation"); + } + + for (const middleware of REQUIRED_SECURITY_MIDDLEWARES) { + if (!bootstrap.includes(`${middleware}(`)) { + fail("A1h", `quick-start-lambda bootstrap missing ${middleware}`); + } + } + if (REQUIRED_SECURITY_MIDDLEWARES.every((middleware) => bootstrap.includes(`${middleware}(`))) { + pass("A1h", "quick-start-lambda configures all required security middleware capabilities"); + } + + if (examplePackageJson.dependencies?.["@croco/ratelimit-core"] !== "workspace:*") { + fail("A1i", "quick-start-lambda must declare @croco/ratelimit-core as a workspace dependency"); + } else { + pass("A1i", "quick-start-lambda declares its rate-limit dependency"); + } + + if (!bootstrap.includes("new SlidingWindowInMemoryStore()")) { + fail("A1k", "quick-start-lambda rate limiter must use SlidingWindowInMemoryStore"); + } else { + pass("A1k", "quick-start-lambda rate limiter is credential-free"); + } + + const missingSecurityDocSnippets = REQUIRED_SECURITY_DOC_SNIPPETS.filter( + (snippet) => !readme.includes(snippet), + ); + if (missingSecurityDocSnippets.length > 0) { + fail( + "A1j", + `quick-start-lambda README missing secure bootstrap rationale: ${missingSecurityDocSnippets.join(", ")}`, + ); + } else { + pass("A1j", "quick-start-lambda README documents the secure bootstrap posture"); + } } // A2. Health endpoint: GET /api/health returns { status: "ok" } @@ -885,6 +981,7 @@ console.log("\nšŸ“‹ D. SaaS billing golden-path contract\n"); const examplePkg = read(paths.saasPkg); const rootPkg = read(join(ROOT, "package.json")); const billingController = read(paths.saasBillingController); + const bootstrap = read(paths.saasBootstrap); const checkoutService = read(paths.saasCheckoutService); const goldenPathSpec = read(paths.saasGoldenPathSpec); const gettingStarted = read(paths.gettingStarted); @@ -1011,6 +1108,77 @@ console.log("\nšŸ“‹ D. SaaS billing golden-path contract\n"); } else { pass("S7b", "Getting started docs document SaaS billing golden-path smoke command"); } + + const unsafeSecurityValidationFiles = findUnsafeSecurityValidationFiles(SAAS_BILLING_DIR); + if (unsafeSecurityValidationFiles.length > 0) { + fail( + "S8a", + `SaaS billing example must not bypass default security validation: ${unsafeSecurityValidationFiles.join(", ")}`, + ); + } else { + pass("S8a", "SaaS billing example uses default security validation"); + } + + for (const middleware of REQUIRED_SECURITY_MIDDLEWARES) { + if (!bootstrap.includes(`${middleware}(`)) { + fail("S8b", `SaaS billing bootstrap missing ${middleware}`); + } + } + if (REQUIRED_SECURITY_MIDDLEWARES.every((middleware) => bootstrap.includes(`${middleware}(`))) { + pass("S8b", "SaaS billing example configures all required security middleware capabilities"); + } + + if (pkg.dependencies?.["@croco/ratelimit-core"] !== "workspace:*") { + fail( + "S8c", + "SaaS billing example must declare @croco/ratelimit-core as a workspace dependency", + ); + } else { + pass("S8c", "SaaS billing example declares its rate-limit dependency"); + } + + if (!bootstrap.includes("new SlidingWindowInMemoryStore()")) { + fail("S8e", "SaaS billing rate limiter must use SlidingWindowInMemoryStore"); + } else { + pass("S8e", "SaaS billing rate limiter is credential-free"); + } + + const missingSecurityDocSnippets = REQUIRED_SECURITY_DOC_SNIPPETS.filter( + (snippet) => !readme.includes(snippet), + ); + if (missingSecurityDocSnippets.length > 0) { + fail( + "S8d", + `SaaS billing README missing secure bootstrap rationale: ${missingSecurityDocSnippets.join(", ")}`, + ); + } else { + pass("S8d", "SaaS billing README documents the secure bootstrap posture"); + } +} + +function findUnsafeSecurityValidationFiles(directory: string): string[] { + return collectSecurityValidationScanFiles(directory).filter((filePath) => { + const content = read(filePath); + return UNSAFE_SECURITY_VALIDATION_PATTERNS.some((pattern) => pattern.test(content)); + }); +} + +function collectSecurityValidationScanFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = join(directory, entry.name); + + if (entry.isDirectory()) { + return SECURITY_VALIDATION_SCAN_IGNORED_DIRECTORIES.has(entry.name) + ? [] + : collectSecurityValidationScanFiles(entryPath); + } + + return SECURITY_VALIDATION_SCAN_FILE_EXTENSIONS.has(extname(entry.name)) || + SECURITY_VALIDATION_SCAN_FILE_NAMES.has(entry.name) || + entry.name.startsWith(".env.") + ? [entryPath] + : []; + }); } // ── E. Docs contract ──────────────────────────────────────────────────────── diff --git a/scripts/tests/first-success-verify.spec.ts b/scripts/tests/first-success-verify.spec.ts index c52314753..7d94ab84c 100644 --- a/scripts/tests/first-success-verify.spec.ts +++ b/scripts/tests/first-success-verify.spec.ts @@ -225,6 +225,81 @@ describe("first-success-verify.mts", () => { expect(result.stdout).toContain("SaaS README missing root smoke command"); }); + it("fails when the quick-start bootstrap bypasses security validation", () => { + const root = createFixture(); + writeFile( + root, + "examples/quick-start-lambda/src/app/bootstrap.ts", + secureBootstrapFixture('securityValidation: "off",'), + ); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("A1g"); + expect(result.stdout).toContain("must not bypass default security validation"); + }); + + it("fails when a non-bootstrap example file uses the alternate security bypass", () => { + const root = createFixture(); + writeFile( + root, + "examples/quick-start-lambda/src/local-demo.ts", + "createApp({ unsafeSkipSecurityValidation: true });\n", + ); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("A1g"); + expect(result.stdout).toContain("local-demo.ts"); + }); + + it("fails when an example environment file overrides security validation", () => { + const root = createFixture(); + writeFile( + root, + "examples/saas-billing-golden-path/.env.staging", + "CROCO_HTTP_SECURITY_VALIDATION: off\n", + ); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("S8a"); + expect(result.stdout).toContain(".env.staging"); + }); + + it("fails when the SaaS bootstrap omits a required security capability", () => { + const root = createFixture(); + writeFile( + root, + "examples/saas-billing-golden-path/src/app/bootstrap.ts", + secureBootstrapFixture().replace("rateLimitHttpMiddleware(),", ""), + ); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("S8b"); + expect(result.stdout).toContain("missing rateLimitHttpMiddleware"); + }); + + it("fails when an example rate limiter does not use the credential-free store", () => { + const root = createFixture(); + writeFile( + root, + "examples/saas-billing-golden-path/src/app/bootstrap.ts", + secureBootstrapFixture().replace("SlidingWindowInMemoryStore", "ExternalRateLimitStore"), + ); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("S8e"); + expect(result.stdout).toContain("must use SlidingWindowInMemoryStore"); + }); + it("fails when the root SaaS smoke script no longer builds workspace dependencies before tests", () => { const root = createFixture({ rootSaasSmokeScript: `pnpm --filter ${saasPackageName} test`, @@ -494,13 +569,21 @@ function createFixture(options: FixtureOptions = {}): string { "x-api-key: test-key", "401", "api_user_create", + "The HTTP bootstrap uses security headers, CORS, a body limit, and an in-memory rate limiter without credentials. Disabling security validation is reserved for temporary local migration or test fixtures.", "", ].join("\n"), ); writeFile( root, "examples/quick-start-lambda/package.json", - JSON.stringify({ scripts: { dev: "tsx src/index.ts" } }, null, 2), + JSON.stringify( + { + dependencies: { "@croco/ratelimit-core": "workspace:*" }, + scripts: { dev: "tsx src/index.ts" }, + }, + null, + 2, + ), ); writeFile( root, @@ -515,6 +598,7 @@ function createFixture(options: FixtureOptions = {}): string { "```bash", ...saasReadmeCommands, "```", + "The HTTP bootstrap uses security headers, CORS, a body limit, and an in-memory rate limiter without credentials. Disabling security validation is reserved for temporary local migration or test fixtures.", "", ].join("\n"), ); @@ -523,6 +607,7 @@ function createFixture(options: FixtureOptions = {}): string { "examples/saas-billing-golden-path/package.json", JSON.stringify( { + dependencies: { "@croco/ratelimit-core": "workspace:*" }, scripts: { build: "tsc --noEmit", dev: "tsx src/index.ts", @@ -580,6 +665,12 @@ function createFixture(options: FixtureOptions = {}): string { "", ].join("\n"), ); + writeFile(root, "examples/quick-start-lambda/src/app/bootstrap.ts", secureBootstrapFixture()); + writeFile( + root, + "examples/saas-billing-golden-path/src/app/bootstrap.ts", + secureBootstrapFixture(), + ); writeFile( root, "examples/quick-start-lambda/src/index.ts", @@ -745,6 +836,22 @@ function createFixture(options: FixtureOptions = {}): string { return root; } +function secureBootstrapFixture(extraConfig = ""): string { + return [ + "createApp({", + extraConfig, + "middlewares: [", + "securityHeadersMiddleware(),", + "corsMiddleware(),", + "bodyLimitMiddleware(),", + "new SlidingWindowInMemoryStore(),", + "rateLimitHttpMiddleware(),", + "],", + "});", + "", + ].join("\n"); +} + function writeFile(root: string, relativePath: string, content: string): void { const filePath = join(root, relativePath); mkdirSync(dirname(filePath), { recursive: true }); From bac8a3533160a73688d1685d3b5b2b25c096575a Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sat, 1 Aug 2026 23:00:48 +0900 Subject: [PATCH 2/2] fix: verify example rate limiters are wired --- scripts/first-success-verify.mts | 46 +++++++++++++++++++--- scripts/tests/first-success-verify.spec.ts | 27 +++++++++++-- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/scripts/first-success-verify.mts b/scripts/first-success-verify.mts index fd1d95b80..261819c99 100644 --- a/scripts/first-success-verify.mts +++ b/scripts/first-success-verify.mts @@ -148,6 +148,34 @@ function runsVerificationScript( }); } +function hasCredentialFreeWiredRateLimiter(bootstrap: string): boolean { + const constructorMatch = bootstrap.match( + /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+RateLimiter\s*\(\s*new\s+SlidingWindowInMemoryStore\s*\(\s*\)\s*,/, + ); + if (!constructorMatch) { + return false; + } + + const variableName = constructorMatch[1]; + const middlewareMatch = bootstrap.match(/\brateLimitHttpMiddleware\s*\(\s*\{([\s\S]*?)\}\s*\)/); + if (!variableName || !middlewareMatch) { + return false; + } + + const middlewareOptions = middlewareMatch[1] ?? ""; + if ( + variableName === "rateLimiter" && + /(?:^|,)\s*rateLimiter\s*(?:,|$)/m.test(middlewareOptions) + ) { + return true; + } + + const escapedVariableName = variableName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(?:^|,)\\s*rateLimiter\\s*:\\s*${escapedVariableName}\\s*(?:,|$)`, "m").test( + middlewareOptions, + ); +} + function readRootArg(): string { const rootIndex = process.argv.indexOf("--root"); @@ -807,10 +835,13 @@ console.log("\nšŸ“‹ A. Quick-start-lambda endpoint contract\n"); pass("A1i", "quick-start-lambda declares its rate-limit dependency"); } - if (!bootstrap.includes("new SlidingWindowInMemoryStore()")) { - fail("A1k", "quick-start-lambda rate limiter must use SlidingWindowInMemoryStore"); + if (!hasCredentialFreeWiredRateLimiter(bootstrap)) { + fail( + "A1k", + "quick-start-lambda rate limiter must use SlidingWindowInMemoryStore and pass it to rateLimitHttpMiddleware", + ); } else { - pass("A1k", "quick-start-lambda rate limiter is credential-free"); + pass("A1k", "quick-start-lambda rate limiter is credential-free and wired to HTTP middleware"); } const missingSecurityDocSnippets = REQUIRED_SECURITY_DOC_SNIPPETS.filter( @@ -1137,10 +1168,13 @@ console.log("\nšŸ“‹ D. SaaS billing golden-path contract\n"); pass("S8c", "SaaS billing example declares its rate-limit dependency"); } - if (!bootstrap.includes("new SlidingWindowInMemoryStore()")) { - fail("S8e", "SaaS billing rate limiter must use SlidingWindowInMemoryStore"); + if (!hasCredentialFreeWiredRateLimiter(bootstrap)) { + fail( + "S8e", + "SaaS billing rate limiter must use SlidingWindowInMemoryStore and pass it to rateLimitHttpMiddleware", + ); } else { - pass("S8e", "SaaS billing rate limiter is credential-free"); + pass("S8e", "SaaS billing rate limiter is credential-free and wired to HTTP middleware"); } const missingSecurityDocSnippets = REQUIRED_SECURITY_DOC_SNIPPETS.filter( diff --git a/scripts/tests/first-success-verify.spec.ts b/scripts/tests/first-success-verify.spec.ts index 7d94ab84c..d8091a09e 100644 --- a/scripts/tests/first-success-verify.spec.ts +++ b/scripts/tests/first-success-verify.spec.ts @@ -275,7 +275,7 @@ describe("first-success-verify.mts", () => { writeFile( root, "examples/saas-billing-golden-path/src/app/bootstrap.ts", - secureBootstrapFixture().replace("rateLimitHttpMiddleware(),", ""), + secureBootstrapFixture().replace("rateLimitHttpMiddleware({ rateLimiter }),", ""), ); const result = runScript(root); @@ -300,6 +300,24 @@ describe("first-success-verify.mts", () => { expect(result.stdout).toContain("must use SlidingWindowInMemoryStore"); }); + it("fails when an example does not pass its credential-free rate limiter to HTTP middleware", () => { + const root = createFixture(); + writeFile( + root, + "examples/saas-billing-golden-path/src/app/bootstrap.ts", + secureBootstrapFixture().replace( + "rateLimitHttpMiddleware({ rateLimiter })", + "rateLimitHttpMiddleware({ rateLimiter: anotherRateLimiter })", + ), + ); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("S8e"); + expect(result.stdout).toContain("pass it to rateLimitHttpMiddleware"); + }); + it("fails when the root SaaS smoke script no longer builds workspace dependencies before tests", () => { const root = createFixture({ rootSaasSmokeScript: `pnpm --filter ${saasPackageName} test`, @@ -838,14 +856,17 @@ function createFixture(options: FixtureOptions = {}): string { function secureBootstrapFixture(extraConfig = ""): string { return [ + "const rateLimiter = new RateLimiter(", + "new SlidingWindowInMemoryStore(),", + 'new RateLimitKeyBuilder(["ip"]),', + ");", "createApp({", extraConfig, "middlewares: [", "securityHeadersMiddleware(),", "corsMiddleware(),", "bodyLimitMiddleware(),", - "new SlidingWindowInMemoryStore(),", - "rateLimitHttpMiddleware(),", + "rateLimitHttpMiddleware({ rateLimiter }),", "],", "});", "",