Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"check:docs": "node scripts/check-markdown-links.mjs",
"check:licenses": "node scripts/check-license-policy.mjs",
"check:public": "node scripts/check-public-repo-safety.mjs",
"check:dependency-security": "corepack pnpm --filter @ling-stack/mobile exec node --test test/dependency-security-patches.test.mjs",
"check:dependency-security": "corepack pnpm --filter @ling-stack/mobile exec node --test test/dependency-security-patches.test.mjs && corepack pnpm --filter @ling-stack/daemon exec node --test test/dependency-security.test.mjs",
"typecheck": "corepack pnpm --workspace-concurrency=1 -r typecheck",
"ling": "node packages/cli/dist/index.js",
"local:restart": "node packages/cli/dist/index.js restart",
Expand Down Expand Up @@ -56,7 +56,8 @@
"protobufjs@>=7.5.0 <7.6.5": "7.6.5",
"tar@>=7.0.0 <=7.5.20": "7.5.22",
"undici@>=7.0.0 <7.29.0": "7.29.0",
"uuid@7.0.3": "11.1.1"
"uuid@7.0.3": "11.1.1",
"officeparser>pdfjs-dist": "6.2.108"
},
"patchedDependencies": {
"image-size@1.2.1": "patches/image-size@1.2.1.patch"
Expand Down
1 change: 0 additions & 1 deletion packages/daemon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
"@modelcontextprotocol/sdk": "^1.30.0",
"@xmldom/xmldom": "0.9.12",
"docx": "9.7.1",
"image-size": "1.2.1",
"jszip": "3.10.1",
"officeparser": "7.8.0",
"pdf-parse": "2.4.5",
Expand Down
157 changes: 157 additions & 0 deletions packages/daemon/src/features/media/image-inspection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import type { MediaProjectAssetContentType } from "@ling-stack/core";

export interface SupportedImageInspection {
readonly contentType: MediaProjectAssetContentType;
readonly width: number;
readonly height: number;
}

export function inspectSupportedImage(content: Buffer): SupportedImageInspection | undefined {
if (content.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"))) {
return inspectPng(content);
}
if (content.length >= 2 && content[0] === 0xff && content[1] === 0xd8) {
return inspectJpeg(content);
}
if (
content.length >= 12 &&
content.toString("ascii", 0, 4) === "RIFF" &&
content.toString("ascii", 8, 12) === "WEBP"
) {
return inspectWebp(content);
}
return undefined;
}

function inspectPng(content: Buffer): SupportedImageInspection | undefined {
let offset = 8;
let dimensions: { readonly width: number; readonly height: number } | undefined;
let sawImageData = false;
while (offset + 12 <= content.length) {
const length = content.readUInt32BE(offset);
const type = content.toString("ascii", offset + 4, offset + 8);
const nextOffset = offset + 12 + length;
if (nextOffset > content.length) return undefined;
if (!dimensions) {
if (type !== "IHDR" || length !== 13) return undefined;
dimensions = {
width: content.readUInt32BE(offset + 8),
height: content.readUInt32BE(offset + 12),
};
} else if (type === "IDAT") {
sawImageData = true;
} else if (type === "IEND") {
if (length !== 0 || !sawImageData || nextOffset !== content.length) return undefined;
return { contentType: "image/png", ...dimensions };
}
offset = nextOffset;
}
return undefined;
}

function inspectJpeg(content: Buffer): SupportedImageInspection | undefined {
if (content.length < 8 || content[content.length - 2] !== 0xff || content[content.length - 1] !== 0xd9) {
return undefined;
}
let offset = 2;
let dimensions: { readonly width: number; readonly height: number } | undefined;
while (offset + 2 <= content.length - 2) {
if (content[offset] !== 0xff) return undefined;
while (offset < content.length && content[offset] === 0xff) offset += 1;
if (offset >= content.length - 2) return undefined;
const marker = content[offset];
offset += 1;
if (marker === 0xda) {
if (offset + 2 > content.length - 2) return undefined;
const length = content.readUInt16BE(offset);
if (!dimensions || length < 2 || offset + length > content.length - 2) return undefined;
return { contentType: "image/jpeg", ...dimensions };
}
if (marker === 0xd8 || marker === 0xd9 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
continue;
}
if (offset + 2 > content.length - 2) return undefined;
const length = content.readUInt16BE(offset);
if (length < 2 || offset + length > content.length - 2) return undefined;
if (isJpegStartOfFrame(marker)) {
if (length < 7) return undefined;
const height = content.readUInt16BE(offset + 3);
const width = content.readUInt16BE(offset + 5);
if (width === 0 || height === 0) return undefined;
dimensions ??= { width, height };
}
offset += length;
}
return undefined;
}

function isJpegStartOfFrame(marker: number): boolean {
return (
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf)
);
}

