diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aac49b8a..7d2094fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,10 @@ jobs: - name: Install dependencies run: npm ci + # Fail if tsconfig.json / tsconfig.app.json weaken strict mode + - name: Check TypeScript strict flags + run: npm run check:tsconfig-strict + # Issue #924 – Fail CI on any ESLint warning or error - name: Run ESLint (zero warnings allowed) run: npm run lint -- --max-warnings=0 diff --git a/README.md b/README.md index d4a444a3..b51afcc9 100644 --- a/README.md +++ b/README.md @@ -474,7 +474,8 @@ For support, email support@propchain.com or join our Slack channel ## Developer Requirements — TypeScript & Linting - **TypeScript strict mode:** The project now enables `strict` TypeScript checks. The base config is in [tsconfig.json](tsconfig.json#L1). -- **Key compiler flags enforced:** `noImplicitAny`, `strictNullChecks` and related strict checks are enabled for app builds via [tsconfig.app.json](tsconfig.app.json#L1). +- **Key compiler flags enforced:** `strict`, `noImplicitAny`, `strictNullChecks`, `useUnknownInCatchVariables` and `noImplicitOverride` are enabled for app builds via [tsconfig.app.json](tsconfig.app.json#L1), which must not override them to `false`. Only `strictPropertyInitialization` is relaxed (NestJS DI-injected properties). +- **Guard:** `npm run check:tsconfig-strict` ([scripts/check-tsconfig-strict.js](scripts/check-tsconfig-strict.js)) fails if either config weakens these flags; CI runs it in the lint job. - **ESLint rules:** `@typescript-eslint/no-explicit-any` is set to `error` and explicit boundary/return types are encouraged via `@typescript-eslint/explicit-module-boundary-types` and `@typescript-eslint/explicit-function-return-type` (set to `warn`). See [.eslintrc.js](.eslintrc.js#L1). Local checks before committing/pushing: @@ -486,6 +487,9 @@ npm ci # Run linter (auto-fixable issues) npm run lint +# Verify tsconfig strict flags are intact +npm run check:tsconfig-strict + # Build to verify TypeScript strict checks npm run build ``` diff --git a/package.json b/package.json index 7e7ade37..013dd4bc 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "prebuild": "rimraf dist tsconfig.tsbuildinfo", "build": "nest build", + "check:tsconfig-strict": "node scripts/check-tsconfig-strict.js", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "prestart": "rimraf dist tsconfig.tsbuildinfo", "start": "nest start", diff --git a/scripts/check-tsconfig-strict.js b/scripts/check-tsconfig-strict.js new file mode 100644 index 00000000..55d71bf5 --- /dev/null +++ b/scripts/check-tsconfig-strict.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/** + * CI guard: fail if the app build config weakens TypeScript strictness. + * + * README "Developer Requirements" promises strict mode, noImplicitAny and + * strictNullChecks for app builds. This script checks that tsconfig.json and + * tsconfig.app.json (which extends it) keep those flags on after resolution. + * + * Usage: node scripts/check-tsconfig-strict.js + */ + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const CONFIGS = ['tsconfig.json', 'tsconfig.app.json']; + +// Flags that must resolve to true. Flags implied by `strict` are listed so an +// explicit `false` override is also caught. +const REQUIRED_TRUE = [ + 'strict', + 'noImplicitAny', + 'strictNullChecks', + 'strictFunctionTypes', + 'strictBindCallApply', + 'noImplicitThis', + 'alwaysStrict', + 'useUnknownInCatchVariables', + 'noImplicitOverride', +]; + +// Implied by `strict`; an explicit false is still a violation. +const IMPLIED_BY_STRICT = new Set([ + 'noImplicitAny', + 'strictNullChecks', + 'strictFunctionTypes', + 'strictBindCallApply', + 'noImplicitThis', + 'alwaysStrict', + 'useUnknownInCatchVariables', +]); + +function readJsonc(file) { + const text = fs.readFileSync(file, 'utf8'); + // Strip // and /* */ comments and trailing commas (tsconfig allows them). + const stripped = text + .replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, str) => str ?? '') + .replace(/,(\s*[}\]])/g, '$1'); + return JSON.parse(stripped); +} + +function resolveCompilerOptions(file) { + const config = readJsonc(file); + let base = {}; + if (config.extends) { + base = resolveCompilerOptions(path.resolve(path.dirname(file), config.extends)); + } + return { ...base, ...(config.compilerOptions ?? {}) }; +} + +const failures = []; +for (const name of CONFIGS) { + const options = resolveCompilerOptions(path.join(ROOT, name)); + for (const flag of REQUIRED_TRUE) { + const value = options[flag]; + const ok = value === true || (value === undefined && IMPLIED_BY_STRICT.has(flag) && options.strict === true); + if (!ok) failures.push(`${name}: "${flag}" resolves to ${JSON.stringify(value)} (expected true)`); + } +} + +if (failures.length) { + console.error('TypeScript strictness check failed:\n ' + failures.join('\n ')); + console.error('\nSee README "Developer Requirements — TypeScript & Linting".'); + process.exit(1); +} + +console.log(`TypeScript strictness check passed for ${CONFIGS.join(', ')}.`); diff --git a/src/config/security-headers.ts b/src/config/security-headers.ts new file mode 100644 index 00000000..d933ef2f --- /dev/null +++ b/src/config/security-headers.ts @@ -0,0 +1,34 @@ +/** + * Security headers middleware. + * + * Every route gets the strict DEFAULT_CONTENT_SECURITY_POLICY. The Swagger UI + * route gets a slightly wider policy (still script-src 'self') so its locally + * served swagger-ui-dist assets render. + */ + +import type { NextFunction, Request, Response } from 'express'; +import { + DEFAULT_CONTENT_SECURITY_POLICY, + SWAGGER_SERVERS, + buildDocsContentSecurityPolicy, + isSwaggerDocsPath, +} from './swagger.config'; + +export function createSecurityHeadersMiddleware( + docsServerUrls: string[] = SWAGGER_SERVERS.map((s) => s.url), +): (req: Request, res: Response, next: NextFunction) => void { + const docsCsp = buildDocsContentSecurityPolicy(docsServerUrls); + + return (req, res, next) => { + res.setHeader( + 'Content-Security-Policy', + isSwaggerDocsPath(req.path) ? docsCsp : DEFAULT_CONTENT_SECURITY_POLICY, + ); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + next(); + }; +} diff --git a/src/config/swagger.config.ts b/src/config/swagger.config.ts index 094f8d34..7108f566 100644 --- a/src/config/swagger.config.ts +++ b/src/config/swagger.config.ts @@ -8,6 +8,52 @@ import { INestApplication, Logger } from '@nestjs/common'; const logger = new Logger('SwaggerConfig'); +/** Route prefix of the Swagger UI (no leading slash). */ +export const SWAGGER_DOCS_PATH = 'api/docs'; + +/** Servers advertised in the spec (and allowed as connect-src on the docs page). */ +export const SWAGGER_SERVERS = [ + { url: 'http://localhost:3000', description: 'Development Server' }, + { url: 'https://api.propchain.io', description: 'Production Server' }, +]; + +/** Content-Security-Policy applied to every non-docs route (see main.ts). */ +export const DEFAULT_CONTENT_SECURITY_POLICY = + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"; + +/** True when the request path is the Swagger UI or one of its assets. */ +export function isSwaggerDocsPath(path: string): boolean { + const docsRoot = `/${SWAGGER_DOCS_PATH}`; + return path === docsRoot || path.startsWith(`${docsRoot}/`) || path.startsWith(`${docsRoot}-`); +} + +/** + * CSP for the Swagger UI route. Scripts are still restricted to 'self' (all + * assets are served locally); only what Swagger UI v5 needs on top of the + * default policy is added: data: images used by swagger-ui.css, and + * connect-src for the servers listed in the spec so "Try it out" works. + */ +export function buildDocsContentSecurityPolicy(serverUrls: string[] = []): string { + const origins = new Set(); + for (const url of serverUrls) { + try { + origins.add(new URL(url).origin); + } catch { + // relative server URLs are covered by 'self' + } + } + return [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + `connect-src ${["'self'", ...origins].join(' ')}`, + "object-src 'none'", + "base-uri 'self'", + "frame-ancestors 'none'", + ].join('; '); +} + interface AppWithOpenApiDoc { openAPIDocument?: OpenAPIObject; } @@ -54,8 +100,6 @@ export function setupSwagger(app: INestApplication): OpenAPIObject { }, 'api-version', ) - .addServer('http://localhost:3000', 'Development Server') - .addServer('https://api.propchain.io', 'Production Server') .addTag('Authentication', 'User authentication and authorization') .addTag('Users', 'User management endpoints') .addTag('Properties', 'Property management endpoints') @@ -78,15 +122,18 @@ export function setupSwagger(app: INestApplication): OpenAPIObject { .addTag('Analytics', 'Analytics and reporting endpoints') .build(); + config.servers = SWAGGER_SERVERS.map(({ url, description }) => ({ url, description })); + const document = SwaggerModule.createDocument(app, config); // Setup Swagger UI at /api/docs - SwaggerModule.setup('api/docs', app, document, { + SwaggerModule.setup(SWAGGER_DOCS_PATH, app, document, { swaggerOptions: { - persistAuthorizationData: true, + persistAuthorization: true, displayRequestDuration: true, filter: true, - showRequestHeaders: true, + // Disable the badge that calls out to validator.swagger.io + validatorUrl: null, supportedSubmitMethods: ['get', 'post', 'put', 'patch', 'delete'], docExpansion: 'list', defaultModelsExpandDepth: 1, @@ -127,10 +174,10 @@ export function setupSwagger(app: INestApplication): OpenAPIObject { color: #00d4ff; } `, - customJs: [ - 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui.js', - 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui-standalone-preset.js', - ], + // No customJs: Swagger UI v5 assets (bundle, preset, init script, CSS) are + // served from the local swagger-ui-dist package under /api/docs, so the + // page works with the strict CSP from buildDocsContentSecurityPolicy and + // makes no external requests. }); logger.log('Swagger UI available at http://localhost:3000/api/docs'); diff --git a/src/main.ts b/src/main.ts index d1c181ad..0e654e7c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,7 +10,8 @@ import { RateLimitGuard } from './auth/guards/rate-limit.guard'; import { RateLimitService } from './auth/rate-limit.service'; import { RateLimitHeadersInterceptor } from './auth/interceptors/rate-limit-headers.interceptor'; import { ResponseFormatInterceptor } from './common/interceptors/response-format.interceptor'; -import { setupOpenAPIEndpoint, setupSwagger } from './config/swagger.config'; +import { setupSwagger } from './config/swagger.config'; +import { createSecurityHeadersMiddleware } from './config/security-headers'; import { validateEnvironment } from './utils/validate-env'; // Issue #914 – Structured JSON logging in production, pretty-print in dev import { AppLogger } from './common/logger'; @@ -73,19 +74,8 @@ async function bootstrap() { allowedHeaders: ['Content-Type', 'Authorization', 'API-Version', 'api-key', 'x-api-key'], }); - // Security headers middleware - app.use((req: any, res: any, next: any) => { - res.setHeader( - 'Content-Security-Policy', - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'", - ); - res.setHeader('X-Frame-Options', 'DENY'); - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); - res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); - res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); - next(); - }); + // Security headers middleware (CSP is relaxed only for the Swagger UI route) + app.use(createSecurityHeadersMiddleware()); // Issue #964 / #1234 – Localize validation messages using the request's // Accept-Language (and optional user preference) captured by middleware diff --git a/test/e2e/swagger-docs-csp.e2e.spec.ts b/test/e2e/swagger-docs-csp.e2e.spec.ts new file mode 100644 index 00000000..7e4010ae --- /dev/null +++ b/test/e2e/swagger-docs-csp.e2e.spec.ts @@ -0,0 +1,125 @@ +/** + * E2E test: Swagger UI at /api/docs renders under the app's CSP. + * + * Previously setupSwagger injected swagger-ui-dist@3 scripts from jsDelivr, + * which the `script-src 'self'` CSP blocked and which don't match the v5 UI + * layout. The UI must now load only same-origin assets, and the CSP on the + * docs route must allow everything the page references. + */ + +import { Controller, Get, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import { createSecurityHeadersMiddleware } from '../../src/config/security-headers'; +import { + DEFAULT_CONTENT_SECURITY_POLICY, + buildDocsContentSecurityPolicy, + isSwaggerDocsPath, + setupSwagger, +} from '../../src/config/swagger.config'; + +@Controller('properties') +class SampleController { + @Get() + list() { + return []; + } +} + +function cspDirectives(header: string): Map { + return new Map( + header + .split(';') + .map((d) => d.trim().split(/\s+/)) + .filter((parts) => parts[0]) + .map(([name, ...values]) => [name, values]), + ); +} + +describe('Swagger UI under CSP', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [SampleController], + }).compile(); + app = moduleRef.createNestApplication({ logger: false }); + app.use(createSecurityHeadersMiddleware()); + setupSwagger(app); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('serves the docs page with only same-origin scripts and stylesheets', async () => { + const res = await request(app.getHttpServer()).get('/api/docs').redirects(1).expect(200); + const html: string = res.text; + + expect(html).toContain('
'); + expect(html).not.toMatch(/cdn\.jsdelivr\.net|unpkg\.com|swagger-ui-dist@3/); + + const scriptSrcs = [...html.matchAll(/]*\ssrc=['"]([^'"]+)['"]/g)].map((m) => m[1]); + const linkHrefs = [...html.matchAll(/]*\shref=['"]([^'"]+)['"]/g)].map((m) => m[1]); + expect(scriptSrcs.length).toBeGreaterThanOrEqual(3); + for (const src of [...scriptSrcs, ...linkHrefs]) { + expect(src).not.toMatch(/^(https?:)?\/\//); + } + + // No inline