feat(sdk): 入队前清洗三轨制对齐服务端(共享向量)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 14:37:23 +08:00
parent 08495bc365
commit 2a9c98c993
3 changed files with 127 additions and 15 deletions
+117 -14
View File
@@ -1,17 +1,40 @@
// 与 server/tracker/constants.py 的 URL_PARAM_DROP_PATTERNS 同源: // 三轨制清洗(spec §2.4)——与 server/tracker/constants.py 同源(SANITIZE_RULE_VERSION v2):
// ("email", "phone", "mobile", "token", "key", "password", "verif"), // ① 参数名黑名单(URL_PARAM_DROP_PATTERNS)整键精确匹配(大小写不敏感,对齐 re.I + fullmatch)
// 整键精确匹配(大小写不敏感,与服务端 re.I + fullmatch 一致)——SDK 先删一遍, // → 删;子串参数(如 monkey 含 key)不误伤;
// 服务端 sanitize_url 再删一遍(双重清洗),子串参数(如 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([ const URL_PARAM_DROP = new Set([
"email", "email",
"phone", "phone",
"mobile", "mobile",
"tel",
"name",
"user",
"token", "token",
"key", "key",
"password", "password",
"verif", "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 { export interface EnvContext {
device_type: string; device_type: string;
os: string; os: string;
@@ -44,17 +67,97 @@ export interface PageContext {
utm: Record<string, string>; utm: Record<string, string>;
} }
export function sanitizeUrl(pathAndQuery: string): string { /** 对齐 Python unquote_plus:+ → 空格;%XX 解码(非法 % 序列 decode 抛异常 → 原样降级) */
const i = pathAndQuery.indexOf("?"); function unquotePlus(s: string): string {
if (i === -1) return pathAndQuery; if (!s.includes("+") && !s.includes("%")) return s;
const path = pathAndQuery.slice(0, i); try {
const kept: string[] = []; return decodeURIComponent(s.replace(/\+/g, " "));
new URLSearchParams(pathAndQuery.slice(i + 1)).forEach((v, k) => { } catch {
if (!URL_PARAM_DROP.has(k.toLowerCase())) { return s.replace(/\+/g, " ");
kept.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`); }
}
/** 对齐 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 {
// 红线: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];
}
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;
}
} }
});
return kept.length ? `${path}?${kept.join("&")}` : path;
} }
export function parseUtm(search: string): Record<string, string> { export function parseUtm(search: string): Record<string, string> {
+10 -2
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, afterEach, vi } from "vitest"; import { describe, it, expect, afterEach, vi } from "vitest";
import vectors from "../../server/tracker/tests/fixtures/sanitize_vectors.json";
import { import {
buildEnvContext, buildEnvContext,
collectHighEntropyHints, collectHighEntropyHints,
@@ -58,14 +59,21 @@ describe("parseUtm", () => {
}); });
describe("sanitizeUrl", () => { 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", () => { it("删除黑名单参数,保留其余(含空值),不误伤 monkey", () => {
expect(sanitizeUrl("/p?email=a@b.com&token=x&id=3&utm_source=g&monkey=1")).toBe( 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", "/p?id=3&utm_source=g&monkey=1",
); );
}); });
it("空值参数保留", () => { it("空值参数按值判别删(_SAFE_VALUE 不匹配空串,对齐服务端)", () => {
expect(sanitizeUrl("/p?empty=&id=1")).toBe("/p?empty=&id=1"); expect(sanitizeUrl("/p?empty=&id=1")).toBe("/p?id=1");
}); });
it("黑名单整键精确匹配(大小写不敏感),子串参数不删", () => { it("黑名单整键精确匹配(大小写不敏感),子串参数不删", () => {
+1
View File
@@ -8,6 +8,7 @@
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noEmit": true "noEmit": true
}, },
"include": ["src", "test"] "include": ["src", "test"]