diff --git a/sdk/src/context.ts b/sdk/src/context.ts index 6a9a030..557fc5d 100644 --- a/sdk/src/context.ts +++ b/sdk/src/context.ts @@ -1,17 +1,40 @@ -// 与 server/tracker/constants.py 的 URL_PARAM_DROP_PATTERNS 同源: -// ("email", "phone", "mobile", "token", "key", "password", "verif"), -// 整键精确匹配(大小写不敏感,与服务端 re.I + fullmatch 一致)——SDK 先删一遍, -// 服务端 sanitize_url 再删一遍(双重清洗),子串参数(如 monkey 含 key)不误伤。 +// 三轨制清洗(spec §2.4)——与 server/tracker/constants.py 同源(SANITIZE_RULE_VERSION v2): +// ① 参数名黑名单(URL_PARAM_DROP_PATTERNS)整键精确匹配(大小写不敏感,对齐 re.I + fullmatch) +// → 删;子串参数(如 monkey 含 key)不误伤; +// ② 归因白名单(ATTRIBUTION_PARAM_WHITELIST)+ utm_ 前缀 → 无条件保留; +// ③ 其余按值判别 _SAFE_VALUE(纯数字 ≤10 位或 slug ≤31 字符)→ 保留,否则删。 +// SDK 入队前先清洗一遍,服务端 sanitize_url 再清洗一遍(双重清洗同规则,共享向量契约: +// server/tracker/tests/fixtures/sanitize_vectors.json)。 const URL_PARAM_DROP = new Set([ "email", "phone", "mobile", + "tel", + "name", + "user", "token", "key", "password", "verif", + "passwd", + "idcard", ]); +const ATTRIBUTION_WHITELIST = new Set([ + "gclid", + "gbraid", + "wbraid", + "fbclid", + "msclkid", + "yclid", + "gad_source", + "gad_campaignid", + "dclid", +]); + +// 对齐 server _SAFE_VALUE:^\d{1,10}|slug —— 32+ 连续字母数字视为 token 删 +const SAFE_VALUE = /^(?:\d{1,10}|[A-Za-z0-9][A-Za-z0-9._-]{0,30})$/; + export interface EnvContext { device_type: string; os: string; @@ -44,17 +67,97 @@ export interface PageContext { utm: Record; } +/** 对齐 Python unquote_plus:+ → 空格;%XX 解码(非法 % 序列 decode 抛异常 → 原样降级) */ +function unquotePlus(s: string): string { + if (!s.includes("+") && !s.includes("%")) return s; + try { + return decodeURIComponent(s.replace(/\+/g, " ")); + } catch { + return s.replace(/\+/g, " "); + } +} + +/** 对齐 Python urlencode 默认 quote_via=quote_plus:补齐 encodeURIComponent 差集—— + * 空格 → +,!'()* 补编码(Python 的不转义集仅 [A-Za-z0-9_.~-]),保证输出逐字节一致 */ +function encodeParam(s: string): string { + return encodeURIComponent(s) + .replace(/%20/g, "+") + .replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase()); +} + +/** 对齐 Python parse_qsl(query, keep_blank_values=True): + * & 切分、空段跳过、首 = 切分、缺 = 时值为空串 */ +function parseQuery(q: string): Array<[string, string]> { + const out: Array<[string, string]> = []; + for (const piece of q.split("&")) { + if (!piece) continue; + const eq = piece.indexOf("="); + out.push([ + unquotePlus(eq === -1 ? piece : piece.slice(0, eq)), + unquotePlus(eq === -1 ? "" : piece.slice(eq + 1)), + ]); + } + return out; +} + +/** authority 清洗(仅绝对 URL 调用):剥 userinfo(末个 @ 之前,对齐 rpartition('@')); + * 端口空/非纯数字/越界(>65535) → 剥除(对齐服务端 p.port 抛 ValueError 时的降级); + * host 小写(对齐 p.hostname)。 */ +function cleanAuthority(authority: string): string { + let host = authority; + const at = host.lastIndexOf("@"); + if (at !== -1) host = host.slice(at + 1); + // IPv6 字面量取 ] 后的 : 段;否则首个 : 段(对齐 Python partition(':')) + const bracketEnd = host.indexOf("]"); + const colon = bracketEnd === -1 ? host.indexOf(":") : host.indexOf(":", bracketEnd); + if (colon !== -1) { + const port = host.slice(colon + 1); + if (!/^\d+$/.test(port) || +port > 65535) host = host.slice(0, colon); + } + return host.toLowerCase(); +} + export function sanitizeUrl(pathAndQuery: string): string { - const i = pathAndQuery.indexOf("?"); - if (i === -1) return pathAndQuery; - const path = pathAndQuery.slice(0, i); - const kept: string[] = []; - new URLSearchParams(pathAndQuery.slice(i + 1)).forEach((v, k) => { - if (!URL_PARAM_DROP.has(k.toLowerCase())) { - kept.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`); + // 红线:pageContext 在 init 路径调用,任何异常不得冒泡——整体兜底, + // 降级返回剥掉 query 的 path 部分(或原串) + try { + if (typeof pathAndQuery !== "string" || pathAndQuery === "") return pathAndQuery; + // 先剥 fragment(# 后的一切不参与 query 解析),再切 path?query——顺序对齐 urlparse + const hash = pathAndQuery.indexOf("#"); + const noFrag = hash === -1 ? pathAndQuery : pathAndQuery.slice(0, hash); + // 相对路径(SDK 主场景 location.pathname+search)不走 new URL——无 origin 会抛, + // 沿用字符串切分;仅 ^https?:// 绝对 URL(如 referrer)才重塑 authority + let base = ""; + let pathQuery = noFrag; + const abs = /^(https?:\/\/)([^/?]*)([\s\S]*)$/i.exec(noFrag); + if (abs) { + base = abs[1].toLowerCase() + cleanAuthority(abs[2]); + pathQuery = abs[3]; } - }); - return kept.length ? `${path}?${kept.join("&")}` : path; + const q = pathQuery.indexOf("?"); + const path = q === -1 ? pathQuery : pathQuery.slice(0, q); + const query = q === -1 ? "" : pathQuery.slice(q + 1); + const kept: string[] = []; + for (const [k, v] of parseQuery(query)) { + const lk = k.toLowerCase(); + if (URL_PARAM_DROP.has(lk)) continue; // 轨①:黑名单整键删 + if (ATTRIBUTION_WHITELIST.has(lk) || lk.startsWith("utm_")) { + // 轨②:归因白名单无条件保留 + kept.push(`${encodeParam(k)}=${encodeParam(v)}`); + continue; + } + if (SAFE_VALUE.test(v)) kept.push(`${encodeParam(k)}=${encodeParam(v)}`); // 轨③:按值判别 + } + const qs = kept.join("&"); + return qs ? `${base}${path}?${qs}` : `${base}${path}`; + } catch { + try { + const i = pathAndQuery.indexOf("?"); + return i === -1 ? pathAndQuery : pathAndQuery.slice(0, i); + } catch { + return pathAndQuery; + } + } } export function parseUtm(search: string): Record { diff --git a/sdk/test/context.test.ts b/sdk/test/context.test.ts index f8df130..0ad86a8 100644 --- a/sdk/test/context.test.ts +++ b/sdk/test/context.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, afterEach, vi } from "vitest"; +import vectors from "../../server/tracker/tests/fixtures/sanitize_vectors.json"; import { buildEnvContext, collectHighEntropyHints, @@ -58,14 +59,21 @@ describe("parseUtm", () => { }); describe("sanitizeUrl", () => { + // 共享向量(T1 落地):server/tracker/tests/fixtures/sanitize_vectors.json 是双侧唯一契约 + // ——SDK 与 server/tracker/services/validation.py 的 sanitize_url 必须逐字节同输出。 + // `in` 是保留字不能作解构绑定名,用整对象参数 v 取属性。 + it.each(vectors.vectors)("共享向量: $in", (v) => { + expect(sanitizeUrl(v.in)).toBe(v.out); + }); + it("删除黑名单参数,保留其余(含空值),不误伤 monkey", () => { expect(sanitizeUrl("/p?email=a@b.com&token=x&id=3&utm_source=g&monkey=1")).toBe( "/p?id=3&utm_source=g&monkey=1", ); }); - it("空值参数保留", () => { - expect(sanitizeUrl("/p?empty=&id=1")).toBe("/p?empty=&id=1"); + it("空值参数按值判别删(_SAFE_VALUE 不匹配空串,对齐服务端)", () => { + expect(sanitizeUrl("/p?empty=&id=1")).toBe("/p?id=1"); }); it("黑名单整键精确匹配(大小写不敏感),子串参数不删", () => { diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json index 77f6b7a..577e6f6 100644 --- a/sdk/tsconfig.json +++ b/sdk/tsconfig.json @@ -8,6 +8,7 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, "noEmit": true }, "include": ["src", "test"]