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