mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
fix(native-chat): reject a conversation name whose joiners are split by blanks
A joiner is subtracted from UNRENDERABLE_RUN by design, so it breaks that run in two and each half collapses to its own space. JOINERS_ONLY was anchored on the joiners alone, so an interposed space made the guard miss and a zero-ink name such as ZWJ TAB ZWJ was accepted, persisted, and — because every display site falls back only on nullish — won over the real fallback label. Widen the guard to blank-only, and replace the hand-picked hostile-input list with a machine sweep of the whole invisible block so the next variant of this cannot land.
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
// The invariant behind four rounds of point fixes to this sanitizer, checked by
|
||||
// machine rather than by a hand-picked list: a name it accepts always draws ink.
|
||||
//
|
||||
// Four separate defects here shared one shape — a class of invisible input the
|
||||
// enumeration of the day happened not to cover. Sweeping the whole invisible
|
||||
// block removes the guessing.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH as MAX,
|
||||
normalizeAgentSessionConversationName
|
||||
} from './agent-session-conversation-name'
|
||||
|
||||
const ZWJ = '\u200D'
|
||||
const ZWNJ = '\u200C'
|
||||
|
||||
/** Everything Unicode says is a format, control or space separator. */
|
||||
const INVISIBLE = /[\p{Cf}\p{Cc}\p{Zs}\p{Zl}\p{Zp}]/u
|
||||
/** One code point a font can actually draw. */
|
||||
const INK = /[^\s\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Zs}]/u
|
||||
const LONE_SURROGATE = /[\uD800-\uDFFF]/u
|
||||
const SURROGATE_PAIR = /[\uD800-\uDBFF][\uDC00-\uDFFF]/gu
|
||||
|
||||
function invisibleCodePoints(): string[] {
|
||||
const out: string[] = []
|
||||
for (let cp = 0; cp <= 0x10ffff; cp += 1) {
|
||||
if (cp >= 0xd800 && cp <= 0xdfff) {
|
||||
continue
|
||||
}
|
||||
const ch = String.fromCodePoint(cp)
|
||||
if (INVISIBLE.test(ch)) {
|
||||
out.push(ch)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const describeCodePoints = (value: string): string =>
|
||||
Array.from(
|
||||
value,
|
||||
(c) => `U+${(c.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, '0')}`
|
||||
).join(' ')
|
||||
|
||||
type SweepResult = {
|
||||
swept: number
|
||||
zeroInk: string[]
|
||||
malformed: string[]
|
||||
}
|
||||
|
||||
function sweep(inputs: Iterable<string>): SweepResult {
|
||||
const result: SweepResult = { swept: 0, zeroInk: [], malformed: [] }
|
||||
for (const input of inputs) {
|
||||
result.swept += 1
|
||||
const normalized = normalizeAgentSessionConversationName(input)
|
||||
if (normalized === null) {
|
||||
continue
|
||||
}
|
||||
if (!INK.test(normalized) && result.zeroInk.length < 5) {
|
||||
result.zeroInk.push(
|
||||
`in=[${describeCodePoints(input)}] out=[${describeCodePoints(normalized)}]`
|
||||
)
|
||||
}
|
||||
const stray =
|
||||
normalized.includes('\uFFFD') || LONE_SURROGATE.test(normalized.replace(SURROGATE_PAIR, ''))
|
||||
if (stray && result.malformed.length < 5) {
|
||||
result.malformed.push(`in=[${describeCodePoints(input)}]`)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function expectClean(result: SweepResult, atLeast: number): void {
|
||||
expect(result.zeroInk).toEqual([])
|
||||
expect(result.malformed).toEqual([])
|
||||
// Vitest passes a filter that matches nothing; assert the sweep ran.
|
||||
expect(result.swept).toBeGreaterThanOrEqual(atLeast)
|
||||
}
|
||||
|
||||
describe('normalizeAgentSessionConversationName never accepts a name that draws no ink', () => {
|
||||
const alphabet = invisibleCodePoints()
|
||||
|
||||
it('covers the joiners it deliberately keeps', () => {
|
||||
expect(alphabet).toContain(ZWJ)
|
||||
expect(alphabet).toContain(ZWNJ)
|
||||
expect(alphabet.length).toBeGreaterThan(200)
|
||||
})
|
||||
|
||||
it('holds for every invisible code point alone and for every ordered pair', () => {
|
||||
function* candidates(): Generator<string> {
|
||||
for (const a of alphabet) {
|
||||
yield a
|
||||
for (const b of alphabet) {
|
||||
yield a + b
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expectClean(sweep(candidates()), 60_000)
|
||||
})
|
||||
|
||||
it('holds with a joiner interposed at every position of every pair', () => {
|
||||
// The defect this closes: a joiner survives the collapsing run, so it breaks
|
||||
// the run in two and each half collapses to its own space.
|
||||
function* candidates(): Generator<string> {
|
||||
for (const joiner of [ZWJ, ZWNJ]) {
|
||||
for (const a of alphabet) {
|
||||
for (const b of alphabet) {
|
||||
yield joiner + a + b
|
||||
yield a + joiner + b
|
||||
yield a + b + joiner
|
||||
yield `${joiner}${a}${joiner}${b}${joiner}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expectClean(sweep(candidates()), 500_000)
|
||||
})
|
||||
|
||||
it('holds for triples drawn from the invisible code points most seen in the wild', () => {
|
||||
const notable = [
|
||||
ZWJ,
|
||||
ZWNJ,
|
||||
' ',
|
||||
'\t',
|
||||
'\n',
|
||||
'\r',
|
||||
'\v',
|
||||
'\f',
|
||||
'\u0000',
|
||||
'\u0007',
|
||||
'\u001B',
|
||||
'\u0085',
|
||||
'\u00A0',
|
||||
'\u00AD',
|
||||
'\u061C',
|
||||
'\u180E',
|
||||
'\u2000',
|
||||
'\u2028',
|
||||
'\u2029',
|
||||
'\u200B',
|
||||
'\u200E',
|
||||
'\u200F',
|
||||
'\u202A',
|
||||
'\u202B',
|
||||
'\u202C',
|
||||
'\u202D',
|
||||
'\u202E',
|
||||
'\u202F',
|
||||
'\u205F',
|
||||
'\u2060',
|
||||
'\u2061',
|
||||
'\u2062',
|
||||
'\u2063',
|
||||
'\u2064',
|
||||
'\u2066',
|
||||
'\u2067',
|
||||
'\u2068',
|
||||
'\u2069',
|
||||
'\u206A',
|
||||
'\u206F',
|
||||
'\u3000',
|
||||
'\uFEFF',
|
||||
'\uFFF9',
|
||||
'\uFFFB',
|
||||
'\u{E0020}',
|
||||
'\u{E0041}',
|
||||
'\u{E007F}'
|
||||
]
|
||||
|
||||
function* candidates(): Generator<string> {
|
||||
for (const a of notable) {
|
||||
for (const b of notable) {
|
||||
for (const c of notable) {
|
||||
yield a + b + c
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expectClean(sweep(candidates()), 100_000)
|
||||
})
|
||||
|
||||
it('holds for a seeded random sweep of longer invisible strings', () => {
|
||||
const pool = [
|
||||
ZWJ,
|
||||
ZWNJ,
|
||||
' ',
|
||||
'\t',
|
||||
'\n',
|
||||
'\r',
|
||||
'\u00A0',
|
||||
'\u3000',
|
||||
'\u2028',
|
||||
'\u2029',
|
||||
'\u202A',
|
||||
'\u202E',
|
||||
'\u2066',
|
||||
'\u2069',
|
||||
'\u061C',
|
||||
'\u200E',
|
||||
'\u200F',
|
||||
'\u200B',
|
||||
'\uFEFF',
|
||||
'\u00AD',
|
||||
'\u2060',
|
||||
'\u180E',
|
||||
'\u{E0020}',
|
||||
'\u{E0041}',
|
||||
'\u{E007F}',
|
||||
'x'
|
||||
]
|
||||
let seed = 0x2f6f2b79
|
||||
const nextInt = (bound: number): number => {
|
||||
seed ^= seed << 13
|
||||
seed ^= seed >>> 17
|
||||
seed ^= seed << 5
|
||||
seed >>>= 0
|
||||
return seed % bound
|
||||
}
|
||||
|
||||
function* candidates(): Generator<string> {
|
||||
for (let i = 0; i < 100_000; i += 1) {
|
||||
let value = ''
|
||||
const length = 1 + nextInt(14)
|
||||
for (let k = 0; k < length; k += 1) {
|
||||
value += pool[nextInt(pool.length)]
|
||||
}
|
||||
yield value
|
||||
}
|
||||
}
|
||||
|
||||
expectClean(sweep(candidates()), 100_000)
|
||||
})
|
||||
|
||||
it('holds when a blank prefix pushes the only ink past the length cap', () => {
|
||||
function* candidates(): Generator<string> {
|
||||
for (const filler of [ZWJ, ZWNJ, ' ', `${ZWJ} `, `${ZWNJ}\u2060`]) {
|
||||
for (let n = 180; n <= 260; n += 1) {
|
||||
for (const ink of ['a', '\u{1F600}', '中']) {
|
||||
yield filler.repeat(n) + ink
|
||||
yield ink + filler.repeat(n) + ink
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expectClean(sweep(candidates()), 2000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeAgentSessionConversationName keeps legitimate text byte-identical', () => {
|
||||
it.each([
|
||||
['family emoji', `Fix \u{1F468}${ZWJ}\u{1F469}${ZWJ}\u{1F467} layout`],
|
||||
['rainbow flag emoji', `Ship \u{1F3F3}\uFE0F${ZWJ}\u{1F308} theme`],
|
||||
['profession emoji', `Add \u{1F469}${ZWJ}\u{1F4BB} avatar`],
|
||||
['Persian ZWNJ', `می${ZWNJ}خواهم تست`],
|
||||
['Hindi ZWNJ conjunct', `क्${ZWNJ}ष ठीक`],
|
||||
['CJK', '修正探针泄漏'],
|
||||
['Arabic', 'إصلاح التحقق'],
|
||||
['Hebrew', 'תיקון הבדיקה'],
|
||||
['combining accents', 'Résumé du fil'],
|
||||
['skin-tone modifier', 'Ship \u{1F44D}\u{1F3FD} review'],
|
||||
['variation selector', 'Ship ❤\uFE0F theme'],
|
||||
['plain ASCII', 'Fix the probe']
|
||||
])('round-trips a %s name', (_label, name) => {
|
||||
const normalized = normalizeAgentSessionConversationName(name)
|
||||
|
||||
expect(describeCodePoints(normalized ?? '')).toBe(describeCodePoints(name))
|
||||
expect(normalized).toBe(name)
|
||||
})
|
||||
|
||||
it('never strands a joiner when the cut lands inside an emoji sequence', () => {
|
||||
const family = `\u{1F468}${ZWJ}\u{1F469}${ZWJ}\u{1F467}`
|
||||
|
||||
for (let prefix = 193; prefix <= 201; prefix += 1) {
|
||||
const normalized = normalizeAgentSessionConversationName('A'.repeat(prefix) + family)
|
||||
|
||||
expect(normalized).not.toBeNull()
|
||||
expect(normalized?.endsWith(ZWJ)).toBe(false)
|
||||
expect(normalized?.endsWith(ZWNJ)).toBe(false)
|
||||
expect(normalized).not.toContain('\uFFFD')
|
||||
expect(LONE_SURROGATE.test((normalized ?? '').replace(SURROGATE_PAIR, ''))).toBe(false)
|
||||
expect((normalized ?? '').length).toBeLessThanOrEqual(MAX)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -116,7 +116,17 @@ describe('normalizeAgentSessionConversationName joiners', () => {
|
||||
['deprecated format characters', '\u206A\u206B\u206C\u206D\u206E\u206F'],
|
||||
['Arabic number signs', '\u0600\u0601\u06DD'],
|
||||
['the joiners themselves', `${ZWNJ}${ZWJ}`],
|
||||
['a bidi and zero-width mix', '\u202E\u200B\u2060']
|
||||
['a bidi and zero-width mix', '\u202E\u200B\u2060'],
|
||||
// A joiner survives the collapsing run, so it splits that run in two and
|
||||
// each half becomes its own space; the guard has to read the spaces too.
|
||||
['joiners split by a tab', `${ZWJ}\t${ZWJ}`],
|
||||
['joiners split by a newline', `${ZWJ}\n${ZWJ}`],
|
||||
['joiners split by a byte order mark', `${ZWJ}\uFEFF${ZWJ}`],
|
||||
['joiners split by zero-width spaces', `\u200B${ZWJ}\u200B${ZWJ}`],
|
||||
['joiners split by literal spaces', ` ${ZWJ} ${ZWJ} `],
|
||||
['joiners split by a bidi override', `\u202E${ZWJ}\u202E${ZWJ}`],
|
||||
['mixed joiners split by a tab', `${ZWJ}\t${ZWNJ}`],
|
||||
['joiners wrapped in tag characters', `\u{E0020}${ZWJ}\u{E0041}${ZWJ}\u{E007F}`]
|
||||
])('rejects a name that is only %s', (_label, name) => {
|
||||
expect(normalizeAgentSessionConversationName(name)).toBeNull()
|
||||
})
|
||||
|
||||
@@ -20,8 +20,9 @@ export const AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH = 200
|
||||
* Wales flags degrade — far cheaper than an invisible payload in a label. */
|
||||
const UNRENDERABLE_RUN = /(?:[\s\p{Cc}\p{Zl}\p{Zp}]|(?![\u200C\u200D])\p{Cf})+/gu
|
||||
|
||||
/** The joiners outlive the run above by design; alone they are still a blank label. */
|
||||
const JOINERS_ONLY = /^[\u200C\u200D]+$/u
|
||||
/** The joiners outlive the run above by design; alone — or separated only by the
|
||||
* spaces that run collapsed to — they are still a blank label. */
|
||||
const BLANK_ONLY = /^[\s\u200C\u200D]+$/u
|
||||
|
||||
/** A cut inside an emoji sequence strands the joiner that attached it. */
|
||||
const TRAILING_DANGLE = /[\s\u200C\u200D]+$/u
|
||||
@@ -31,7 +32,7 @@ export function normalizeAgentSessionConversationName(value: unknown): string |
|
||||
return null
|
||||
}
|
||||
const collapsed = value.replace(UNRENDERABLE_RUN, ' ').trim()
|
||||
if (!collapsed || JOINERS_ONLY.test(collapsed)) {
|
||||
if (!collapsed || BLANK_ONLY.test(collapsed)) {
|
||||
return null
|
||||
}
|
||||
if (collapsed.length <= AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH) {
|
||||
|
||||
Reference in New Issue
Block a user