function inspectWebp(content: Buffer): SupportedImageInspection | undefined {
if (content.length < 20 || content.readUInt32LE(4) + 8 !== content.length) return undefined;
let offset = 12;
let dimensions: { readonly width: number; readonly height: number } | undefined;
while (offset + 8 <= content.length) {
const type = content.toString("ascii", offset, offset + 4);
const length = content.readUInt32LE(offset + 4);
const nextOffset = offset + 8 + length + (length % 2);
if (nextOffset > content.length) return undefined;
const dataOffset = offset + 8;
const chunkDimensions = readWebpChunkDimensions(content, type, dataOffset, length);
if (chunkDimensions === false) return undefined;
dimensions ??= chunkDimensions || undefined;
offset = nextOffset;
}
if (offset !== content.length || !dimensions || dimensions.width === 0 || dimensions.height === 0) {
return undefined;
}
return { contentType: "image/webp", ...dimensions };
}

function readWebpChunkDimensions(
content: Buffer,
type: string,
dataOffset: number,
length: number,
): { readonly width: number; readonly height: number } | false | undefined {
if (type === "VP8X") {
if (length < 10) return false;
return {
width: 1 + content.readUIntLE(dataOffset + 4, 3),
height: 1 + content.readUIntLE(dataOffset + 7, 3),
};
}
if (type === "VP8 ") {
if (
length < 10 ||
content[dataOffset + 3] !== 0x9d ||
content[dataOffset + 4] !== 0x01 ||
content[dataOffset + 5] !== 0x2a
) {
return false;
}
return {
width: content.readUInt16LE(dataOffset + 6) & 0x3fff,
height: content.readUInt16LE(dataOffset + 8) & 0x3fff,
};
}
if (type === "VP8L") {
if (length < 5 || content[dataOffset] !== 0x2f) return false;
const b1 = content[dataOffset + 1];
const b2 = content[dataOffset + 2];
const b3 = content[dataOffset + 3];
const b4 = content[dataOffset + 4];
return {
width: 1 + (b1 | ((b2 & 0x3f) << 8)),
height: 1 + ((b2 >> 6) | (b3 << 2) | ((b4 & 0x0f) << 10)),
};
}
return undefined;
}
103 changes: 6 additions & 97 deletions packages/daemon/src/features/media/project-asset-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
type MediaProjectAssetContentType,
type RunStore,
} from "@ling-stack/core";
import { imageSize } from "image-size";
import type { ResolvedDaemonConfig } from "../../config.js";
import { HttpError } from "../../errors.js";
import type { RunStoreRegistry } from "../../runtime/store-registry.js";
Expand All @@ -28,6 +27,7 @@ import { isPathInsideRoot, normalizeArtifactPath } from "../../shared/path.js";
import { setCorsHeaders, writeJson } from "../../shared/response.js";
import { isRecord, readString } from "../../shared/validation.js";
import { resolveArtifactContentPath } from "../runs/artifacts.js";
import { inspectSupportedImage } from "./image-inspection.js";

