link-leak.ts raw
1 export type LeakFinding = {
2 category: 'credential' | 'email' | 'jwt' | 'token' | 'coordinates';
3 severity: 'warning' | 'info';
4 message: string;
5 };
6
7 const TOKEN_PARAM_NAMES: ReadonlySet<string> = new Set([
8 'access_token', 'refresh_token', 'id_token', 'token', 'auth',
9 'authorization', 'auth_token', 'otp', 'one_time_code', 'reset',
10 'reset_token', 'password_reset_token', 'invite', 'invite_code',
11 'invitation_token', 'api_key', 'apikey', 'secret', 'client_secret',
12 'session', 'session_id', 'session_token', 'sessionid', 'signature',
13 'sig', 'private_token', 'share_token', 'confirmation_token',
14 'unlock_token', 'recovery_token', 'bearer',
15 ]);
16
17 const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
18 const JWT_RE = /eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/;
19 const MIN_TOKEN_LEN = 8;
20
21 function pct(s: string): string { try { return decodeURIComponent(s); } catch { return s; } }
22
23 function checkSensitiveData(raw: string, part: string): LeakFinding[] {
24 const f: LeakFinding[] = [];
25 const d = pct(raw);
26 const e = d.match(EMAIL_RE);
27 if (e) f.push({ category: 'email', severity: 'warning', message: `Email in ${part}: ${e[0]}` });
28 const j = d.match(JWT_RE);
29 if (j) f.push({ category: 'jwt', severity: 'warning', message: `JWT in ${part} (${j[0].slice(0, 30)}…)` });
30 return f;
31 }
32
33 export function checkLinkLeaks(url: string): LeakFinding[] {
34 const findings: LeakFinding[] = [];
35 try {
36 const u = new URL(url);
37 if (u.password && u.password.length > 0) {
38 findings.push({ category: 'credential', severity: 'warning', message: 'URL contains a password in userinfo.' });
39 }
40 if (u.pathname && u.pathname !== '/') findings.push(...checkSensitiveData(u.pathname, 'path'));
41 const tp: string[] = [];
42 for (const [k, v] of u.searchParams) {
43 const lk = k.toLowerCase();
44 if (TOKEN_PARAM_NAMES.has(lk) && pct(v).length >= MIN_TOKEN_LEN) tp.push(lk);
45 findings.push(...checkSensitiveData(v, `query param '${k}'`));
46 }
47 if (tp.length) findings.push({ category: 'token', severity: 'warning', message: `Token param(s): ${tp.join(', ')}` });
48 const lat = u.searchParams.get('lat');
49 const lon = u.searchParams.get('lon');
50 if (lat && /^[-+]?\d+\.\d{4,}$/.test(lat) && lon && /^[-+]?\d+\.\d{4,}$/.test(lon)) {
51 findings.push({ category: 'coordinates', severity: 'info', message: `GPS coordinates: lat=${lat}, lon=${lon}` });
52 }
53 if (u.hash) findings.push(...checkSensitiveData(u.hash.slice(1), 'fragment'));
54 } catch {}
55 return findings;
56 }
57
58 export function leakSummary(findings: LeakFinding[]): string {
59 if (findings.length === 0) return '';
60 const lines = findings.map((f) => `• ${f.message}`);
61 lines.unshift('This link may contain sensitive information:');
62 lines.push('', 'The receiver could see this data in the URL.');
63 return lines.join('\n');
64 }
65