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): rate limiting + GraphQL query complexity (CVG-56)

Rate limiting via @nestjs/throttler with three tiers: short (10 req/10s burst), medium (60 req/min sustained), long (300 req/hour). Global ThrottlerGuard. Storage in-memory for now; Postgres-backed adapter tracked in CVG-66 for multi-instance. GraphQL query complexity via graphql-query-complexity. Apollo plugin estimates per-field cost (default 1, override via @Field({ extensions: { complexity } })) and rejects above MAX_COMPLEXITY=1000. Per-role budgets tracked in CVG-65.

+137 -23
+2
apps/api/package.json
··· 51 51 "@nestjs/mapped-types": "^2.1.0", 52 52 "@nestjs/passport": "^11.0.5", 53 53 "@nestjs/platform-express": "^11.1.18", 54 + "@nestjs/throttler": "^6.4.0", 54 55 "@prisma/adapter-pg": "^7.1.0", 55 56 "@prisma/client": "^7.1.0", 56 57 "@riotbyte-com/nest-service-locator": "0.3.0-rc.8", ··· 68 69 "dataloader": "^2.2.3", 69 70 "express": "^5.1.0", 70 71 "graphql": "^16.12.0", 72 + "graphql-query-complexity": "^1.1.0", 71 73 "graphql-scalars": "^1.23.0", 72 74 "graphql-type-json": "^0.3.2", 73 75 "handlebars": "^4.7.9",
+53
apps/api/src/config/graphql-complexity.plugin.ts
··· 1 + import { Plugin } from "@nestjs/apollo"; 2 + import type { ApolloServerPlugin, GraphQLRequestListener } from "@apollo/server"; 3 + import { 4 + fieldExtensionsEstimator, 5 + getComplexity, 6 + simpleEstimator, 7 + } from "graphql-query-complexity"; 8 + import { GraphQLError } from "graphql"; 9 + 10 + const MAX_COMPLEXITY = 1000; 11 + const DEFAULT_FIELD_COMPLEXITY = 1; 12 + 13 + /** 14 + * Reject GraphQL queries whose total field-complexity exceeds MAX_COMPLEXITY. 15 + * 16 + * Each field defaults to complexity 1; resolvers can override via 17 + * `@Field({ extensions: { complexity: 10 } })` (or use a multiplier-aware 18 + * estimator on Connection-style fields). 19 + * 20 + * This is the GraphQL-aware companion to @nestjs/throttler's request-count 21 + * rate limit (CVG-56). A single deeply-nested query can hit the DB 100x 22 + * harder than a flat one with the same request count. 23 + */ 24 + @Plugin() 25 + export class GraphQLComplexityPlugin implements ApolloServerPlugin { 26 + async requestDidStart(): Promise<GraphQLRequestListener<{ request: unknown }>> { 27 + return { 28 + async didResolveOperation({ request, document, schema }) { 29 + const complexity = getComplexity({ 30 + schema, 31 + query: document, 32 + estimators: [ 33 + fieldExtensionsEstimator(), 34 + simpleEstimator({ defaultComplexity: DEFAULT_FIELD_COMPLEXITY }), 35 + ], 36 + ...(request.operationName !== undefined && { 37 + operationName: request.operationName, 38 + }), 39 + ...(request.variables !== undefined && { 40 + variables: request.variables, 41 + }), 42 + }); 43 + 44 + if (complexity > MAX_COMPLEXITY) { 45 + throw new GraphQLError( 46 + `Query is too complex: ${complexity}. Maximum allowed: ${MAX_COMPLEXITY}.`, 47 + { extensions: { code: "QUERY_TOO_COMPLEX" } }, 48 + ); 49 + } 50 + }, 51 + }; 52 + } 53 + }
+20 -1
apps/api/src/modules/app.module.ts
··· 5 5 import { ApolloDriver, type ApolloDriverConfig } from "@nestjs/apollo"; 6 6 import { Module } from "@nestjs/common"; 7 7 import { ConfigModule, ConfigService } from "@nestjs/config"; 8 + import { APP_GUARD } from "@nestjs/core"; 8 9 import { GraphQLModule } from "@nestjs/graphql"; 9 10 import { JwtModule } from "@nestjs/jwt"; 11 + import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler"; 10 12 import type { Request, Response } from "express"; 11 13 import { AppConfigModule } from "@/config/config.module"; 12 14 import { validate } from "@/config/env.validation"; 13 15 import { FileStorageConfig } from "@/config/file-storage.config"; 16 + import { GraphQLComplexityPlugin } from "@/config/graphql-complexity.plugin"; 14 17 import { AdminModule } from "./admin/admin.module"; 15 18 import { AiCallLogModule } from "./admin/ai-call-log.module"; 16 19 import { AppModule as AppModuleComponent } from "./app/app.module"; ··· 54 57 }, 55 58 inject: [ConfigService], 56 59 }), 60 + // Rate limiting (CVG-56). Three tiers cover different attack profiles: 61 + // - short: burst protection (10 req / 10s) for credential stuffing 62 + // - medium: sustained quota (60 req / min) for general API abuse 63 + // - long: AI/upload-budget (300 req / hour) for cost-runaway attacks 64 + // Per-route overrides via @Throttle({ ... }) on auth + upload + AI endpoints. 65 + // Storage is in-memory (per-instance). Multi-instance deploys need a 66 + // shared store - tracked in CVG-65 (Postgres-backed adapter); we already 67 + // run Postgres so adding Redis just for this isn't worth it. 68 + ThrottlerModule.forRoot([ 69 + { name: "short", ttl: 10000, limit: 10 }, 70 + { name: "medium", ttl: 60000, limit: 60 }, 71 + { name: "long", ttl: 3600000, limit: 300 }, 72 + ]), 57 73 GraphQLModule.forRoot<ApolloDriverConfig>({ 58 74 driver: ApolloDriver, 59 75 autoSchemaFile: true, ··· 98 114 ProfileModule, 99 115 AdminModule, 100 116 ], 101 - providers: [], 117 + providers: [ 118 + { provide: APP_GUARD, useClass: ThrottlerGuard }, 119 + GraphQLComplexityPlugin, 120 + ], 102 121 }) 103 122 export class AppModule {}
+27 -22
packages/file-upload/src/validators.ts
··· 44 44 } 45 45 }; 46 46 47 + type MagicByteRule = { 48 + check: (buffer: Buffer) => boolean; 49 + error: string; 50 + }; 51 + 52 + const utf8Rule: MagicByteRule = { 53 + check: isValidUtf8, 54 + error: "File is not valid UTF-8 text", 55 + }; 56 + 57 + const RULES: Record<SupportedMimeType, MagicByteRule> = { 58 + [SupportedMimeTypes.PDF]: { 59 + check: containsPdfSignature, 60 + error: "File contents do not look like a PDF", 61 + }, 62 + [SupportedMimeTypes.DOCX]: { 63 + check: startsWithAnyZipSignature, 64 + error: "File contents do not look like a DOCX (ZIP archive)", 65 + }, 66 + [SupportedMimeTypes.TXT]: utf8Rule, 67 + [SupportedMimeTypes.MD]: utf8Rule, 68 + }; 69 + 47 70 /** 48 71 * Verify the actual file bytes match the declared MIME type. Defense against 49 72 * an attacker uploading evil.exe with Content-Type: application/pdf and ··· 56 79 if (buffer.length === 0) { 57 80 return { valid: false, error: "File is empty" }; 58 81 } 59 - 60 - if (mimeType === SupportedMimeTypes.PDF) { 61 - return containsPdfSignature(buffer) 62 - ? { valid: true } 63 - : { valid: false, error: "File contents do not look like a PDF" }; 64 - } 65 - 66 - if (mimeType === SupportedMimeTypes.DOCX) { 67 - return startsWithAnyZipSignature(buffer) 68 - ? { valid: true } 69 - : { valid: false, error: "File contents do not look like a DOCX (ZIP archive)" }; 70 - } 71 - 72 - if ( 73 - mimeType === SupportedMimeTypes.TXT || 74 - mimeType === SupportedMimeTypes.MD 75 - ) { 76 - return isValidUtf8(buffer) 77 - ? { valid: true } 78 - : { valid: false, error: "File is not valid UTF-8 text" }; 82 + const rule = RULES[mimeType]; 83 + if (!rule) { 84 + return { valid: false, error: `No magic-byte rule for MIME ${mimeType}` }; 79 85 } 80 - 81 - return { valid: false, error: `No magic-byte rule for MIME ${mimeType}` }; 86 + return rule.check(buffer) ? { valid: true } : { valid: false, error: rule.error }; 82 87 }; 83 88 84 89 /**
+35
pnpm-lock.yaml
··· 90 90 '@nestjs/platform-express': 91 91 specifier: ^11.1.18 92 92 version: 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) 93 + '@nestjs/throttler': 94 + specifier: ^6.4.0 95 + version: 6.5.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(reflect-metadata@0.2.2) 93 96 '@prisma/adapter-pg': 94 97 specifier: ^7.1.0 95 98 version: 7.2.0 ··· 141 144 graphql: 142 145 specifier: ^16.12.0 143 146 version: 16.12.0 147 + graphql-query-complexity: 148 + specifier: ^1.1.0 149 + version: 1.1.0(graphql@16.12.0) 144 150 graphql-scalars: 145 151 specifier: ^1.23.0 146 152 version: 1.25.0(graphql@16.12.0) ··· 2824 2830 '@nestjs/platform-express': 2825 2831 optional: true 2826 2832 2833 + '@nestjs/throttler@6.5.0': 2834 + resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==} 2835 + peerDependencies: 2836 + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 2837 + '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 2838 + reflect-metadata: ^0.1.13 || ^0.2.0 2839 + 2827 2840 '@noble/hashes@1.8.0': 2828 2841 resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} 2829 2842 engines: {node: ^14.21.3 || >=16} ··· 5429 5442 peerDependenciesMeta: 5430 5443 cosmiconfig-toml-loader: 5431 5444 optional: true 5445 + 5446 + graphql-query-complexity@1.1.0: 5447 + resolution: {integrity: sha512-6sfAX+9CgkcPeZ7UiuBwgTGA+M1FYgHrQOXvORhQGd6SiaXbNVkLDcJ9ZSvNgzyChIfH0uPFFOY3Jm4wFZ4qEA==} 5448 + peerDependencies: 5449 + graphql: ^15.0.0 || ^16.0.0 5432 5450 5433 5451 graphql-request@6.1.0: 5434 5452 resolution: {integrity: sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==} ··· 6113 6131 resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} 6114 6132 engines: {node: '>=8'} 6115 6133 6134 + lodash.get@4.4.2: 6135 + resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} 6136 + deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. 6137 + 6116 6138 lodash.includes@4.3.0: 6117 6139 resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} 6118 6140 ··· 10972 10994 optionalDependencies: 10973 10995 '@nestjs/platform-express': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) 10974 10996 10997 + '@nestjs/throttler@6.5.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(reflect-metadata@0.2.2)': 10998 + dependencies: 10999 + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) 11000 + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) 11001 + reflect-metadata: 0.2.2 11002 + 10975 11003 '@noble/hashes@1.8.0': {} 10976 11004 10977 11005 '@nodelib/fs.scandir@2.1.5': ··· 13848 13876 - uWebSockets.js 13849 13877 - utf-8-validate 13850 13878 13879 + graphql-query-complexity@1.1.0(graphql@16.12.0): 13880 + dependencies: 13881 + graphql: 16.12.0 13882 + lodash.get: 4.4.2 13883 + 13851 13884 graphql-request@6.1.0(encoding@0.1.13)(graphql@16.12.0): 13852 13885 dependencies: 13853 13886 '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) ··· 14625 14658 locate-path@5.0.0: 14626 14659 dependencies: 14627 14660 p-locate: 4.1.0 14661 + 14662 + lodash.get@4.4.2: {} 14628 14663 14629 14664 lodash.includes@4.3.0: {} 14630 14665