diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ec354e..67358bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,5 +19,6 @@ jobs: cache: npm - run: npm ci - run: npm run format:check + - run: npm audit --audit-level=critical - run: npm test - run: npm run build diff --git a/package.json b/package.json index b0428a0..1d02087 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,11 @@ "lint": "eslint .", "preview": "vite preview", "storybook": "storybook dev -p 6006", - "test": "vitest run test/integration test/unit test/lighthouse-config.test.js", + "test": "vitest run test/integration test/unit test/lighthouse-config.test.js test/security", "test:lighthouse": "vitest run test/lighthouse-config.test.js", - "test:watch": "vitest" + "test:security": "vitest run test/security", + "test:watch": "vitest", + "audit": "npm audit --audit-level=critical" }, "dependencies": { "@stellar/stellar-sdk": "^12.0.0", diff --git a/test/security/csp-headers.test.js b/test/security/csp-headers.test.js new file mode 100644 index 0000000..863bcba --- /dev/null +++ b/test/security/csp-headers.test.js @@ -0,0 +1,173 @@ +/** + * test/security/csp-headers.test.js + * + * Regression tests for the Content-Security-Policy and companion security + * headers produced by vite-plugin-security-headers.js. + * + * These tests guard against accidental policy weakening (the original failure + * mode: no CSP at all, leaving the app open to XSS injection and data + * exfiltration). + */ + +import { describe, it, expect } from 'vitest'; +import { + buildCsp, + SECURITY_HEADERS, +} from '../../vite-plugin-security-headers.js'; + +// ─── buildCsp ──────────────────────────────────────────────────────────────── + +describe('buildCsp()', () => { + it('returns a non-empty string', () => { + expect(typeof buildCsp()).toBe('string'); + expect(buildCsp().length).toBeGreaterThan(0); + }); + + it('includes default-src self', () => { + expect(buildCsp()).toContain("default-src 'self'"); + }); + + it('blocks unsafe-inline and unsafe-eval in script-src', () => { + const csp = buildCsp(); + // style-src may carry 'unsafe-inline' for CSS-in-JS; only script-src must not. + const scriptSrc = csp + .split(';') + .find((d) => d.trim().startsWith('script-src')); + expect(scriptSrc).toBeDefined(); + expect(scriptSrc).not.toContain("'unsafe-inline'"); + expect(scriptSrc).not.toContain("'unsafe-eval'"); + }); + + it('allows Stellar Horizon testnet in connect-src', () => { + expect(buildCsp()).toContain('https://horizon-testnet.stellar.org'); + }); + + it('allows Stellar Horizon mainnet in connect-src', () => { + expect(buildCsp()).toContain('https://horizon.stellar.org'); + }); + + it('includes the API origin when provided', () => { + const csp = buildCsp('https://api.remitflow.app'); + expect(csp).toContain('https://api.remitflow.app'); + }); + + it('does not duplicate self in connect-src', () => { + const csp = buildCsp(); + const connectSrc = csp + .split(';') + .find((d) => d.trim().startsWith('connect-src')); + expect(connectSrc).toBeDefined(); + const selfCount = (connectSrc.match(/'self'/g) || []).length; + expect(selfCount).toBe(1); + }); + + it('blocks object-src', () => { + expect(buildCsp()).toContain("object-src 'none'"); + }); + + it('blocks frame-src and frame-ancestors', () => { + const csp = buildCsp(); + expect(csp).toContain("frame-src 'none'"); + expect(csp).toContain("frame-ancestors 'none'"); + }); + + it('restricts base-uri to self', () => { + expect(buildCsp()).toContain("base-uri 'self'"); + }); + + it('restricts form-action to self', () => { + expect(buildCsp()).toContain("form-action 'self'"); + }); + + it('includes upgrade-insecure-requests', () => { + expect(buildCsp()).toContain('upgrade-insecure-requests'); + }); + + it('ignores a null or empty API origin gracefully', () => { + expect(() => buildCsp(null)).not.toThrow(); + expect(() => buildCsp('')).not.toThrow(); + expect(() => buildCsp('null')).not.toThrow(); + }); + + it('does not inject a localhost API origin into the CSP', () => { + // localhost URLs must not bleed into a production CSP string + const csp = buildCsp('http://localhost:4000'); + // The function accepts the value; callers should gate on env. The test + // verifies the function does not throw and returns a valid string. + expect(typeof csp).toBe('string'); + }); +}); + +// ─── SECURITY_HEADERS object ───────────────────────────────────────────────── + +describe('SECURITY_HEADERS', () => { + it('exports a Content-Security-Policy header', () => { + expect(SECURITY_HEADERS['Content-Security-Policy']).toBeDefined(); + expect(SECURITY_HEADERS['Content-Security-Policy'].length).toBeGreaterThan( + 0, + ); + }); + + it('sets X-Content-Type-Options to nosniff', () => { + expect(SECURITY_HEADERS['X-Content-Type-Options']).toBe('nosniff'); + }); + + it('sets X-Frame-Options to DENY', () => { + expect(SECURITY_HEADERS['X-Frame-Options']).toBe('DENY'); + }); + + it('sets a restrictive Referrer-Policy', () => { + expect(SECURITY_HEADERS['Referrer-Policy']).toBe( + 'strict-origin-when-cross-origin', + ); + }); + + it('sets Permissions-Policy that disables sensitive APIs', () => { + const pp = SECURITY_HEADERS['Permissions-Policy']; + expect(pp).toContain('camera=()'); + expect(pp).toContain('microphone=()'); + expect(pp).toContain('geolocation=()'); + }); + + it('sets Cross-Origin-Opener-Policy to same-origin', () => { + expect(SECURITY_HEADERS['Cross-Origin-Opener-Policy']).toBe('same-origin'); + }); + + it('sets Cross-Origin-Resource-Policy to same-origin', () => { + expect(SECURITY_HEADERS['Cross-Origin-Resource-Policy']).toBe( + 'same-origin', + ); + }); + + it('sets a HSTS header', () => { + const hsts = SECURITY_HEADERS['Strict-Transport-Security']; + expect(hsts).toContain('max-age='); + expect(hsts).toContain('includeSubDomains'); + }); + + it('has no header set to an empty string', () => { + for (const [name, value] of Object.entries(SECURITY_HEADERS)) { + expect(value, `Header "${name}" must not be empty`).not.toBe(''); + } + }); +}); + +// ─── Regression: original failure mode ─────────────────────────────────────── + +describe('regression – CSP must always be present', () => { + it('SECURITY_HEADERS always contains a CSP key (was missing before fix)', () => { + // This is the original failure mode: no CSP header was set at all. + expect( + Object.prototype.hasOwnProperty.call( + SECURITY_HEADERS, + 'Content-Security-Policy', + ), + ).toBe(true); + }); + + it('CSP is not a wildcard policy', () => { + const csp = SECURITY_HEADERS['Content-Security-Policy']; + expect(csp).not.toContain('default-src *'); + expect(csp).not.toContain('script-src *'); + }); +}); diff --git a/test/security/dependency-audit.test.js b/test/security/dependency-audit.test.js new file mode 100644 index 0000000..de1d267 --- /dev/null +++ b/test/security/dependency-audit.test.js @@ -0,0 +1,79 @@ +/** + * test/security/dependency-audit.test.js + * + * Smoke tests that verify the dependency-audit configuration is wired up + * correctly in the project so CI will actually catch critical findings. + * + * These tests run in the Vitest unit environment and do NOT invoke npm audit + * directly (that would be slow and network-dependent). The live audit runs as + * a dedicated CI step (`npm audit --audit-level=critical`). + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +const rootDir = resolve(import.meta.dirname, '../..'); + +function readJson(rel) { + return JSON.parse(readFileSync(resolve(rootDir, rel), 'utf8')); +} + +function readText(rel) { + return readFileSync(resolve(rootDir, rel), 'utf8'); +} + +// ─── package.json audit script ─────────────────────────────────────────────── + +describe('package.json – audit script', () => { + const pkg = readJson('package.json'); + + it('defines an "audit" script', () => { + expect(pkg.scripts).toHaveProperty('audit'); + }); + + it('audit script runs npm audit at critical level', () => { + expect(pkg.scripts.audit).toContain('npm audit'); + expect(pkg.scripts.audit).toContain('--audit-level=critical'); + }); + + it('test script includes the security test folder', () => { + expect(pkg.scripts.test).toContain('test/security'); + }); +}); + +// ─── CI workflow wires up the audit step ───────────────────────────────────── + +describe('CI workflow – npm audit step present', () => { + const ci = readText('.github/workflows/ci.yml'); + + it('ci.yml contains an npm audit step', () => { + expect(ci).toContain('npm audit'); + }); + + it('ci.yml audit step uses --audit-level=critical', () => { + expect(ci).toContain('--audit-level=critical'); + }); + + it('audit step appears before the test step (fail fast)', () => { + const auditIdx = ci.indexOf('npm audit'); + const testIdx = ci.indexOf('npm test'); + expect(auditIdx).toBeGreaterThanOrEqual(0); + expect(testIdx).toBeGreaterThanOrEqual(0); + expect(auditIdx).toBeLessThan(testIdx); + }); +}); + +// ─── Security headers plugin is registered in vite config ──────────────────── + +describe('vite.config.js – security-headers plugin registered', () => { + const viteConfig = readText('vite.config.js'); + + it('imports securityHeaders plugin', () => { + expect(viteConfig).toContain('vite-plugin-security-headers'); + }); + + it('registers securityHeaders() in plugins array', () => { + expect(viteConfig).toMatch(/securityHeaders\s*\(\s*\)/); + }); +}); diff --git a/vite-plugin-security-headers.js b/vite-plugin-security-headers.js new file mode 100644 index 0000000..af430cf --- /dev/null +++ b/vite-plugin-security-headers.js @@ -0,0 +1,126 @@ +/** + * vite-plugin-security-headers.js + * + * Injects production-quality HTTP security headers into Vite's dev and preview + * servers. The same header values are exported as `SECURITY_HEADERS` so that + * a CDN/reverse-proxy config (nginx, Cloudflare, etc.) can import and reuse + * them, and so the test suite can assert the exact policy without duplicating + * the string. + * + * Design notes + * ───────────── + * • `script-src 'self'` – React/router are fully bundled; no CDN scripts. + * • `connect-src` – Stellar Horizon (testnet + mainnet) and the + * backend API origin are the only XHR/fetch targets. + * Freighter and Albedo wallets communicate via the + * browser-extension postMessage bridge, NOT via + * fetch, so they need no connect-src entry. + * • `form-action 'self'` – No cross-origin form POSTs. + * • `frame-ancestors 'none'`– Prevents clickjacking; stronger than X-Frame. + * • `upgrade-insecure-requests` – Forces HTTPS for sub-resource loads in prod. + * • `object-src 'none'` – Blocks // (plugin code). + * + * Wallet / provider exceptions + * ───────────────────────────── + * Freighter (STELLAR_EXPERT) and Albedo wallet extensions inject a content + * script into the page and communicate through `window.postMessage`. They do + * NOT load external scripts or make fetch calls from the page context, so no + * special CSP exemption is required. If a future integration loads the + * Freighter SDK from a CDN, add that CDN origin to `script-src` here and + * document the reason. + */ + +/** Stellar Horizon origins that the Stellar SDK connects to. */ +const STELLAR_HORIZON_ORIGINS = [ + 'https://horizon-testnet.stellar.org', + 'https://horizon.stellar.org', +]; + +/** + * Backend API origin. Falls back to 'self' when the env var is absent (e.g. + * in the mock / demo mode where all API calls are localStorage-only). + * We extract only the origin (scheme + host + port) to avoid leaking paths. + */ +function apiOrigin() { + const raw = process.env.VITE_API_BASE_URL || ''; + try { + return raw ? new URL(raw).origin : null; + } catch { + return null; + } +} + +/** + * Build the Content-Security-Policy header value. + * Kept as a function so it can be called with a custom apiOrigin in tests. + * + * @param {string|null} [apiOrig] - override the API origin (for tests) + * @returns {string} + */ +export function buildCsp(apiOrig = apiOrigin()) { + const connectSrc = ["'self'", ...STELLAR_HORIZON_ORIGINS]; + if (apiOrig && apiOrig !== 'null' && !connectSrc.includes(apiOrig)) { + connectSrc.push(apiOrig); + } + + const directives = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", // CSS-in-JS / Vite inlines a tiny style tag + "img-src 'self' data:", // data: URIs used by chart canvas toDataURL + `connect-src ${connectSrc.join(' ')}`, + "font-src 'self'", + "object-src 'none'", + "frame-src 'none'", + "frame-ancestors 'none'", + "form-action 'self'", + "base-uri 'self'", + 'upgrade-insecure-requests', + ]; + + return directives.join('; '); +} + +/** + * The full set of security response headers applied to every request. + * Exported so nginx/Cloudflare config generators and tests can import them. + */ +export const SECURITY_HEADERS = { + 'Content-Security-Policy': buildCsp(), + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Permissions-Policy': + 'camera=(), microphone=(), geolocation=(), payment=()', + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Resource-Policy': 'same-origin', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', +}; + +/** + * Vite plugin: applies SECURITY_HEADERS to the dev server and the preview + * server so that local testing reflects production behaviour. + * + * @returns {import('vite').Plugin} + */ +export default function securityHeaders() { + /** Middleware that sets every header on every response. */ + function middleware(_req, res, next) { + for (const [name, value] of Object.entries(SECURITY_HEADERS)) { + res.setHeader(name, value); + } + next(); + } + + return { + name: 'security-headers', + // dev server + configureServer(server) { + server.middlewares.use(middleware); + }, + // `vite preview` (production-equivalent) + configurePreviewServer(server) { + server.middlewares.use(middleware); + }, + }; +} diff --git a/vite.config.js b/vite.config.js index f4b7be1..12e523f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,10 +1,11 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import securityHeaders from './vite-plugin-security-headers.js'; // Vite configuration for the RemitFlow frontend. // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), securityHeaders()], server: { port: 5173, open: false,