|
| 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