privacy.ts 3.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
  1. /**
  2. * What leaves the device in error and vitals hits: URLs without query strings and fragments
  3. * (only UNA's page `i` and API route `r` parameters stay), the site's own origin dropped from
  4. * scripts and stack frames, tokens and emails redacted. UNA strips and redacts again.
  5. */
  6. /** Query parameters kept: they name a page or an API service, not a person. */
  7. const KEEP_PARAMS = ['i', 'r'];
  8. const MAX_PARAM_LENGTH = 128;
  9. const MAX_STACK_FRAMES = 30;
  10. /** BxAnalyticsErrors MAX_STACK_INPUT */
  11. const MAX_STACK_LENGTH = 8000;
  12. const REDACTED = '<redacted>';
  13. const URL_PATTERN = /\b(?:https?|wss?):\/\/[^\s()<>"'`]+/gi;
  14. const EXTENSION_PATTERN = /\b[a-z-]*extension:\/\//i;
  15. /** Tokens and secrets. */
  16. const SECRET_PATTERNS: Array<[RegExp, string]> = [
  17. [/\beyJ[\w-]+\.[\w-]+\.[\w-]+/g, REDACTED],
  18. [/\bBearer\s+[^\s"'`]+/gi, `Bearer ${REDACTED}`],
  19. [/\b(token|key|secret|password|pass|auth|sid|session)=[^&\s"'`]+/gi, `$1=${REDACTED}`],
  20. ];
  21. /**
  22. * Emails. Safari and Firefox stack frames are `fn@file`, but a cleaned frame is `fn@/path` or
  23. * `fn@https://host/...`: neither has a dot right after the first word, so neither matches.
  24. */
  25. const EMAIL_PATTERN = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g;
  26. function redact(text: string): string {
  27. const redacted = SECRET_PATTERNS.reduce((result, [pattern, replacement]) => result.replace(pattern, replacement), text);
  28. return redacted.replace(EMAIL_PATTERN, REDACTED);
  29. }
  30. /**
  31. * `url` without its query string and fragment (the `i` and `r` parameters stay) and without
  32. * `ownOrigin`: `https://site/view-post?id=5#c` -> `/view-post`.
  33. */
  34. export function cleanUrl(url: string, ownOrigin = ''): string {
  35. const match = /^([a-z][a-z0-9+.-]*:\/\/[^/?#]*)?([^?#]*)(\?[^#]*)?/i.exec(String(url ?? ''));
  36. if (!match) return '';
  37. const origin = match[1] ?? '';
  38. const path = match[2] ?? '';
  39. const kept = (match[3] ?? '')
  40. .slice(1)
  41. .split('&')
  42. .filter((pair) => {
  43. const [key, value = ''] = pair.split('=');
  44. return !!key && KEEP_PARAMS.includes(key) && value !== '' && value.length <= MAX_PARAM_LENGTH;
  45. });
  46. const keepOrigin = origin && origin.toLowerCase() !== ownOrigin.toLowerCase();
  47. return `${keepOrigin ? origin : ''}${path}${kept.length ? `?${kept.join('&')}` : ''}`;
  48. }
  49. /** Every URL in `text` cleaned (a frame's `:line:column` stays), secrets and emails redacted. */
  50. export function cleanText(text: unknown, ownOrigin = ''): string {
  51. const value = typeof text === 'string' ? text : '';
  52. if (!value) return '';
  53. const cleaned = value.replace(URL_PATTERN, (url) => {
  54. const position = /(?::\d+){1,2}$/.exec(url)?.[0] ?? '';
  55. return cleanUrl(position ? url.slice(0, -position.length) : url, ownOrigin) + position;
  56. });
  57. return redact(cleaned);
  58. }
  59. /** A message: cleaned, at most `max` characters. */
  60. export function cleanMessage(text: unknown, ownOrigin = '', max = 500): string {
  61. return cleanText(text, ownOrigin).trim().slice(0, max);
  62. }
  63. export function isExtensionUrl(text: unknown): boolean {
  64. return typeof text === 'string' && EXTENSION_PATTERN.test(text);
  65. }
  66. /** At most 30 frames, without browser extensions' frames, cleaned. */
  67. export function cleanStack(stack: unknown, ownOrigin = ''): string {
  68. if (typeof stack !== 'string' || !stack) return '';
  69. const frames = stack
  70. .split('\n')
  71. .filter((line) => line.trim() !== '' && !isExtensionUrl(line))
  72. .slice(0, MAX_STACK_FRAMES);
  73. return cleanText(frames.join('\n'), ownOrigin).slice(0, MAX_STACK_LENGTH);
  74. }