Skip to content

Commit e90de33

Browse files
authored
Merge pull request #2777 from ryanfowler/fix/bugs-security-cleanup
fix: correctness bugs, security hardening, and code cleanup
2 parents 87f6cdb + 3c2cc74 commit e90de33

7 files changed

Lines changed: 104 additions & 37 deletions

File tree

lib/fetch.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,18 @@ export class Client {
6464
let redirectCount = 0;
6565

6666
while (true) {
67-
// Validate host before each request (initial + redirects)
67+
// Validate host before each request (initial + redirects).
68+
// Note: There is a TOCTOU window between validateHost and makeRequest
69+
// where DNS could resolve to a different IP (DNS rebinding). Fully fixing
70+
// this requires IP-pinning at the socket level, which is complex with TLS.
6871
await this.validateHost(currentUrl);
6972

7073
const res = await this.makeRequest(currentUrl);
7174

7275
// Check for redirect responses
7376
if (res.status >= 300 && res.status < 400) {
7477
if (redirectCount >= MAX_REDIRECTS) {
75-
throw new HttpError(400, "fetch: too many redirects");
78+
throw new HttpError(502, "fetch: too many redirects");
7679
}
7780

7881
const location = res.headers.get("location");
@@ -99,7 +102,7 @@ export class Client {
99102
throw new HttpError(502, `fetch: received status ${res.status}`);
100103
}
101104
if (!res.body) {
102-
throw new HttpError(400, "fetch: no body in response");
105+
throw new HttpError(502, "fetch: no body in response");
103106
}
104107

105108
return res;
@@ -115,7 +118,7 @@ export class Client {
115118
redirect: "manual",
116119
});
117120
} catch {
118-
throw new HttpError(400, "fetch: unable to make request");
121+
throw new HttpError(502, "fetch: unable to make request");
119122
}
120123

121124
return res;

lib/image.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,18 @@ describe("ImageEngine", () => {
400400
).rejects.toThrow(/encoding type svg is not supported/);
401401
});
402402