const MEDIA_PROJECT_ASSET_MAX_BYTES = 25 * 1024 * 1024;
const MEDIA_PROJECT_ASSET_REQUEST_MAX_BYTES = 40 * 1024 * 1024;
Expand Down Expand Up @@ -297,21 +297,15 @@ async function validateImageBytes(
declaredContentType: string | undefined,
): Promise<{ readonly contentType: MediaProjectAssetContentType; readonly width: number; readonly height: number }> {
assertMediaProjectAssetByteLength(content.byteLength);
let dimensions: { readonly type?: string; readonly width?: number; readonly height?: number };
try {
dimensions = imageSize(content);
} catch {
throw new HttpError(400, "invalid_media_asset", "无法识别图片内容,请上传有效的 JPEG、PNG 或 WebP。");
}
const contentType = contentTypeForImageType(dimensions.type);
if (!contentType || !dimensions.width || !dimensions.height || !hasValidImageContainer(content, contentType)) {
const inspection = inspectSupportedImage(content);
if (!inspection) {
throw new HttpError(400, "invalid_media_asset", "图片结构无效,只支持完整的 JPEG、PNG 或 WebP 图片。");
}
if (declaredContentType && declaredContentType !== contentType) {
if (declaredContentType && declaredContentType !== inspection.contentType) {
throw new HttpError(400, "invalid_media_asset", "图片声明格式与实际内容不一致。");
}
assertMediaProjectAssetDimensions(dimensions.width, dimensions.height);
return { contentType, width: dimensions.width, height: dimensions.height };
assertMediaProjectAssetDimensions(inspection.width, inspection.height);
return inspection;
}

function handleMediaProjectAssetContent(
Expand Down Expand Up @@ -423,91 +417,6 @@ function normalizeDisplayFileName(fileName: string, extension: string): string {
return extname(fileName) ? fileName : `${fileName}${extension}`;
}

function contentTypeForImageType(type: string | undefined): MediaProjectAssetContentType | undefined {
if (type === "jpg" || type === "jpeg") return "image/jpeg";
if (type === "png") return "image/png";
if (type === "webp") return "image/webp";
return undefined;
}

function hasValidImageContainer(content: Buffer, contentType: MediaProjectAssetContentType): boolean {
if (contentType === "image/png") return hasValidPngContainer(content);
if (contentType === "image/jpeg") return hasValidJpegContainer(content);
return hasValidWebpContainer(content);
}

function hasValidPngContainer(content: Buffer): boolean {
if (!content.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"))) return false;
let offset = 8;
let sawHeader = false;
let sawImageData = false;
while (offset + 12 <= content.length) {
const length = content.readUInt32BE(offset);
const type = content.toString("ascii", offset + 4, offset + 8);
const nextOffset = offset + 12 + length;
if (nextOffset > content.length) return false;
if (!sawHeader) {
if (type !== "IHDR" || length !== 13) return false;
sawHeader = true;
} else if (type === "IDAT") {
sawImageData = true;
} else if (type === "IEND") {
return length === 0 && sawImageData && nextOffset === content.length;
}
offset = nextOffset;
}
return false;
}

function hasValidJpegContainer(content: Buffer): boolean {
if (content.length < 8 || content[0] !== 0xff || content[1] !== 0xd8) return false;
if (content[content.length - 2] !== 0xff || content[content.length - 1] !== 0xd9) return false;
let offset = 2;
let sawFrame = false;
while (offset + 4 <= content.length - 2) {
if (content[offset] !== 0xff) return false;
while (offset < content.length && content[offset] === 0xff) offset += 1;
const marker = content[offset];
offset += 1;
if (marker === 0xda) {
if (offset + 2 > content.length - 2) return false;
const length = content.readUInt16BE(offset);
return sawFrame && length >= 2 && offset + length <= content.length - 2;
}
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) continue;
if (offset + 2 > content.length - 2) return false;
const length = content.readUInt16BE(offset);
if (length < 2 || offset + length > content.length - 2) return false;
if ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)) {
sawFrame = true;
}
offset += length;
}
return false;
}

function hasValidWebpContainer(content: Buffer): boolean {
if (
content.length < 20 ||
content.toString("ascii", 0, 4) !== "RIFF" ||
content.toString("ascii", 8, 12) !== "WEBP" ||
content.readUInt32LE(4) + 8 !== content.length
) {
return false;
}
let offset = 12;
let sawImageChunk = false;
while (offset + 8 <= content.length) {
const type = content.toString("ascii", offset, offset + 4);
const length = content.readUInt32LE(offset + 4);
const nextOffset = offset + 8 + length + (length % 2);
if (nextOffset > content.length) return false;
if (type === "VP8 " || type === "VP8L" || type === "VP8X") sawImageChunk = true;
offset = nextOffset;
}
return sawImageChunk && offset === content.length;
}

function extensionForContentType(contentType: MediaProjectAssetContentType): string {
if (contentType === "image/jpeg") return ".jpg";
if (contentType === "image/webp") return ".webp";
Expand Down
15 changes: 15 additions & 0 deletions packages/daemon/test/dependency-security.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import { readFileSync } from "node:fs";
import test from "node:test";

const localRequire = createRequire(import.meta.url);

test("officeparser resolves a non-vulnerable pdfjs-dist version", () => {
const officeParserEntry = localRequire.resolve("officeparser");
const officeParserRequire = createRequire(resolve(dirname(officeParserEntry), "..", "package.json"));
const pdfjsEntry = officeParserRequire.resolve("pdfjs-dist/legacy/build/pdf.mjs");
const pdfjsPackage = JSON.parse(readFileSync(resolve(dirname(pdfjsEntry), "..", "..", "package.json"), "utf8"));
assert.equal(pdfjsPackage.version, "6.2.108");
});
Loading
Loading