Skip to content

Commit e3cd5bb

Browse files
marcstraubeclaude
andauthored
feat(request): add Content-Type response validation (#56)
## Summary - Add built-in MIME type validation for fetch responses (defense-in-depth against MIME confusion attacks) - Configurable via `expectedContentType` on instance level and per-request override via middleware - Case-insensitive matching, parameters (charset etc.) stripped automatically - Fails closed: missing Content-Type header with validation enabled throws `CONTENT_TYPE_MISMATCH` - 18 new tests (unit + integration) Closes #4 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 48dfffa commit e3cd5bb

5 files changed

Lines changed: 343 additions & 5 deletions

File tree

src/request/RequestInterceptor.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@ import {
5252
runErrorMiddleware,
5353
emitTiming,
5454
} from './RequestMiddleware.js';
55-
import { validateUrl, validateCredentialOrigin, combineAbortSignals } from './RequestValidation.js';
55+
import {
56+
validateUrl,
57+
validateCredentialOrigin,
58+
combineAbortSignals,
59+
validateContentType,
60+
} from './RequestValidation.js';
5661

5762
// =============================================================================
5863
// Error Types
@@ -71,7 +76,8 @@ export type RequestErrorCode =
7176
| 'TIMEOUT'
7277
| 'ABORTED'
7378
| 'CREDENTIAL_LEAK'
74-
| 'SSRF_BLOCKED';
79+
| 'SSRF_BLOCKED'
80+
| 'CONTENT_TYPE_MISMATCH';
7581

7682
/**
7783
* Request-specific error.
@@ -132,6 +138,18 @@ export class RequestError extends BrowserUtilsError {
132138
`Request to private IP address blocked for SSRF protection: ${hostname}`
133139
);
134140
}
141+
142+
static contentTypeMismatch(
143+
expected: string | readonly string[],
144+
actual: string | null
145+
): RequestError {
146+
const expectedStr = Array.isArray(expected) ? expected.join(', ') : String(expected);
147+
const actualStr = actual ?? '(none)';
148+
return new RequestError(
149+
'CONTENT_TYPE_MISMATCH',
150+
`Response Content-Type mismatch: expected "${expectedStr}" but received "${actualStr}"`
151+
);
152+
}
135153
}
136154

137155
// =============================================================================
@@ -189,6 +207,8 @@ export interface RequestConfig {
189207
readonly signal?: AbortSignal;
190208
/** Additional metadata for middleware */
191209
readonly metadata?: Readonly<Record<string, unknown>>;
210+
/** Expected Content-Type for the response (overrides instance default) */
211+
readonly expectedContentType?: string | readonly string[];
192212
}
193213

194214
/**
@@ -209,6 +229,8 @@ export interface MutableRequestConfig {
209229
signal?: AbortSignal;
210230
/** Additional metadata for middleware */
211231
metadata?: Record<string, unknown>;
232+
/** Expected Content-Type for the response (overrides instance default) */
233+
expectedContentType?: string | readonly string[];
212234
}
213235

214236
/**
@@ -298,6 +320,18 @@ export interface RequestInterceptorConfig {
298320
* cannot be detected. This provides defense-in-depth for direct IP access.
299321
*/
300322
readonly blockPrivateIPs?: boolean;
323+
/**
324+
* Expected Content-Type for responses (MIME type validation).
325+
* Validates that the response Content-Type header matches.
326+
* Comparison is case-insensitive on type/subtype; parameters
327+
* (e.g., charset) are ignored.
328+
*
329+
* Can be a single MIME type or an array of accepted types.
330+
* Fails closed: missing Content-Type header with this set will throw.
331+
*
332+
* Default: undefined (no validation).
333+
*/
334+
readonly expectedContentType?: string | readonly string[];
301335
}
302336

