diff --git a/package.json b/package.json index f2602533d..b00467b45 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 636dc604a..9db894c85 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -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", diff --git a/packages/daemon/src/features/media/image-inspection.ts b/packages/daemon/src/features/media/image-inspection.ts new file mode 100644 index 000000000..a7eb22345 --- /dev/null +++ b/packages/daemon/src/features/media/image-inspection.ts @@ -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; +} diff --git a/packages/daemon/src/features/media/project-asset-routes.ts b/packages/daemon/src/features/media/project-asset-routes.ts index 50a433e15..defb23159 100644 --- a/packages/daemon/src/features/media/project-asset-routes.ts +++ b/packages/daemon/src/features/media/project-asset-routes.ts @@ -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"; @@ -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; @@ -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( @@ -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"; diff --git a/packages/daemon/test/dependency-security.test.mjs b/packages/daemon/test/dependency-security.test.mjs new file mode 100644 index 000000000..947ce79e1 --- /dev/null +++ b/packages/daemon/test/dependency-security.test.mjs @@ -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"); +}); diff --git a/packages/daemon/test/media-image-input-limits.test.mjs b/packages/daemon/test/media-image-input-limits.test.mjs index 04f3c9287..753bf6791 100644 --- a/packages/daemon/test/media-image-input-limits.test.mjs +++ b/packages/daemon/test/media-image-input-limits.test.mjs @@ -4,6 +4,7 @@ import { assertMediaProjectAssetByteLength, assertMediaProjectAssetDimensions, } from "../dist/features/media/project-asset-routes.js"; +import { inspectSupportedImage } from "../dist/features/media/image-inspection.js"; import { assertProviderImageInputTotalBytes } from "../dist/features/media/video-image-materializer.js"; const MIB = 1024 * 1024; @@ -42,6 +43,84 @@ test("provider image aggregate byte limit includes 45 MiB and rejects the next b assertInvalidMediaAsset(() => assertProviderImageInputTotalBytes(45 * MIB + 1), /45 MiB/); }); +test("supported image dimension parsing covers PNG, JPEG, and WebP without image-size", () => { + assert.deepEqual(inspectSupportedImage(createPngFixture(1280, 720)), { + contentType: "image/png", + width: 1280, + height: 720, + }); + assert.deepEqual(inspectSupportedImage(createJpegFixture(1280, 720)), { + contentType: "image/jpeg", + width: 1280, + height: 720, + }); + assert.deepEqual(inspectSupportedImage(createWebpFixture(1280, 720)), { + contentType: "image/webp", + width: 1280, + height: 720, + }); +}); + +test("supported image dimension parsing rejects truncated containers", () => { + assert.equal(inspectSupportedImage(Buffer.from("89504e470d0a1a0a", "hex")), undefined); + const zeroLengthWebpBox = Buffer.alloc(20); + zeroLengthWebpBox.write("RIFF", 0, "ascii"); + zeroLengthWebpBox.writeUInt32LE(12, 4); + zeroLengthWebpBox.write("WEBP", 8, "ascii"); + zeroLengthWebpBox.write("meta", 12, "ascii"); + assert.equal(inspectSupportedImage(zeroLengthWebpBox), undefined); +}); + +function createPngFixture(width, height) { + const chunk = (type, data) => { + const result = Buffer.alloc(12 + data.length); + result.writeUInt32BE(data.length, 0); + result.write(type, 4, "ascii"); + data.copy(result, 8); + return result; + }; + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 2; + return Buffer.concat([ + Buffer.from("89504e470d0a1a0a", "hex"), + chunk("IHDR", ihdr), + chunk("IDAT", Buffer.alloc(0)), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +function createJpegFixture(width, height) { + const sof = Buffer.alloc(9); + sof[0] = 8; + sof.writeUInt16BE(height, 1); + sof.writeUInt16BE(width, 3); + sof[5] = 3; + sof[6] = 1; + sof[7] = 0x11; + sof[8] = 0; + return Buffer.concat([ + Buffer.from([0xff, 0xd8, 0xff, 0xc0, 0, 11]), + sof, + Buffer.from([0xff, 0xda, 0, 2, 0xff, 0xd9]), + ]); +} + +function createWebpFixture(width, height) { + const data = Buffer.alloc(10); + data.writeUIntLE(width - 1, 4, 3); + data.writeUIntLE(height - 1, 7, 3); + const chunk = Buffer.alloc(18); + chunk.write("VP8X", 0, "ascii"); + chunk.writeUInt32LE(data.length, 4); + data.copy(chunk, 8); + const result = Buffer.concat([Buffer.from("RIFF"), Buffer.alloc(4), Buffer.from("WEBP"), chunk]); + result.writeUInt32LE(result.length - 8, 4); + return result; +} + function assertInvalidMediaAsset(action, messagePattern) { assert.throws( action, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63e32b278..0b20dfc84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,7 @@ overrides: 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 + officeparser>pdfjs-dist: 6.2.108 patchedDependencies: image-size@1.2.1: @@ -371,9 +372,6 @@ importers: docx: specifier: 9.7.1 version: 9.7.1 - image-size: - specifier: 1.2.1 - version: 1.2.1(patch_hash=43bb822cac079e46add6c6bbaed80033abe5280c5b09c066254f8354478e6377) jszip: specifier: 3.10.1 version: 3.10.1 @@ -5147,8 +5145,8 @@ packages: resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} engines: {node: '>=20.16.0 || >=22.3.0'} - pdfjs-dist@6.1.200: - resolution: {integrity: sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==} + pdfjs-dist@6.2.108: + resolution: {integrity: sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==} engines: {node: '>=22.13.0 || >=24'} pe-library@0.4.1: @@ -11829,7 +11827,7 @@ snapshots: '@xmldom/xmldom': 0.9.12 fflate: 0.8.3 file-type: 22.0.2 - pdfjs-dist: 6.1.200 + pdfjs-dist: 6.2.108 tesseract.js: 7.0.0 transitivePeerDependencies: - encoding @@ -11943,7 +11941,7 @@ snapshots: optionalDependencies: '@napi-rs/canvas': 0.1.80 - pdfjs-dist@6.1.200: + pdfjs-dist@6.2.108: optionalDependencies: '@napi-rs/canvas': 1.0.7