mirror of
https://github.com/supabase/supabase.git
synced 2026-05-08 09:50:33 -04:00
d8f7cc0d57
* o11y: mirror and sanitize breadcrumbs Mirror Sentry breadcrumbs as the basis for our own support logging. Also adds more sanitization to breadcrumbs. * feat(support form): toggle for attaching dashboard logs Add a toggle to the support form when the category is "Dashboard bug", to attach recent dashboard logs. Users can preview the attached logs and opt out. * feat(support links): dedicated support link component Add a new component for support links, which: - Uses the serializer for support link params to ensure serialization/deserialization pairs correctly - Snapshots breadcrumbs so the attached log on the support form will be cut off at the support link click (otherwise we will get support form actions cluttering up the log) * tests(support form): extend timeout on flaky test * Minor clean up * fix(support form): allow url to specifically indicate no specified project * minor nits * Fix tests * Fix tests --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
219 lines
6.3 KiB
TypeScript
219 lines
6.3 KiB
TypeScript
export function sanitizeUrlHashParams(url: string): string {
|
||
return url.split('#')[0]
|
||
}
|
||
|
||
/**
|
||
* Best-effort sanitizer for arrays of objects.
|
||
* - Redacts likely secrets by key name (password, token, apiKey, etc.)
|
||
* - Redacts likely secrets by value pattern (IPv4/IPv6, AWS keys, Bearer/JWT, generic long tokens)
|
||
* - Recurses into nested arrays/objects up to `maxDepth`; beyond that replaces with a notice
|
||
* - Handles circular references
|
||
*
|
||
* @param {any[]} inputArr - Array of items to sanitize (non-objects are copied as-is).
|
||
* @param {Object} [opts]
|
||
* @param {number} [opts.maxDepth=3] - Maximum depth to traverse (0 == only top level).
|
||
* @param {string} [opts.redaction="[REDACTED]"] - Replacement text for sensitive values.
|
||
* @param {string} [opts.truncationNotice="[REDACTED: max depth reached]"] - Used when depth limit is hit.
|
||
* @param {string[]} [opts.sensitiveKeys] - Extra key names to treat as sensitive (case-insensitive).
|
||
* @returns {any[]} a deeply-sanitized clone of the input array
|
||
*/
|
||
export function sanitizeArrayOfObjects(
|
||
inputArr: unknown[],
|
||
opts: {
|
||
maxDepth?: number
|
||
redaction?: string
|
||
truncationNotice?: string
|
||
sensitiveKeys?: string[]
|
||
} = {}
|
||
): unknown[] {
|
||
const {
|
||
maxDepth = 3,
|
||
redaction = '[REDACTED]',
|
||
truncationNotice = '[REDACTED: max depth reached]',
|
||
sensitiveKeys = [],
|
||
} = opts
|
||
|
||
// Common sensitive key names (case-insensitive). Extendable via opts.sensitiveKeys.
|
||
const sensitiveKeySet = new Set(
|
||
[
|
||
'password',
|
||
'passwd',
|
||
'pwd',
|
||
'pass',
|
||
'secret',
|
||
'token',
|
||
'id_token',
|
||
'access_token',
|
||
'refresh_token',
|
||
'apikey',
|
||
'api_key',
|
||
'api-key',
|
||
'apiKey',
|
||
'key',
|
||
'privatekey',
|
||
'private_key',
|
||
'client_secret',
|
||
'clientSecret',
|
||
'auth',
|
||
'authorization',
|
||
'ssh_key',
|
||
'sshKey',
|
||
'bearer',
|
||
'session',
|
||
'cookie',
|
||
'csrf',
|
||
'xsrf',
|
||
'ip',
|
||
'ip_address',
|
||
'ipAddress',
|
||
'aws_access_key_id',
|
||
'aws_secret_access_key',
|
||
'gcp_service_account_key',
|
||
...sensitiveKeys,
|
||
].map((k) => k.toLowerCase())
|
||
)
|
||
|
||
// Value patterns that often indicate secrets or PII
|
||
const patterns = [
|
||
// IPv4
|
||
{ re: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g, reason: 'ip' },
|
||
// IPv6 (simplified but effective)
|
||
{ re: /\b(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{1,4}\b/g, reason: 'ip6' },
|
||
// AWS Access Key ID (starts with AKIA/ASIA, 16 remaining upper alnum)
|
||
{ re: /\b(AKI|ASI)A[0-9A-Z]{16}\b/g, reason: 'aws_access_key_id' },
|
||
// AWS Secret Access Key (40 base64-ish chars)
|
||
{ re: /\b[0-9A-Za-z/+]{40}\b/g, reason: 'aws_secret_access_key_like' },
|
||
// Bearer tokens
|
||
{ re: /\bBearer\s+[A-Za-z0-9\-._~+/]+=*\b/g, reason: 'bearer' },
|
||
// JWT (three base64url segments separated by dots)
|
||
{ re: /\b[A-Za-z0-9-_]+?\.[A-Za-z0-9-_]+?\.[A-Za-z0-9-_]+?\b/g, reason: 'jwt_like' },
|
||
// Generic long API-ish token (conservative: 24–64 safe chars)
|
||
{ re: /\b[A-Za-z0-9_\-]{24,64}\b/g, reason: 'long_token' },
|
||
]
|
||
|
||
const seen = new WeakMap()
|
||
|
||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||
if (v === null || typeof v !== 'object') return false
|
||
const proto = Object.getPrototypeOf(v)
|
||
return proto === Object.prototype || proto === null
|
||
}
|
||
|
||
function redactString(str: string) {
|
||
let out = str
|
||
for (const { re } of patterns) out = out.replace(re, redaction)
|
||
return out
|
||
}
|
||
|
||
function shouldRedactByKey(key: string | symbol | number) {
|
||
return sensitiveKeySet.has(String(key).toLowerCase())
|
||
}
|
||
|
||
function sanitizeValue(value: unknown, depth: number): unknown {
|
||
if (
|
||
value == null ||
|
||
typeof value === 'number' ||
|
||
typeof value === 'boolean' ||
|
||
typeof value === 'bigint'
|
||
) {
|
||
return value
|
||
}
|
||
|
||
if (typeof value === 'string') {
|
||
return redactString(value)
|
||
}
|
||
|
||
if (typeof value === 'function') {
|
||
return '[Function]'
|
||
}
|
||
|
||
if (value instanceof Date) {
|
||
return value.toISOString()
|
||
}
|
||
|
||
if (value instanceof RegExp) {
|
||
return value.toString()
|
||
}
|
||
|
||
if (ArrayBuffer.isView(value) && !(value instanceof DataView)) {
|
||
return `[TypedArray byteLength=${value.byteLength}]`
|
||
}
|
||
if (value instanceof ArrayBuffer) {
|
||
return `[ArrayBuffer byteLength=${value.byteLength}]`
|
||
}
|
||
|
||
if (depth >= maxDepth) {
|
||
return truncationNotice
|
||
}
|
||
|
||
if (typeof value === 'object') {
|
||
if (seen.has(value)) {
|
||
return '[Circular]'
|
||
}
|
||
|
||
if (Array.isArray(value)) {
|
||
const outArr: unknown[] = []
|
||
seen.set(value, outArr)
|
||
for (let i = 0; i < value.length; i++) {
|
||
outArr[i] = sanitizeValue(value[i], depth + 1)
|
||
}
|
||
return outArr
|
||
}
|
||
|
||
if (isPlainObject(value)) {
|
||
const outObj: Record<string | symbol | number, unknown> = {}
|
||
seen.set(value, outObj)
|
||
for (const [k, v] of Object.entries(value)) {
|
||
if (shouldRedactByKey(k)) {
|
||
outObj[k] = redaction
|
||
} else {
|
||
outObj[k] = sanitizeValue(v, depth + 1)
|
||
}
|
||
}
|
||
return outObj
|
||
}
|
||
|
||
if (value instanceof Map) {
|
||
const out: unknown[] = []
|
||
seen.set(value, out)
|
||
for (const [k, v] of value.entries()) {
|
||
const redactedKey = shouldRedactByKey(k) ? redaction : sanitizeValue(k, depth + 1)
|
||
const redactedVal = shouldRedactByKey(k) ? redaction : sanitizeValue(v, depth + 1)
|
||
out.push([redactedKey, redactedVal])
|
||
}
|
||
return out
|
||
}
|
||
|
||
if (value instanceof Set) {
|
||
const out: unknown[] = []
|
||
seen.set(value, out)
|
||
for (const v of value.values()) {
|
||
out.push(sanitizeValue(v, depth + 1))
|
||
}
|
||
return out
|
||
}
|
||
|
||
if (value instanceof URL) return value.toString()
|
||
if (value instanceof Error) {
|
||
const o = {
|
||
name: value.name,
|
||
message: redactString(value.message),
|
||
stack: truncationNotice,
|
||
}
|
||
seen.set(value, o)
|
||
return o
|
||
}
|
||
|
||
try {
|
||
return redactString(String(value))
|
||
} catch {
|
||
return redactString(Object.prototype.toString.call(value))
|
||
}
|
||
}
|
||
|
||
return redactString(String(value))
|
||
}
|
||
|
||
return inputArr.map((item) => sanitizeValue(item, 0))
|
||
}
|