303337
/**
@@ -358,11 +392,12 @@ export interface RequestInterceptorInstance {
358392
// =============================================================================
359393

360394
const DEFAULT_CONFIG: Required<
361-
Omit<RequestInterceptorConfig, 'auth' | 'baseUrl' | 'blockedPatterns'>
395+
Omit<RequestInterceptorConfig, 'auth' | 'baseUrl' | 'blockedPatterns' | 'expectedContentType'>
362396
> & {
363397
auth: AuthConfig | null;
364398
baseUrl: string;
365399
blockedPatterns: readonly RegExp[];
400+
expectedContentType: string | readonly string[] | undefined;
366401
} = {
367402
baseUrl: '',
368403
timeout: 30000,
@@ -373,6 +408,7 @@ const DEFAULT_CONFIG: Required<
373408
blockedPatterns: [],
374409
validateCredentialOrigin: true,
375410
blockPrivateIPs: false,
411+
expectedContentType: undefined,
376412
} as const;
377413

378414
/**
@@ -566,6 +602,7 @@ export const RequestInterceptor = {
566602
timeout: options.timeout,
567603
signal: init?.signal ?? undefined,
568604
metadata: {},
605+
expectedContentType: options.expectedContentType,
569606
};
570607
};
571608

@@ -612,6 +649,7 @@ export const RequestInterceptor = {
612649
timeout: config.timeout,
613650
signal: config.signal,
614651
metadata: config.metadata ? Object.freeze({ ...config.metadata }) : undefined,
652+
expectedContentType: config.expectedContentType,
615653
});
616654
};
617655

@@ -667,6 +705,11 @@ export const RequestInterceptor = {
667705

668706
interceptedResponse = await runResponseMiddleware(interceptedResponse, middlewares);
669707

708+
// Validate Content-Type if expected type is configured
709+
if (config.expectedContentType !== undefined) {
710+
validateContentType(interceptedResponse.headers, config.expectedContentType);
711+
}
712+
670713
emitTiming(
671714
{
672715
url: config.url,
@@ -778,6 +821,7 @@ export const RequestInterceptor = {
778821
blockedPatterns: Object.freeze([...options.blockedPatterns]),
779822
validateCredentialOrigin: options.validateCredentialOrigin,
780823
blockPrivateIPs: options.blockPrivateIPs,
824+
expectedContentType: options.expectedContentType,
781825
});
782826
},
783827

src/request/RequestValidation.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,41 @@ export function combineAbortSignals(signal1: AbortSignal, signal2: AbortSignal):
219219

220220
return controller.signal;
221221
}
222+
223+
/**
224+
* Extract base MIME type (type/subtype) from a Content-Type header,
225+
* stripping parameters like charset.
226+
*/
227+
function parseBaseContentType(contentType: string | null): string | null {
228+
if (contentType === null || contentType === '') {
229+
return null;
230+
}
231+
const semicolonIndex = contentType.indexOf(';');
232+
const base = semicolonIndex === -1 ? contentType : contentType.substring(0, semicolonIndex);
233+
const trimmed = base.trim().toLowerCase();
234+
return trimmed === '' ? null : trimmed;
235+
}
236+
237+
/**
238+
* Validate response Content-Type header against expected types.
239+
* Fails closed: missing Content-Type with expectedContentType set throws.
240+
*
241+
* @param responseHeaders - Response headers
242+
* @param expectedContentType - Expected MIME type(s)
243+
* @throws {RequestError} If Content-Type does not match expected type(s)
244+
*/
245+
export function validateContentType(
246+
responseHeaders: Headers,
247+
expectedContentType: string | readonly string[]
248+
): void {
249+
const rawContentType = responseHeaders.get('content-type');
250+
const actualBase = parseBaseContentType(rawContentType);
251+
252+
const expected = Array.isArray(expectedContentType) ? expectedContentType : [expectedContentType];
253+
254+
const normalizedExpected = expected.map((t) => t.toLowerCase().trim());
255+
256+
if (actualBase === null || !normalizedExpected.includes(actualBase)) {
257+
throw RequestError.contentTypeMismatch(expectedContentType, rawContentType);
258+
}
259+
}

src/request/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ export type {
1313
RequestInterceptorConfig,
1414
RequestInterceptorInstance,
1515
} from './RequestInterceptor.js';
16-
export { combineAbortSignals } from './RequestValidation.js';
16+
export { combineAbortSignals, validateContentType } from './RequestValidation.js';

tests/request/RequestInterceptor.test.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1170,6 +1170,201 @@ describe('RequestInterceptor', () => {
11701170
});
11711171
});
11721172