403+
test("honors effort: 0 for WebP", async () => {
404+
// effort: 0 is valid for WebP (range 0-6), must not be replaced by preset default
405+
const result = await engine.perform({
406+
data: jpegBuffer,
407+
format: ImageType.Webp,
408+
effort: 0,
409+
});
410+
411+
expect(result.format).toBe(ImageType.Webp);
412+
expect(detectImageFormat(result.data)).toBe(ImageType.Webp);
413+
});
414+
403415
test("handles lossless WebP", async () => {
404416
const result = await engine.perform({
405417
data: pngBuffer,

lib/image.ts

Lines changed: 13 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,8 @@ export class ImageEngine {
166166

167167
return {
168168
format: input,
169-
width: meta.autoOrient?.width || meta.width,
170-
height: meta.autoOrient?.height || meta.height,
169+
width: meta.autoOrient?.width ?? meta.width,
170+
height: meta.autoOrient?.height ?? meta.height,
171171
size: ops.data.length,
172172
space: meta.space,
173173
channels: meta.channels,
@@ -198,8 +198,8 @@ function applyFormat(img: sharp.Sharp, ops: ImageOptions): sharp.Sharp {
198198
switch (ops.format) {
199199
case ImageType.Avif:
200200
return img.avif({
201-
quality: ops.quality || preset.quality,
202-
effort: ops.effort || preset.effort,
201+
quality: ops.quality ?? preset.quality,
202+
effort: ops.effort ?? preset.effort,
203203
chromaSubsampling: preset.chromaSubsampling,
204204
lossless: ops.lossless,
205205
});
@@ -208,23 +208,23 @@ function applyFormat(img: sharp.Sharp, ops: ImageOptions): sharp.Sharp {
208208
case ImageType.Heic:
209209
return img.heif({
210210
compression: "hevc",
211-
quality: ops.quality || preset.quality,
212-
effort: ops.effort || preset.effort,
211+
quality: ops.quality ?? preset.quality,
212+
effort: ops.effort ?? preset.effort,
213213
chromaSubsampling: preset.chromaSubsampling,
214214
lossless: ops.lossless,
215215
});
216216
case ImageType.Jpeg:
217217
return img.jpeg({
218-
quality: ops.quality || preset.quality,
219-
progressive: ops.progressive || preset.progressive,
218+
quality: ops.quality ?? preset.quality,
219+
progressive: ops.progressive ?? preset.progressive,
220220
chromaSubsampling: preset.chromaSubsampling,
221221
mozjpeg: preset.mozjpeg,
222222
optimiseCoding: preset.optimiseCoding,
223223
});
224224
case ImageType.JpegXL:
225225
return img.jxl({
226-
quality: ops.quality || preset.quality,
227-
effort: ops.effort || preset.effort,
226+
quality: ops.quality ?? preset.quality,
227+
effort: ops.effort ?? preset.effort,
228228
decodingTier: preset.decodingTier,
229229
lossless: ops.lossless,
230230
});
@@ -245,15 +245,15 @@ function applyFormat(img: sharp.Sharp, ops: ImageOptions): sharp.Sharp {
245245
throw new HttpError(400, "image: encoding type svg is not supported");
246246
case ImageType.Tiff:
247247
return img.tiff({
248-
quality: ops.quality || preset.quality,
248+
quality: ops.quality ?? preset.quality,
249249
compression: preset.compression,
250250
predictor: preset.predictor,
251251
});
252252
case ImageType.Webp:
253253
return img.webp({
254-
quality: ops.quality || preset.quality,
254+
quality: ops.quality ?? preset.quality,
255255
lossless: ops.lossless,
256-
effort: ops.effort || preset.effort,
256+
effort: ops.effort ?? preset.effort,
257257
smartSubsample: preset.smartSubsample,
258258
smartDeblock: preset.smartDeblock,
259259
alphaQuality: preset.alphaQuality,
@@ -316,21 +316,6 @@ export function detectImageFormat(buf: Uint8Array): ImageType {
316316
return ImageType.Tiff;
317317
}
318318

319-
// BMP
320-
// if (buf[0] === 0x42 && buf[1] === 0x4d) {
321-
// return ImageType.Bmp;
322-
// }
323-
324-
// ICO
325-
// if (buf[0] === 0x00 && buf[1] === 0x00 && buf[2] === 0x01 && buf[3] === 0x00) {
326-
// return ImageType.Ico;
327-
// }
328-
329-
// PSD
330-
// if (buf[0] === 0x38 && buf[1] === 0x42 && buf[2] === 0x50 && buf[3] === 0x53) {
331-
// return ImageType.Psd;
332-
// }
333-
334319
// JPEG XL
335320
if (
336321
(buf[0] === 0xff && buf[1] === 0x0a) ||
@@ -358,8 +343,6 @@ export function detectImageFormat(buf: Uint8Array): ImageType {
358343
if (brand.startsWith("heic")) return ImageType.Heic;
359344
if (brand.startsWith("heix")) return ImageType.Heic;
360345
if (brand.startsWith("hevc")) return ImageType.Heic;
361-
// if (brand.startsWith("heif")) return ImageType.HEIF;
362-
// if (brand.startsWith("mif1")) return ImageType.HEIF;
363346
}
364347

365348
// PDF

lib/pipeline.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, test, expect, mock } from "bun:test";
22
import { PipelineExecutor } from "./pipeline.ts";
33
import { HttpError, type PipelineConfig, type S3Config, ImageType } from "./types.ts";
4-
import { getMimetype } from "./validation.ts";
4+
import { getMimetype, validateContentType } from "./validation.ts";
55

66
// Mock S3 config for testing
77
const mockS3Config: S3Config = {
@@ -72,6 +72,48 @@ describe("getMimetype", () => {
7272
});
7373
});
7474

75+
describe("validateContentType", () => {
76+
test("accepts image/jpeg", () => {
77+
expect(validateContentType("image/jpeg", "test")).toBe("image/jpeg");
78+
});
79+
80+
test("accepts image/png", () => {
81+
expect(validateContentType("image/png", "test")).toBe("image/png");
82+
});
83+
84+
test("accepts image/webp", () => {
85+
expect(validateContentType("image/webp", "test")).toBe("image/webp");
86+
});
87+
88+
test("accepts application/pdf", () => {
89+
expect(validateContentType("application/pdf", "test")).toBe("application/pdf");
90+
});
91+
92+
test("accepts application/octet-stream", () => {
93+
expect(validateContentType("application/octet-stream", "test")).toBe(
94+
"application/octet-stream",
95+
);
96+
});
97+
98+
test("rejects text/html", () => {
99+
expect(() => validateContentType("text/html", "test")).toThrow(
100+
"content type 'text/html' is not allowed",
101+
);
102+
});
103+
104+
test("rejects text/javascript", () => {
105+
expect(() => validateContentType("text/javascript", "test")).toThrow(
106+
"content type 'text/javascript' is not allowed",
107+
);
108+
});
109+
110+
test("rejects arbitrary content type", () => {
111+
expect(() => validateContentType("application/json", "test")).toThrow(
112+
"content type 'application/json' is not allowed",
113+
);
114+
});
115+
});
116+
75117
describe("PipelineExecutor", () => {
76118
// Create mock dependencies
77119
const createMockEngine = () => ({

lib/pipeline.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from "./types.ts";
1414
import {
1515
getMimetype,
16+
validateContentType,
1617
validateBlurStrict,
1718
validateBooleanStrict,
1819
validateDimensionStrict,
@@ -229,7 +230,9 @@ export class PipelineExecutor {
229230
});
230231

231232
// Upload to S3
232-
const contentType = task.output.contentType || getMimetype(result.format);
233+
const contentType = task.output.contentType
234+
? validateContentType(task.output.contentType, `task '${task.id}'`)
235+
: getMimetype(result.format);
233236
await uploadToS3(
234237
result.data,
235238
task.output.bucket,

lib/s3.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export async function uploadToS3(
5252
): Promise<void> {
5353
const file = client.file(key, {
5454
bucket,
55-
acl: options.acl as undefined, // Hack the type system
55+
...(options.acl != null && { acl: options.acl as "private" }),
5656
type: options.contentType,
5757
});
5858

lib/validation.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,30 @@ export function normalizeFormat(value: string): string {
9999
return v;
100100
}
101101

102+
// Allowed content types for S3 uploads
103+
const ALLOWED_CONTENT_TYPES = new Set([
104+
"image/avif",
105+
"image/gif",
106+
"image/heic",
107+
"image/jpeg",
108+
"image/jxl",
109+
"image/png",
110+
"image/svg+xml",
111+
"image/tiff",
112+
"image/webp",
113+
"application/pdf",
114+
"application/octet-stream",
115+
]);
116+
117+
// Validate that a content type is an allowed image MIME type.
118+
// Returns the validated content type, or throws HttpError for disallowed types.
119+
export function validateContentType(contentType: string, prefix: string): string {
120+
if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
121+
throw new HttpError(400, `${prefix}: content type '${contentType}' is not allowed`);
122+
}
123+
return contentType;
124+
}
125+
102126
// Get mime type for image format
103127
export function getMimetype(format: ImageType): string {
104128
switch (format) {

0 commit comments

Comments
 (0)