feat(sdk): 入队前清洗三轨制对齐服务端(共享向量)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+117
-14
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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)}`);
|
||||
/** 对齐 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 {
|
||||
// 红线: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> {
|
||||
|
||||
@@ -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("黑名单整键精确匹配(大小写不敏感),子串参数不删", () => {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
|
||||
Reference in New Issue
Block a user