Root cause
SSRF & network boundaries introduced
A Claude-assisted change adds an HTTPS-only URL guard whose parse-failure path returns instead of rejecting.
How AI contributed: Direct introductionPublished Jul 2, 2026 in
asymmetric-effort/specifyjsAll 7 findings in this repository
Provisional finding. Full causal and release verification remains incomplete.
SpecifyJS's secure-fetch helper was meant to refuse any URL that is not HTTPS, but it wrapped URL parsing in a catch that simply returned when parsing failed, so the protocol and host checks were skipped for such input. A Claude-assisted change wrote the helper this way, and it shipped from v0.0.7 through 0.2.135; the advisory lists 0.2.136 as fixed. The research confirmed the policy bypass but could not reproduce the request forgery the advisory describes: in the tested Node and Bun versions, fetch itself still rejected every string the parser rejected.
Root cause
A Claude-assisted change adds an HTTPS-only URL guard whose parse-failure path returns instead of rejecting.
Fix
The 0.2.136 security release makes the guard reject any URL it cannot parse.
Case facts
@@ -0,0 +1,64 @@+/**+ * Secure fetch wrapper — enforces HTTPS-only policy.+ *+ * LiquidJS prohibits HTTP connections. All network requests must use+ * HTTPS or be relative URLs. This prevents data from being transmitted+ * in plaintext, protecting against eavesdropping and MITM attacks.+ *+ * Exceptions:+ * - Relative URLs (./path, /path) — resolved by the browser+ * - localhost and 127.0.0.1 — development only+ */++/**+ * Validate that a URL uses HTTPS or is a relative/localhost URL.+ * Throws if the URL uses plaintext HTTP.+ */+export function assertSecureUrl(url: string): void {+ // Relative URLs are always allowed+ if (url.startsWith('/') || url.startsWith('./') || url.startsWith('../')) {+ return;+ }++ // Data URLs are allowed+ if (url.startsWith('data:')) {+ return;+ }++ // Parse absolute URLs+ let parsed: URL;+ try {+ parsed = new URL(url, typeof window !== 'undefined' ? window.location.href : undefined);+ } catch {+ // If URL can't be parsed, allow it (likely a relative path the browser will resolve)+ return;+ }++ // Allow HTTPS+ if (parsed.protocol === 'https:') {+ return;+ }++ // Allow localhost/127.0.0.1 for development (any protocol)+ if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') {+ return;+ }++ // Reject HTTP and other insecure protocols+ throw new Error(+ `[LiquidJS] Insecure URL rejected: "${url}". ` ++ `LiquidJS enforces HTTPS-only for all network requests. ` ++ `Use https:// or a relative URL. ` ++ `Localhost URLs are allowed for development.`,+ );+}++/**+ * Secure fetch — drop-in replacement for window.fetch that enforces HTTPS.+ * Use this instead of fetch() directly to comply with the HTTPS-only policy.+ */+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);+}@@ -30,27 +30,38 @@ 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 if (parsed.protocol === 'https:') { 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; } AI-assisted change 1515e48b9b29b5022573f5e53bdbfa1290784be94d0afd5d34d49c15c9c454ec · Fix b8343bf31c71c423615094c6801e7d77cf1ea20989ab9bb22e52978f086658ce