Skip to content
Merged
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
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
48 changes: 48 additions & 0 deletions backend/src/middleware/sriHeaders.js
Original file line number Diff line number Diff line change
@@ -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;
39 changes: 39 additions & 0 deletions backend/src/utils/sriHash.js
Original file line number Diff line number Diff line change
@@ -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,
};
107 changes: 107 additions & 0 deletions backend/tests/sriHash.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
99 changes: 92 additions & 7 deletions docs/guides/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<script
src="https://cdn.example.com/library.js"
integrity="sha256-abc123def456..."
crossorigin="anonymous"
></script>
```

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=<hex>` header. You must verify this before processing the payload.

Expand Down Expand Up @@ -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.

Expand Down
48 changes: 48 additions & 0 deletions frontend/src/hooks/useCSRFToken.js
Original file line number Diff line number Diff line change
@@ -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<string, string>} headers - existing headers object
* @param {string | null} csrfToken - CSRF token from useCSRFToken hook
* @returns {Record<string, string>} headers with CSRF token added
*/
export function addCSRFTokenToHeaders(headers, csrfToken) {
if (!csrfToken) return headers;

return {
...headers,
'X-CSRF-Token': csrfToken,
};
}
21 changes: 21 additions & 0 deletions frontend/src/hooks/useDebounce.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading