Root cause
SSRF & network boundaries introduced
feat: HTTPS-only enforcement, Go cert tool, SHA-pinned actions
AI-assisted change: Claude
How AI contributed: Direct introductionPublished Jul 2, 2026 in
asymmetric-effort/specifyjsAll 7 findings in this repository
The AI-written assertSecureUrl helper in core/src/shared/secure-fetch.ts exempted the literal hosts localhost and 127.0.0.1 from its HTTPS-only policy for local development, but matched them by exact string. Requests aimed at [::1], 0.0.0.0, or any other address in the 127.0.0.0/8 range still reached loopback services, so an attacker could steer the client at internal HTTP endpoints the policy was meant to block. The fix widens the loopback detection and rejects unparseable URLs and oversized data URIs.
assertSecureUrl in core/src/shared/secure-fetch.ts treated the exact strings localhost and 127.0.0.1 as the whole development exception, so every other loopback spelling, including [::1], 0.0.0.0, and the rest of the 127.0.0.0/8 range, bypassed the HTTPS requirement the helper exists to enforce.
Root cause
feat: HTTPS-only enforcement, Go cert tool, SHA-pinned actions
AI-assisted change: Claude
Fix
security: v0.2.136 — fix pentest findings (PT-001 through PT-005)
AI-assisted fix: Claude Code · Sam Caldwell
Case facts
@@ -0,0 +1,155 @@+package main++import (+ "crypto/ecdsa"+ "crypto/x509"+ "encoding/pem"+ "os"+ "path/filepath"+ "testing"+)++func TestGenerateCA(t *testing.T) {+ key, cert, certBytes := generateCA(365)++ if key == nil {+ t.Fatal("CA key is nil")+ }+ if cert == nil {+ t.Fatal("CA cert is nil")+ }+ if len(certBytes) == 0 {+ t.Fatal("CA cert bytes empty")+ }+ if !cert.IsCA {+ t.Error("CA cert should have IsCA=true")+ }+ if cert.Subject.CommonName != "LiquidJS Dev Root CA" {+ t.Errorf("CA CN = %q, want %q", cert.Subject.CommonName, "LiquidJS Dev Root CA")+ }+}++func TestGenerateLeaf(t *testing.T) {+ caKey, caCert, _ := generateCA(365)+ leafKey, leafCertBytes := generateLeaf(caKey, caCert, "localhost", "localhost,127.0.0.1", 30)++ if leafKey == nil {+ t.Fatal("leaf key is nil")+ }+ if len(leafCertBytes) == 0 {+ t.Fatal("leaf cert bytes empty")+ }++ leafCert, err := x509.ParseCertificate(leafCertBytes)+ if err != nil {+ t.Fatalf("parse leaf cert: %v", err)+ }+ if leafCert.Subject.CommonName != "localhost" {+ t.Errorf("leaf CN = %q, want %q", leafCert.Subject.CommonName, "localhost")+ }+ if len(leafCert.DNSNames) == 0 {+ t.Error("leaf cert should have DNS SANs")+ }+ if len(leafCert.IPAddresses) == 0 {+ t.Error("leaf cert should have IP SANs")+ }++ // Verify leaf is signed by CA+ roots := x509.NewCertPool()+ roots.AddCert(caCert)+ _, err = leafCert.Verify(x509.VerifyOptions{Roots: roots})+ if err != nil {+ t.Errorf("leaf cert verification failed: %v", err)+ }+}++func TestPemEncode(t *testing.T) {+ data := []byte("test data")+ encoded := pemEncode("CERTIFICATE", data)+ block, _ := pem.Decode(encoded)+ if block == nil {+ t.Fatal("PEM decode returned nil")+ }+ if block.Type != "CERTIFICATE" {+ t.Errorf("PEM type = %q, want CERTIFICATE", block.Type)+ }+}++func TestPemEncodeECKey(t *testing.T) {+ caKey, _, _ := generateCA(1)+ encoded := pemEncodeECKey(caKey)+ block, _ := pem.Decode(encoded)+ if block == nil {+ t.Fatal("PEM decode returned nil")+ }+ if block.Type != "EC PRIVATE KEY" {+ t.Errorf("PEM type = %q, want EC PRIVATE KEY", block.Type)+ }+ _, err := x509.ParseECPrivateKey(block.Bytes)+ if err != nil {@@ -0,0 +1,170 @@+// liquidjs-cert generates self-signed CA chains and leaf certificates+// for local HTTPS development with LiquidJS.+//+// Usage:+//+// liquidjs-cert [options]+// -out string Output directory (default "certs")+// -cn string Common name for leaf cert (default "localhost")+// -sans string Comma-separated SANs (default "localhost,127.0.0.1")+// -days int Certificate validity in days (default 365)+// -ca-days int CA validity in days (default 3650)+package main++import (+ "crypto/ecdsa"+ "crypto/elliptic"+ "crypto/rand"+ "crypto/x509"+ "crypto/x509/pkix"+ "encoding/pem"+ "flag"+ "fmt"+ "math/big"+ "net"+ "os"+ "path/filepath"+ "strings"+ "time"+)++func main() {+ outDir := flag.String("out", "certs", "Output directory for certificate files")+ cn := flag.String("cn", "localhost", "Common name for leaf certificate")+ sans := flag.String("sans", "localhost,127.0.0.1", "Comma-separated Subject Alternative Names")+ days := flag.Int("days", 365, "Leaf certificate validity in days")+ caDays := flag.Int("ca-days", 3650, "CA certificate validity in days")+ flag.Parse()++ if err := os.MkdirAll(*outDir, 0o755); err != nil {+ fatalf("create output dir: %v", err)+ }++ // Generate CA+ caKey, caCert, caCertBytes := generateCA(*caDays)+ writeFile(filepath.Join(*outDir, "ca.pem"), pemEncode("CERTIFICATE", caCertBytes))+ writeFile(filepath.Join(*outDir, "ca-key.pem"), pemEncodeECKey(caKey))++ // Generate leaf certificate+ leafKey, leafCertBytes := generateLeaf(caKey, caCert, *cn, *sans, *days)+ writeFile(filepath.Join(*outDir, "cert.pem"), pemEncode("CERTIFICATE", leafCertBytes))+ writeFile(filepath.Join(*outDir, "key.pem"), pemEncodeECKey(leafKey))++ fmt.Printf("Certificates generated in %s/\n", *outDir)+ fmt.Printf(" CA: ca.pem, ca-key.pem (valid %d days)\n", *caDays)+ fmt.Printf(" Leaf: cert.pem, key.pem (CN=%s, valid %d days)\n", *cn, *days)+ fmt.Printf(" SANs: %s\n", *sans)+ fmt.Println()+ fmt.Println("To trust the CA on macOS:")+ fmt.Printf(" sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain %s/ca.pem\n", *outDir)+ fmt.Println()+ fmt.Println("To trust the CA on Linux:")+ fmt.Printf(" sudo cp %s/ca.pem /usr/local/share/ca-certificates/liquidjs-dev.crt && sudo update-ca-certificates\n", *outDir)+}++func generateCA(validDays int) (*ecdsa.PrivateKey, *x509.Certificate, []byte) {+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)+ if err != nil {+ fatalf("generate CA key: %v", err)+ }++ serial := newSerial()+ template := &x509.Certificate{+ SerialNumber: serial,+ Subject: pkix.Name{+ Organization: []string{"LiquidJS Development CA"},+ CommonName: "LiquidJS Dev Root CA",+ },+ NotBefore: time.Now().Add(-1 * time.Hour),+ NotAfter: time.Now().Add(time.Duration(validDays) * 24 * time.Hour),+ KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,+ BasicConstraintsValid: true,+ IsCA: true,+ MaxPathLen: 1,+ }++ certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)+ if err != nil {+ fatalf("create CA cert: %v", err)+ }@@ -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; } @@ -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 }); }@@ -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}`); }@@ -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; }AI-assisted change 5f6beacc9cc89eff4100aeffa734a62bedea4e44c3c8dc7c7b934f44e9ef75b6 · Fix 262603e70aeda24db356f588a575eac1dfc1ddebbdb53220fbcefa407dc185ac