源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
import { lstat, mkdir, open, realpath, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export const DESKTOP_TRANSFER_MAX_BYTES = 250_000_000;
|
||||
const MAX_HEADERS = 64;
|
||||
const MAX_FIELDS = 32;
|
||||
const MAX_METADATA_LENGTH = 1_024;
|
||||
const MAX_RESPONSE_TEXT_BYTES = 1_000_000;
|
||||
|
||||
function transferError(message, code) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
function boundedString(value, label, { required = false, maxLength = MAX_METADATA_LENGTH } = {}) {
|
||||
if (typeof value !== "string") {
|
||||
if (!required && value === undefined) return undefined;
|
||||
throw transferError(`${label} must be a string.`, "invalid-metadata");
|
||||
}
|
||||
const result = value.trim();
|
||||
if (required && !result) throw transferError(`${label} is required.`, "invalid-metadata");
|
||||
if (result.length > maxLength) throw transferError(`${label} is too long.`, "invalid-metadata");
|
||||
if (/\0|[\r\n]/.test(result)) throw transferError(`${label} contains invalid characters.`, "invalid-metadata");
|
||||
return result;
|
||||
}
|
||||
|
||||
function boundedRecord(value, label, maximumEntries) {
|
||||
if (value === undefined) return {};
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw transferError(`${label} must be an object.`, "invalid-metadata");
|
||||
}
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length > maximumEntries) {
|
||||
throw transferError(`${label} has too many entries.`, "invalid-metadata");
|
||||
}
|
||||
return Object.fromEntries(entries.map(([key, entryValue]) => [
|
||||
boundedString(key, `${label} name`, { required: true, maxLength: 128 }),
|
||||
boundedString(entryValue, `${label} value`, { required: false, maxLength: 8_192 }) ?? "",
|
||||
]));
|
||||
}
|
||||
|
||||
function boundedByteCount(value, label, { allowUndefined = false } = {}) {
|
||||
if (allowUndefined && value === undefined) return DESKTOP_TRANSFER_MAX_BYTES;
|
||||
if (!Number.isSafeInteger(value)) throw transferError(`${label} must be an integer.`, "invalid-size");
|
||||
if (value === 0) throw transferError(`${label} must be greater than zero.`, "zero-byte-file");
|
||||
if (value < 0) throw transferError(`${label} must be greater than zero.`, "invalid-size");
|
||||
if (value > DESKTOP_TRANSFER_MAX_BYTES) {
|
||||
throw transferError(`${label} exceeds the ${DESKTOP_TRANSFER_MAX_BYTES}-byte limit.`, "file-too-large");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function urlWithinPrefix(candidate, prefixValue) {
|
||||
if (typeof prefixValue !== "string" || !prefixValue.trim()) return false;
|
||||
let prefix;
|
||||
try {
|
||||
prefix = new URL(prefixValue.trim());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (prefix.protocol !== candidate.protocol || prefix.host !== candidate.host) return false;
|
||||
const prefixPath = prefix.pathname.replace(/\/+$/, "");
|
||||
return !prefixPath || candidate.pathname === prefixPath || candidate.pathname.startsWith(`${prefixPath}/`);
|
||||
}
|
||||
|
||||
function remoteUrl(value, allowedUrlPrefixes) {
|
||||
const raw = boundedString(value, "URL", { required: true, maxLength: 8_192 });
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw transferError("URL is invalid.", "invalid-url");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw transferError("URL must use HTTP or HTTPS.", "invalid-url");
|
||||
}
|
||||
if (["127.0.0.1", "localhost", "[::1]"].includes(parsed.hostname)) {
|
||||
throw transferError("Dedicated file transfers are only available for remote destinations.", "loopback-url");
|
||||
}
|
||||
// The renderer never gets to pick an arbitrary destination: the URL must sit
|
||||
// under a remote workspace endpoint the main process itself has on record.
|
||||
const prefixes = Array.isArray(allowedUrlPrefixes) ? allowedUrlPrefixes : [];
|
||||
if (!prefixes.some((prefixValue) => urlWithinPrefix(parsed, prefixValue))) {
|
||||
throw transferError("URL does not belong to a connected remote workspace.", "unauthorized-url");
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function isInside(root, target) {
|
||||
const relative = path.relative(root, target);
|
||||
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
// A download destination must sit directly inside an authorized workspace
|
||||
// root. The root itself is app-owned state that a workspace-content writer
|
||||
// cannot rename, and exclusive creation never follows a final symlink, so no
|
||||
// attacker-controllable directory component exists between this validation
|
||||
// and the exclusive open: there is no parent that could be swapped for a
|
||||
// symbolic link to redirect creation outside the workspace.
|
||||
async function resolveAuthorizedPath(candidateValue, rootsValue) {
|
||||
const candidateRaw = boundedString(candidateValue, "Destination path", {
|
||||
required: true,
|
||||
maxLength: 32_768,
|
||||
});
|
||||
if (!path.isAbsolute(candidateRaw)) {
|
||||
throw transferError("Transfer path must be absolute.", "unauthorized-path");
|
||||
}
|
||||
const candidate = path.resolve(candidateRaw);
|
||||
const roots = Array.isArray(rootsValue) ? rootsValue : [];
|
||||
let insideNestedOnly = false;
|
||||
for (const rootValue of roots) {
|
||||
if (typeof rootValue !== "string" || !path.isAbsolute(rootValue)) continue;
|
||||
const root = path.resolve(rootValue);
|
||||
if (!isInside(root, candidate)) continue;
|
||||
if (candidate === root || path.dirname(candidate) !== root) {
|
||||
insideNestedOnly = true;
|
||||
continue;
|
||||
}
|
||||
const rootInfo = await lstat(root).catch(() => null);
|
||||
if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink()) {
|
||||
throw transferError("Authorized workspace root is unavailable or symbolic.", "unauthorized-path");
|
||||
}
|
||||
const rootRealPath = await realpath(root).catch(() => null);
|
||||
if (!rootRealPath) continue;
|
||||
const existing = await lstat(candidate).catch(() => null);
|
||||
if (existing?.isSymbolicLink()) {
|
||||
throw transferError("Destination path must not be a symbolic link.", "symlink-path");
|
||||
}
|
||||
return { path: candidate, rootRealPath };
|
||||
}
|
||||
if (insideNestedOnly) {
|
||||
throw transferError(
|
||||
"Download destinations must sit directly inside an authorized workspace root.",
|
||||
"nested-destination",
|
||||
);
|
||||
}
|
||||
throw transferError("Transfer path is outside every authorized workspace root.", "unauthorized-path");
|
||||
}
|
||||
|
||||
// Path-based validation is inherently time-of-check/time-of-use racy: a parent
|
||||
// directory can be swapped for a symbolic link after validation. This binds the
|
||||
// transfer to the opened file handle instead: the handle's device and inode
|
||||
// must match the freshly resolved path, and that resolved path must still sit
|
||||
// inside the authorized root. All bytes then flow through the verified handle,
|
||||
// which no later path swap can redirect.
|
||||
async function verifyOpenFileWithinRoot(file, candidatePath, rootRealPath, label) {
|
||||
const resolvedPath = await realpath(candidatePath).catch(() => null);
|
||||
if (!resolvedPath || !isInside(rootRealPath, resolvedPath)) {
|
||||
throw transferError(`${label} escapes the authorized workspace.`, "unauthorized-path");
|
||||
}
|
||||
const handleInfo = await file.stat();
|
||||
const pathInfo = await lstat(resolvedPath).catch(() => null);
|
||||
if (!pathInfo || pathInfo.dev !== handleInfo.dev || pathInfo.ino !== handleInfo.ino) {
|
||||
throw transferError(`${label} changed while the transfer was starting.`, "path-changed");
|
||||
}
|
||||
return handleInfo;
|
||||
}
|
||||
|
||||
async function responseText(response, maximumBytes = MAX_RESPONSE_TEXT_BYTES) {
|
||||
if (!response.body) return "";
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let total = 0;
|
||||
let result = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maximumBytes) {
|
||||
await reader.cancel();
|
||||
throw transferError("Text response is too large.", "response-too-large");
|
||||
}
|
||||
result += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return result + decoder.decode();
|
||||
}
|
||||
|
||||
function responseMetadata(response) {
|
||||
return {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: Array.from(response.headers.entries()),
|
||||
};
|
||||
}
|
||||
|
||||
function timeoutSignal(timeoutMs, parentSignal) {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return parentSignal;
|
||||
return parentSignal
|
||||
? AbortSignal.any([parentSignal, AbortSignal.timeout(timeoutMs)])
|
||||
: AbortSignal.timeout(timeoutMs);
|
||||
}
|
||||
|
||||
async function writeAll(file, value) {
|
||||
let offset = 0;
|
||||
while (offset < value.byteLength) {
|
||||
const { bytesWritten } = await file.write(value, offset, value.byteLength - offset);
|
||||
if (bytesWritten === 0) throw transferError("Download could not be written.", "write-failed");
|
||||
offset += bytesWritten;
|
||||
}
|
||||
}
|
||||
|
||||
// Uploads deliberately take bytes, not a path. The renderer only ever holds
|
||||
// bytes it was already allowed to read (a user-selected File), so the main
|
||||
// process gains no filesystem read authority on the renderer's behalf and no
|
||||
// configuration value can widen what an upload may exfiltrate.
|
||||
export async function uploadMultipartFromBytes(input, options) {
|
||||
const url = remoteUrl(input?.url, options?.allowedUrlPrefixes);
|
||||
const expectedBytes = boundedByteCount(input?.size, "File size");
|
||||
const bytes = input?.bytes;
|
||||
let data = null;
|
||||
if (bytes instanceof ArrayBuffer) {
|
||||
data = new Uint8Array(bytes);
|
||||
} else if (ArrayBuffer.isView(bytes)) {
|
||||
// Copy into an owned buffer so the payload is an immutable snapshot.
|
||||
data = new Uint8Array(bytes.byteLength);
|
||||
data.set(new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength));
|
||||
}
|
||||
if (!data) throw transferError("Upload bytes are required.", "invalid-file");
|
||||
boundedByteCount(data.byteLength, "File size");
|
||||
if (data.byteLength !== expectedBytes) {
|
||||
throw transferError(
|
||||
`File size changed: expected ${expectedBytes} bytes but received ${data.byteLength}.`,
|
||||
"size-mismatch",
|
||||
);
|
||||
}
|
||||
const filename = boundedString(input?.filename, "Filename", { required: true });
|
||||
if (filename !== path.basename(filename) || /[\\/]/.test(filename)) {
|
||||
throw transferError("Filename must not contain a path.", "invalid-metadata");
|
||||
}
|
||||
const fieldName = boundedString(input?.fieldName ?? "file", "File field name", { required: true, maxLength: 128 });
|
||||
const contentType = boundedString(input?.contentType, "Content type", { maxLength: 256 });
|
||||
const fields = boundedRecord(input?.fields, "Multipart fields", MAX_FIELDS);
|
||||
const headers = boundedRecord(input?.headers, "Headers", MAX_HEADERS);
|
||||
const method = boundedString(input?.method ?? "POST", "Method", { required: true, maxLength: 16 });
|
||||
const signal = timeoutSignal(input?.timeoutMs, options?.signal);
|
||||
const form = new FormData();
|
||||
form.append(fieldName, new Blob([data], contentType ? { type: contentType } : undefined), filename);
|
||||
for (const [name, value] of Object.entries(fields)) form.append(name, value);
|
||||
const response = await options.fetcher(url, {
|
||||
method,
|
||||
headers,
|
||||
body: form,
|
||||
credentials: "omit",
|
||||
cache: "no-store",
|
||||
// The endpoint allowlist covers only the initial URL, so a redirect
|
||||
// must never be followed to an unvalidated destination.
|
||||
redirect: "error",
|
||||
signal,
|
||||
});
|
||||
return { ...responseMetadata(response), body: await responseText(response) };
|
||||
}
|
||||
|
||||
// Downloads stream into a private staging file inside an app-owned directory
|
||||
// first, and only a fully successful download is placed into the workspace.
|
||||
// The staging directory sits outside every authorized workspace root, so a
|
||||
// process with workspace write access cannot swap its parents; its cleanup is
|
||||
// the only path-based removal this module performs. Inside the workspace the
|
||||
// destination is created exclusively, verified by device and inode, written
|
||||
// through the verified handle, and never unlinked by path: failure cleanup
|
||||
// truncates through the handle instead.
|
||||
export async function downloadBinaryToPath(input, options) {
|
||||
const url = remoteUrl(input?.url, options?.allowedUrlPrefixes);
|
||||
const destination = await resolveAuthorizedPath(input?.destinationPath, options?.authorizedRoots);
|
||||
const destinationPath = destination.path;
|
||||
const maxBytes = boundedByteCount(input?.maxBytes, "Maximum download size", { allowUndefined: true });
|
||||
const headers = boundedRecord(input?.headers, "Headers", MAX_HEADERS);
|
||||
const method = boundedString(input?.method ?? "GET", "Method", { required: true, maxLength: 16 });
|
||||
const signal = timeoutSignal(input?.timeoutMs, options?.signal);
|
||||
const stagingDir = boundedString(options?.stagingDir, "Staging directory", { required: true, maxLength: 32_768 });
|
||||
if (!path.isAbsolute(stagingDir)) {
|
||||
throw transferError("Staging directory must be absolute.", "invalid-staging");
|
||||
}
|
||||
let stagingPath;
|
||||
let stagingFile;
|
||||
let destinationFile;
|
||||
try {
|
||||
const response = await options.fetcher(url, {
|
||||
method,
|
||||
headers,
|
||||
credentials: "omit",
|
||||
cache: "no-store",
|
||||
// The endpoint allowlist covers only the initial URL, so a redirect
|
||||
// must never be followed to an unvalidated destination.
|
||||
redirect: "error",
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return { ...responseMetadata(response), body: await responseText(response), path: null, bytes: 0 };
|
||||
}
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
throw transferError(`Download exceeds the ${maxBytes}-byte limit.`, "file-too-large");
|
||||
}
|
||||
await mkdir(stagingDir, { recursive: true });
|
||||
stagingPath = path.join(stagingDir, `download-${randomUUID()}.part`);
|
||||
stagingFile = await open(stagingPath, "wx+");
|
||||
const reader = response.body?.getReader();
|
||||
let bytes = 0;
|
||||
if (reader) {
|
||||
while (true) {
|
||||
signal?.throwIfAborted();
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > maxBytes) {
|
||||
await reader.cancel();
|
||||
throw transferError(`Download exceeds the ${maxBytes}-byte limit.`, "file-too-large");
|
||||
}
|
||||
await writeAll(stagingFile, value);
|
||||
}
|
||||
}
|
||||
if (bytes === 0) throw transferError("Download response was empty.", "zero-byte-file");
|
||||
// Only a complete download reaches the workspace. Revalidate the
|
||||
// destination, create it exclusively ("wx" never follows a final
|
||||
// symlink), and prove by device and inode that the created file resides
|
||||
// inside the authorized root before a single byte is written to it.
|
||||
await resolveAuthorizedPath(input?.destinationPath, options?.authorizedRoots);
|
||||
try {
|
||||
destinationFile = await open(destinationPath, "wx");
|
||||
} catch (error) {
|
||||
if (error?.code === "EEXIST") {
|
||||
throw transferError("Download destination already exists.", "destination-exists");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await verifyOpenFileWithinRoot(destinationFile, destinationPath, destination.rootRealPath, "Download destination");
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
||||
let position = 0;
|
||||
while (position < bytes) {
|
||||
signal?.throwIfAborted();
|
||||
const { bytesRead } = await stagingFile.read(buffer, 0, Math.min(buffer.length, bytes - position), position);
|
||||
if (bytesRead === 0) {
|
||||
throw transferError("Downloaded data changed while it was being saved.", "size-mismatch");
|
||||
}
|
||||
await writeAll(destinationFile, buffer.subarray(0, bytesRead));
|
||||
position += bytesRead;
|
||||
}
|
||||
await destinationFile.sync();
|
||||
await destinationFile.close();
|
||||
destinationFile = undefined;
|
||||
return { ...responseMetadata(response), path: destinationPath, bytes };
|
||||
} catch (error) {
|
||||
// Cleanup goes through the verified handle, never through the path, so a
|
||||
// swapped parent cannot turn cleanup into an out-of-workspace deletion.
|
||||
if (destinationFile) {
|
||||
await destinationFile.truncate(0).catch(() => undefined);
|
||||
await destinationFile.close().catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await stagingFile?.close().catch(() => undefined);
|
||||
if (stagingPath) {
|
||||
await rm(stagingPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user