Files
ComfyUI-OpenClaw/web/openclaw_text_output.js

295 lines
9.6 KiB
JavaScript

export const TEXT_OUTPUT_MAX_BYTES = 64 * 1024;
export const TEXT_OUTPUT_MAX_CHARS = 4096;
export const TEXT_OUTPUT_TIMEOUT_MS = 5000;
const ALLOWED_QUERY_KEYS = new Set(["filename", "subfolder", "type"]);
const ALLOWED_FILE_TYPES = new Set(["input", "output", "temp"]);
const ALLOWED_TEXT_EXTENSIONS = new Set([
"txt", "md", "markdown", "json", "csv", "yaml", "yml", "xml", "log",
]);
const MAX_FILE_FIELD_CHARS = 1024;
const ACTIVE_MIME_TYPES = new Set([
"text/html",
"text/javascript",
"text/css",
"image/svg+xml",
"application/xhtml+xml",
]);
const APPLICATION_TEXT_MIME_TYPES = new Set([
"application/json",
"application/xml",
"application/yaml",
"application/x-yaml",
]);
function fixedResult(status, reason = "", content = "", truncated = false) {
return { status, content, truncated, reason };
}
function resolveLocationHref() {
return globalThis.location?.href || "http://127.0.0.1/";
}
function codePointLength(value = "") {
return Array.from(String(value)).length;
}
function hasUnsafeCharacters(value = "") {
return Array.from(String(value)).some((char) => {
const code = char.charCodeAt(0);
return code < 32 || code === 127;
});
}
function isSafeFilename(filename) {
if (
!filename
|| codePointLength(filename) > MAX_FILE_FIELD_CHARS
|| hasUnsafeCharacters(filename)
|| filename === "."
|| filename === ".."
|| filename.includes("/")
|| filename.includes("\\")
) {
return false;
}
const dotIndex = filename.lastIndexOf(".");
const suffix = dotIndex >= 0 ? filename.slice(dotIndex + 1).toLowerCase() : "";
return ALLOWED_TEXT_EXTENSIONS.has(suffix);
}
function isSafeSubfolder(subfolder) {
return codePointLength(subfolder) <= MAX_FILE_FIELD_CHARS
&& !hasUnsafeCharacters(subfolder)
&& !subfolder.includes("\\")
&& !subfolder.startsWith("/")
&& !subfolder.split("/").some((part) => part === "." || part === "..");
}
function validateViewUrl(input, baseHref = resolveLocationHref()) {
if (typeof input !== "string" || !input) {
return null;
}
try {
const base = new URL(baseHref);
const resolved = new URL(input, base);
if (
resolved.origin !== base.origin
|| !["http:", "https:"].includes(resolved.protocol)
|| resolved.username
|| resolved.password
|| resolved.hash
|| !resolved.pathname.endsWith("/view")
) {
return null;
}
const keys = Array.from(resolved.searchParams.keys());
if (keys.some((key) => !ALLOWED_QUERY_KEYS.has(key))) {
return null;
}
if (
resolved.searchParams.getAll("filename").length !== 1
|| !isSafeFilename(resolved.searchParams.get("filename") || "")
|| resolved.searchParams.getAll("type").length !== 1
|| !ALLOWED_FILE_TYPES.has(resolved.searchParams.get("type") || "")
|| resolved.searchParams.getAll("subfolder").length > 1
|| !isSafeSubfolder(resolved.searchParams.get("subfolder") || "")
) {
return null;
}
return resolved;
} catch {
return null;
}
}
export function resolveTextOutputViewUrl(input) {
return validateViewUrl(input)?.href || "";
}
function isAllowedTextMime(value) {
const parts = String(value || "").split(";");
const mime = parts.shift()?.trim().toLowerCase() || "";
if (!mime || ACTIVE_MIME_TYPES.has(mime)) {
return false;
}
for (const parameter of parts) {
const separator = parameter.indexOf("=");
if (separator < 0) {
continue;
}
const key = parameter.slice(0, separator).trim().toLowerCase();
const rawValue = parameter.slice(separator + 1).trim();
const parameterValue = rawValue.replace(/^"|"$/g, "").toLowerCase();
if (key === "charset" && !["utf-8", "utf8"].includes(parameterValue)) {
return false;
}
}
if (mime.startsWith("text/")) {
return true;
}
return APPLICATION_TEXT_MIME_TYPES.has(mime);
}
function parseDeclaredLength(value) {
const raw = String(value || "").trim();
if (!/^\d+$/.test(raw)) {
return null;
}
const parsed = Number(raw);
return Number.isSafeInteger(parsed) ? parsed : null;
}
async function cancelReader(reader) {
try {
await reader?.cancel?.();
} catch {
// Best-effort release after fail-closed size rejection.
}
}
function getByteChunkLength(value) {
const isByteView = ArrayBuffer.isView(value) && value?.BYTES_PER_ELEMENT === 1;
const hasByteTag = Object.prototype.toString.call(value) === "[object Uint8Array]";
if (!isByteView && !hasByteTag) {
return null;
}
const byteLength = Number(value?.byteLength);
return Number.isSafeInteger(byteLength) && byteLength >= 0 ? byteLength : null;
}
function copyByteChunk(value) {
try {
return Uint8Array.from(value);
} catch {
return null;
}
}
export async function loadBoundedTextOutput(viewUrl, { fetchFn = globalThis.fetch, signal } = {}) {
const validatedHref = resolveTextOutputViewUrl(viewUrl);
const validatedUrl = validatedHref ? new URL(validatedHref) : null;
if (!validatedUrl || typeof fetchFn !== "function") {
return fixedResult("unavailable", "invalid_url");
}
const controller = new AbortController();
let timedOut = false;
let cancelled = false;
const onCallerAbort = () => {
cancelled = true;
controller.abort();
};
if (signal?.aborted) {
return fixedResult("unavailable", "cancelled");
}
signal?.addEventListener?.("abort", onCallerAbort, { once: true });
const timeoutId = setTimeout(() => {
timedOut = true;
controller.abort();
}, TEXT_OUTPUT_TIMEOUT_MS);
try {
// SECURITY: do not route through the generic JSON/text wrapper; it uses an
// unbounded body reader. This loader owns every byte and never adds tokens.
const response = await fetchFn(validatedUrl.href, {
method: "GET",
credentials: "same-origin",
redirect: "error",
headers: {
Accept: "text/plain, text/markdown, text/csv, application/json, application/xml, application/yaml",
},
signal: controller.signal,
});
if (!response || !response.ok) {
return fixedResult("unavailable", "http_error");
}
if (response.redirected) {
return fixedResult("unavailable", "redirected");
}
if (response.url) {
const responseUrl = validateViewUrl(response.url);
if (!responseUrl || responseUrl.href !== validatedUrl.href) {
return fixedResult("unavailable", "redirected");
}
}
if (!isAllowedTextMime(response.headers?.get?.("content-type"))) {
return fixedResult("unavailable", "mime_rejected");
}
const declaredLength = parseDeclaredLength(response.headers?.get?.("content-length"));
if (declaredLength !== null && declaredLength > TEXT_OUTPUT_MAX_BYTES) {
controller.abort();
return fixedResult("unavailable", "oversized");
}
if (!response.body || typeof response.body.getReader !== "function") {
return fixedResult("link_only", "stream_unavailable");
}
const reader = response.body.getReader();
const chunks = [];
let totalBytes = 0;
while (true) {
const step = await reader.read();
if (step.done) {
break;
}
const chunkLength = getByteChunkLength(step.value);
if (chunkLength === null) {
await cancelReader(reader);
return fixedResult("link_only", "stream_unavailable");
}
if (chunkLength > TEXT_OUTPUT_MAX_BYTES - totalBytes) {
controller.abort();
await cancelReader(reader);
return fixedResult("unavailable", "oversized");
}
const chunk = copyByteChunk(step.value);
if (!chunk || chunk.byteLength !== chunkLength) {
await cancelReader(reader);
return fixedResult("link_only", "stream_unavailable");
}
totalBytes += chunkLength;
chunks.push(chunk);
}
const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
let content;
try {
content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return fixedResult("unavailable", "invalid_utf8");
}
const contentCodePoints = Array.from(content);
if (contentCodePoints.length > TEXT_OUTPUT_MAX_CHARS) {
return fixedResult(
"truncated",
"display_limit",
contentCodePoints.slice(0, TEXT_OUTPUT_MAX_CHARS).join(""),
true
);
}
return fixedResult("success", "", content, false);
} catch {
if (cancelled || signal?.aborted) {
return fixedResult("unavailable", "cancelled");
}
if (timedOut) {
return fixedResult("unavailable", "timeout");
}
return fixedResult("unavailable", "network_error");
} finally {
clearTimeout(timeoutId);
signal?.removeEventListener?.("abort", onCallerAbort);
}
}