From 9dcbe711d63e0b7bf9261575dc25324a44fa63d5 Mon Sep 17 00:00:00 2001 From: yudhi Date: Thu, 25 Jun 2026 22:12:06 +0700 Subject: [PATCH] perf: bound token-validation cost on adversarial input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small guards so a hostile DESIGN.md cannot pin CPU or exhaust the call stack. All inputs are at the documented untrusted boundary (arbitrary file/stdin), and none of the changes alter results for legitimate input. - parseDimensionParts (and token-like-ignored's CSS_DIMENSION_RE) backtrack quadratically on long all-digit strings. Cap value length to 64 chars before matching; real CSS dimensions are far shorter. - unknown-key runs an O(n*m) Levenshtein DP against every schema key for each unknown key. Skip a schema key whose length differs by more than the typo threshold — edit distance is at least the length difference, so the set of suggestions is unchanged. - parseCssColor recurses for nested color-mix() with no depth bound. Thread a depth counter and stop at 32, so an over-deep value resolves to an invalid color (a precise error finding) instead of a RangeError that collapses the whole model build. --- .../linter/linter/rules/token-like-ignored.ts | 10 +++++++++- .../src/linter/linter/rules/unknown-key.test.ts | 8 ++++++++ .../cli/src/linter/linter/rules/unknown-key.ts | 5 +++++ packages/cli/src/linter/model/color-parser.ts | 13 ++++++++++--- packages/cli/src/linter/model/handler.test.ts | 16 ++++++++++++++++ packages/cli/src/linter/model/spec.test.ts | 9 +++++++++ packages/cli/src/linter/model/spec.ts | 8 ++++++++ 7 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/linter/linter/rules/token-like-ignored.ts b/packages/cli/src/linter/linter/rules/token-like-ignored.ts index 376eaa5d..3fbc4c3e 100644 --- a/packages/cli/src/linter/linter/rules/token-like-ignored.ts +++ b/packages/cli/src/linter/linter/rules/token-like-ignored.ts @@ -25,6 +25,14 @@ const HEX_COLOR_RE = /^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; */ const CSS_DIMENSION_RE = /^-?\d*\.?\d+[a-zA-Z%]+$/; +/** + * Upper bound on a token-like leaf value's length before pattern matching. + * A hex color or CSS dimension is short; longer strings cannot match either, + * so capping the length avoids pathological regex backtracking on oversized + * attacker-supplied values. + */ +const MAX_TOKEN_VALUE_LENGTH = 64; + /** * Typography-flavored property names that strongly suggest this map holds * design tokens rather than arbitrary metadata. @@ -54,7 +62,7 @@ function hasTokenLikeContent(obj: Record): boolean { if (TYPOGRAPHY_PROPS.has(key)) return true; if (typeof val === 'string') { - if (HEX_COLOR_RE.test(val) || CSS_DIMENSION_RE.test(val)) return true; + if (val.length <= MAX_TOKEN_VALUE_LENGTH && (HEX_COLOR_RE.test(val) || CSS_DIMENSION_RE.test(val))) return true; } else if (val !== null && typeof val === 'object' && !Array.isArray(val)) { // Recurse one level for nested token maps (e.g. base_colors: { light: { ink: "#0B0F14" } }) if (hasTokenLikeContent(val as Record)) return true; diff --git a/packages/cli/src/linter/linter/rules/unknown-key.test.ts b/packages/cli/src/linter/linter/rules/unknown-key.test.ts index 32d23509..ad8f7d65 100644 --- a/packages/cli/src/linter/linter/rules/unknown-key.test.ts +++ b/packages/cli/src/linter/linter/rules/unknown-key.test.ts @@ -111,4 +111,12 @@ describe('unknownKey', () => { const findings = unknownKey(state); expect(findings.map(f => f.path).sort()).toEqual(['colours', 'typografy']); }); + + it('stays silent (and cheap) for very long keys via the length short-circuit', () => { + const state = buildState({ + sourceMap: new Map([['a'.repeat(50000), loc]]), + }); + const findings = unknownKey(state); + expect(findings.length).toBe(0); + }); }); diff --git a/packages/cli/src/linter/linter/rules/unknown-key.ts b/packages/cli/src/linter/linter/rules/unknown-key.ts index 159b7819..d70f492f 100644 --- a/packages/cli/src/linter/linter/rules/unknown-key.ts +++ b/packages/cli/src/linter/linter/rules/unknown-key.ts @@ -34,6 +34,11 @@ export function unknownKey(state: DesignSystemState): RuleFinding[] { let bestMatch: string | undefined; let bestDist = Infinity; for (const known of SCHEMA_KEYS) { + // Edit distance is at least the length difference, so a key whose length + // differs from a known key by more than the typo threshold can never be a + // typo — skip the O(n*m) distance computation for it. This keeps the rule + // cheap on long, attacker-supplied keys without changing any result. + if (Math.abs(key.length - known.length) > MAX_TYPO_DISTANCE) continue; const dist = levenshtein(key.toLowerCase(), known.toLowerCase()); if (dist < bestDist) { bestDist = dist; diff --git a/packages/cli/src/linter/model/color-parser.ts b/packages/cli/src/linter/model/color-parser.ts index f7127e15..6d65e6b3 100644 --- a/packages/cli/src/linter/model/color-parser.ts +++ b/packages/cli/src/linter/model/color-parser.ts @@ -55,12 +55,19 @@ const CSS_NAMED_COLORS: Record = { yellowgreen: '#9acd32', transparent: '#00000000', }; +/** + * Maximum nesting depth for recursive color-mix() resolution. Guards against + * stack exhaustion from pathologically nested, attacker-supplied color values. + */ +const MAX_COLOR_MIX_DEPTH = 32; + /** * Parse a CSS color string into its sRGB representation + WCAG relative luminance. * Returns null if the color is invalid. */ -export function parseCssColor(colorStr: string): ParsedColorResult | null { +export function parseCssColor(colorStr: string, depth = 0): ParsedColorResult | null { if (typeof colorStr !== 'string') return null; + if (depth > MAX_COLOR_MIX_DEPTH) return null; const clean = colorStr.trim().toLowerCase(); if (!clean) return null; @@ -202,8 +209,8 @@ export function parseCssColor(colorStr: string): ParsedColorResult | null { const parsed2 = parseColorWithWeight(subArgs[2]!); if (!parsed1 || !parsed2) return null; - const c1 = parseCssColor(parsed1.colorStr); - const c2 = parseCssColor(parsed2.colorStr); + const c1 = parseCssColor(parsed1.colorStr, depth + 1); + const c2 = parseCssColor(parsed2.colorStr, depth + 1); if (!c1 || !c2) return null; // Normalize weights diff --git a/packages/cli/src/linter/model/handler.test.ts b/packages/cli/src/linter/model/handler.test.ts index 191e31ae..a7424737 100644 --- a/packages/cli/src/linter/model/handler.test.ts +++ b/packages/cli/src/linter/model/handler.test.ts @@ -665,4 +665,20 @@ describe('ModelHandler', () => { expect(result.designSystem.colors.has(path)).toBe(true); }); }); + + describe('color-mix nesting depth limit', () => { + it('rejects pathologically nested color-mix as an invalid color without collapsing the model', () => { + let nested = 'red'; + for (let i = 0; i < 50; i++) nested = `color-mix(in srgb, ${nested}, blue)`; + const result = handler.execute(makeParsed({ + colors: { ok: '#ffffff', deep: nested }, + })); + // The over-deep color resolves to "invalid" (a precise per-token error), + // not a thrown RangeError that collapses the whole model build. + expect(result.designSystem.colors.has('deep')).toBe(false); + expect(result.findings.some(f => f.path === 'colors.deep' && f.severity === 'error')).toBe(true); + // Other valid tokens are unaffected. + expect(result.designSystem.colors.get('ok')?.hex).toBe('#ffffff'); + }); + }); }); \ No newline at end of file diff --git a/packages/cli/src/linter/model/spec.test.ts b/packages/cli/src/linter/model/spec.test.ts index 01a0a330..b63169d1 100644 --- a/packages/cli/src/linter/model/spec.test.ts +++ b/packages/cli/src/linter/model/spec.test.ts @@ -79,6 +79,15 @@ describe('parseDimensionParts', () => { expect(parseDimensionParts('auto')).toBeNull(); expect(parseDimensionParts('inherit')).toBeNull(); }); + + it('returns null for oversized values without pathological backtracking', () => { + // Length-capped: an absurdly long value is rejected immediately rather than + // triggering quadratic regex backtracking. + expect(parseDimensionParts('1'.repeat(100000))).toBeNull(); + expect(parseDimensionParts('1'.repeat(100000) + 'px')).toBeNull(); + // Legitimate dimensions well under the cap still parse. + expect(parseDimensionParts('999999.999999px')).toEqual({ value: 999999.999999, unit: 'px' }); + }); }); describe('isTokenReference', () => { diff --git a/packages/cli/src/linter/model/spec.ts b/packages/cli/src/linter/model/spec.ts index 115ee47c..96765560 100644 --- a/packages/cli/src/linter/model/spec.ts +++ b/packages/cli/src/linter/model/spec.ts @@ -141,6 +141,13 @@ const CSS_UNITS = new Set([ '%', ]); +/** + * Upper bound on a dimension string's length. Real CSS dimensions are a handful + * of characters; capping the length keeps validation linear and prevents + * pathological regex backtracking on oversized, attacker-supplied values. + */ +const MAX_DIMENSION_LENGTH = 64; + /** * Parse a dimension string into its numeric value and unit suffix. * Accepts an optional leading sign and optional decimal (`.5rem` is valid). @@ -148,6 +155,7 @@ const CSS_UNITS = new Set([ */ export function parseDimensionParts(raw: string): { value: number; unit: string } | null { if (typeof raw !== 'string') return null; + if (raw.length > MAX_DIMENSION_LENGTH) return null; const match = raw.match(/^(-?\d*\.?\d+)([a-zA-Z%]+)$/); if (!match) return null; const value = parseFloat(match[1]!);