1173+
describe('Content-Type validation', () => {
1174+
it('should not validate Content-Type by default', async () => {
1175+
mockFetch.mockResolvedValueOnce(
1176+
new Response('hello', {
1177+
status: 200,
1178+
statusText: 'OK',
1179+
headers: { 'Content-Type': 'text/html' },
1180+
})
1181+
);
1182+
1183+
const api = RequestInterceptor.create({
1184+
baseUrl: 'https://api.example.com',
1185+
});
1186+
1187+
const response = await api.fetch('/page');
1188+
expect(response.status).toBe(200);
1189+
1190+
api.destroy();
1191+
});
1192+
1193+
it('should pass when response Content-Type matches expected type', async () => {
1194+
mockFetch.mockResolvedValueOnce(
1195+
new Response('{"ok":true}', {
1196+
status: 200,
1197+
statusText: 'OK',
1198+
headers: { 'Content-Type': 'application/json' },
1199+
})
1200+
);
1201+
1202+
const api = RequestInterceptor.create({
1203+
baseUrl: 'https://api.example.com',
1204+
expectedContentType: 'application/json',
1205+
});
1206+
1207+
const response = await api.fetch('/data');
1208+
expect(response.status).toBe(200);
1209+
1210+
api.destroy();
1211+
});
1212+
1213+
it('should pass when Content-Type has parameters', async () => {
1214+
mockFetch.mockResolvedValueOnce(
1215+
new Response('{"ok":true}', {
1216+
status: 200,
1217+
statusText: 'OK',
1218+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
1219+
})
1220+
);
1221+
1222+
const api = RequestInterceptor.create({
1223+
baseUrl: 'https://api.example.com',
1224+
expectedContentType: 'application/json',
1225+
});
1226+
1227+
const response = await api.fetch('/data');
1228+
expect(response.status).toBe(200);
1229+
1230+
api.destroy();
1231+
});
1232+
1233+
it('should throw CONTENT_TYPE_MISMATCH on mismatch', async () => {
1234+
mockFetch.mockResolvedValueOnce(
1235+
new Response('<html></html>', {
1236+
status: 200,
1237+
statusText: 'OK',
1238+
headers: { 'Content-Type': 'text/html' },
1239+
})
1240+
);
1241+
1242+
const api = RequestInterceptor.create({
1243+
baseUrl: 'https://api.example.com',
1244+
expectedContentType: 'application/json',
1245+
});
1246+
1247+
await expect(api.fetch('/page')).rejects.toThrow('Content-Type mismatch');
1248+
1249+
api.destroy();
1250+
});
1251+
1252+
it('should throw when response has no Content-Type (fail closed)', async () => {
1253+
mockFetch.mockResolvedValueOnce(
1254+
new Response('data', {
1255+
status: 200,
1256+
statusText: 'OK',
1257+
})
1258+
);
1259+
1260+
const api = RequestInterceptor.create({
1261+
baseUrl: 'https://api.example.com',
1262+
expectedContentType: 'application/json',
1263+
});
1264+
1265+
await expect(api.fetch('/data')).rejects.toThrow('Content-Type mismatch');
1266+
1267+
api.destroy();
1268+
});
1269+
1270+
it('should support array of expected types', async () => {
1271+
mockFetch.mockResolvedValueOnce(
1272+
new Response('plain text', {
1273+
status: 200,
1274+
statusText: 'OK',
1275+
headers: { 'Content-Type': 'text/plain' },
1276+
})
1277+
);
1278+
1279+
const api = RequestInterceptor.create({
1280+
baseUrl: 'https://api.example.com',
1281+
expectedContentType: ['application/json', 'text/plain'],
1282+
});
1283+
1284+
const response = await api.fetch('/data');
1285+
expect(response.status).toBe(200);
1286+
1287+
api.destroy();
1288+
});
1289+
1290+
it('should call error middleware on Content-Type mismatch', async () => {
1291+
mockFetch.mockResolvedValueOnce(
1292+
new Response('<html></html>', {
1293+
status: 200,
1294+
statusText: 'OK',
1295+
headers: { 'Content-Type': 'text/html' },
1296+
})
1297+
);
1298+
1299+
const api = RequestInterceptor.create({
1300+
baseUrl: 'https://api.example.com',
1301+
expectedContentType: 'application/json',
1302+
});
1303+
1304+
const onError = vi.fn();
1305+
api.use({ onError });
1306+
1307+
await expect(api.fetch('/page')).rejects.toThrow();
1308+
expect(onError).toHaveBeenCalled();
1309+
1310+
api.destroy();
1311+
});
1312+
1313+
it('should allow per-request override via middleware', async () => {
1314+
mockFetch.mockResolvedValueOnce(
1315+
new Response('<html></html>', {
1316+
status: 200,
1317+
statusText: 'OK',
1318+
headers: { 'Content-Type': 'text/html' },
1319+
})
1320+
);
1321+
1322+
const api = RequestInterceptor.create({
1323+
baseUrl: 'https://api.example.com',
1324+
expectedContentType: 'application/json',
1325+
});
1326+
1327+
api.use({
1328+
onRequest: (config) => {
1329+
config.expectedContentType = 'text/html';
1330+
return config;
1331+
},
1332+
});
1333+
1334+
const response = await api.fetch('/page');
1335+
expect(response.status).toBe(200);
1336+
1337+
api.destroy();
1338+
});
1339+
1340+
it('should allow middleware to clear expectedContentType', async () => {
1341+
mockFetch.mockResolvedValueOnce(
1342+
new Response('<html></html>', {
1343+
status: 200,
1344+
statusText: 'OK',
1345+
headers: { 'Content-Type': 'text/html' },
1346+
})
1347+
);
1348+
1349+
const api = RequestInterceptor.create({
1350+
baseUrl: 'https://api.example.com',
1351+
expectedContentType: 'application/json',
1352+
});
1353+
1354+
api.use({
1355+
onRequest: (config) => {
1356+
config.expectedContentType = undefined;
1357+
return config;
1358+
},
1359+
});
1360+
1361+
const response = await api.fetch('/page');
1362+
expect(response.status).toBe(200);
1363+
1364+
api.destroy();
1365+
});
1366+
});
1367+
11731368
describe('getConfig', () => {
11741369
it('should return current configuration', () => {
11751370
const api = RequestInterceptor.create({

0 commit comments

Comments
 (0)