Original flaw
Earlier flawAttacker-controlled GraphQL metacharacters reached query-string concatenation.
Sink: SpecifyJS graphql.ts gql() construction
Originally written by AI (Sam Caldwell)
How AI contributed: Incomplete remediationPublished Jul 2, 2026 in
asymmetric-effort/specifyjsAll 7 findings in this repository
Finding Location: core/src/client/graphql.ts:66-80 The gql template tag function warned about interpolated values containing GraphQL metacharacters ({}():) but still concatenated them into the query string, enabling potential GraphQL injection. Status Fixed in v0.2.136 — The gql function now throws an error when metacharacters are detected in interpolated values, forcing developers to use the variables parameter.
Count Claude-marked single-parent caa8fbfa M-8 warn-on-metacharacter that still concatenates into gql(). Do not count parent 5abd6575 concat-without-guard as origin. Do not bind this row to 30f9b76f. Count 25d1fb49 PT-002 warn-to-throw as the same-boundary amendment. graphql.ts blob 83221c09 is identical from caa8fbfa through npm 0.2.134/0.2.135.
Case facts
Only the highlighted steps are this advisory. The first step is the earlier flaw the AI tried, and failed, to close.
Original flaw
Earlier flawSink: SpecifyJS graphql.ts gql() construction
Originally written by AI (Sam Caldwell)
This advisoryGHSA-5C7W-4WM3-85VW
AI tried to fix this
The AI-assisted change was a real security patch, but it left the same advisory reachable.
Missed: Continued concatenating the value into gql() after the warning.
Incomplete AI-assisted fix · Claude
Fixed again
This is the patch that actually stops the same attack path.
AI-assisted fix: Claude Code · Sam Caldwell
--- a/core/src/client/graphql.ts+++ b/core/src/client/graphql.ts@@ -5,6 +5,7 @@ // SPDX-License-Identifier: MIT import { useState, useEffect, useRef, useCallback } from '../hooks/index';+import { assertSecureUrl } from '../shared/secure-fetch'; // --------------------------------------------------------------------------- // Types@@ -63,7 +64,16 @@ export function gql(strings: TemplateStringsArray, ...values: unknown[]): string for (let i = 0; i < strings.length; i++) { result += strings[i]; if (i < values.length) {- result += String(values[i]);+ const val = String(values[i]);+ // M-8: Warn if interpolated values contain GraphQL metacharacters+ if (/[{}():]/.test(val)) {+ if (typeof console !== 'undefined') {+ console.warn(+ '[SpecifyJS] gql: Interpolated value contains GraphQL metacharacters. Use variables parameter instead of string interpolation to prevent injection.',+ );+ }+ }+ result += val; } } return result.replace(/\s+/g, ' ').trim();@@ -79,6 +89,7 @@ export function gql(strings: TemplateStringsArray, ...values: unknown[]): string export function createGraphQLClient(config: GraphQLClientConfig): GraphQLClient { const { url, headers: configHeaders, credentials, cache: cacheEnabled = false } = config; + const MAX_CACHE_SIZE = 100; const responseCache = new Map<string, GraphQLResponse<unknown>>(); function buildCacheKey(query: string, variables?: Record<string, unknown>): string {@@ -96,6 +107,7 @@ export function createGraphQLClient(config: GraphQLClientConfig): GraphQLClient const body = JSON.stringify({ query, variables }); + assertSecureUrl(url); const response = await fetch(url, { method: 'POST', headers: reqHeaders,@@ -128,6 +140,10 @@ export function createGraphQLClient(config: GraphQLClientConfig): GraphQLClient return cached as GraphQLResponse<T>; } const result = await execute<T>(query, variables);+ if (responseCache.size >= MAX_CACHE_SIZE) {+ const oldest = responseCache.keys().next().value;+ if (oldest !== undefined) responseCache.delete(oldest);+ } responseCache.set(key, result); return result; }--- a/core/src/server/render-to-string.ts+++ b/core/src/server/render-to-string.ts@@ -228,6 +228,9 @@ function renderHostElement(tag: string, props: Props, includeDataAttrs: boolean) const attrName = PROP_TO_ATTR[key] || key.toLowerCase(); + // C-1: Validate attribute names to prevent XSS via attribute name injection+ if (!/^[a-zA-Z_][\w\-:.]*$/.test(attrName)) continue;+ if (key === 'style') { html += ` style="${renderStyle(value as Record<string, string | number>)}"`; } else if (key === 'value' || key === 'checked' || key === 'selected') {@@ -276,11 +279,16 @@ function renderStyle(style: Record<string, string | number>): string { // Convert camelCase to kebab-case const cssProp = prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);- const cssValue =+ let cssValue = typeof value === 'number' && value !== 0 && !isUnitlessProp(prop) ? `${value}px` : String(value); + // L-6: Sanitize CSS values — strip dangerous patterns+ cssValue = cssValue+ .replace(/expression\s*\(/gi, '')+ .replace(/url\s*\(\s*javascript:/gi, 'url(');+ parts.push(`${cssProp}:${cssValue}`); } return parts.join(';');--- a/core/src/shared/secure-fetch.ts+++ b/core/src/shared/secure-fetch.ts@@ -18,6 +18,13 @@ * Throws if the URL uses plaintext HTTP. */ export function assertSecureUrl(url: string): void {+ // Block protocol-relative URLs (//evil.com) — they inherit the page's protocol+ if (url.startsWith('//')) {+ throw new Error(+ `[SpecifyJS] Protocol-relative URL rejected: "${url}". ` + `Use an explicit https:// prefix.`,+ );+ }+ // Relative URLs are always allowed if (url.startsWith('/') || url.startsWith('./') || url.startsWith('../')) { return;--- a/core/src/client/graphql.ts+++ b/core/src/client/graphql.ts@@ -65,13 +65,12 @@ export function gql(strings: TemplateStringsArray, ...values: unknown[]): string result += strings[i]; if (i < values.length) { const val = String(values[i]);- // M-8: Warn if interpolated values contain GraphQL metacharacters+ // Reject interpolated values with GraphQL metacharacters to prevent injection if (/[{}():]/.test(val)) {- if (typeof console !== 'undefined') {- console.warn(- '[SpecifyJS] gql: Interpolated value contains GraphQL metacharacters. Use variables parameter instead of string interpolation to prevent injection.',- );- }+ throw new Error(+ `[SpecifyJS] gql: Interpolated value "${val}" contains GraphQL metacharacters ({, }, (, ), :). ` ++ 'Use the variables parameter instead of string interpolation to prevent injection.',+ ); } result += val; }--- a/core/src/server/render-to-string.ts+++ b/core/src/server/render-to-string.ts@@ -304,10 +304,15 @@ function renderStyle(style: Record<string, string | number>): string { ? `${value}px` : String(value); - // L-6: Sanitize CSS values — strip dangerous patterns- cssValue = cssValue- .replace(/expression\s*\(/gi, '')- .replace(/url\s*\(\s*javascript:/gi, 'url(');+ // L-6: Reject CSS values containing dangerous patterns.+ // Normalize unicode escapes and strip CSS comments before checking+ // to prevent bypass via \65xpression( or exp/**/ression( patterns.+ const normalizedCss = cssValue+ .replace(/\\[0-9a-fA-F]{1,6}\s?/g, '_')+ .replace(/\/\*[\s\S]*?\*\//g, '');+ if (/expression\s*\(|javascript\s*:|behavior\s*:|moz-binding|-o-link/i.test(normalizedCss)) {+ cssValue = '';+ } parts.push(`${cssProp}:${cssValue}`); }--- a/core/src/shared/secure-fetch.ts+++ b/core/src/shared/secure-fetch.ts@@ -30,18 +30,23 @@ export function assertSecureUrl(url: string): void { return; } - // Data URLs are allowed+ // Data URIs: allow but limit size to prevent memory exhaustion if (url.startsWith('data:')) {+ if (url.length > 1024 * 1024) {+ throw new Error('[SpecifyJS] secureFetch: data: URI exceeds 1MB limit');+ } return; } - // Parse absolute URLs+ // Parse absolute URLs — reject if unparseable let parsed: URL; try { parsed = new URL(url, typeof window !== 'undefined' ? window.location.href : undefined); } catch {- /* v8 ignore next 2 -- URL parse failure is environment-specific; jsdom always provides a base */- return;+ throw new Error(+ `[SpecifyJS] secureFetch: unable to validate URL "${url}". ` ++ `Provide a valid absolute HTTPS URL or a relative path.`,+ ); } // Allow HTTPS@@ -49,8 +54,14 @@ export function assertSecureUrl(url: string): void { return; } - // Allow localhost/127.0.0.1 for development (any protocol)- if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') {+ // Allow localhost for development (any protocol) — covers IPv4/IPv6 loopback+ if (+ parsed.hostname === 'localhost' ||+ parsed.hostname === '127.0.0.1' ||+ parsed.hostname === '[::1]' ||+ parsed.hostname === '0.0.0.0' ||+ parsed.hostname.startsWith('127.')+ ) { return; } @@ -70,5 +81,7 @@ export function assertSecureUrl(url: string): void { export function secureFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; assertSecureUrl(url);- return fetch(input, init);+ // Default to rejecting redirects to prevent redirect-based SSRF.+ // Callers can override with { redirect: 'follow' } if they trust the target.+ return fetch(input, { redirect: 'error', ...init }); }AI-assisted change 9d180a79bf39af0a6c141b44ecb6f75503b7f0673c7323ba199a046738a08d07 · Fix 97b6dd3693e913815e327db8989d03626f2e2353d6dde02d1943ac8df0826e0e