From da049b77e3c78b37ec58904118fd14554ffec178 Mon Sep 17 00:00:00 2001 From: Hoid Date: Mon, 9 Mar 2026 08:08:37 +0100 Subject: [PATCH] fix(cors): dynamic origin for staging support (BUG-111) + eliminate all 'as any' casts - CORS middleware now allows both docfast.dev and staging.docfast.dev origins for auth/billing routes, with Vary: Origin header for proper caching - Unknown origins fall back to production origin (not reflected) - 13 TDD tests added for CORS behavior Type safety improvements: - Augment Express.Request with requestId, acquirePdfSlot, releasePdfSlot - Use Puppeteer's PaperFormat and PuppeteerLifeCycleEvent types in browser.ts - Use 'as const' for format literals in convert/demo/templates routes - Replace Stripe apiVersion 'as any' with @ts-expect-error - Zero 'as any' casts remaining in production code 579 tests passing (13 new), 51 test files --- src/__tests__/cors-staging.test.ts | 46 ++++++++++++++++++++++++++++++ src/index.ts | 12 ++++++-- src/middleware/pdfRateLimit.ts | 4 +-- src/routes/billing.ts | 3 +- src/routes/convert.ts | 6 ++-- src/routes/demo.ts | 8 +++--- src/routes/templates.ts | 2 +- src/services/browser.ts | 12 ++++---- src/types.ts | 14 +++++++++ 9 files changed, 89 insertions(+), 18 deletions(-) create mode 100644 src/__tests__/cors-staging.test.ts diff --git a/src/__tests__/cors-staging.test.ts b/src/__tests__/cors-staging.test.ts new file mode 100644 index 0000000..58d21b8 --- /dev/null +++ b/src/__tests__/cors-staging.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import supertest from "supertest"; +import { app } from "../index.js"; + +describe("CORS — staging origin support (BUG-111)", () => { + const authRoutes = ["/v1/recover", "/v1/email-change", "/v1/billing", "/v1/demo"]; + + for (const route of authRoutes) { + it(`${route} allows staging origin`, async () => { + const res = await supertest(app) + .options(route) + .set("Origin", "https://staging.docfast.dev") + .set("Access-Control-Request-Method", "POST") + .set("Access-Control-Request-Headers", "Content-Type"); + expect(res.headers["access-control-allow-origin"]).toBe("https://staging.docfast.dev"); + }); + + it(`${route} allows production origin`, async () => { + const res = await supertest(app) + .options(route) + .set("Origin", "https://docfast.dev") + .set("Access-Control-Request-Method", "POST") + .set("Access-Control-Request-Headers", "Content-Type"); + expect(res.headers["access-control-allow-origin"]).toBe("https://docfast.dev"); + }); + + it(`${route} rejects unknown origin`, async () => { + const res = await supertest(app) + .options(route) + .set("Origin", "https://evil.com") + .set("Access-Control-Request-Method", "POST") + .set("Access-Control-Request-Headers", "Content-Type"); + // Should NOT reflect the evil origin + expect(res.headers["access-control-allow-origin"]).not.toBe("https://evil.com"); + }); + } + + it("non-auth routes still allow wildcard origin", async () => { + const res = await supertest(app) + .options("/v1/convert/html") + .set("Origin", "https://random-app.com") + .set("Access-Control-Request-Method", "POST") + .set("Access-Control-Request-Headers", "Content-Type"); + expect(res.headers["access-control-allow-origin"]).toBe("*"); + }); +}); diff --git a/src/index.ts b/src/index.ts index 2f31c02..30f15ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import express, { Request, Response, NextFunction } from "express"; import { randomUUID } from "crypto"; import { AuthenticatedRequest } from "./types.js"; +import "./types.js"; // Augments Express.Request with requestId, acquirePdfSlot, releasePdfSlot import { createRequire } from "module"; import { compressionMiddleware } from "./middleware/compression.js"; import logger from "./services/logger.js"; @@ -36,7 +37,7 @@ app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })); // Request ID + request logging middleware app.use((req, res, next) => { const requestId = (req.headers["x-request-id"] as string) || randomUUID(); - (req as any).requestId = requestId; + req.requestId = requestId; res.setHeader("X-Request-Id", requestId); const start = Date.now(); res.on("finish", () => { @@ -66,6 +67,7 @@ app.use((req, res, next) => { }); // Differentiated CORS middleware +const ALLOWED_ORIGINS = new Set(["https://docfast.dev", "https://staging.docfast.dev"]); app.use((req, res, next) => { const isAuthBillingRoute = req.path.startsWith('/v1/signup') || req.path.startsWith('/v1/recover') || @@ -74,7 +76,13 @@ app.use((req, res, next) => { req.path.startsWith('/v1/email-change'); if (isAuthBillingRoute) { - res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev"); + const origin = req.headers.origin; + if (origin && ALLOWED_ORIGINS.has(origin)) { + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + } else { + res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev"); + } } else { res.setHeader("Access-Control-Allow-Origin", "*"); } diff --git a/src/middleware/pdfRateLimit.ts b/src/middleware/pdfRateLimit.ts index 30005de..ce9fe2d 100644 --- a/src/middleware/pdfRateLimit.ts +++ b/src/middleware/pdfRateLimit.ts @@ -139,8 +139,8 @@ export function pdfRateLimitMiddleware(req: Request, res: Response, next: NextFu } // Add concurrency control to the request (pass apiKey for fairness) - (req as any).acquirePdfSlot = () => acquireConcurrencySlot(apiKey); - (req as any).releasePdfSlot = releaseConcurrencySlot; + req.acquirePdfSlot = () => acquireConcurrencySlot(apiKey); + req.releasePdfSlot = releaseConcurrencySlot; next(); } diff --git a/src/routes/billing.ts b/src/routes/billing.ts index f1ca331..233c8a2 100644 --- a/src/routes/billing.ts +++ b/src/routes/billing.ts @@ -11,7 +11,8 @@ function getStripe(): Stripe { if (!_stripe) { const key = process.env.STRIPE_SECRET_KEY; if (!key) throw new Error("STRIPE_SECRET_KEY not configured"); - _stripe = new Stripe(key, { apiVersion: "2025-01-27.acacia" as any }); + // @ts-expect-error Stripe SDK types lag behind API versions + _stripe = new Stripe(key, { apiVersion: "2025-01-27.acacia" }); } return _stripe; } diff --git a/src/routes/convert.ts b/src/routes/convert.ts index 7551379..9657420 100644 --- a/src/routes/convert.ts +++ b/src/routes/convert.ts @@ -78,7 +78,7 @@ interface ConvertBody { * 500: * description: PDF generation failed */ -convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { +convertRouter.post("/html", async (req: Request, res: Response) => { let slotAcquired = false; try { // Reject non-JSON content types @@ -188,7 +188,7 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi * 500: * description: PDF generation failed */ -convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { +convertRouter.post("/markdown", async (req: Request, res: Response) => { let slotAcquired = false; try { // Reject non-JSON content types @@ -299,7 +299,7 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P * 500: * description: PDF generation failed */ -convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { +convertRouter.post("/url", async (req: Request, res: Response) => { let slotAcquired = false; try { // Reject non-JSON content types diff --git a/src/routes/demo.ts b/src/routes/demo.ts index a60c4d1..c381925 100644 --- a/src/routes/demo.ts +++ b/src/routes/demo.ts @@ -97,7 +97,7 @@ interface DemoBody { * 504: * description: PDF generation timed out */ -router.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { +router.post("/html", async (req: Request, res: Response) => { let slotAcquired = false; try { const ct = req.headers["content-type"] || ""; @@ -128,7 +128,7 @@ router.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise Promise Promise; releasePdfSlot?: () => void }, res: Response) => { +router.post("/markdown", async (req: Request, res: Response) => { let slotAcquired = false; try { const ct = req.headers["content-type"] || ""; @@ -233,7 +233,7 @@ router.post("/markdown", async (req: Request & { acquirePdfSlot?: () => Promise< const fullHtml = injectWatermark(wrapHtml(htmlContent, body.css)); const defaultOpts = { - format: "A4", + format: "A4" as const, landscape: false, printBackground: true, margin: { top: "0", right: "0", bottom: "0", left: "0" }, diff --git a/src/routes/templates.ts b/src/routes/templates.ts index b9c2d40..37a1648 100644 --- a/src/routes/templates.ts +++ b/src/routes/templates.ts @@ -163,7 +163,7 @@ templatesRouter.post("/:id/render", async (req: Request, res: Response) => { res.status(400).json({ error: validation.error }); return; } - const sanitizedPdf = { format: "A4" as string, ...validation.sanitized }; + const sanitizedPdf = { format: "A4" as const, ...validation.sanitized }; const html = renderTemplate(id, data); const { pdf, durationMs } = await renderPdf(html, sanitizedPdf); diff --git a/src/services/browser.ts b/src/services/browser.ts index 338e8d5..0263cfb 100644 --- a/src/services/browser.ts +++ b/src/services/browser.ts @@ -221,8 +221,10 @@ export async function closeBrowser(): Promise { instances.length = 0; } +import type { PaperFormat, PuppeteerLifeCycleEvent } from "puppeteer"; + export interface PdfRenderOptions { - format?: string; + format?: PaperFormat; landscape?: boolean; margin?: { top?: string; right?: string; bottom?: string; left?: string }; printBackground?: boolean; @@ -250,7 +252,7 @@ export async function renderPdf( await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 15_000 }); await page.addStyleTag({ content: "* { margin: 0; padding: 0; } body { margin: 0; }" }); const pdf = await page.pdf({ - format: (options.format as any) || "A4", + format: options.format || "A4", landscape: options.landscape || false, printBackground: options.printBackground !== false, margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" }, @@ -280,7 +282,7 @@ export async function renderPdf( export async function renderUrlPdf( url: string, options: PdfRenderOptions & { - waitUntil?: string; + waitUntil?: PuppeteerLifeCycleEvent; hostResolverRules?: string; } = {} ): Promise<{ pdf: Buffer; durationMs: number }> { @@ -325,11 +327,11 @@ export async function renderUrlPdf( const result = await Promise.race([ (async () => { await page.goto(url, { - waitUntil: (options.waitUntil as any) || "domcontentloaded", + waitUntil: options.waitUntil || "domcontentloaded", timeout: 30_000, }); const pdf = await page.pdf({ - format: (options.format as any) || "A4", + format: options.format || "A4", landscape: options.landscape || false, printBackground: options.printBackground !== false, margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" }, diff --git a/src/types.ts b/src/types.ts index ca4ab70..8ae5cd5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,3 +8,17 @@ import { ApiKey } from "./services/keys.js"; export interface AuthenticatedRequest extends Request { apiKeyInfo: ApiKey; } + +// Augment Express Request with custom properties added by middleware +declare global { + namespace Express { + interface Request { + /** Unique request ID (set by request logging middleware) */ + requestId?: string; + /** Acquire a PDF concurrency slot (set by pdfRateLimitMiddleware) */ + acquirePdfSlot?: () => Promise; + /** Release a PDF concurrency slot (set by pdfRateLimitMiddleware) */ + releasePdfSlot?: () => void; + } + } +}