UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

114 lines (113 loc) 5.43 kB
import { d as normalizeMimeType } from "./mime-CVpcq9ju.js"; import { t as matchesHttpIfNoneMatch } from "./http-conditional-BWrY1Un1.js"; import path from "node:path"; import { createHash } from "node:crypto"; import { fileTypeFromBuffer } from "file-type"; //#region src/gateway/http-image-response.ts /** Authenticated UI images are deliberately small, bounded presentation assets. */ const HTTP_IMAGE_MAX_BYTES = 524288; /** Vector images are markup the renderer must parse, so they get a tighter cap. */ const HTTP_SVG_MAX_BYTES = 65536; const SVG_MIME_TYPE = "image/svg+xml"; const ICO_MIME_TYPE = "image/x-icon"; /** Image types accepted by the authenticated Control UI image routes. */ const ALLOWED_HTTP_IMAGE_MIME_TYPES = /* @__PURE__ */ new Set([ "image/avif", "image/gif", "image/jpeg", "image/png", SVG_MIME_TYPE, "image/webp", ICO_MIME_TYPE ]); function resolveHttpImageMimeType(value) { const normalized = normalizeMimeType(value); const contentType = normalized === "image/vnd.microsoft.icon" ? ICO_MIME_TYPE : normalized; return contentType && ALLOWED_HTTP_IMAGE_MIME_TYPES.has(contentType) ? contentType : void 0; } /** Hash final, validated response bytes once when their cached representation is created. */ function createHttpImageRepresentation(body, contentType) { return { body, contentType, etag: `"${createHash("sha256").update(body).digest("base64url")}"` }; } const SVG_PROLOGUE_WHITESPACE_RE = /\s*/y; function skipSvgPrologueWhitespace(text, index) { SVG_PROLOGUE_WHITESPACE_RE.lastIndex = index; SVG_PROLOGUE_WHITESPACE_RE.exec(text); return SVG_PROLOGUE_WHITESPACE_RE.lastIndex; } function startsWithToken(text, index, token) { return text.slice(index, index + token.length).toLowerCase() === token; } /** * Recognizes an SVG root element after an optional XML declaration and comments. * * An index scan rather than a regex on purpose: the equivalent * `(?:<!--[\s\S]*?-->\s*)*<svg` backtracks exponentially on comment-like bytes that * never reach a root element, and these bytes arrive from remote icon and * link-favicon responses on the Gateway's single event loop, so one crafted * response would stall every session. A comment ends at its first `-->`, so text * between a closed comment and the root element is rejected, not absorbed. */ function startsWithSvgRootElement(text) { let index = skipSvgPrologueWhitespace(text, 0); if (startsWithToken(text, index, "<?xml")) { const declarationEnd = text.indexOf(">", index); if (declarationEnd < 0) return false; index = skipSvgPrologueWhitespace(text, declarationEnd + 1); } while (startsWithToken(text, index, "<!--")) { const commentEnd = text.indexOf("-->", index + 4); if (commentEnd < 0) return false; index = skipSvgPrologueWhitespace(text, commentEnd + 3); } if (!startsWithToken(text, index, "<svg")) return false; const delimiter = text[index + 4]; return delimiter === ">" || delimiter === "/" && text[index + 5] === ">" || delimiter !== void 0 && /\s/u.test(delimiter); } /** * SVG images stay self-contained: no script, document expansion, embedded * documents, or outbound fetches can reach the browser through an image route. */ function isRenderableHttpSvg(body) { if (body.byteLength > 65536) return false; const text = body.toString("utf8"); return !text.includes("\0") && !/<!doctype|<!entity/iu.test(text) && !/<\s*(?:script|foreignObject|image|use|iframe)\b/iu.test(text) && !/\b(?:href|xlink:href|src)\s*=/iu.test(text) && startsWithSvgRootElement(text); } /** Sniffs and validates bytes before they become a browser image response. */ async function resolveHttpImageRepresentation(sourceName, body) { if (body.byteLength === 0 || body.byteLength > 524288) return; let contentType; if (path.extname(sourceName).toLowerCase() === ".svg") contentType = isRenderableHttpSvg(body) ? SVG_MIME_TYPE : void 0; else contentType = resolveHttpImageMimeType((await fileTypeFromBuffer(body))?.mime); if (!contentType) return; return createHttpImageRepresentation(body, contentType); } /** Prevent image documents from executing scripts or making cross-origin requests. */ function applyHttpImageContentSecurityPolicy(res) { res.setHeader("content-security-policy", "default-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; sandbox"); } /** Writes the shared private-cache and document-sandbox policy for image bytes. */ function sendHttpImageResponse(params) { const { req, res, image } = params; res.setHeader("etag", image.etag); res.setHeader("cache-control", params.cacheControl ?? "private, max-age=3600"); res.setHeader("cross-origin-resource-policy", "same-origin"); res.setHeader("x-content-type-options", "nosniff"); applyHttpImageContentSecurityPolicy(res); res.setHeader("content-disposition", `attachment; filename="${params.filename}"`); if (matchesHttpIfNoneMatch(req.headers["if-none-match"], image.etag)) { res.statusCode = 304; res.end(); return; } res.statusCode = 200; res.setHeader("content-type", image.contentType); res.setHeader("content-length", String(image.body.byteLength)); res.end(req.method === "HEAD" ? void 0 : image.body); } //#endregion export { resolveHttpImageMimeType as a, startsWithSvgRootElement as c, createHttpImageRepresentation as i, HTTP_SVG_MAX_BYTES as n, resolveHttpImageRepresentation as o, applyHttpImageContentSecurityPolicy as r, sendHttpImageResponse as s, HTTP_IMAGE_MAX_BYTES as t };