fix: execute sandboxed file ops inside containers (#4026)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 795ec6aa2f
Co-authored-by: davidbors-snyk <240482518+davidbors-snyk@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
This commit is contained in:
davidbors-snyk
2026-02-13 17:29:10 +02:00
committed by GitHub
parent 1def8c5448
commit 29d7839582
20 changed files with 862 additions and 152 deletions

View File

@@ -29,6 +29,22 @@ function buildDeterministicBytes(length: number): Buffer {
return buffer;
}
async function createLargeTestJpeg(): Promise<{ buffer: Buffer; file: string }> {
const buffer = await sharp({
create: {
width: 1600,
height: 1600,
channels: 3,
background: "#ff0000",
},
})
.jpeg({ quality: 95 })
.toBuffer();
const file = await writeTempFile(buffer, ".jpg");
return { buffer, file };
}
afterEach(async () => {
await Promise.all(tmpFiles.map((file) => fs.rm(file, { force: true })));
tmpFiles.length = 0;
@@ -70,6 +86,25 @@ describe("web media loading", () => {
expect(result.buffer.length).toBeLessThan(buffer.length);
});
it("optimizes images when options object omits optimizeImages", async () => {
const { buffer, file } = await createLargeTestJpeg();
const cap = Math.max(1, Math.floor(buffer.length * 0.8));
const result = await loadWebMedia(file, { maxBytes: cap });
expect(result.buffer.length).toBeLessThanOrEqual(cap);
expect(result.buffer.length).toBeLessThan(buffer.length);
});
it("allows callers to disable optimization via options object", async () => {
const { buffer, file } = await createLargeTestJpeg();
const cap = Math.max(1, Math.floor(buffer.length * 0.8));
await expect(loadWebMedia(file, { maxBytes: cap, optimizeImages: false })).rejects.toThrow(
/Media exceeds/i,
);
});
it("sniffs mime before extension when loading local files", async () => {
const pngBuffer = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#00ff00" },

View File

@@ -28,6 +28,7 @@ type WebMediaOptions = {
ssrfPolicy?: SsrFPolicy;
/** Allowed root directories for local path reads. "any" skips the check (caller already validated). */
localRoots?: string[] | "any";
readFile?: (filePath: string) => Promise<Buffer>;
};
function getDefaultLocalRoots(): string[] {
@@ -165,7 +166,13 @@ async function loadWebMediaInternal(
mediaUrl: string,
options: WebMediaOptions = {},
): Promise<WebMediaResult> {
const { maxBytes, optimizeImages = true, ssrfPolicy, localRoots } = options;
const {
maxBytes,
optimizeImages = true,
ssrfPolicy,
localRoots,
readFile: readFileOverride,
} = options;
// Use fileURLToPath for proper handling of file:// URLs (handles file://localhost/path, etc.)
if (mediaUrl.startsWith("file://")) {
try {
@@ -267,7 +274,7 @@ async function loadWebMediaInternal(
await assertLocalMediaAllowed(mediaUrl, localRoots);
// Local path
const data = await fs.readFile(mediaUrl);
const data = readFileOverride ? await readFileOverride(mediaUrl) : await fs.readFile(mediaUrl);
const mime = await detectMime({ buffer: data, filePath: mediaUrl });
const kind = mediaKindFromMime(mime);
let fileName = path.basename(mediaUrl) || undefined;
@@ -287,27 +294,39 @@ async function loadWebMediaInternal(
export async function loadWebMedia(
mediaUrl: string,
maxBytes?: number,
maxBytesOrOptions?: number | WebMediaOptions,
options?: { ssrfPolicy?: SsrFPolicy; localRoots?: string[] | "any" },
): Promise<WebMediaResult> {
if (typeof maxBytesOrOptions === "number" || maxBytesOrOptions === undefined) {
return await loadWebMediaInternal(mediaUrl, {
maxBytes: maxBytesOrOptions,
optimizeImages: true,
ssrfPolicy: options?.ssrfPolicy,
localRoots: options?.localRoots,
});
}
return await loadWebMediaInternal(mediaUrl, {
maxBytes,
optimizeImages: true,
ssrfPolicy: options?.ssrfPolicy,
localRoots: options?.localRoots,
...maxBytesOrOptions,
optimizeImages: maxBytesOrOptions.optimizeImages ?? true,
});
}
export async function loadWebMediaRaw(
mediaUrl: string,
maxBytes?: number,
maxBytesOrOptions?: number | WebMediaOptions,
options?: { ssrfPolicy?: SsrFPolicy; localRoots?: string[] | "any" },
): Promise<WebMediaResult> {
if (typeof maxBytesOrOptions === "number" || maxBytesOrOptions === undefined) {
return await loadWebMediaInternal(mediaUrl, {
maxBytes: maxBytesOrOptions,
optimizeImages: false,
ssrfPolicy: options?.ssrfPolicy,
localRoots: options?.localRoots,
});
}
return await loadWebMediaInternal(mediaUrl, {
maxBytes,
...maxBytesOrOptions,
optimizeImages: false,
ssrfPolicy: options?.ssrfPolicy,
localRoots: options?.localRoots,
});
}