Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
173 changes: 173 additions & 0 deletions test/security/csp-headers.test.js
Original file line number Diff line number Diff line change
@@ -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 *');
});
});
79 changes: 79 additions & 0 deletions test/security/dependency-audit.test.js
Original file line number Diff line number Diff line change
@@ -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*\)/);
});
});
Loading