Root cause
Resource abuse & availability introduced
security: fix all SAST findings (Critical through Low)
How AI contributed: Direct introductionPublished Jul 2, 2026 in
asymmetric-effort/specifyjsAll 7 findings in this repository
CWE-209 information exposure: console.warn calls in core/src/core/scheduler.ts (queue overflow), core/src/hooks/dispatcher.ts (hook queue overflow), and core/src/client/graphql.ts (gql metacharacter interpolation) were not gated behind a production check, so internal framework state (queue sizes, component names, query fragments) appears in user-facing browser consoles in production builds.
Root cause
security: fix all SAST findings (Critical through Low)
Fix
security: v0.2.139 — suppress console warnings in production (PT-012)
AI-assisted fix: Claude Code · Sam Caldwell
Case facts
@@ -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; }@@ -8,6 +8,7 @@ type Task = () => void; +const MAX_PENDING_TASKS = 10000; let isBatchingUpdates = false; let pendingTasks: Task[] = []; @@ -17,6 +18,13 @@ let pendingTasks: Task[] = []; */ export function scheduleUpdate(task: Task): void { if (isBatchingUpdates) {+ if (pendingTasks.length >= MAX_PENDING_TASKS) {+ if (typeof console !== 'undefined')+ console.warn(+ '[SpecifyJS] Scheduler: pending task queue exceeded 10000 — dropping oldest tasks',+ );+ pendingTasks = pendingTasks.slice(-Math.floor(MAX_PENDING_TASKS / 2));+ } pendingTasks.push(task); return; }@@ -96,7 +96,10 @@ export function useStateImpl<T>( const lane = requestUpdateLane(); /* v8 ignore start -- overflow guard tested in security regression tests */ if (queue.length >= 10000) {- if (typeof console !== 'undefined')+ if (+ typeof console !== 'undefined' &&+ !(typeof process !== 'undefined' && process.env?.NODE_ENV === 'production')+ ) console.warn('[SpecifyJS] Hook update queue exceeded 10000 — dropping oldest updates'); queue.splice(0, queue.length - 5000); }@@ -3,16 +3,24 @@ /** * Warning system for development-time diagnostics.- * Warnings are suppressed in production builds.+ * Warnings are suppressed in production builds (NODE_ENV === 'production'). */ const MAX_WARNINGS = 1000; const warnedMessages = new Set<string>(); +/** True when running in a production environment. Bundlers tree-shake the guarded code. */+const IS_PRODUCTION =+ typeof process !== 'undefined' &&+ typeof process.env !== 'undefined' &&+ process.env.NODE_ENV === 'production';+ /** * Issue a one-time warning (deduplicated by message).+ * Suppressed in production builds. */ export function warn(message: string): void {+ if (IS_PRODUCTION) return; if (warnedMessages.has(message)) return; if (warnedMessages.size >= MAX_WARNINGS) return; warnedMessages.add(message);@@ -25,8 +33,10 @@ export function warn(message: string): void { /** * Issue an error-level warning.+ * Suppressed in production builds. */ export function error(message: string): void {+ if (IS_PRODUCTION) return; if (typeof console !== 'undefined') { // eslint-disable-next-line no-console console.error(`[SpecifyJS] ${message}`);@@ -19,7 +19,10 @@ let pendingTasks: Task[] = []; export function scheduleUpdate(task: Task): void { if (isBatchingUpdates) { if (pendingTasks.length >= MAX_PENDING_TASKS) {- if (typeof console !== 'undefined')+ if (+ typeof console !== 'undefined' &&+ !(typeof process !== 'undefined' && process.env?.NODE_ENV === 'production')+ ) console.warn( '[SpecifyJS] Scheduler: pending task queue exceeded 10000 — dropping oldest tasks', );AI-assisted change 7f92ab12c89f5a62c7fe2e63ef6833288008ea4cacaf3b2245e1ffb580fd252e · Fix b210c79bf70fe116cbc3bed08fb91ed01bddec3d233b3e987245fa4cb44706fa