Skip to content

Commit 288df98

Browse files
authored
Merge branch 'main' into feature/solvency-monitoring
2 parents 4ce3d20 + 4a93b9b commit 288df98

152 files changed

Lines changed: 8599 additions & 559 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"generationMode": "requirements-first"}
Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
# Design Document: Claim Evidence URL Sanitization
2+
3+
## Overview
4+
5+
Evidence URLs stored on-chain in a claim's `imageUrls` field are untrusted input. Without
6+
validation they could point to internal network addresses, enabling SSRF attacks through the
7+
backend's IPFS fetch or preview endpoints. This feature introduces a dedicated `UrlValidatorService`
8+
that enforces an environment-configurable gateway allowlist and SSRF prevention rules. All
9+
evidence URLs are validated before being returned to clients or used in server-side fetches;
10+
non-allowlisted URLs are replaced with a safe placeholder. Security events are logged in a
11+
structured, PII-safe format.
12+
13+
## Architecture
14+
15+
```mermaid
16+
flowchart TD
17+
subgraph Config
18+
ENV[ALLOWED_IPFS_GATEWAYS env var]
19+
CFG[ConfigService / env.validation.ts]
20+
ENV -->|parsed on startup + hot-reload| CFG
21+
end
22+
23+
subgraph Claims Pipeline
24+
CS[ClaimsService.transformClaim]
25+
UV[UrlValidatorService]
26+
SL[SecurityLogger]
27+
CS -->|sanitizeEvidenceUrl| UV
28+
UV -->|rejected URL event| SL
29+
end
30+
31+
subgraph IPFS Pipeline
32+
IS[IpfsService]
33+
UV2[UrlValidatorService.ssrfCheck]
34+
IS -->|before outbound fetch| UV2
35+
UV2 -->|rejected URL event| SL
36+
end
37+
38+
CFG -->|allowlist| UV
39+
CFG -->|allowlist| UV2
40+
```
41+
42+
The `UrlValidatorService` is the single source of truth for URL safety decisions. Both the
43+
claims pipeline (response serialization) and the IPFS pipeline (server-side fetches) delegate
44+
to it. The `SanitizationService` retains its existing responsibilities (XSS, wallet address,
45+
IPFS hash sanitization) and delegates evidence URL validation to `UrlValidatorService`.
46+
47+
## Components and Interfaces
48+
49+
### UrlValidatorService
50+
51+
New injectable service at `backend/src/claims/url-validator.service.ts`.
52+
53+
```typescript
54+
export const PLACEHOLDER_URL = 'redacted:non-allowlisted-url';
55+
56+
export type RejectionReason =
57+
| 'scheme-not-https'
58+
| 'hostname-not-allowlisted'
59+
| 'private-ip-range'
60+
| 'dns-resolution-failed'
61+
| 'malformed-url'
62+
| 'non-standard-port'
63+
| 'file-scheme';
64+
65+
export interface ValidationResult {
66+
safe: boolean;
67+
url: string; // original URL if safe, PLACEHOLDER_URL if not
68+
reason?: RejectionReason;
69+
}
70+
71+
@Injectable()
72+
export class UrlValidatorService {
73+
/**
74+
* Validate an evidence URL for inclusion in an API response.
75+
* Checks: parseable, https scheme, port 443 (or absent), hostname in allowlist.
76+
* Does NOT perform DNS resolution (response-path only).
77+
*/
78+
validateForResponse(url: string, claimId?: number): ValidationResult;
79+
80+
/**
81+
* Validate a URL before a server-side fetch.
82+
* Performs all response-path checks PLUS DNS resolution and private-IP rejection.
83+
*/
84+
validateForFetch(url: string, claimId?: number): Promise<ValidationResult>;
85+
86+
/**
87+
* Reload the allowlist from ConfigService.
88+
* Called on a 60-second interval by the hot-reload scheduler.
89+
*/
90+
reloadAllowlist(): void;
91+
}
92+
```
93+
94+
### SecurityLogger
95+
96+
Thin wrapper around NestJS `Logger` at `backend/src/claims/security-logger.service.ts`.
97+
98+
```typescript
99+
export interface SanitizationEvent {
100+
redactedHash: string; // SHA-256 hex of original URL
101+
claimId?: number;
102+
reason: RejectionReason;
103+
timestamp: string; // ISO 8601 UTC
104+
}
105+
106+
@Injectable()
107+
export class SecurityLoggerService {
108+
logRejection(originalUrl: string, reason: RejectionReason, claimId?: number): void;
109+
// Emits warn-level log with SanitizationEvent fields only.
110+
// Tracks rejection counts per redactedHash; emits error-level if > 10 in 60 s.
111+
}
112+
```
113+
114+
### SanitizationService (modified)
115+
116+
`sanitizeEvidenceUrl` is updated to delegate to `UrlValidatorService.validateForResponse`.
117+
The hardcoded `allowedDomains` set is removed from this class.
118+
119+
### ClaimsService (modified)
120+
121+
`transformClaim` calls `sanitization.sanitizeEvidenceUrl` (unchanged call site) which now
122+
routes through `UrlValidatorService`. No direct changes to `ClaimsService` are required beyond
123+
injecting `UrlValidatorService` into `SanitizationService`.
124+
125+
### IpfsService (modified)
126+
127+
Any method that constructs an outbound HTTP request from a URL derived from claim evidence
128+
calls `urlValidator.validateForFetch(url)` before proceeding. If the result is not safe, the
129+
method throws a `BadRequestException` with a generic message (no original URL in the message).
130+
131+
### Config / env.validation.ts (modified)
132+
133+
```typescript
134+
ALLOWED_IPFS_GATEWAYS: Joi.string()
135+
.default('ipfs.io,cloudflare-ipfs.com,gateway.pinata.cloud,dweb.link,nftstorage.link')
136+
.description('Comma-separated list of permitted IPFS gateway hostnames')
137+
.custom((value: string, helpers) => {
138+
const entries = value.split(',').map(s => s.trim()).filter(Boolean);
139+
for (const entry of entries) {
140+
if (/\s|\//.test(entry)) {
141+
return helpers.error('any.invalid', {
142+
message: `ALLOWED_IPFS_GATEWAYS entry "${entry}" must not contain whitespace or path separators`,
143+
});
144+
}
145+
}
146+
const nodeEnv = helpers.state.ancestors[0]?.NODE_ENV ?? 'development';
147+
if (nodeEnv === 'production' && entries.length === 0) {
148+
return helpers.error('any.invalid', {
149+
message: 'ALLOWED_IPFS_GATEWAYS must not be empty in production',
150+
});
151+
}
152+
return value;
153+
}),
154+
```
155+
156+
### Hot-Reload Scheduler
157+
158+
A NestJS `@Cron` or `setInterval`-based scheduler in `UrlValidatorService` calls
159+
`reloadAllowlist()` every 60 seconds, re-reading `ALLOWED_IPFS_GATEWAYS` from `ConfigService`.
160+
Because NestJS `ConfigService` reads from `process.env` at call time (when not using a cached
161+
snapshot), updating the environment variable is sufficient for hot-reload in containerized
162+
deployments that support live env injection.
163+
164+
## Data Models
165+
166+
### SanitizationEvent (log payload)
167+
168+
```typescript
169+
{
170+
level: 'warn' | 'error',
171+
context: 'UrlValidatorService',
172+
redactedHash: string, // SHA-256(originalUrl), hex-encoded, 64 chars
173+
claimId: number | null,
174+
reason: RejectionReason,
175+
timestamp: string // ISO 8601 UTC, e.g. "2024-01-15T12:34:56.789Z"
176+
}
177+
```
178+
179+
The `originalUrl` field MUST NOT appear anywhere in this payload.
180+
181+
### Environment Variables (additions)
182+
183+
| Variable | Type | Default | Description |
184+
|---|---|---|---|
185+
| `ALLOWED_IPFS_GATEWAYS` | `string` | `ipfs.io,cloudflare-ipfs.com,gateway.pinata.cloud,dweb.link,nftstorage.link` | Comma-separated allowlisted IPFS gateway hostnames |
186+
187+
### Private IP Ranges (SSRF block list)
188+
189+
| Range | Description |
190+
|---|---|
191+
| `10.0.0.0/8` | RFC 1918 Class A |
192+
| `172.16.0.0/12` | RFC 1918 Class B |
193+
| `192.168.0.0/16` | RFC 1918 Class C |
194+
| `127.0.0.0/8` | IPv4 loopback |
195+
| `::1/128` | IPv6 loopback |
196+
| `169.254.0.0/16` | IPv4 link-local |
197+
| `fe80::/10` | IPv6 link-local |
198+
| `fc00::/7` | IPv6 ULA |
199+
| `0.0.0.0/8` | "This" network |
200+
201+
## Correctness Properties
202+
203+
*A property is a characteristic or behavior that should hold true across all valid executions
204+
of a system — essentially, a formal statement about what the system should do. Properties serve
205+
as the bridge between human-readable specifications and machine-verifiable correctness
206+
guarantees.*
207+
208+
---
209+
210+
Property 1: Allowlisted https URLs pass validation unchanged
211+
212+
*For any* URL whose scheme is `https`, whose port is absent or 443, and whose hostname is in
213+
the configured allowlist, `validateForResponse` SHALL return the original (or normalized) URL,
214+
not the Placeholder_URL.
215+
216+
**Validates: Requirements 2.1, 2.2, 2.3**
217+
218+
---
219+
220+
Property 2: Non-allowlisted or unsafe URLs always yield the Placeholder_URL
221+
222+
*For any* URL that fails at least one of: parseable, `https` scheme, port 443/absent, hostname
223+
in allowlist — `validateForResponse` SHALL return exactly `"redacted:non-allowlisted-url"`.
224+
This covers malformed strings, `file://` URLs, `http://` URLs, non-standard ports, and
225+
hostnames not in the allowlist.
226+
227+
**Validates: Requirements 2.2, 2.3, 2.4, 3.2, 3.3**
228+
229+
---
230+
231+
Property 3: Private-IP URLs are rejected by the fetch validator
232+
233+
*For any* URL that resolves (or whose literal hostname is) a Private_IP_Range address,
234+
`validateForFetch` SHALL return the Placeholder_URL.
235+
236+
**Validates: Requirements 3.1**
237+
238+
---
239+
240+
Property 4: Security log entries never contain the original URL
241+
242+
*For any* URL rejected by `validateForResponse` or `validateForFetch`, the structured log
243+
entry emitted by `SecurityLoggerService` SHALL contain the `redactedHash`, `reason`, and
244+
`timestamp` fields, and SHALL NOT contain the original URL string in any field or message.
245+
246+
**Validates: Requirements 5.1, 5.2**
247+
248+
---
249+
250+
Property 5: ClaimsService response transformation replaces all unsafe URLs
251+
252+
*For any* claim record whose `imageUrls` array contains at least one non-allowlisted URL, the
253+
`ClaimDetailResponseDto` produced by `transformClaim` SHALL contain the Placeholder_URL in
254+
`evidence.gatewayUrl` and SHALL NOT contain the original non-allowlisted URL string anywhere
255+
in the response object.
256+
257+
**Validates: Requirements 4.1, 4.2, 2.5**
258+
259+
---
260+
261+
Property 6: Allowlist hot-reload is reflected within one reload cycle
262+
263+
*For any* hostname added to `ALLOWED_IPFS_GATEWAYS` after service startup, after
264+
`reloadAllowlist()` is called, `validateForResponse` SHALL accept URLs with that hostname.
265+
Conversely, *for any* hostname removed from the allowlist, after `reloadAllowlist()` is called,
266+
`validateForResponse` SHALL reject URLs with that hostname.
267+
268+
**Validates: Requirements 1.4**
269+
270+
---
271+
272+
Property 7: Env schema rejects invalid gateway entries
273+
274+
*For any* `ALLOWED_IPFS_GATEWAYS` string containing an entry with whitespace or a `/`
275+
character, the Joi validation schema SHALL return a validation error and prevent application
276+
startup.
277+
278+
**Validates: Requirements 1.3**
279+
280+
## Error Handling
281+
282+
| Scenario | Behavior |
283+
|---|---|
284+
| URL parse throws | Catch, return Placeholder_URL, log `malformed-url` |
285+
| DNS resolution timeout | Return Placeholder_URL, log `dns-resolution-failed` |
286+
| DNS resolution returns private IP | Return Placeholder_URL, log `private-ip-range` |
287+
| `file://` scheme | Return Placeholder_URL, log `file-scheme` (no DNS call) |
288+
| Non-`https` scheme | Return Placeholder_URL, log `scheme-not-https` |
289+
| Non-standard port | Return Placeholder_URL, log `non-standard-port` |
290+
| Hostname not in allowlist | Return Placeholder_URL, log `hostname-not-allowlisted` |
291+
| Empty / null input | Return Placeholder_URL, log `malformed-url` |
292+
| ConfigService unavailable at reload | Retain previous allowlist, log `warn` |
293+
294+
All error paths return the Placeholder_URL — they never throw to callers. This ensures the
295+
claims API always returns HTTP 200 even when evidence URLs are invalid.
296+
297+
## Testing Strategy
298+
299+
### Dual Testing Approach
300+
301+
Both unit tests and property-based tests are required. Unit tests cover specific examples and
302+
integration points; property-based tests verify universal correctness across the full input
303+
space.
304+
305+
### Unit Tests
306+
307+
Location: `backend/src/__tests__/url-validator.service.test.ts` and
308+
`backend/src/__tests__/security-logger.service.test.ts`
309+
310+
Cover:
311+
- Each `RejectionReason` with a concrete example URL
312+
- Default allowlist fallback when env var is absent
313+
- Production startup rejection when allowlist is empty
314+
- DNS mock returning private IP → rejection
315+
- DNS mock failing → rejection
316+
- Hot-reload: add hostname → accepted; remove hostname → rejected
317+
- `ClaimsService.transformClaim` integration: mock `UrlValidatorService`, verify it is called
318+
for every URL in `imageUrls`
319+
- HTTP 200 is returned even when all URLs are replaced with Placeholder_URL
320+
321+
### Property-Based Tests
322+
323+
Library: `fast-check` (already installed in backend).
324+
Each property test runs a minimum of 100 iterations.
325+
326+
Location: `backend/src/__tests__/url-validator.property.test.ts`
327+
328+
| Test | Property | Tag |
329+
|---|---|---|
330+
| PBT-1 | Allowlisted https URLs pass | `Feature: claim-evidence-url-sanitization, Property 1` |
331+
| PBT-2 | Non-allowlisted/unsafe URLs yield placeholder | `Feature: claim-evidence-url-sanitization, Property 2` |
332+
| PBT-3 | Private-IP URLs rejected by fetch validator | `Feature: claim-evidence-url-sanitization, Property 3` |
333+
| PBT-4 | Log entries never contain original URL | `Feature: claim-evidence-url-sanitization, Property 4` |
334+
| PBT-5 | ClaimsService response replaces all unsafe URLs | `Feature: claim-evidence-url-sanitization, Property 5` |
335+
| PBT-6 | Hot-reload reflected within one cycle | `Feature: claim-evidence-url-sanitization, Property 6` |
336+
| PBT-7 | Env schema rejects invalid gateway entries | `Feature: claim-evidence-url-sanitization, Property 7` |
337+
338+
### fast-check Generators
339+
340+
```typescript
341+
// Arbitrary for a valid allowlisted URL
342+
const allowlistedUrl = (allowlist: string[]) =>
343+
fc.constantFrom(...allowlist).map(h => `https://${h}/ipfs/QmTest`);
344+
345+
// Arbitrary for a URL with a non-allowlisted hostname
346+
const nonAllowlistedUrl = (allowlist: string[]) =>
347+
fc.domain().filter(d => !allowlist.includes(d))
348+
.map(d => `https://${d}/ipfs/QmTest`);
349+
350+
// Arbitrary for a private IPv4 address
351+
const privateIpv4 = () =>
352+
fc.oneof(
353+
fc.ipV4().filter(ip => ip.startsWith('10.')),
354+
fc.ipV4().filter(ip => ip.startsWith('192.168.')),
355+
fc.constant('127.0.0.1'),
356+
);
357+
358+
// Arbitrary for a malformed / non-https URL string
359+
const unsafeUrlString = () =>
360+
fc.oneof(
361+
fc.string(), // random garbage
362+
fc.webUrl().map(u => u.replace('https', 'http')), // http scheme
363+
fc.constant('file:///etc/passwd'), // file scheme
364+
fc.webUrl().map(u => u + ':8080'), // non-standard port
365+
);
366+
```
367+
368+
### Allowlist Update Process (inline documentation target)
369+
370+
The `UrlValidatorService` module file will include a JSDoc block describing:
371+
1. How to add a new hostname to `ALLOWED_IPFS_GATEWAYS` in each environment's `.env` file or
372+
secrets manager.
373+
2. The requirement that new hostnames must use `https` and must not resolve to private IPs.
374+
3. How the 60-second hot-reload cycle picks up the change without a restart.
375+
4. How to verify the change took effect by checking the `warn`-level log for the new hostname
376+
being accepted.

0 commit comments

Comments
 (0)