Skip to content

Commit 1cacd99

Browse files
committed
fix(normalize): cover modern IDs, matrix params, gRPC-Web and tRPC
1 parent 4e3910c commit 1cacd99

6 files changed

Lines changed: 160 additions & 15 deletions

File tree

packages/cyberstrike/src/session/normalize/parser.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,14 @@ export function parseRawRequest({ raw, scheme }: ParseInput): ParsedRequest {
4545
// for non-JSON, where the value-bearing bodyHash remains the fallback.
4646
const bodyKeyHash = bodyKeyShapeHash(body, bodyContentType)
4747

48-
// Body/header-dispatched protocols (GraphQL, JSON-RPC): derive a per-operation
49-
// identity so each operation is its own dedup unit. Undefined ⇒ plain REST.
48+
// Body/header-dispatched protocols (GraphQL, JSON-RPC, tRPC, gRPC-Web): derive a
49+
// per-operation identity so each operation is its own dedup unit. Undefined ⇒ plain REST.
5050
const op = extractOperation({
5151
method,
5252
bodyContentType,
5353
body,
5454
query: targetUrl.search.replace(/^\?/, ""),
55+
path: canonicalPath,
5556
})
5657

5758
return {
@@ -109,7 +110,16 @@ function findAuthority(lines: string[]): string | undefined {
109110
// (%20, %40, ...) are decoded so regex classification works on a clean form.
110111
function canonicalizePath(path: string): { canonicalPath: string; segments: string[] } {
111112
const rawSegments = path.split("/")
112-
const decoded = rawSegments.map((seg) => safeDecodePreservingSlash(seg).toLowerCase())
113+
const decoded = rawSegments.map((seg) => {
114+
// Strip matrix params (;-delimited per-segment params like ;jsessionid= or
115+
// ;color=red). They are not part of endpoint identity and may leak session tokens.
116+
const withoutMatrix = seg.split(";")[0] ?? ""
117+
const dec = safeDecodePreservingSlash(withoutMatrix)
118+
// Preserve case for opaque case-sensitive ids (base62/nanoid etc) so
119+
// differing-case ids do not collapse; otherwise lowercase for normalization.
120+
if (isCaseSensitiveId(dec)) return dec
121+
return dec.toLowerCase()
122+
})
113123

114124
// Strip a single trailing empty segment (from trailing slash); keep the
115125
// leading empty segment that represents the root.
@@ -122,6 +132,22 @@ function canonicalizePath(path: string): { canonicalPath: string; segments: stri
122132
}
123133
}
124134

135+
function isCaseSensitiveId(seg: string): boolean {
136+
if (seg.includes("%")) return false
137+
if (seg.length < 16) return false
138+
// TypeID suffix, KSUID, nanoid, cuid, Stripe suffix, base62 mixed-case
139+
if (/^[0-9A-HJKMNP-TV-Z]{26}$/i.test(seg)) return false // ULID is case-insensitive (Crockford)
140+
if (/^[A-Za-z0-9_-]{21}$/.test(seg)) return true
141+
if (/^[0-9A-Za-z]{27}$/.test(seg)) return true
142+
if (/^c[a-z0-9]{24,}$/.test(seg)) return false // cuid is lowercase
143+
if (/^[a-z]{2,10}_[A-Za-z0-9]{14,}$/.test(seg)) return true
144+
if (/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])[A-Za-z0-9_-]{16,}$/.test(seg)) return true
145+
if (/^[a-z][a-z0-9_]*_[0-9A-HJKMNP-TV-Z]{26}$/i.test(seg)) return true
146+
// generic: contains both upper and lower + digit, length >=16
147+
if (seg.length >= 16 && /[a-z]/.test(seg) && /[A-Z]/.test(seg) && /[0-9]/.test(seg)) return true
148+
return false
149+
}
150+
125151
// Decode percent-encoding in a segment while keeping %2F encoded. We replace
126152
// %2F with a sentinel from the Unicode Private Use Area (unlikely in real
127153
// paths), decode the rest, and restore the sentinel — preventing `/` from

packages/cyberstrike/src/session/normalize/protocol.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { parseXmlToObject } from "./xml"
1717
import { extractInlineArgPaths } from "./graphql-inline"
1818

1919
export interface OperationInfo {
20-
protocol: "graphql" | "jsonrpc"
20+
protocol: "graphql" | "jsonrpc" | "trpc" | "grpc-web"
2121
operation: string // human label, e.g. "mutation:deleteUser", "user.delete"
2222
opKeyHash: string // 16-char dedup discriminator (values stripped)
2323
}
@@ -444,6 +444,12 @@ function parseMultipartFields(body: string): BodyField[] {
444444
* before the parse was unified (guarded by a golden characterization test).
445445
*/
446446
export function bodyKeyShapeHash(body: string | undefined, contentType: string | undefined): string | undefined {
447+
const ct = (contentType ?? "").toLowerCase()
448+
// gRPC-Web binary bodies are not JSON-parseable; provide a stable shape hash so
449+
// the endpoint does not fragment per body value (fallback would be value-bearing bodyHash).
450+
if (ct.includes("grpc-web") || ct.includes("application/grpc")) {
451+
return sha16("grpc-web-shape")
452+
}
447453
const parsed = parseBody(body, contentType)
448454
switch (parsed.kind) {
449455
case "json": {
@@ -472,13 +478,92 @@ export function bodyKeyShapeHash(body: string | undefined, contentType: string |
472478
* plain REST (the caller then falls back to body_hash/query_hash dedup).
473479
* Deterministic, pure — safe for the tier0 parser.
474480
*/
481+
function trpcFrom(query: string, path: string): OperationInfo | undefined {
482+
// tRPC is an operation-over-URL protocol like GraphQL: procedures live in the
483+
// path after /trpc/ (comma-separated, batch=1) and inputs in query `input`.
484+
// Normalize to per-operation identity so /trpc/a,b?batch=1 does not fragment.
485+
const m = path.match(/\/trpc\/([^?]+)/i)
486+
if (!m) return undefined
487+
const rawProcs = m[1]!
488+
const procedures = rawProcs
489+
.split(",")
490+
.map((p) => p.trim())
491+
.filter(Boolean)
492+
.sort()
493+
if (procedures.length === 0) return undefined
494+
const params = new URLSearchParams(query)
495+
const inputRaw = params.get("input")
496+
let keyShape: string[] = []
497+
if (inputRaw) {
498+
try {
499+
const parsed = JSON.parse(inputRaw)
500+
// tRPC batch wraps under numeric keys: {"0":{"json":{...}}}
501+
// Unwrap to get real input shape; values stripped.
502+
const toShape = (v: unknown): string[] => {
503+
if (!v || typeof v !== "object") return []
504+
const obj = v as Record<string, unknown>
505+
const numericKeys = Object.keys(obj).filter((k) => /^\d+$/.test(k))
506+
if (numericKeys.length > 0) {
507+
const merged: Record<string, unknown> = {}
508+
for (const k of numericKeys) {
509+
const j = (obj[k] as Record<string, unknown> | undefined)?.json
510+
if (j && typeof j === "object") Object.assign(merged, j)
511+
else if (j) merged[k] = j
512+
}
513+
return keyPaths(merged).sort()
514+
}
515+
const j = obj.json
516+
if (j && typeof j === "object") return keyPaths(j).sort()
517+
return keyPaths(obj).sort()
518+
}
519+
// For batch, shape is union of per-procedure shapes
520+
if (procedures.length > 1) {
521+
const perProc = Object.keys(parsed as Record<string, unknown>).sort()
522+
const all: string[] = []
523+
for (const k of perProc) all.push(...toShape((parsed as Record<string, unknown>)[k]))
524+
keyShape = [...new Set(all)].sort()
525+
} else {
526+
keyShape = toShape(parsed)
527+
}
528+
} catch {
529+
// ignore parse failure
530+
}
531+
}
532+
const label = procedures.length === 1 ? procedures[0]! : `batch[${procedures.join(",")}]`
533+
const keyParts = ["trpc", procedures.join(","), keyShape.join(",")]
534+
return { protocol: "trpc", operation: label, opKeyHash: sha16(keyParts.join("<|>")) }
535+
}
536+
475537
export function extractOperation(input: {
476538
method: string
477539
bodyContentType?: string
478540
body?: string
479541
query?: string // raw URL query string (without leading '?')
542+
path?: string // canonical path for tRPC detection
480543
}): OperationInfo | undefined {
481544
const ct = input.bodyContentType ?? ""
545+
const lowerCt = ct.toLowerCase()
546+
547+
// tRPC — path-based procedures, query-carried inputs. Check before GraphQL
548+
// because tRPC may also have ?input JSON.
549+
const pathForTrpc = input.path ?? ""
550+
if (pathForTrpc.toLowerCase().includes("/trpc/")) {
551+
const trpc = trpcFrom(input.query ?? "", pathForTrpc)
552+
if (trpc) return trpc
553+
}
554+
// Also detect tRPC from query when path not supplied (caller passes only query)
555+
if (input.query && input.query.includes("batch=") && pathForTrpc === "") {
556+
// opportunistic: if query looks like trpc batch, try generic
557+
const maybe = trpcFrom(input.query, "/trpc/" + (new URLSearchParams(input.query).get("trpc") ?? ""))
558+
if (maybe) return maybe
559+
}
560+
561+
// gRPC-Web — content-type based, no body JSON. Alias the path as operation so
562+
// each service/method is its own endpoint, but body values do not fragment.
563+
if (lowerCt.includes("grpc-web") || lowerCt.includes("application/grpc")) {
564+
const op = (input.path ?? "").replace(/^\//, "") || "grpc"
565+
return { protocol: "grpc-web", operation: op, opKeyHash: sha16("grpc-web<|>" + op) }
566+
}
482567

483568
// GraphQL-over-GET: the query lives in the URL, not the body. (queryKeyHash
484569
// keys on param names only, so without this `?query=A` and `?query=B` would
@@ -487,6 +572,11 @@ export function extractOperation(input: {
487572
const params = new URLSearchParams(input.query)
488573
const q = params.get("query")
489574
if (q && q.includes("{")) return graphqlFrom(q, params.get("variables"))
575+
// tRPC GET fallback when extractOperation called without path (parser passes path)
576+
if (pathForTrpc.toLowerCase().includes("/trpc/")) {
577+
const t = trpcFrom(input.query, pathForTrpc)
578+
if (t) return t
579+
}
490580
return undefined
491581
}
492582

packages/cyberstrike/src/session/normalize/slots.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,13 @@ function pathSlots(normalizedPath: string, canonicalPath: string): ParamSlot[] {
5252
const out: ParamSlot[] = []
5353
for (let i = 0; i < t.length; i++) {
5454
const seg = t[i]!
55-
if (seg.length > 2 && seg[0] === "{" && seg[seg.length - 1] === "}" && c[i] != null && c[i] !== "") {
56-
out.push({ loc: "path", name: seg.slice(1, -1), value: c[i]! })
55+
if (seg.includes("{") && seg.includes("}") && c[i] != null && c[i] !== "") {
56+
// For partial placeholders like order_{ulid}, extract name inside braces
57+
const m = seg.match(/\{([^}]+)\}/)
58+
const name = m ? m[1]! : seg.slice(1, -1)
59+
// Preserve prefix context for TypeID: order_{ulid} → name "order_{ulid}" keeps prefix
60+
const slotName = seg.includes("_{ulid}") ? seg : name
61+
out.push({ loc: "path", name: slotName, value: c[i]! })
5762
}
5863
}
5964
return out

packages/cyberstrike/src/session/normalize/tier1.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,18 @@ const DYNAMIC_PATTERNS: ReadonlyArray<readonly [RegExp, Placeholder, string]> =
2222
[/^[0-9a-f]{12,}$/i, "{hash}", "hex hash >=12 chars"],
2323
// ULID — 26-char Crockford base32 (excludes I/L/O/U). Placed AFTER hex so a
2424
// 26-char all-hex string keeps {hash}; real ULIDs carry g–z letters and won't
25-
// collide. Case-insensitive because segments arrive lowercased. Slotted to the
26-
// shared {id} (not a new placeholder) so it is stable regardless of which
27-
// provider's Tier-3 would otherwise guess {uuid} vs {id}. Other modern IDs
28-
// (KSUID/nanoid/cuid/base62/Stripe/TypeID) use broader, case-sensitive alphabets
29-
// with real false-positive risk, so they stay ambiguous for Tier 3.
30-
[/^[0-9A-HJKMNP-TV-Z]{26}$/i, "{id}", "ULID (Crockford base32, 26 chars)"],
25+
// collide.
26+
[/^[0-9A-HJKMNP-TV-Z]{26}$/i, "{ulid}", "ULID (Crockford base32, 26 chars)"],
27+
// KSUID — 27-char base62 (0-9A-Za-z). Length + mixed case keeps false positives low.
28+
[/^[0-9A-Za-z]{27}$/, "{id}", "KSUID (27-char base62)"],
29+
// nanoid — 21-char URL-safe (A-Za-z0-9_-). Exact 21 avoids over-matching short slugs.
30+
[/^[A-Za-z0-9_-]{21}$/, "{id}", "nanoid (21-char)"],
31+
// cuid / cuid2 — starts with 'c' then 24+ lowercase alphanumeric
32+
[/^c[a-z0-9]{24,}$/, "{id}", "cuid/cuid2"],
33+
// Stripe-style prefixed ids — e.g. cus_, price_, prod_, sub_, ch_, in_, etc.
34+
[/^[a-z]{2,10}_[A-Za-z0-9]{14,}$/, "{id}", "Stripe-style prefixed id"],
35+
// base62-ish opaque ids — 16+ alphanumeric with mixed case (case-sensitive)
36+
[/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])[A-Za-z0-9_-]{16,}$/, "{id}", "base62 mixed-case id"],
3137
]
3238

3339
// Tight static patterns — anything unrecognized falls through to "ambiguous"
@@ -60,12 +66,21 @@ function classifySegment(segment: string): SegmentClassification {
6066
// indices align with tier 2 template segments.
6167
if (segment === "") return { kind: "static", literal: "", reason: "path root" }
6268

69+
// TypeID — <type>_<ulid> preserves the type prefix so polymorphic endpoints
70+
// like /node/order_<ulid> vs /node/user_<ulid> do not collapse. The suffix is
71+
// the 26-char ULID; the prefix is lowercase type discriminator.
72+
const typeIdMatch = segment.match(/^([a-z][a-z0-9_]*)_([0-9A-HJKMNP-TV-Z]{26})$/i)
73+
if (typeIdMatch) {
74+
const prefix = typeIdMatch[1]!.toLowerCase()
75+
return { kind: "dynamic", placeholder: `${prefix}_{ulid}`, reason: "TypeID (prefix + ULID)" }
76+
}
77+
6378
for (const [pattern, placeholder, reason] of DYNAMIC_PATTERNS) {
6479
if (pattern.test(segment)) return { kind: "dynamic", placeholder, reason }
6580
}
6681

6782
for (const [pattern, reason] of STATIC_PATTERNS) {
68-
if (pattern.test(segment)) return { kind: "static", literal: segment, reason }
83+
if (pattern.test(segment)) return { kind: "static", literal: segment.toLowerCase(), reason }
6984
}
7085

7186
return { kind: "ambiguous", literal: segment, reason: "no deterministic pattern matched" }

packages/cyberstrike/src/session/normalize/tier2.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,13 @@ export function scoreTemplate(
160160
if (isPlaceholder(tseg)) {
161161
// Don't let a known-static (non-empty) segment fill a placeholder slot.
162162
if (cls.kind === "static" && pseg !== "") return null
163+
// For TypeID partial placeholder e.g. order_{ulid}, enforce prefix match
164+
if (tseg.includes("_{ulid}")) {
165+
const prefix = tseg.split("_{ulid}")[0]!
166+
if (!pseg.startsWith(prefix + "_")) return null
167+
const suffix = pseg.slice(prefix.length + 1)
168+
if (!/^[0-9A-HJKMNP-TV-Z]{26}$/i.test(suffix)) return null
169+
}
163170
score += 1
164171
} else {
165172
if (tseg !== pseg) return null
@@ -170,5 +177,6 @@ export function scoreTemplate(
170177
}
171178

172179
function isPlaceholder(seg: string): boolean {
180+
if (seg.includes("{") && seg.includes("}")) return true
173181
return seg.startsWith("{") && seg.endsWith("}") && seg.length > 2
174182
}

packages/cyberstrike/src/session/normalize/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,16 @@
1313

1414
export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"
1515

16-
export type Placeholder = "{id}" | "{uuid}" | "{hash}" | "{email}" | "{token}" | "{slug}"
16+
export type Placeholder = "{id}" | "{uuid}" | "{hash}" | "{email}" | "{token}" | "{slug}" | "{ulid}" | `${string}_{ulid}` | string
1717

18-
export const ALLOWED_PLACEHOLDERS: ReadonlySet<Placeholder> = new Set([
18+
export const ALLOWED_PLACEHOLDERS: ReadonlySet<string> = new Set([
1919
"{id}",
2020
"{uuid}",
2121
"{hash}",
2222
"{email}",
2323
"{token}",
2424
"{slug}",
25+
"{ulid}",
2526
])
2627

2728
// Tier 0 output — deterministic parse of the raw HTTP request.

0 commit comments

Comments
 (0)