{"record":{"id":"e2ed873cf4b5ea8c","repo":"Hmbown/CodeWhale","slug":"invalid-channel","errorCode":"invalid-channel","errorMessage":"invalid-channel","messagePattern":"invalid-channel","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"web/lib/cloud-facts.ts","lineNumber":252,"sourceCode":"  if (expires !== null && now >= expires) return { ok: false, reason: \"expired\" };\n  return { ok: true, keyId, mode: \"verified\" };\n}\n\n/** Only publishable keys (or legacy anon JWTs), never secret/service-role keys. */\nfunction isPublishableKey(key: string): boolean {\n  if (/^sb_publishable_[A-Za-z0-9_-]+$/.test(key)) return true;\n  if (key.length > 8192) return false;\n  try {\n    const parts = key.split(\".\");\n    if (parts.length !== 3) return false;\n    const middle = parts[1].replace(/-/g, \"+\").replace(/_/g, \"/\");\n    const payload = JSON.parse(atob(middle.padEnd(Math.ceil(middle.length / 4) * 4, \"=\")));\n    return isObject(payload) && payload.role === \"anon\";\n  } catch { return false; }\n}\n\nexport async function fetchCurrentRow(channel: string, env: CloudFactsEnv, opts: ResolveOptions = {}): Promise<FactsCurrentRow | null> {\n  if (!isValidChannel(channel)) throw new Error(\"invalid-channel\");\n  const key = env.SUPABASE_PUBLISHABLE_KEY;\n  let base: URL;\n  try {\n    base = new URL(env.SUPABASE_URL ?? \"\");\n    if (base.protocol !== \"https:\" || base.username || base.password || base.search || base.hash || !key || !isPublishableKey(key)) throw new Error();\n  } catch { throw new Error(\"supabase-not-configured\"); }\n  const controller = new AbortController();\n  const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? SUPABASE_TIMEOUT_MS);\n  try {\n    const url = new URL(`${base.href.replace(/\\/+$/, \"\")}/rest/v1/facts_current`);\n    url.search = new URLSearchParams({ channel: `eq.${channel}`, scope: \"eq.global\", select: \"channel,release_id,facts_version,schema_version,envelope_version,applies_to,key_id,payload_b64,sig_b64,sigs,payload_sha256,published_at,not_after\", limit: \"1\" }).toString();\n    const res = await (opts.fetchImpl ?? fetch)(url, {\n      headers: { apikey: key!, Authorization: `Bearer ${key}`, Accept: \"application/json\" },\n      signal: controller.signal,\n      redirect: \"error\",\n    });\n    if (!res.ok) { await res.body?.cancel(); throw new Error(`supabase-http-${res.status}`); }\n    const bytes = await readBoundedBody(res, MAX_ENVELOPE_BYTES);","sourceCodeStart":234,"sourceCodeEnd":270,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/433685b2024e7bc4c99e1e2e326bcad39b4d9d65/web/lib/cloud-facts.ts#L234-L270","documentation":"fetchCurrentRow validates the channel name with isValidChannel before touching Supabase and throws \"invalid-channel\" for anything that fails, preventing attacker-controlled channel strings from reaching query construction.","triggerScenarios":"Calling fetchCurrentRow (via the row resolver) with a channel string failing isValidChannel — wrong characters, wrong length, wrong format, or a non-conforming value from a request parameter.","commonSituations":"Passing raw URL path/query segments as the channel without sanitizing; a client using an old channel naming scheme; typos or URL-decoding artifacts (e.g. %20) in the channel value.","solutions":["Validate/sanitize the channel against the allowed pattern before calling fetchCurrentRow.","Check where the channel value originates (route params, query string) and reject invalid values at the route with a 400.","Compare against the isValidChannel pattern in web/lib/cloud-facts.ts to see exactly which characters/length are accepted."],"exampleFix":"// before\nconst row = await fetchCurrentRow(params.get(\"channel\") ?? \"\", env);\n// after\nconst channel = params.get(\"channel\") ?? \"\";\nif (!/^[a-z0-9-]{1,64}$/.test(channel)) return new Response(\"invalid channel\", { status: 400 });\nconst row = await fetchCurrentRow(channel, env);","handlingStrategy":"type-guard","validationCode":"// Route-level guard before resolving a channel\nconst channel = new URL(request.url).searchParams.get(\"channel\") ?? \"\";\nif (!/^[a-z0-9-]{1,64}$/.test(channel)) return new Response(\"invalid channel\", { status: 400 });","typeGuard":"function isSafeChannel(v: unknown): v is string {\n  return typeof v === \"string\" && /^[a-z0-9-]{1,64}$/.test(v); // match isValidChannel's actual pattern\n}","tryCatchPattern":"try {\n  const row = await fetchCurrentRow(channel, env);\n} catch (err) {\n  if (err.message === \"invalid-channel\") {\n    return new Response(\"invalid channel\", { status: 400 });\n  }\n  throw err;\n}","preventionTips":["Validate channel route params against the allowed pattern before passing them on.","URL-decode and trim inputs once, at the route boundary, and reject anything non-conforming.","Keep the channel regex in one shared helper so clients and server agree on the format."],"tags":["validation","input-validation","supabase"],"backgroundTag":"invalid-argument-value","analyzedSha":"433685b2024e7bc4c99e1e2e326bcad39b4d9d65","analyzedAt":"2026-09-15T12:24:24.634Z","contentChangedAt":"2026-09-15T12:24:24.634Z","schemaVersion":2},"datasetVersion":"2026-09-22T11:17:16.035Z"}