openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
114 lines (113 loc) • 5.43 kB
JavaScript
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 };