trustsource 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,448 @@
1
+ import { Router, Request, Response } from "express";
2
+ import dns from "dns/promises";
3
+
4
+ const router = Router();
5
+
6
+ // ─── Constants ────────────────────────────────────────────────────────────────
7
+
8
+ const VALID_DOMAIN_RE = /^[a-zA-Z0-9][a-zA-Z0-9\-.]{1,251}[a-zA-Z0-9]$/;
9
+
10
+ // Private/internal IP ranges (IPv4 + IPv6) — block to prevent SSRF
11
+ const PRIVATE_IPV4_RE = /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|0\.0\.0\.0|169\.254\.)/;
12
+ const PRIVATE_IPV6_RE = /^(::1|fc00:|fd00:|fe80:)/i;
13
+
14
+ // Port allowlist — prevents using this API for port scanning of arbitrary services.
15
+ // "" (empty) means default port for scheme (80 for http, 443 for https).
16
+ const ALLOWED_PORTS = new Set(["", "80", "443", "8080", "8443"]);
17
+
18
+ const FETCH_TIMEOUT_MS = 8000;
19
+ const MAX_REDIRECTS = 3;
20
+
21
+ // ─── Cache (4 hour TTL) ───────────────────────────────────────────────────────
22
+
23
+ interface CacheEntry {
24
+ data: Record<string, unknown>;
25
+ expiresAt: number;
26
+ }
27
+ const cache = new Map<string, CacheEntry>();
28
+
29
+ function getCached(key: string): Record<string, unknown> | null {
30
+ const entry = cache.get(key);
31
+ if (!entry) return null;
32
+ if (Date.now() > entry.expiresAt) { cache.delete(key); return null; }
33
+ return entry.data;
34
+ }
35
+
36
+ function setCached(key: string, data: Record<string, unknown>): void {
37
+ cache.set(key, { data, expiresAt: Date.now() + 4 * 60 * 60 * 1000 });
38
+ if (cache.size > 1000) {
39
+ const firstKey = cache.keys().next().value;
40
+ if (firstKey) cache.delete(firstKey);
41
+ }
42
+ }
43
+
44
+ // ─── URL parsing & SSRF protection ────────────────────────────────────────────
45
+
46
+ interface ParsedUrl {
47
+ url: URL;
48
+ hostname: string;
49
+ }
50
+
51
+ function parseAndValidateUrl(input: string): ParsedUrl | { error: string } {
52
+ let url: URL;
53
+ try {
54
+ const withProto = input.match(/^https?:\/\//i) ? input : `https://${input}`;
55
+ url = new URL(withProto);
56
+ } catch {
57
+ return { error: "Could not parse URL" };
58
+ }
59
+
60
+ // Only http/https — block file://, ftp://, javascript:, data:, gopher:, etc.
61
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
62
+ return { error: "Only http:// and https:// URLs are supported" };
63
+ }
64
+
65
+ // Port allowlist — block port scanning attempts
66
+ if (!ALLOWED_PORTS.has(url.port)) {
67
+ return { error: `Port ${url.port} not permitted (allowed: 80, 443, 8080, 8443)` };
68
+ }
69
+
70
+ const hostname = url.hostname.toLowerCase();
71
+
72
+ if (!hostname) return { error: "Missing hostname" };
73
+ if (hostname === "localhost") return { error: "Localhost not permitted" };
74
+
75
+ // Block raw private/internal IPs at parse time (defense in depth — DNS check happens too)
76
+ if (PRIVATE_IPV4_RE.test(hostname) || PRIVATE_IPV6_RE.test(hostname)) {
77
+ return { error: "Private/internal addresses not permitted" };
78
+ }
79
+
80
+ // Hostname allowlist for domain names (IPs handled above)
81
+ const isIp = /^[\d.]+$/.test(hostname) || hostname.includes(":");
82
+ if (!isIp && !VALID_DOMAIN_RE.test(hostname)) {
83
+ return { error: "Invalid hostname" };
84
+ }
85
+
86
+ return { url, hostname };
87
+ }
88
+
89
+ // Resolve hostname and ensure it doesn't point at private IPs.
90
+ // Catches DNS-rebinding-style hostnames that resolve to internal addresses.
91
+ //
92
+ // NOTE: This is a TOCTOU-vulnerable check — between this resolution and the
93
+ // fetch's own resolution, DNS records can change. Acceptable risk in Railway's
94
+ // network model where private IPs aren't reachable from app containers.
95
+ async function isHostnameSafe(hostname: string): Promise<boolean> {
96
+ // Skip DNS check if already a public IP literal
97
+ if (/^[\d.]+$/.test(hostname) || hostname.includes(":")) {
98
+ return !PRIVATE_IPV4_RE.test(hostname) && !PRIVATE_IPV6_RE.test(hostname);
99
+ }
100
+ try {
101
+ const addresses = await Promise.race([
102
+ dns.resolve(hostname),
103
+ new Promise<string[]>((_, reject) =>
104
+ setTimeout(() => reject(new Error("DNS timeout")), 3000)
105
+ ),
106
+ ]);
107
+ for (const addr of addresses) {
108
+ if (PRIVATE_IPV4_RE.test(addr) || PRIVATE_IPV6_RE.test(addr)) return false;
109
+ }
110
+ return addresses.length > 0;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ // ─── Fetch with redirect handling & SSRF re-check on each hop ─────────────────
117
+
118
+ interface FetchResult {
119
+ finalUrl: string;
120
+ status: number;
121
+ headers: Record<string, string>;
122
+ redirects: number;
123
+ }
124
+
125
+ async function safeFetch(initialUrl: URL): Promise<FetchResult> {
126
+ let currentUrl = initialUrl;
127
+ let redirects = 0;
128
+
129
+ while (redirects <= MAX_REDIRECTS) {
130
+ // Re-verify each redirect target — prevents redirect-to-internal attacks
131
+ const safe = await isHostnameSafe(currentUrl.hostname);
132
+ if (!safe) {
133
+ throw new Error(`Refused: hostname ${currentUrl.hostname} resolves to a private address`);
134
+ }
135
+
136
+ const controller = new AbortController();
137
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
138
+
139
+ try {
140
+ const response = await fetch(currentUrl.toString(), {
141
+ method: "GET",
142
+ redirect: "manual", // we handle redirects ourselves
143
+ signal: controller.signal,
144
+ headers: {
145
+ "User-Agent": "TrustSource-HeaderCheck/1.0 (+https://trustsource.cc)",
146
+ "Accept": "*/*",
147
+ "Accept-Encoding": "identity", // disable compression — prevents decompression-bomb DoS
148
+ },
149
+ });
150
+ clearTimeout(timer);
151
+
152
+ // Collect headers (lowercased keys for consistency)
153
+ const headers: Record<string, string> = {};
154
+ response.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; });
155
+
156
+ // Handle redirect
157
+ if (response.status >= 300 && response.status < 400 && headers["location"]) {
158
+ try {
159
+ currentUrl = new URL(headers["location"], currentUrl);
160
+ } catch {
161
+ throw new Error("Invalid redirect Location header");
162
+ }
163
+
164
+ // Re-validate scheme + port on each redirect — Location header could be hostile
165
+ if (currentUrl.protocol !== "http:" && currentUrl.protocol !== "https:") {
166
+ throw new Error(`Refused redirect to ${currentUrl.protocol} scheme`);
167
+ }
168
+ if (!ALLOWED_PORTS.has(currentUrl.port)) {
169
+ throw new Error(`Refused redirect to port ${currentUrl.port}`);
170
+ }
171
+
172
+ redirects++;
173
+
174
+ // Discard response body to avoid memory accumulation
175
+ try { await response.body?.cancel(); } catch { /* ignore */ }
176
+ continue;
177
+ }
178
+
179
+ // Final response — discard body, we only care about headers
180
+ try { await response.body?.cancel(); } catch { /* ignore */ }
181
+
182
+ return {
183
+ finalUrl: currentUrl.toString(),
184
+ status: response.status,
185
+ headers,
186
+ redirects,
187
+ };
188
+ } catch (err) {
189
+ clearTimeout(timer);
190
+ throw err;
191
+ }
192
+ }
193
+ throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`);
194
+ }
195
+
196
+ // ─── Header analysis ──────────────────────────────────────────────────────────
197
+
198
+ interface HeaderAnalysis {
199
+ present: boolean;
200
+ value: string | null;
201
+ score: number;
202
+ maxScore: number;
203
+ notes: string[];
204
+ }
205
+
206
+ function analyzeHsts(value: string | undefined): HeaderAnalysis {
207
+ if (!value) {
208
+ return { present: false, value: null, score: 0, maxScore: 20, notes: ["missing"] };
209
+ }
210
+ const notes: string[] = [];
211
+ let score = 8; // base for presence
212
+
213
+ const maxAgeMatch = value.match(/max-age=(\d+)/i);
214
+ const maxAge = maxAgeMatch ? parseInt(maxAgeMatch[1], 10) : 0;
215
+ if (maxAge >= 31536000) { score += 7; } // ≥ 1 year
216
+ else if (maxAge >= 15768000) { score += 4; notes.push("max_age_short"); } // ≥ 6 months
217
+ else { notes.push("max_age_too_short"); }
218
+
219
+ if (/includeSubDomains/i.test(value)) score += 3;
220
+ else notes.push("missing_includeSubDomains");
221
+
222
+ if (/preload/i.test(value)) score += 2;
223
+
224
+ return { present: true, value, score: Math.min(score, 20), maxScore: 20, notes };
225
+ }
226
+
227
+ function analyzeCsp(value: string | undefined): HeaderAnalysis {
228
+ if (!value) {
229
+ return { present: false, value: null, score: 0, maxScore: 20, notes: ["missing"] };
230
+ }
231
+ const notes: string[] = [];
232
+ let score = 10; // base for presence
233
+
234
+ // Penalize known weak directives
235
+ if (/unsafe-inline/i.test(value)) { score -= 4; notes.push("uses_unsafe_inline"); }
236
+ if (/unsafe-eval/i.test(value)) { score -= 4; notes.push("uses_unsafe_eval"); }
237
+ if (/\*/.test(value) && !/strict-dynamic/i.test(value)) {
238
+ notes.push("wildcard_source");
239
+ } else {
240
+ score += 5;
241
+ }
242
+
243
+ // Reward strong directives
244
+ if (/default-src/i.test(value)) score += 3;
245
+ if (/frame-ancestors\s+(none|'self')/i.test(value)) score += 2;
246
+
247
+ return {
248
+ present: true,
249
+ value: value.length > 200 ? value.slice(0, 200) + "…" : value,
250
+ score: Math.max(0, Math.min(score, 20)),
251
+ maxScore: 20,
252
+ notes,
253
+ };
254
+ }
255
+
256
+ function analyzeXFrameOptions(value: string | undefined): HeaderAnalysis {
257
+ if (!value) {
258
+ return { present: false, value: null, score: 0, maxScore: 10, notes: ["missing"] };
259
+ }
260
+ const v = value.toUpperCase();
261
+ if (v === "DENY") return { present: true, value, score: 10, maxScore: 10, notes: [] };
262
+ if (v === "SAMEORIGIN") return { present: true, value, score: 8, maxScore: 10, notes: [] };
263
+ return { present: true, value, score: 3, maxScore: 10, notes: ["weak_value"] };
264
+ }
265
+
266
+ function analyzeXContentTypeOptions(value: string | undefined): HeaderAnalysis {
267
+ if (!value) {
268
+ return { present: false, value: null, score: 0, maxScore: 10, notes: ["missing"] };
269
+ }
270
+ if (value.toLowerCase() === "nosniff") {
271
+ return { present: true, value, score: 10, maxScore: 10, notes: [] };
272
+ }
273
+ return { present: true, value, score: 3, maxScore: 10, notes: ["weak_value"] };
274
+ }
275
+
276
+ function analyzeReferrerPolicy(value: string | undefined): HeaderAnalysis {
277
+ if (!value) {
278
+ return { present: false, value: null, score: 0, maxScore: 10, notes: ["missing"] };
279
+ }
280
+ const safe = ["no-referrer", "same-origin", "strict-origin", "strict-origin-when-cross-origin"];
281
+ const v = value.toLowerCase();
282
+ if (safe.some(s => v.includes(s))) {
283
+ return { present: true, value, score: 10, maxScore: 10, notes: [] };
284
+ }
285
+ if (v.includes("unsafe-url")) {
286
+ return { present: true, value, score: 0, maxScore: 10, notes: ["unsafe_policy"] };
287
+ }
288
+ return { present: true, value, score: 5, maxScore: 10, notes: [] };
289
+ }
290
+
291
+ function analyzePermissionsPolicy(value: string | undefined): HeaderAnalysis {
292
+ if (!value) {
293
+ return { present: false, value: null, score: 0, maxScore: 10, notes: ["missing"] };
294
+ }
295
+ return {
296
+ present: true,
297
+ value: value.length > 150 ? value.slice(0, 150) + "…" : value,
298
+ score: 10,
299
+ maxScore: 10,
300
+ notes: [],
301
+ };
302
+ }
303
+
304
+ function analyzeCoop(value: string | undefined): HeaderAnalysis {
305
+ if (!value) {
306
+ return { present: false, value: null, score: 0, maxScore: 10, notes: ["missing"] };
307
+ }
308
+ const v = value.toLowerCase();
309
+ if (v === "same-origin") return { present: true, value, score: 10, maxScore: 10, notes: [] };
310
+ if (v === "same-origin-allow-popups") return { present: true, value, score: 7, maxScore: 10, notes: [] };
311
+ return { present: true, value, score: 3, maxScore: 10, notes: ["weak_value"] };
312
+ }
313
+
314
+ function analyzeServerDisclosure(headers: Record<string, string>): HeaderAnalysis {
315
+ const server = headers["server"];
316
+ const xPowered = headers["x-powered-by"];
317
+ const notes: string[] = [];
318
+ let score = 10;
319
+
320
+ // Version disclosure penalty — "nginx/1.18.0" leaks version, "nginx" alone is fine
321
+ if (server && /\d+\.\d+/.test(server)) {
322
+ score -= 5;
323
+ notes.push("server_version_disclosed");
324
+ } else if (server) {
325
+ score -= 2;
326
+ notes.push("server_disclosed");
327
+ }
328
+
329
+ if (xPowered) {
330
+ score -= 5;
331
+ notes.push("x_powered_by_disclosed");
332
+ }
333
+
334
+ return {
335
+ present: !!(server || xPowered),
336
+ value: server || xPowered || null,
337
+ score: Math.max(0, score),
338
+ maxScore: 10,
339
+ notes,
340
+ };
341
+ }
342
+
343
+ // ─── Grade & tier ─────────────────────────────────────────────────────────────
344
+
345
+ function scoreToGrade(score: number): string {
346
+ if (score >= 90) return "A+";
347
+ if (score >= 80) return "A";
348
+ if (score >= 70) return "B";
349
+ if (score >= 50) return "C";
350
+ if (score >= 30) return "D";
351
+ return "F";
352
+ }
353
+
354
+ // ─── Main route ───────────────────────────────────────────────────────────────
355
+
356
+ router.get("/headers", async (req: Request, res: Response) => {
357
+ const raw = (req.query.url as string) || (req.query.domain as string);
358
+
359
+ if (!raw) {
360
+ res.status(400).json({
361
+ error: "Missing parameter",
362
+ message: "Provide ?url=https://example.com or ?domain=example.com",
363
+ });
364
+ return;
365
+ }
366
+ if (raw.length > 2048) {
367
+ res.status(400).json({
368
+ error: "Invalid input",
369
+ message: "URL must be 2048 characters or fewer",
370
+ });
371
+ return;
372
+ }
373
+
374
+ const parsed = parseAndValidateUrl(raw);
375
+ if ("error" in parsed) {
376
+ res.status(400).json({ error: "Invalid URL", message: parsed.error });
377
+ return;
378
+ }
379
+
380
+ // Cache key uses normalized URL (origin + path)
381
+ const cacheKey = parsed.url.origin + parsed.url.pathname;
382
+ const cached = getCached(cacheKey);
383
+ if (cached) {
384
+ res.json({ ...cached, meta: { ...(cached.meta as object), cached: true } });
385
+ return;
386
+ }
387
+
388
+ try {
389
+ const fetchResult = await safeFetch(parsed.url);
390
+ const h = fetchResult.headers;
391
+
392
+ const analysis = {
393
+ hsts: analyzeHsts(h["strict-transport-security"]),
394
+ csp: analyzeCsp(h["content-security-policy"]),
395
+ xFrameOptions: analyzeXFrameOptions(h["x-frame-options"]),
396
+ xContentTypeOptions: analyzeXContentTypeOptions(h["x-content-type-options"]),
397
+ referrerPolicy: analyzeReferrerPolicy(h["referrer-policy"]),
398
+ permissionsPolicy: analyzePermissionsPolicy(h["permissions-policy"]),
399
+ coop: analyzeCoop(h["cross-origin-opener-policy"]),
400
+ serverDisclosure: analyzeServerDisclosure(h),
401
+ };
402
+
403
+ const total = Object.values(analysis).reduce((sum, a) => sum + a.score, 0);
404
+ const maxTotal = Object.values(analysis).reduce((sum, a) => sum + a.maxScore, 0);
405
+ const grade = scoreToGrade(total);
406
+
407
+ // Build warnings — flatten all "notes" arrays except plain "missing"
408
+ const warnings: string[] = [];
409
+ for (const [name, a] of Object.entries(analysis)) {
410
+ if (!a.present) warnings.push(`missing_${name}`);
411
+ else for (const n of a.notes) if (n !== "missing") warnings.push(`${name}:${n}`);
412
+ }
413
+
414
+ const response = {
415
+ url: fetchResult.finalUrl,
416
+ hostname: parsed.hostname,
417
+ grade,
418
+ score: total,
419
+ maxScore: maxTotal,
420
+ analysis,
421
+ warnings,
422
+ response: {
423
+ status: fetchResult.status,
424
+ redirects: fetchResult.redirects,
425
+ },
426
+ meta: {
427
+ checkedAt: new Date().toISOString(),
428
+ apiVersion: "1.0",
429
+ paidWith: "x402/USDC",
430
+ cached: false,
431
+ },
432
+ };
433
+
434
+ setCached(cacheKey, response);
435
+ res.json(response);
436
+
437
+ } catch (err) {
438
+ const msg = err instanceof Error ? err.message : "Unknown error";
439
+ res.status(502).json({
440
+ error: "Header check failed",
441
+ url: parsed.url.toString(),
442
+ message: msg,
443
+ meta: { checkedAt: new Date().toISOString(), apiVersion: "1.0" },
444
+ });
445
+ }
446
+ });
447
+
448
+ export default router;