because I got bored of customising my CV for every job
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(security): Helmet+CSP, Origin-based CSRF protection, default-deny iframe sandbox

Three launch-blocking security fixes wired in apps/api/src/main.ts boot order: helmet first (security headers), cookieParser, CORS, then CSRF middleware. CVG-4: Helmet config in apps/api/src/config/helmet.configuration.ts - CSP enforced in prod, report-only in dev. Allowlist tuned for Apollo Sandbox CDN, Google Fonts, same-origin everything else. HSTS only in prod. X-Frame-Options DENY, hidePoweredBy, strict-origin-when-cross-origin referrer. CVG-6: Origin/Referer check on all state-changing requests. Browsers always send Origin and it can't be forged from a cross-site context. Combined with the existing sameSite:strict cookies (production) this is robust CSRF protection. Token-based double-submit pattern is defense-in-depth and tracked separately because it requires Apollo client link changes. CVViewPage iframe: sandbox=allow-same-origin allow-modals -> sandbox= (default deny). The CV preview srcDoc is user-supplied HTML (sanitized server-side via DOMPurify but defense-in-depth). Print button now uses parent window.print() with the existing .no-print toolbar class instead of iframe.contentWindow.print() so we don't need allow-same-origin.

+180 -2
+1
.docker/.manifests/packages__file-upload__package.json
··· 10 10 "test:watch": "vitest" 11 11 }, 12 12 "dependencies": { 13 + "@cv/utils": "*", 13 14 "@nestjs/common": "^11.1.3", 14 15 "canvas": "^3.1.0", 15 16 "mammoth": "^1.6.0",
+1
apps/api/package.json
··· 71 71 "graphql-scalars": "^1.23.0", 72 72 "graphql-type-json": "^0.3.2", 73 73 "handlebars": "^4.7.9", 74 + "helmet": "^8.0.0", 74 75 "mammoth": "^1.11.0", 75 76 "multer": "^2.0.2", 76 77 "nest-commander": "^3.16.0",
+81
apps/api/src/config/csrf.middleware.ts
··· 1 + import type { NextFunction, Request, Response } from "express"; 2 + import type { CorsConfigService } from "./cors.config"; 3 + 4 + const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); 5 + 6 + const getOriginFromHeaders = (req: Request): string | undefined => { 7 + const origin = req.headers.origin; 8 + if (typeof origin === "string" && origin.length > 0) { 9 + return origin; 10 + } 11 + const referer = req.headers.referer; 12 + if (typeof referer !== "string" || referer.length === 0) { 13 + return undefined; 14 + } 15 + // Strip everything after origin so a malicious referer can't masquerade 16 + // by appending a trusted origin in the path. 17 + try { 18 + return new URL(referer).origin; 19 + } catch { 20 + return undefined; 21 + } 22 + }; 23 + 24 + /** 25 + * CSRF protection by Origin/Referer check. 26 + * 27 + * Browsers always send Origin (or Referer fallback for older browsers) on 28 + * cross-origin requests, and the value can't be forged by an attacker page. 29 + * Combined with `sameSite: strict` cookies (which prevent the auth cookie 30 + * from being sent at all on cross-site mutations), this is robust CSRF 31 + * protection without requiring an explicit token. 32 + * 33 + * Token-based double-submit cookie is a defense-in-depth follow-up; tracked 34 + * separately because it requires client-side Apollo link changes. 35 + * 36 + * Skips safe methods (GET/HEAD/OPTIONS) per RFC 7231 - they MUST be 37 + * idempotent and any mutating GET is already a security bug. 38 + */ 39 + export const createCsrfMiddleware = (corsConfig: CorsConfigService) => { 40 + const isDevelopment = corsConfig.isDevelopment(); 41 + return (req: Request, res: Response, next: NextFunction): void => { 42 + if (SAFE_METHODS.has(req.method.toUpperCase())) { 43 + next(); 44 + return; 45 + } 46 + 47 + const origin = getOriginFromHeaders(req); 48 + 49 + // Same-origin requests from non-browser callers (curl, server-to-server) 50 + // typically don't send Origin. In dev that's fine; in prod we block them. 51 + // If you need API-to-API access, use a bearer token endpoint and a 52 + // separate auth path that doesn't depend on cookies. 53 + if (!origin) { 54 + if (isDevelopment) { 55 + next(); 56 + return; 57 + } 58 + res.status(403).json({ 59 + error: "Forbidden", 60 + message: "Origin or Referer header required for state-changing requests", 61 + }); 62 + return; 63 + } 64 + 65 + const allowed = corsConfig.getAllowedOrigins(); 66 + const isAllowed = 67 + allowed.includes(origin) || 68 + (isDevelopment && 69 + /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?$/.test(origin)); 70 + 71 + if (!isAllowed) { 72 + res.status(403).json({ 73 + error: "Forbidden", 74 + message: `Origin '${origin}' not allowed`, 75 + }); 76 + return; 77 + } 78 + 79 + next(); 80 + }; 81 + };
+70
apps/api/src/config/helmet.configuration.ts
··· 1 + import type { ConfigService } from "@nestjs/config"; 2 + import type { HelmetOptions } from "helmet"; 3 + 4 + /** 5 + * Helmet config tuned for the cv-generator API. 6 + * 7 + * - CSP is enforced in production, report-only in dev so a noisy violation 8 + * doesn't block local work while a tightening pass is in progress. 9 + * - GraphQL Playground / introspection runs at /graphql; its asset origin 10 + * needs to be in the script/style allowlist when enabled. We keep the 11 + * default-src tight and explicitly open script/style for the playground 12 + * CDN. Production introspection is controlled separately. 13 + * - The CV preview iframe consumes `srcDoc` (data URL inlined into the parent 14 + * page), not a remote frame; no `frame-src` opening is needed. 15 + * - PDFs are downloaded as blobs from /api/cv/:id/pdf - same-origin. 16 + */ 17 + export const configureHelmet = (config: ConfigService): HelmetOptions => { 18 + const isProduction = config.get<string>("NODE_ENV") === "production"; 19 + 20 + return { 21 + // crossOriginEmbedderPolicy off: we don't need it and it breaks the 22 + // GraphQL Playground iframe in some browsers. 23 + crossOriginEmbedderPolicy: false, 24 + // crossOriginResourcePolicy "same-site" lets the client (different 25 + // subdomain in prod) consume PDFs and avatars from this origin. 26 + crossOriginResourcePolicy: { policy: "same-site" }, 27 + contentSecurityPolicy: { 28 + // In dev, run report-only so we can see violations without breaking work. 29 + reportOnly: !isProduction, 30 + directives: { 31 + defaultSrc: ["'self'"], 32 + // Apollo Sandbox and GraphQL Playground load assets from these CDNs. 33 + scriptSrc: [ 34 + "'self'", 35 + "'unsafe-inline'", 36 + "https://embeddable-sandbox.cdn.apollographql.com", 37 + "https://apollo-server-landing-page.cdn.apollographql.com", 38 + ], 39 + styleSrc: [ 40 + "'self'", 41 + "'unsafe-inline'", 42 + "https://embeddable-sandbox.cdn.apollographql.com", 43 + "https://apollo-server-landing-page.cdn.apollographql.com", 44 + "https://fonts.googleapis.com", 45 + ], 46 + fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"], 47 + imgSrc: ["'self'", "data:", "blob:", "https:"], 48 + connectSrc: ["'self'", "https://apollo-server-landing-page.cdn.apollographql.com"], 49 + // CV preview iframe uses srcDoc, not src; frameSrc 'self' covers any 50 + // future same-origin embed. 51 + frameSrc: ["'self'"], 52 + objectSrc: ["'none'"], 53 + baseUri: ["'self'"], 54 + formAction: ["'self'"], 55 + // upgradeInsecureRequests is handled at the proxy/CDN layer in prod. 56 + }, 57 + }, 58 + // HSTS: only ship in prod, behind TLS-terminating proxy. preload off until 59 + // the domain is added to the HSTS preload list. 60 + strictTransportSecurity: isProduction 61 + ? { maxAge: 31536000, includeSubDomains: true, preload: false } 62 + : false, 63 + // X-Frame-Options redundant with CSP frame-ancestors but cheaper for 64 + // older browsers. 65 + frameguard: { action: "deny" }, 66 + // Hide X-Powered-By: Express 67 + hidePoweredBy: true, 68 + referrerPolicy: { policy: "strict-origin-when-cross-origin" }, 69 + }; 70 + };
+6
apps/api/src/main.ts
··· 3 3 import { ExpressAdapter } from "@nestjs/platform-express"; 4 4 import cookieParser from "cookie-parser"; 5 5 import express from "express"; 6 + import helmet from "helmet"; 6 7 import { ZodValidationPipe } from "nestjs-zod"; 8 + import { CorsConfigService } from "./config/cors.config"; 7 9 import { configureCors } from "./config/cors.configuration"; 10 + import { createCsrfMiddleware } from "./config/csrf.middleware"; 11 + import { configureHelmet } from "./config/helmet.configuration"; 8 12 import { DomainExceptionFilter } from "./config/exception-to-http.pipe"; 9 13 import { AppModule } from "./modules/app.module"; 10 14 import { GraphQLExceptionFilter } from "./modules/base/graphql-exception.filter"; ··· 13 17 const app = await NestFactory.create(AppModule, new ExpressAdapter()); 14 18 const configService = app.get(ConfigService); 15 19 20 + app.use(helmet(configureHelmet(configService))); 16 21 app.use(express.json({ limit: "15mb" })); 17 22 app.use(cookieParser()); 18 23 configureCors(app); 24 + app.use(createCsrfMiddleware(app.get(CorsConfigService))); 19 25 20 26 app.useGlobalPipes(new ZodValidationPipe()); 21 27 app.useGlobalFilters(
+12 -2
apps/client/src/pages/CVViewPage.tsx
··· 106 106 const pdf = usePdfDownload(id || ""); 107 107 108 108 const handlePrint = useCallback(() => { 109 - iframeRef.current?.contentWindow?.print(); 109 + // window.print() on the parent: the @media print stylesheet (.no-print 110 + // hides the toolbar) leaves only the iframe content visible. Avoids 111 + // calling iframe.contentWindow.print() which requires allow-same-origin 112 + // on the iframe sandbox - the iframe holds untrusted user-supplied HTML 113 + // and the tighter sandbox="" is preferred. 114 + window.print(); 110 115 }, []); 111 116 112 117 if (cvLoading || renderLoading) { ··· 172 177 <iframe 173 178 ref={iframeRef} 174 179 srcDoc={renderData.renderCV.html} 175 - sandbox="allow-same-origin allow-modals" 180 + // Default-deny sandbox: srcDoc is user-controlled (CV content 181 + // sanitized server-side via DOMPurify, but defense-in-depth). 182 + // No allow-scripts - rendered HTML has no live JS by design. 183 + // No allow-same-origin - parent never needs contentWindow access 184 + // (Print uses parent window.print() with no-print toolbar class). 185 + sandbox="" 176 186 title="CV Preview" 177 187 className="w-full bg-white rounded-lg shadow-lg border-0" 178 188 style={{ minHeight: "calc(100vh - 120px)" }}
+9
pnpm-lock.yaml
··· 150 150 handlebars: 151 151 specifier: ^4.7.9 152 152 version: 4.7.9 153 + helmet: 154 + specifier: ^8.0.0 155 + version: 8.1.0 153 156 mammoth: 154 157 specifier: ^1.11.0 155 158 version: 1.11.0 ··· 5540 5543 5541 5544 header-case@2.0.4: 5542 5545 resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} 5546 + 5547 + helmet@8.1.0: 5548 + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} 5549 + engines: {node: '>=18.0.0'} 5543 5550 5544 5551 highlight.js@11.11.1: 5545 5552 resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} ··· 13968 13975 dependencies: 13969 13976 capital-case: 1.0.4 13970 13977 tslib: 2.8.1 13978 + 13979 + helmet@8.1.0: {} 13971 13980 13972 13981 highlight.js@11.11.1: {} 13973 13982