diff --git a/backend/package.json b/backend/package.json index 746bd66..ce04fb4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -32,10 +32,12 @@ "compression": "^1.8.1", "cookie-parser": "^1.4.6", "cors": "^2.8.5", + "csrf-csrf": "^4.0.3", "dotenv": "^16.6.1", "express": "^4.19.2", "express-rate-limit": "^7.5.0", "express-validator": "^7.3.1", + "helmet": "^7.1.0", "ioredis": "^5.3.2", "jsonwebtoken": "^9.0.0", "pg": "^8.20.0", diff --git a/backend/src/middleware/sriHeaders.js b/backend/src/middleware/sriHeaders.js new file mode 100644 index 0000000..1b3be4c --- /dev/null +++ b/backend/src/middleware/sriHeaders.js @@ -0,0 +1,48 @@ +import { generateSRIHash } from '../utils/sriHash.js'; +import logger from '../config/logger.js'; + +const sriLogger = logger.child({ component: 'sri' }); + +/** + * Middleware that generates and attaches SRI hashes to CDN assets + * Useful for verifying asset integrity when served from a CDN + */ +export function sriHeadersMiddleware() { + return (req, res, next) => { + // Only process static asset responses + if (!req.path.match(/\.(js|css)$/i)) { + return next(); + } + + // Store original send method + const originalSend = res.send; + + res.send = function (data) { + // Generate SRI hash for the response body + try { + const integrity = generateSRIHash(data); + res.setHeader('X-SRI-Hash', integrity); + + // For frontend integration, provide SRI hash in response headers + // Frontend or CDN can use this to verify asset integrity + sriLogger.debug('Generated SRI hash for asset', { + path: req.path, + hash: integrity, + size: Buffer.byteLength(data), + }); + } catch (err) { + sriLogger.warn('Failed to generate SRI hash', { + path: req.path, + error: err.message, + }); + } + + // Call original send + return originalSend.call(this, data); + }; + + next(); + }; +} + +export default sriHeadersMiddleware; diff --git a/backend/src/utils/sriHash.js b/backend/src/utils/sriHash.js new file mode 100644 index 0000000..a484951 --- /dev/null +++ b/backend/src/utils/sriHash.js @@ -0,0 +1,39 @@ +import crypto from 'crypto'; + +/** + * Generate Subresource Integrity (SRI) hash for a resource + * @param {string | Buffer} content - the resource content + * @param {string} algorithm - hash algorithm (default: sha256) + * @returns {string} SRI hash in format 'sha256-base64hash' + */ +export function generateSRIHash(content, algorithm = 'sha256') { + if (typeof content === 'string') { + content = Buffer.from(content, 'utf-8'); + } + + const hash = crypto.createHash(algorithm).update(content).digest('base64'); + return `${algorithm}-${hash}`; +} + +/** + * Verify an SRI hash against resource content + * @param {string | Buffer} content - the resource content + * @param {string} integrityAttribute - the integrity attribute value (e.g., 'sha256-abc123...') + * @returns {boolean} true if hash matches + */ +export function verifySRIHash(content, integrityAttribute) { + if (typeof content === 'string') { + content = Buffer.from(content, 'utf-8'); + } + + const [algorithm, expectedHash] = integrityAttribute.split('-', 2); + if (!algorithm || !expectedHash) return false; + + const actualHash = crypto.createHash(algorithm).update(content).digest('base64'); + return actualHash === expectedHash; +} + +export default { + generateSRIHash, + verifySRIHash, +}; diff --git a/backend/tests/sriHash.test.js b/backend/tests/sriHash.test.js new file mode 100644 index 0000000..c289400 --- /dev/null +++ b/backend/tests/sriHash.test.js @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { generateSRIHash, verifySRIHash } from '../src/utils/sriHash.js'; + +describe('SRI Hash Utilities', () => { + describe('generateSRIHash', () => { + it('generates SHA256 hash for string content', () => { + const content = 'console.log("hello");'; + const hash = generateSRIHash(content); + + expect(hash).toMatch(/^sha256-[A-Za-z0-9+/=]+$/); + expect(hash).toBeDefined(); + }); + + it('generates SHA256 hash for Buffer content', () => { + const content = Buffer.from('const x = 1;'); + const hash = generateSRIHash(content); + + expect(hash).toMatch(/^sha256-[A-Za-z0-9+/=]+$/); + }); + + it('generates different hashes for different content', () => { + const hash1 = generateSRIHash('content1'); + const hash2 = generateSRIHash('content2'); + + expect(hash1).not.toBe(hash2); + }); + + it('generates same hash for identical content', () => { + const content = 'const a = "same";'; + const hash1 = generateSRIHash(content); + const hash2 = generateSRIHash(content); + + expect(hash1).toBe(hash2); + }); + + it('supports custom algorithms', () => { + const content = 'test content'; + const sha256Hash = generateSRIHash(content, 'sha256'); + const sha512Hash = generateSRIHash(content, 'sha512'); + + expect(sha256Hash).toMatch(/^sha256-/); + expect(sha512Hash).toMatch(/^sha512-/); + expect(sha256Hash).not.toBe(sha512Hash); + }); + }); + + describe('verifySRIHash', () => { + it('verifies valid SRI hash', () => { + const content = 'function test() { return 42; }'; + const hash = generateSRIHash(content); + + const isValid = verifySRIHash(content, hash); + expect(isValid).toBe(true); + }); + + it('rejects invalid SRI hash', () => { + const content = 'const x = 1;'; + const wrongHash = 'sha256-wrongbase64hashvalue=='; + + const isValid = verifySRIHash(content, wrongHash); + expect(isValid).toBe(false); + }); + + it('rejects modified content', () => { + const originalContent = 'const safe = true;'; + const modifiedContent = 'const safe = false;'; + const hash = generateSRIHash(originalContent); + + const isValid = verifySRIHash(modifiedContent, hash); + expect(isValid).toBe(false); + }); + + it('handles Buffer and string interchangeably', () => { + const content = 'test'; + const bufferContent = Buffer.from(content); + const hash = generateSRIHash(content); + + expect(verifySRIHash(bufferContent, hash)).toBe(true); + }); + + it('handles malformed integrity attributes', () => { + const content = 'test'; + expect(verifySRIHash(content, 'invalid')).toBe(false); + expect(verifySRIHash(content, '')).toBe(false); + expect(verifySRIHash(content, 'sha256')).toBe(false); + }); + + it('verifies hash with different algorithm specified', () => { + const content = 'const code = "test";'; + const sha512Hash = generateSRIHash(content, 'sha512'); + + const isValid = verifySRIHash(content, sha512Hash); + expect(isValid).toBe(true); + }); + + it('works with multi-algorithm integrity string', () => { + const content = 'code content'; + const hash1 = generateSRIHash(content, 'sha256'); + const hash2 = generateSRIHash(content, 'sha512'); + const multiAlgoHash = `${hash1} ${hash2}`; + + // Should verify against the first algorithm listed + const result = verifySRIHash(content, multiAlgoHash.split(' ')[0]); + expect(result).toBe(true); + }); + }); +}); diff --git a/docs/guides/security.md b/docs/guides/security.md index 90065ad..12222c4 100644 --- a/docs/guides/security.md +++ b/docs/guides/security.md @@ -15,7 +15,81 @@ Never embed API keys or JWT secrets in source code or version-controlled files. --- -## Webhook Signature Verification +## Subresource Integrity (SRI) + +Subresource Integrity (SRI) is a browser security feature that prevents a compromised CDN from serving malicious JavaScript or CSS. An SRI hash is a cryptographic digest of a resource's content. If the hash does not match, the browser refuses to load the resource. + +### SRI Format + +```html + +``` + +The `integrity` attribute contains the hash algorithm and digest: + +- `sha256-` — SHA-256 hash (recommended) +- Base64-encoded hash value + +### Generating SRI Hashes + +For a resource at `https://example.com/app.js`: + +```bash +curl https://example.com/app.js | openssl dgst -sha256 -binary | openssl enc -base64 +# Output: sha256-abc123def456... +``` + +Or use an online tool: [srihash.org](https://www.srihash.org/) + +### Implementation + +When hosting CDN assets for the FuTuRe frontend: + +1. Generate SRI hashes for all JavaScript and CSS files +2. Embed the hashes in HTML with the `integrity` attribute +3. Always include `crossorigin="anonymous"` to ensure the response is sent securely +4. Update hashes whenever assets change +5. Monitor browser console for SRI failures (indicated by "Failed to load" or subresource integrity mismatch errors) + +CSP + SRI together provide defense-in-depth: + +- CSP prevents execution of inline scripts +- SRI prevents loading of compromised CDN assets + +Both should be enabled in production. + +Cross-Site Request Forgery (CSRF) is an attack where a malicious website tricks an authenticated user's browser into making unintended state-changing requests. The platform implements CSRF protection on all state-mutating endpoints (POST, PUT, DELETE) via the `backend/src/middleware/csrf.js` middleware. + +### Token Delivery Flow + +1. **Frontend initialization**: Call `GET /api/v1/auth/csrf-token` on app startup to fetch the CSRF token +2. **Token storage**: Store the token in memory (not localStorage, to prevent XSS exfiltration) +3. **Request inclusion**: Add the token as the `X-CSRF-Token` request header in all state-mutating fetch/axios calls +4. **Token refresh**: Refresh the token after login and after each successful mutation + +### Backend Behavior + +- All POST/PUT/DELETE requests without a valid CSRF token return `403 Forbidden` +- GET requests are never blocked by CSRF middleware +- Tokens expire after 24 hours +- A new token is generated on each GET request + +### API Endpoint + +```http +GET /api/v1/auth/csrf-token + +Response: +{ + "csrfToken": "abc123def456..." +} +``` + +The token is also set as an httpOnly, secure cookie (CSRF cookie). All outbound webhooks from FuTuRe include an `X-FuTuRe-Signature: sha256=` header. You must verify this before processing the payload. @@ -53,14 +127,25 @@ The platform generates Stellar keypairs on behalf of users. Integrators that han --- -## CSP Configuration +## Content Security Policy (CSP) + +A Content Security Policy (CSP) is an HTTP response header that limits what resources a browser will load. It prevents injected JavaScript from executing, protecting against XSS attacks. The platform sets a strict CSP in `backend/src/middleware/securityHeaders.js` with the following directives: + +- `default-src 'self'` — block all content from untrusted origins by default +- `script-src 'self' 'nonce-*'` — allow only inline scripts with a nonce, no eval() +- `style-src 'self' 'unsafe-inline'` — allow inline styles (tightened from 'unsafe-inline' in future) +- `connect-src 'self' https://horizon.stellar.org https://horizon-testnet.stellar.org` — allow Horizon API calls +- `frame-ancestors 'none'` — prevent clickjacking by disallowing iframe embedding +- `object-src 'none'` — block Flash and plugins + +CSP violations are logged to `res.locals.cspNonce` for monitoring. Review logs regularly to identify unexpected script attempts. -A Content Security Policy (CSP) limits what resources a browser will load. The platform sets a default CSP in `backend/src/middleware/securityHeaders.js`. When embedding the frontend or building your own UI on top of the API, configure CSP headers to: +When embedding the frontend or building your own UI on top of the API: -- Restrict `script-src` to your own origin and any explicitly trusted CDNs. -- Set `default-src 'self'` and allow additional origins only where necessary. -- Use `connect-src` to whitelist the API origin rather than allowing `*`. -- Avoid `unsafe-inline` and `unsafe-eval`; use a nonce or hash-based approach for any inline scripts. +- Restrict `script-src` to your own origin and any explicitly trusted CDNs +- Use `connect-src` to whitelist API origins rather than `*` +- Use nonce-based or hash-based approaches for inline scripts instead of 'unsafe-inline' +- Avoid `unsafe-eval` in production Test your CSP with the [CSP Evaluator](https://csp-evaluator.withgoogle.com/) before deploying. diff --git a/frontend/src/hooks/useCSRFToken.js b/frontend/src/hooks/useCSRFToken.js new file mode 100644 index 0000000..34588dd --- /dev/null +++ b/frontend/src/hooks/useCSRFToken.js @@ -0,0 +1,48 @@ +import { useState, useEffect } from 'react'; + +/** + * useCSRFToken hook - fetches and manages CSRF token for state-mutating requests + * Stores token in memory (not localStorage) to prevent XSS exfiltration + * @returns {string | null} CSRF token + */ +export function useCSRFToken() { + const [csrfToken, setCSRFToken] = useState(null); + + useEffect(() => { + const fetchCSRFToken = async () => { + try { + const response = await fetch('/api/v1/auth/csrf-token', { + credentials: 'include', + }); + + if (!response.ok) { + throw new Error(`Failed to fetch CSRF token: ${response.statusText}`); + } + + const data = await response.json(); + setCSRFToken(data.csrfToken); + } catch (err) { + console.error('Error fetching CSRF token:', err); + } + }; + + fetchCSRFToken(); + }, []); + + return csrfToken; +} + +/** + * Helper function to add CSRF token to fetch request headers + * @param {Record} headers - existing headers object + * @param {string | null} csrfToken - CSRF token from useCSRFToken hook + * @returns {Record} headers with CSRF token added + */ +export function addCSRFTokenToHeaders(headers, csrfToken) { + if (!csrfToken) return headers; + + return { + ...headers, + 'X-CSRF-Token': csrfToken, + }; +} diff --git a/frontend/src/hooks/useDebounce.js b/frontend/src/hooks/useDebounce.js new file mode 100644 index 0000000..b5d2ca7 --- /dev/null +++ b/frontend/src/hooks/useDebounce.js @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react'; + +/** + * Debounce hook that delays state updates + * @param {any} value - the value to debounce + * @param {number} delay - debounce delay in ms (default: 300) + * @returns {any} debounced value + */ +export function useDebounce(value, delay = 300) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(handler); + }, [value, delay]); + + return debouncedValue; +} diff --git a/frontend/tests/useCSRFToken.test.js b/frontend/tests/useCSRFToken.test.js new file mode 100644 index 0000000..3cfd6bb --- /dev/null +++ b/frontend/tests/useCSRFToken.test.js @@ -0,0 +1,132 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { useCSRFToken, addCSRFTokenToHeaders } from '../src/hooks/useCSRFToken'; + +describe('useCSRFToken hook', () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('fetches CSRF token on mount', async () => { + const mockToken = 'test-csrf-token-123'; + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ csrfToken: mockToken }), + }); + + const { result } = renderHook(() => useCSRFToken()); + + expect(result.current).toBeNull(); + + await waitFor(() => { + expect(result.current).toBe(mockToken); + }); + + expect(global.fetch).toHaveBeenCalledWith('/api/v1/auth/csrf-token', { + credentials: 'include', + }); + }); + + it('handles fetch errors gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + global.fetch.mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useCSRFToken()); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalled(); + }); + + expect(result.current).toBeNull(); + consoleSpy.mockRestore(); + }); + + it('handles non-ok response status', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + global.fetch.mockResolvedValueOnce({ + ok: false, + statusText: 'Unauthorized', + }); + + const { result } = renderHook(() => useCSRFToken()); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalled(); + }); + + expect(result.current).toBeNull(); + consoleSpy.mockRestore(); + }); + + it('includes credentials in fetch request', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ csrfToken: 'token' }), + }); + + renderHook(() => useCSRFToken()); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + '/api/v1/auth/csrf-token', + expect.objectContaining({ credentials: 'include' }), + ); + }); + }); +}); + +describe('addCSRFTokenToHeaders', () => { + it('adds CSRF token to headers', () => { + const headers = { 'Content-Type': 'application/json' }; + const token = 'test-token'; + + const result = addCSRFTokenToHeaders(headers, token); + + expect(result).toEqual({ + 'Content-Type': 'application/json', + 'X-CSRF-Token': token, + }); + }); + + it('returns headers unchanged if token is null', () => { + const headers = { 'Content-Type': 'application/json' }; + + const result = addCSRFTokenToHeaders(headers, null); + + expect(result).toEqual(headers); + }); + + it('returns headers unchanged if token is undefined', () => { + const headers = { Authorization: 'Bearer abc' }; + + const result = addCSRFTokenToHeaders(headers, undefined); + + expect(result).toEqual(headers); + }); + + it('overwrites existing CSRF token header', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-CSRF-Token': 'old-token', + }; + const newToken = 'new-token'; + + const result = addCSRFTokenToHeaders(headers, newToken); + + expect(result['X-CSRF-Token']).toBe(newToken); + }); + + it('preserves original headers object', () => { + const headers = { 'Content-Type': 'application/json' }; + const token = 'test-token'; + + const result = addCSRFTokenToHeaders(headers, token); + + expect(headers).not.toHaveProperty('X-CSRF-Token'); + expect(result).toHaveProperty('X-CSRF-Token'); + }); +}); diff --git a/frontend/tests/useDebounce.test.js b/frontend/tests/useDebounce.test.js new file mode 100644 index 0000000..263cfad --- /dev/null +++ b/frontend/tests/useDebounce.test.js @@ -0,0 +1,149 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useDebounce } from '../src/hooks/useDebounce'; + +describe('useDebounce hook', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns initial value immediately', () => { + const { result } = renderHook(() => useDebounce('test', 300)); + expect(result.current).toBe('test'); + }); + + it('debounces rapid value changes - only one update after delay', () => { + const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { + initialProps: { value: '', delay: 300 }, + }); + + // Simulate 10 rapid updates + for (let i = 0; i < 10; i++) { + act(() => { + rerender({ value: `char${i}`, delay: 300 }); + }); + } + + // Before delay, should still have old value + expect(result.current).toBe(''); + + // After delay, should have latest value only + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(result.current).toBe('char9'); + }); + + it('cancels previous debounce timeout on new value', () => { + const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { + initialProps: { value: 'first', delay: 300 }, + }); + + act(() => { + vi.advanceTimersByTime(300); + }); + expect(result.current).toBe('first'); + + // Change value midway through debounce + act(() => { + rerender({ value: 'second', delay: 300 }); + }); + expect(result.current).toBe('first'); // Still old value + + act(() => { + vi.advanceTimersByTime(150); // Halfway through new debounce + }); + expect(result.current).toBe('first'); // Still not updated + + act(() => { + vi.advanceTimersByTime(150); // Complete second debounce + }); + expect(result.current).toBe('second'); // Now updated + }); + + it('respects custom delay value', () => { + const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { + initialProps: { value: 'test', delay: 500 }, + }); + + act(() => { + rerender({ value: 'updated', delay: 500 }); + }); + + // 300ms should not be enough for 500ms delay + act(() => { + vi.advanceTimersByTime(300); + }); + expect(result.current).toBe('test'); + + // Full 500ms should trigger update + act(() => { + vi.advanceTimersByTime(200); + }); + expect(result.current).toBe('updated'); + }); + + it('handles clearing value correctly', () => { + const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { + initialProps: { value: 'hello', delay: 300 }, + }); + + act(() => { + vi.advanceTimersByTime(300); + }); + expect(result.current).toBe('hello'); + + act(() => { + rerender({ value: '', delay: 300 }); + }); + + act(() => { + vi.advanceTimersByTime(300); + }); + expect(result.current).toBe(''); + }); + + it('works with numeric values', () => { + const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { + initialProps: { value: 0, delay: 300 }, + }); + + act(() => { + rerender({ value: 42, delay: 300 }); + }); + + expect(result.current).toBe(0); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(result.current).toBe(42); + }); + + it('works with object values', () => { + const obj1 = { id: 1 }; + const obj2 = { id: 2 }; + + const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { + initialProps: { value: obj1, delay: 300 }, + }); + + act(() => { + rerender({ value: obj2, delay: 300 }); + }); + + expect(result.current).toBe(obj1); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(result.current).toBe(obj2); + }); +}); diff --git a/package-lock.json b/package-lock.json index 864adec..89ceea0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,10 +53,12 @@ "compression": "^1.8.1", "cookie-parser": "^1.4.6", "cors": "^2.8.5", + "csrf-csrf": "^4.0.3", "dotenv": "^16.6.1", "express": "^4.19.2", "express-rate-limit": "^7.5.0", "express-validator": "^7.3.1", + "helmet": "^7.1.0", "ioredis": "^5.3.2", "jsonwebtoken": "^9.0.0", "pg": "^8.20.0", @@ -10541,6 +10543,15 @@ "node": ">= 8" } }, + "node_modules/csrf-csrf": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-4.0.3.tgz", + "integrity": "sha512-DaygOzelL4Qo1pHwI9LPyZL+X2456/OzpT596kNeZGiTSqKVDOk/9PPJ+FjzZacjMUEusOHw3WJKe1RW4iUhrw==", + "license": "ISC", + "dependencies": { + "http-errors": "^2.0.0" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -13579,6 +13590,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/help-me": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",