import https from 'node:https'; import http from 'node:http'; import { lookup as dnsLookup } from 'node:dns'; import { constants as bufferConstants } from 'node:buffer'; import { log } from './config.js'; import { tryExtractPdf } from './pdf.js'; import { isPrivateIp, resolvePublicAddresses } from './net-safety.js'; import { decodePng } from './vendor/png.js'; import jpegDecode from './vendor/jpeg-js/decoder.js'; import jpegEncode from './vendor/jpeg-js/encoder.js'; // Exported because the connect route (devin-connect.js:inlineRemoteImages) reuses // fetchImageUrl and re-checks its result: one SSRF/size/content-type policy for // both routes instead of a second copy that can drift. export const MAX_SIZE = 5 * 1024 * 1024; // 5 MB export const MAX_BASE64_LEN = Math.ceil(MAX_SIZE * 4 / 3) + 100; const MAX_REDIRECTS = 3; export const MIME_OK = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']); // --------------------------------------------------------------------------- // Inbound image inspection (pure-Node header parsing — NO pixel decode) // // Algorithm/defaults adapted from ZyphrZero kiro.rs `src/image_resize.rs` (MIT) — // thanks. Anthropic's vision encoder caps the long side at 1568px (patch-grid // boundary); AWS Q / Windsurf upstreams enforce a hard per-field byte limit, so // oversized inbound images either get billed at full size or fail the request. // // Pure-Node header parsing (format detection, dimension reads, oversize // classification) is the cheap fast path. Actual pixel downscale / re-encode // is done by `shrinkPixels` using vendored zero-dependency codecs (a pure-Node // PNG decoder over node:zlib + the BSD-3 jpeg-js decode/encode, both under // `src/vendor/`). Any decode/encode failure — including an unsupported format // such as WebP or an exotic PNG variant — degrades to a safe passthrough rather // than failing the request or the server. // // What this module does: // - magic-byte format detection (corrects a mislabeled media_type so the // upstream's strict MIME check doesn't reject the image) // - header-only dimension reads for PNG / JPEG / GIF / WebP (< 1ms, no decode) // - structured oversize classification (bytes vs. dimensions) // - real pixel downscale + JPEG re-encode for oversized images so they are // sent (like the Devin CLI / Desktop) instead of dropped or billed at full // size — see `shrinkPixels` // --------------------------------------------------------------------------- const IMAGE_MAX_LONG_SIDE = parseInt(process.env.WINDSURFAPI_IMAGE_MAX_LONG_SIDE || '1568', 10); // Re-encode TARGET budget, measured in base64-string length (the on-the-wire // unit the upstream per-field limit applies to). Oversized images are // downscaled / re-encoded toward this. Default 400 KB mirrors ZyphrZero's // `DEFAULT_MAX_BYTES` (a safe margin under the upstream hard field limit). const IMAGE_MAX_BYTES = parseInt(process.env.WINDSURFAPI_IMAGE_MAX_BYTES || '400000', 10); // JPEG re-encode starting quality (two-stage convergence steps down from here). const IMAGE_JPEG_QUALITY = parseInt(process.env.WINDSURFAPI_IMAGE_JPEG_QUALITY || '85', 10); // Hard drop ceiling: kept at the existing 5 MB so behavior does not regress. // Only used as a last resort when even a re-encode cannot get under it (or the // image could not be decoded). A re-encode normally lands far below this. const IMAGE_MAX_BASE64_LEN = MAX_BASE64_LEN; // Pixel-count ceiling for attempting a pixel decode. A crafted PNG can declare // enormous IHDR dimensions with a few KB of high-ratio IDAT (a decode bomb); // decoding it would allocate gigabytes. The vendored png.js rejects such // dimensions itself, but we also predict the pixel budget here from the cheap // header read so we never even hand oversized dimensions to the decoder. Kept // aligned with png.js MAX_PIXELS (~40M pixels; 4K is ~8MP, so ample headroom). const IMAGE_MAX_DECODE_PIXELS = 40 * 1024 * 1024; // Decode a PNG or JPEG buffer to { width, height, data:RGBA } using the vendored // zero-dependency codecs. WebP / GIF / exotic variants throw here, which the // caller turns into a safe passthrough. Format is decided by magic bytes, not the // declared label, so a mislabeled image still decodes. function decodePixels(buf) { const b = buf.subarray(0, 4); // PNG magic: 89 50 4E 47 if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) { return decodePng(buf); } // JPEG magic: FF D8 if (b[0] === 0xff && b[1] === 0xd8) { // Ceiling aligned with IMAGE_MAX_DECODE_PIXELS: the decoder is the backstop for a // frame whose header the module could not classify (truncated/unusual SOF), and a // looser number here would let exactly those frames through. 512 MB memory guard kept. const img = jpegDecode(buf, { useTArray: true, maxResolutionInMP: IMAGE_MAX_DECODE_PIXELS / 1_000_000, maxMemoryUsageInMB: 512, }); return { width: img.width, height: img.height, data: img.data }; } throw new Error('unsupported image format for re-encode (only PNG/JPEG)'); } // Bilinear downscale of an RGBA buffer to (dstW x dstH). Pure Node, no deps. // Only ever called to shrink, so no special upscale handling is needed. function scaleRGBA(src, srcW, srcH, dstW, dstH) { // Every RGBA byte is overwritten below before the buffer can escape. const out = Buffer.allocUnsafe(dstW * dstH * 4); // Map dst pixel centers back into src space. const xRatio = srcW / dstW; const yRatio = srcH / dstH; for (let dy = 0; dy < dstH; dy++) { const sy = (dy + 0.5) * yRatio - 0.5; let y0 = Math.floor(sy); const wy = sy - y0; if (y0 < 0) y0 = 0; const y1 = Math.min(y0 + 1, srcH - 1); for (let dx = 0; dx < dstW; dx++) { const sx = (dx + 0.5) * xRatio - 0.5; let x0 = Math.floor(sx); const wx = sx - x0; if (x0 < 0) x0 = 0; const x1 = Math.min(x0 + 1, srcW - 1); const o = (dy * dstW + dx) * 4; const i00 = (y0 * srcW + x0) * 4; const i01 = (y0 * srcW + x1) * 4; const i10 = (y1 * srcW + x0) * 4; const i11 = (y1 * srcW + x1) * 4; for (let c = 0; c < 4; c++) { const top = src[i00 + c] * (1 - wx) + src[i01 + c] * wx; const bot = src[i10 + c] * (1 - wx) + src[i11 + c] * wx; out[o + c] = (top * (1 - wy) + bot * wy + 0.5) | 0; } } } return out; } // Real pixel downscale + JPEG re-encode. Decodes the base64 image, scales it so // the long side <= maxLongSide, then runs a two-stage convergence to fit // maxBytes (base64-length budget): first step JPEG quality down to a floor, // then shrink the long side and retry. GIF is skipped by the caller (may be // animated). On ANY decode/encode failure this returns { ok:false } and the // caller keeps the original image — a bad image must never fail the request. // Algorithm/defaults adapted from ZyphrZero kiro.rs `src/image_resize.rs` (MIT). export async function shrinkPixels(base64, opts = {}) { const maxLongSide = opts.maxLongSide ?? IMAGE_MAX_LONG_SIDE; const maxBytes = opts.maxBytes ?? IMAGE_MAX_BYTES; const startQuality = opts.quality ?? IMAGE_JPEG_QUALITY; const MIN_JPEG_QUALITY = 60; // quality floor before we shrink dimensions further // The rounded 1568 -> 128 schedule has 13 passes, including both endpoints. const MAX_DEFAULT_DIMENSION_PASSES = 13; // Dimension floor for the byte-convergence loop. The vendored jpeg-js encoder // produces larger output than jimp's for incompressible content, so allow the // long side to shrink further than jimp needed (128 vs the old 256) to still // meet a tight byte budget for near-random images. Real screenshots compress // well and rarely reach this floor. const MIN_LONG_SIDE = 128; try { const buf = Buffer.from(base64, 'base64'); let finalJpeg; let outBase64Len; // Pre-decode budget check: read the header dimensions (cheap, no pixel // decode) and bail before allocating anything if the declared pixel count is // absurd. This shields the decoders from a crafted decode bomb (tiny bytes, // giant IHDR/SOF dimensions) — the vendored png.js also self-guards, but // refusing here means we never even attempt the multi-hundred-MB allocation. // Returning ok:false makes the caller keep the original image (passthrough). const headerDims = readImageDimensions(base64); if (headerDims && headerDims.width * headerDims.height > IMAGE_MAX_DECODE_PIXELS) { return { ok: false, error: `image ${headerDims.width}x${headerDims.height} exceeds decode pixel budget` }; } // The header read above is deliberately cheap (256 KiB for JPEG), so a SOF that // sits behind a large EXIF/ICC/filler run is invisible to it and the guard above // is skipped entirely. Re-check the dimensions on the FULL decoded buffer, which // is the same ceiling applied at the same boundary — just over every byte we // already hold, not a window of it. readJpegDimensions allocates nothing. if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xd8) { const jpegDims = readJpegDimensions(buf); if (jpegDims && jpegDims.width * jpegDims.height > IMAGE_MAX_DECODE_PIXELS) { return { ok: false, error: `image ${jpegDims.width}x${jpegDims.height} exceeds decode pixel budget` }; } } const original = decodePixels(buf); // { width, height, data:RGBA } const srcW = original.width; const srcH = original.height; if (!srcW || !srcH) return { ok: false, error: 'decoded image has no dimensions' }; let curLong = Math.min(maxLongSide, Math.max(srcW, srcH)); let maxDimensionPasses = MAX_DEFAULT_DIMENSION_PASSES; if (!Number.isFinite(curLong)) { // Preserve legacy non-finite option behavior; validation is a separate policy. maxDimensionPasses = Infinity; } else if (curLong > 1568) { // Larger overrides retain every pass of the same rounded recurrence. maxDimensionPasses = 1; for (let probe = curLong; probe > MIN_LONG_SIDE; probe = Math.max(MIN_LONG_SIDE, Math.round(probe * 0.8))) { maxDimensionPasses++; } } let outBase64 = ''; for (let dimensionPass = 0; dimensionPass < maxDimensionPasses; dimensionPass++) { // Scale from the full-res original each pass so repeated downscales never // compound quality loss. let w = srcW, h = srcH, data = original.data; if (Math.max(srcW, srcH) > curLong) { const scale = curLong / Math.max(srcW, srcH); w = Math.max(1, Math.round(srcW * scale)); h = Math.max(1, Math.round(srcH * scale)); data = scaleRGBA(original.data, srcW, srcH, w, h); } let quality = startQuality; for (;;) { const jpg = jpegEncode({ data, width: w, height: h }, quality); finalJpeg = jpg.data; // Padded base64 emits four characters per complete or partial triplet. outBase64Len = 4 * Math.ceil(finalJpeg.length / 3); if (outBase64Len > bufferConstants.MAX_STRING_LENGTH) { // Preserve the native string-limit failure instead of trying a later candidate. finalJpeg.toString('base64'); } if (outBase64Len <= maxBytes || quality <= MIN_JPEG_QUALITY) break; quality = Math.max(MIN_JPEG_QUALITY, quality - 10); } if (outBase64Len <= maxBytes || curLong <= MIN_LONG_SIDE) break; curLong = Math.max(MIN_LONG_SIDE, Math.round(curLong * 0.8)); } // No candidate is encoded again: serialize only the buffer selected above. outBase64 = finalJpeg.toString('base64'); return { ok: true, base64_data: outBase64, mime_type: 'image/jpeg' }; } catch (e) { return { ok: false, error: e?.message || String(e) }; } } const FORMAT_TO_MIME = { png: 'image/png', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', }; // Decode the leading `n` base64 chars into bytes (cheap header probe). function decodeHead(base64, n) { // base64 is 4 chars -> 3 bytes; round the char count down to a 4-char boundary // so Buffer.from never sees a partial group. const chars = Math.min(base64.length, Math.ceil(n / 3) * 4) & ~3; if (chars <= 0) return Buffer.alloc(0); try { return Buffer.from(base64.slice(0, chars), 'base64'); } catch { return Buffer.alloc(0); } } // Detect the real format from magic bytes. Returns 'png'|'jpeg'|'gif'|'webp'|null. // `null` means "undetectable" — callers keep the declared label, never drop. export function detectImageFormat(base64) { if (typeof base64 !== 'string' || base64.length === 0) return null; const b = decodeHead(base64, 16); if (b.length < 4) return null; // PNG: 89 50 4E 47 0D 0A 1A 0A if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) return 'png'; // JPEG: FF D8 FF if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpeg'; // GIF: "GIF87a" / "GIF89a" if (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38 && (b[4] === 0x37 || b[4] === 0x39) && b[5] === 0x61) return 'gif'; // WebP: "RIFF" .... "WEBP" if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return 'webp'; return null; } const JPEG_SOF_MARKERS = new Set([ 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf, ]); function readPngDimensions(b) { // signature(8) + len(4) + 'IHDR'(4) + width(4 BE) + height(4 BE) if (b.length < 24) return null; if (b[12] !== 0x49 || b[13] !== 0x48 || b[14] !== 0x44 || b[15] !== 0x52) return null; // 'IHDR' const width = b.readUInt32BE(16); const height = b.readUInt32BE(20); return width > 0 && height > 0 ? { width, height } : null; } function readJpegDimensions(b) { if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return null; let i = 2; while (i + 9 <= b.length) { if (b[i] !== 0xff) { i++; continue; } // resync over fill bytes const marker = b[i + 1]; // Standalone markers carry no length payload. if (marker === 0xd8 || marker === 0xd9 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; } if (i + 4 > b.length) break; const segLen = b.readUInt16BE(i + 2); if (segLen < 2) break; if (JPEG_SOF_MARKERS.has(marker)) { // SOF: marker(2) + len(2) + precision(1) + height(2 BE) + width(2 BE) if (i + 9 > b.length) break; const height = b.readUInt16BE(i + 5); const width = b.readUInt16BE(i + 7); return width > 0 && height > 0 ? { width, height } : null; } i += 2 + segLen; } return null; } function readGifDimensions(b) { if (b.length < 10) return null; const width = b.readUInt16LE(6); const height = b.readUInt16LE(8); return width > 0 && height > 0 ? { width, height } : null; } function readWebpDimensions(b) { if (b.length < 16) return null; const chunk = b.toString('ascii', 12, 16); if (chunk === 'VP8 ') { // Lossy: key-frame start code 9D 01 2A at offset 23, dims at 26/28 (14-bit LE). if (b.length < 30) return null; if (b[23] !== 0x9d || b[24] !== 0x01 || b[25] !== 0x2a) return null; const width = b.readUInt16LE(26) & 0x3fff; const height = b.readUInt16LE(28) & 0x3fff; return width > 0 && height > 0 ? { width, height } : null; } if (chunk === 'VP8L') { // Lossless: signature 0x2F at offset 20, then 14-bit width-1 / height-1. if (b.length < 25 || b[20] !== 0x2f) return null; const b0 = b[21], b1 = b[22], b2 = b[23], b3 = b[24]; const width = 1 + (((b1 & 0x3f) << 8) | b0); const height = 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | (b1 >> 6)); return { width, height }; } if (chunk === 'VP8X') { // Extended: 24-bit LE width-1 / height-1 at offsets 24 / 27. if (b.length < 30) return null; const width = 1 + (b[24] | (b[25] << 8) | (b[26] << 16)); const height = 1 + (b[27] | (b[28] << 8) | (b[29] << 16)); return { width, height }; } return null; } // Read pixel dimensions from the header only. Returns {width,height} or null. // `format` is a hint ('png'|'jpeg'|'gif'|'webp'); detection falls back to magic // bytes when the hint is missing/unknown. export function readImageDimensions(base64, format) { if (typeof base64 !== 'string' || base64.length === 0) return null; // Magic-byte detection always wins: a mislabeled media_type (e.g. jpeg bytes // tagged image/png) must not pick the wrong header parser. The hint is only a // fallback for the rare case detection can't classify the bytes. const fmt = detectImageFormat(base64) || ((format && FORMAT_TO_MIME[format]) ? format : null); if (!fmt) return null; // JPEG SOF can sit behind large EXIF/ICC segments; decode a generous head. const headBytes = fmt === 'jpeg' ? 256 * 1024 : 64; const b = decodeHead(base64, headBytes); switch (fmt) { case 'png': return readPngDimensions(b); case 'jpeg': return readJpegDimensions(b); case 'gif': return readGifDimensions(b); case 'webp': return readWebpDimensions(b); default: return null; } } function mimeToFormat(mime) { const sub = String(mime || '').toLowerCase().split('/')[1] || ''; if (sub === 'jpg' || sub === 'jpeg') return 'jpeg'; if (sub === 'png') return 'png'; if (sub === 'gif') return 'gif'; if (sub === 'webp') return 'webp'; return null; } // Inbound image gate. Inspects one image, corrects its declared mime from the // real magic bytes, classifies oversize (bytes vs. dimensions), and — for a // byte-oversized image — attempts a real pixel downscale + JPEG re-encode via // `shrinkPixels` so the image is sent (like the Devin CLI/Desktop) instead of // dropped. Async because the re-encode decodes pixels. // - mime corrected from magic bytes (prevents upstream MIME mismatch) // - oversized dimensions within byte budget -> forwarded as-is (token cost only) // - byte-oversized -> re-encode; only if the re-encode still can't fit (or the // image is a GIF / undecodable) is it dropped, with a structured reason // // Returns { base64_data, mime_type, format, width, height, base64Len, // oversizeBytes, oversizeDimensions, resized, dropped, reason }. // `dropped:true` means the caller should skip sending this image. `resized:true` // means base64_data/mime_type were replaced with the re-encoded JPEG. export async function maybeShrinkImage(img, opts = {}) { const base64 = img?.base64_data || ''; const declaredMime = (img?.mime_type || 'image/png').toLowerCase(); const maxLongSide = opts.maxLongSide ?? IMAGE_MAX_LONG_SIDE; const maxBase64Len = opts.maxBase64Len ?? IMAGE_MAX_BASE64_LEN; const maxBytes = opts.maxBytes ?? IMAGE_MAX_BYTES; const quality = opts.quality ?? IMAGE_JPEG_QUALITY; const base64Len = base64.length; const detected = detectImageFormat(base64); const declaredFormat = mimeToFormat(declaredMime); // Correct the format only when detection succeeds and disagrees with the label. const format = detected || declaredFormat || null; const mime_type = (detected && FORMAT_TO_MIME[detected]) || declaredMime; const dims = readImageDimensions(base64, format); const width = dims?.width ?? null; const height = dims?.height ?? null; const oversizeDimensions = dims ? Math.max(width, height) > maxLongSide : false; const oversizeBytes = base64Len > maxBase64Len; // Byte-oversized: try a real downscale + re-encode to rescue the image rather // than forwarding it (would fail the whole upstream request) or dropping it. // GIF is left alone (may be animated); a re-encode to JPEG would kill the // animation, so a byte-oversized GIF is still dropped. if (oversizeBytes) { if (format !== 'gif') { const shrunk = await shrinkPixels(base64, { maxLongSide, maxBytes, quality }); if (shrunk.ok && shrunk.base64_data.length <= maxBase64Len) { return { base64_data: shrunk.base64_data, mime_type: shrunk.mime_type, format: 'jpeg', width, height, base64Len: shrunk.base64_data.length, oversizeBytes: false, oversizeDimensions: false, resized: true, dropped: false, reason: `re-encoded ${base64Len}B -> ${shrunk.base64_data.length}B JPEG (was byte-oversized)`, }; } return { base64_data: base64, mime_type, format, width, height, base64Len, oversizeBytes: true, oversizeDimensions, resized: false, dropped: true, reason: shrunk.ok ? `base64 length ${base64Len} exceeds limit ${maxBase64Len}; re-encode reached ${shrunk.base64_data.length} but still over limit` : `base64 length ${base64Len} exceeds limit ${maxBase64Len} and re-encode failed: ${shrunk.error}`, }; } return { base64_data: base64, mime_type, format, width, height, base64Len, oversizeBytes: true, oversizeDimensions, resized: false, dropped: true, reason: `base64 length ${base64Len} exceeds limit ${maxBase64Len} and GIF is not re-encoded (may be animated)`, }; } // Within the hard byte budget but the long side is over the cap: downscale + // re-encode so we send a correctly-sized image (saves tokens, avoids upstream // re-downscaling). GIF stays untouched. A failed re-encode forwards as-is. if (oversizeDimensions && format !== 'gif') { const shrunk = await shrinkPixels(base64, { maxLongSide, maxBytes, quality }); if (shrunk.ok && shrunk.base64_data.length <= maxBase64Len) { return { base64_data: shrunk.base64_data, mime_type: shrunk.mime_type, format: 'jpeg', width, height, base64Len: shrunk.base64_data.length, oversizeBytes: false, oversizeDimensions: false, resized: true, dropped: false, reason: `re-encoded long side ${Math.max(width, height)}px -> <=${maxLongSide}px JPEG`, }; } // Re-encode failed or didn't help: forward the original (token cost only). return { base64_data: base64, mime_type, format, width, height, base64Len, oversizeBytes: false, oversizeDimensions: true, resized: false, dropped: false, reason: `long side ${Math.max(width, height)}px exceeds ${maxLongSide}px (forwarded as-is; re-encode unavailable)`, }; } return { base64_data: base64, mime_type, format, width, height, base64Len, oversizeBytes: false, oversizeDimensions, resized: false, dropped: false, reason: oversizeDimensions ? `long side ${Math.max(width, height)}px exceeds ${maxLongSide}px (GIF forwarded as-is)` : null, }; } // http/https `lookup` hook: runs in place of the default DNS resolution. // Rejecting here means the request never opens a socket to the internal // address, closing the DNS-rebinding gap in the string-based host check. function safeLookup(hostname, options, callback) { dnsLookup(hostname, options, (err, address, family) => { if (err) return callback(err); const addrs = Array.isArray(address) ? address : [{ address, family }]; for (const a of addrs) { if (isPrivateIp(a.address)) { return callback(new Error(`Image URL resolves to private address: ${a.address}`)); } } callback(null, address, family); }); } // Plaintext http image bytes are a MITM surface: any middlebox between us and the // image host can replace what is later inlined into the model's context (#10 / // Cascade image blocks). https is the default. This escape hatch re-allows // plaintext http for PUBLIC hosts only — private/loopback targets are rejected // by the SSRF check below regardless of the switch — and is read live (exactly // `1`, like net-safety's trustedProxyHops) so an operator or a test can flip it. function allowPlaintextImageUrls(env = process.env) { return env.WINDSURFAPI_ALLOW_HTTP_IMAGES === '1'; } /** Host only — never log a full URL, which may carry a signed query string. */ function imageUrlHost(url) { try { return new URL(url).host || '(unparseable)'; } catch { return '(unparseable)'; } } function validateImageUrl(url, env = process.env) { let parsed; try { parsed = new URL(url); } catch { throw new Error('Invalid image URL'); } if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') throw new Error('Image URL must be http or https'); // Private/internal is checked BEFORE the plaintext gate on purpose: an http URL // aimed at an internal address is refused as SSRF first, and no escape hatch can // make that address reachable. if (String(parsed.hostname).toLowerCase() === 'localhost' || isPrivateIp(parsed.hostname)) throw new Error('Image URL targets a private/internal address'); // fetchImageHop calls this on EVERY hop, so an https -> http redirect is caught // by the same check as a direct http URL. if (parsed.protocol === 'http:' && !allowPlaintextImageUrls(env)) { log.warn(`Image URL rejected: plaintext http (${imageUrlHost(url)}) — set WINDSURFAPI_ALLOW_HTTP_IMAGES=1 to allow it`); throw new Error('Image URL must use https (plaintext http is rejected; set WINDSURFAPI_ALLOW_HTTP_IMAGES=1 to allow it)'); } return parsed; } export function parseDataUrl(url) { const clean = url.replace(/\s/g, ''); const m = clean.match(/^data:(image\/[a-z+]+);base64,(.+)$/i); if (!m) return null; if (m[2].length > MAX_BASE64_LEN) throw new Error(`Image data URL exceeds ${MAX_SIZE} byte limit`); return { base64_data: m[2], mime_type: m[1].toLowerCase() }; } // Extract base64 body from a data URL of any mime type. Used for PDF // payloads which don't match parseDataUrl's image-only regex. export function parseGenericDataUrl(url) { const clean = url.replace(/\s/g, ''); const m = clean.match(/^data:([a-z0-9][a-z0-9.+/-]+);base64,(.+)$/i); if (!m) return null; if (m[2].length > MAX_BASE64_LEN) throw new Error(`Data URL exceeds ${MAX_SIZE} byte limit`); return { base64_data: m[2], mime_type: m[1].toLowerCase() }; } export async function assertPublicUrlHost(urlOrHost, lookupFn = dnsLookup) { let host = urlOrHost; try { host = new URL(urlOrHost).hostname; } catch {} return resolvePublicAddresses(host, lookupFn); } // Gate one image through `maybeShrinkImage`, then either push it (corrected // mime, real downscale/re-encode when oversized) or log a structured skip. // Async because `maybeShrinkImage` may decode pixels to re-encode. Returns the // pushed image object or null. export async function pushImage(images, image) { if (!image || !image.base64_data) return null; const decision = await maybeShrinkImage(image); if (decision.dropped) { log.warn(`Image skipped: ${decision.reason} (format=${decision.format || 'unknown'}, ${decision.width || '?'}x${decision.height || '?'})`); return null; } if (decision.resized) { log.info(`Image re-encoded: ${decision.reason}`); } else if (decision.oversizeDimensions) { log.warn(`Image oversized: ${decision.reason}`); } const out = { base64_data: decision.base64_data, mime_type: decision.mime_type }; images.push(out); return out; } // One absolute wall-clock budget for a whole image fetch: armed once by the root // call, shared by every redirect hop, and cleared on settlement. The per-hop // `timeout` option stays what node documents it as — a socket-INACTIVITY guard — // so a chain of 7 s redirects or a body trickling one byte at a time can no longer // hold the fetch open for minutes (each hop used to receive the full budget again). // // Every handle this helper owns (the active request/response) is tracked in `ctx`, // so settlement destroys them and drops the helper's own listeners exactly once, // whether the fetch ends by deadline, byte cap, bad status/MIME, transport error or // success. Late events after settlement neither resolve again nor start a request. const IMAGE_FETCH_TIMEOUT_MS = 8000; // Remove the listeners this hop registered, so a settled (or abandoned) hop leaves // nothing behind that can fire later. function detach(emitter, listeners) { if (!emitter) return; for (const [event, fn] of listeners) emitter.removeListener(event, fn); listeners.length = 0; } // Abort-shaped error for a cancelled image extraction. `signal.reason` is // preserved when it is an Error/DOMException (AbortSignal.timeout gives a // TimeoutError, abort(err) gives that exact error); otherwise a fresh // AbortError is created. Either way callers see `name === 'AbortError'`. function imageAbortError(signal) { const reason = signal?.reason; if (reason && typeof reason === 'object' && typeof reason.message === 'string') return reason; const err = new Error(typeof reason === 'string' && reason ? reason : 'Image extraction aborted'); err.name = 'AbortError'; err.code = 'ABORT_ERR'; return err; } // A terminal error ends the extraction; an ordinary per-image failure (bad // MIME, 404, SSRF, size, decode) keeps the log-and-continue behaviour. function isTerminalImageError(err, signal) { if (signal?.aborted) return true; if (err?.name === 'AbortError' || err?.code === 'ABORT_ERR') return true; // `beforeFetch` is the caller's turn deadline (client.js assertBudget). The // marker is the one request-budget.js sets; checking it here instead of // importing the predicate keeps image.js's import shape unchanged (the codec // seam helper asserts it). return err?.budgetExceeded === true || err?.code === 'DEADLINE_EXCEEDED'; } export function fetchImageUrl(url, timeoutMs = IMAGE_FETCH_TIMEOUT_MS, _depth = 0, _ctx = null, signal = null) { if (_depth > MAX_REDIRECTS) return Promise.reject(new Error('Too many image redirects')); if (_ctx) return fetchImageHop(url, _depth, _ctx); // Root call: one timer, one settlement, one set of owned handles. The // caller's AbortSignal is an ADDITIONAL stop next to the absolute timer: an // abort settles once through the same path, so every owned request/response // is destroyed exactly as it is on a deadline. The timer is never replaced. const ctx = { done: false, timer: null, handles: new Set(), timeoutMs, signal, onAbort: null }; const promise = new Promise((resolve, reject) => { const settle = (error, value) => { if (ctx.done) return; ctx.done = true; if (ctx.timer) { clearTimeout(ctx.timer); ctx.timer = null; } if (ctx.onAbort) { ctx.signal?.removeEventListener?.('abort', ctx.onAbort); ctx.onAbort = null; } for (const handle of ctx.handles) { detach(handle.res, handle.resListeners); detach(handle.req, handle.reqListeners); if (!handle.res?.destroyed) handle.res?.destroy(); if (!handle.req?.destroyed) handle.req?.destroy(); } ctx.handles.clear(); if (error) reject(error); else resolve(value); }; ctx.timer = setTimeout( () => settle(new Error(`Image fetch deadline exceeded (${timeoutMs} ms)`)), timeoutMs, ); if (typeof ctx.timer.unref === 'function') ctx.timer.unref(); if (signal) { // Already-aborted signal: settle before any hop starts, so a turn that is // already over opens zero requests. if (signal.aborted) { settle(imageAbortError(signal)); return; } ctx.onAbort = () => settle(imageAbortError(signal)); signal.addEventListener('abort', ctx.onAbort, { once: true }); } fetchImageHop(url, _depth, ctx).then( (value) => settle(null, value), (error) => settle(error), ); }); return promise; } function fetchImageHop(url, depth, ctx) { return new Promise((resolve, reject) => { if (ctx.done) { reject(new Error('Image fetch deadline exceeded')); return; } if (ctx.signal?.aborted) { reject(imageAbortError(ctx.signal)); return; } let parsed; try { parsed = validateImageUrl(url); } catch (e) { reject(e); return; } const handle = { req: null, res: null, reqListeners: [], resListeners: [] }; ctx.handles.add(handle); let hopSettled = false; const finish = (error, value) => { if (hopSettled) return; hopSettled = true; detach(handle.res, handle.resListeners); detach(handle.req, handle.reqListeners); ctx.handles.delete(handle); if (error) { // A failed hop aborts whatever it still owns: no request/response may stay // live (or keep streaming) after the fetch it belonged to has failed. if (!handle.res?.destroyed) handle.res?.destroy(); if (!handle.req?.destroyed) handle.req?.destroy(); reject(error); } else { resolve(value); } }; const on = (emitter, list, event, fn) => { emitter.on(event, fn); list.push([event, fn]); }; const mod = parsed.protocol === 'https:' ? https : http; const req = mod.get(parsed.href, { timeout: ctx.timeoutMs ?? IMAGE_FETCH_TIMEOUT_MS, headers: { 'Accept': 'image/*' }, lookup: safeLookup }, (res) => { handle.res = res; if (ctx.done) { res.destroy(); return; } if (ctx.signal?.aborted) { finish(imageAbortError(ctx.signal)); return; } if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { // Destroy the redirect body instead of draining it: an endless 3xx response // must not keep a socket (or its bytes) alive after we have moved on. detach(res, handle.resListeners); ctx.handles.delete(handle); handle.res = null; res.destroy(); // The hop must not start a request the caller has already cancelled. if (ctx.signal?.aborted) { finish(imageAbortError(ctx.signal)); return; } fetchImageUrl(res.headers.location, ctx.timeoutMs, depth + 1, ctx).then( (v) => finish(null, v), (e) => finish(e), ); return; } if (res.statusCode !== 200) { res.resume(); finish(new Error(`Image fetch HTTP ${res.statusCode}`)); return; } const mime = (res.headers['content-type'] || '').split(';')[0].trim().toLowerCase(); if (!MIME_OK.has(mime)) { res.resume(); finish(new Error(`Unsupported image type: ${mime}`)); return; } const chunks = []; let size = 0; on(res, handle.resListeners, 'data', (d) => { if (hopSettled || ctx.done) return; size += d.length; if (size > MAX_SIZE) { res.destroy(); finish(new Error(`Image exceeds ${MAX_SIZE} bytes`)); } else chunks.push(d); }); on(res, handle.resListeners, 'end', () => { if (hopSettled || ctx.done) return; finish(null, { base64_data: Buffer.concat(chunks).toString('base64'), mime_type: mime }); }); on(res, handle.resListeners, 'error', (e) => finish(e)); }); handle.req = req; on(req, handle.reqListeners, 'error', (e) => finish(e)); on(req, handle.reqListeners, 'timeout', () => finish(new Error('Image fetch timeout'))); }); } // Test seam: extractImages resolves remote images through this indirection so // cancellation can be exercised without the network. Production always points // at fetchImageUrl; tests restore it in `finally`. let fetchImageUrlImpl = fetchImageUrl; export function __setFetchImageUrlForTest(fn) { fetchImageUrlImpl = typeof fn === 'function' ? fn : fetchImageUrl; } /** * Flatten content blocks to {text, images}. * * `signal` and `beforeFetch` are the Cascade turn's cancellation surface: * `beforeFetch` (the caller's budget assertion) runs before every remote fetch * and every pushImage, and an aborted `signal` stops extraction the same way. * A terminal abort/deadline error is rethrown; an ordinary per-image failure * (bad MIME, 404, SSRF, size, decode) still logs and continues, unchanged. */ export async function extractImages(contentBlocks, { signal = null, beforeFetch = null } = {}) { if (!Array.isArray(contentBlocks)) return { text: String(contentBlocks ?? ''), images: [] }; const checkpoint = () => { beforeFetch?.(); if (signal?.aborted) throw imageAbortError(signal); }; let text = ''; const images = []; for (const block of contentBlocks) { if (!block || typeof block === 'string') { text += block || ''; continue; } if (block.type === 'text') { text += block.text || ''; } else if (block.type === 'document') { const src = block.source || {}; const mime = (src.media_type || '').toLowerCase(); if (mime === 'application/pdf' && src.data) { const pdf = tryExtractPdf(src.data); if (pdf?.text) { text += `\n[PDF Document — ${pdf.pageCount} page(s)]\n${pdf.text}\n`; log.info(`PDF extracted: ${pdf.pageCount} pages, ${pdf.text.length} chars`); } else { text += '\n[PDF Document — no extractable text (scanned/image-only PDF)]\n'; } } } else if (block.type === 'image') { const src = block.source || {}; const mime = (src.media_type || '').toLowerCase(); if (mime === 'application/pdf' && src.data) { const pdf = tryExtractPdf(src.data); if (pdf?.text) { text += `\n[PDF Document — ${pdf.pageCount} page(s)]\n${pdf.text}\n`; } continue; } try { if ((src.type === 'base64' || !src.type) && src.data) { checkpoint(); await pushImage(images, { base64_data: src.data, mime_type: src.media_type || 'image/png' }); } else if (src.type === 'url' && src.url) { checkpoint(); const fetched = await fetchImageUrlImpl(src.url, IMAGE_FETCH_TIMEOUT_MS, 0, null, signal); // A turn can be spent while the fetch runs: do not attach an image // the caller can no longer use. checkpoint(); await pushImage(images, fetched); } } catch (e) { if (isTerminalImageError(e, signal)) throw e; log.warn(`Image extraction failed: ${e.message}`); } } else if (block.type === 'image_url') { const url = block.image_url?.url || ''; try { if (url.startsWith('data:')) { // PDF-as-data-URL: let the model "see" it via text extraction // rather than treating it as an unsupported image type. const lower = url.slice(0, 40).toLowerCase(); if (lower.startsWith('data:application/pdf')) { const g = parseGenericDataUrl(url); if (g?.base64_data) { const pdf = tryExtractPdf(g.base64_data); if (pdf?.text) { text += `\n[PDF Document — ${pdf.pageCount} page(s)]\n${pdf.text}\n`; log.info(`PDF extracted (image_url data URL): ${pdf.pageCount} pages, ${pdf.text.length} chars`); } else { text += '\n[PDF Document — no extractable text (scanned/image-only PDF)]\n'; } } continue; } const parsed = parseDataUrl(url); if (parsed) { checkpoint(); await pushImage(images, parsed); } } else if (/^https?:\/\//i.test(url)) { // Schemes are case-insensitive (RFC 3986 §3.1) and `new URL()` normalizes // them, so a case-sensitive prefix test only SKIPPED the block: uppercase // HTTPS never fetched, uppercase HTTP never reached the https-only gate — // both vanished silently. The plaintext rejection still comes from // validateImageUrl, unchanged. checkpoint(); const fetched = await fetchImageUrlImpl(url, IMAGE_FETCH_TIMEOUT_MS, 0, null, signal); checkpoint(); await pushImage(images, fetched); } } catch (e) { if (isTerminalImageError(e, signal)) throw e; log.warn(`Image fetch failed: ${e.message}`); } } else if (block.type === 'file' || block.type === 'input_file') { // OpenAI PDF input: { type:'file', file:{ filename, file_data:'data:application/pdf;base64,...' } } // or file_id (uploaded via Files API — we can't fetch, so ignore). const file = block.file || {}; const dataUrl = file.file_data || file.url || ''; if (dataUrl.startsWith('data:application/pdf')) { const g = parseGenericDataUrl(dataUrl); if (g?.base64_data) { const pdf = tryExtractPdf(g.base64_data); if (pdf?.text) { const label = file.filename ? ` "${file.filename}"` : ''; text += `\n[PDF Document${label} — ${pdf.pageCount} page(s)]\n${pdf.text}\n`; log.info(`PDF extracted (OpenAI file block): ${pdf.pageCount} pages, ${pdf.text.length} chars`); } else { text += '\n[PDF Document — no extractable text (scanned/image-only PDF)]\n'; } } } else if (dataUrl && !file.file_id) { log.warn(`Unsupported file block data URL: ${dataUrl.slice(0, 40)}...`); } else if (file.file_id) { log.warn(`File block references file_id=${file.file_id} — upload API not supported, skipping`); } } } // M7: the last block's fetch/push can outlive the abort — every checkpoint // above runs before an await, so an abort landing inside the final // pushImage (or during trailing non-image blocks) used to return the // cancelled turn's images as if extraction had finished. if (signal?.aborted) throw imageAbortError(signal); return { text, images }; }