Skip to content
Open
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
3 changes: 3 additions & 0 deletions packages/backend/src/checks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import gitConfigScan from "./git-config";
import hashDisclosureScan from "./hash-disclosure";
import jsonHtmlResponseScan from "./json-html-response";
import missingContentTypeScan from "./missing-content-type";
import multipleContentTypesScan from "./multiple-content-types";
import openRedirectScan from "./open-redirect";
import pathTraversalScan from "./path-traversal";
import phpinfoScan from "./phpinfo";
Expand Down Expand Up @@ -59,6 +60,7 @@ export const Checks = {
HASH_DISCLOSURE: "hash-disclosure",
JSON_HTML_RESPONSE: "json-html-response",
MISSING_CONTENT_TYPE: "missing-content-type",
MULTIPLE_CONTENT_TYPES: "multiple-content-types",
OPEN_REDIRECT: "open-redirect",
PATH_TRAVERSAL: "path-traversal",
PHPINFO: "phpinfo",
Expand Down Expand Up @@ -99,6 +101,7 @@ export const checks = [
hashDisclosureScan,
jsonHtmlResponseScan,
missingContentTypeScan,
multipleContentTypesScan,
openRedirectScan,
pathTraversalScan,
phpinfoScan,
Expand Down
149 changes: 149 additions & 0 deletions packages/backend/src/checks/multiple-content-types/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { createMockRequest, createMockResponse, runCheck } from "engine";
import { describe, expect, it } from "vitest";

import multipleContentTypesCheck from "./index";

const buildTarget = (headers: Record<string, string[]>) => {
const request = createMockRequest({
id: "req-1",
host: "example.com",
method: "GET",
path: "/resource",
headers: { Host: ["example.com"] },
});

const response = createMockResponse({
id: "res-1",
code: 200,
headers,
body: "<html></html>",
});

return { request, response };
};

const collectFindings = async (
headers: Record<string, string[]>,
): Promise<unknown[]> => {
const target = buildTarget(headers);
const execution = await runCheck(multipleContentTypesCheck, [target]);
return execution[0]?.steps[execution[0].steps.length - 1]?.findings ?? [];
};

describe("Multiple Content-Type headers check", () => {
describe("Detection", () => {
it("reports when multiple distinct content types are present", async () => {
const findings = await collectFindings({
"content-type": ["text/html", "application/json"],
});

expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
name: "Multiple Content-Type headers detected",
severity: "medium",
});
});

it("reports when multiple content types are comma-separated in a single header", async () => {
const findings = await collectFindings({
"content-type": ["text/html, application/json"],
});

expect(findings).toHaveLength(1);
});

it("reports three or more distinct content types", async () => {
const findings = await collectFindings({
"content-type": ["text/html", "application/json", "text/plain"],
});

expect(findings).toHaveLength(1);
expect(findings[0].description).toContain("text/html");
expect(findings[0].description).toContain("application/json");
expect(findings[0].description).toContain("text/plain");
});

it("reports mixed headers and comma-separated values", async () => {
const findings = await collectFindings({
"content-type": ["text/html, application/json", "text/xml"],
});

expect(findings).toHaveLength(1);
});

it("detects conflicting types with charset parameters", async () => {
const findings = await collectFindings({
"content-type": [
"text/html; charset=utf-8",
"application/json; charset=utf-8",
],
});

expect(findings).toHaveLength(1);
});
});

describe("False Positive Prevention", () => {
it("does not report when only one content type is present", async () => {
const findings = await collectFindings({
"content-type": ["text/html"],
});

expect(findings).toHaveLength(0);
});

it("does not report duplicate identical content types", async () => {
const findings = await collectFindings({
"content-type": ["text/html", "text/html"],
});

expect(findings).toHaveLength(0);
});

it("does not report case variations of the same type", async () => {
const findings = await collectFindings({
"content-type": ["text/html", "TEXT/HTML", "Text/Html"],
});

expect(findings).toHaveLength(0);
});

it("does not report duplicate content types with same parameters", async () => {
const findings = await collectFindings({
"content-type": [
"text/html; charset=utf-8",
"text/html; charset=utf-8",
],
});

expect(findings).toHaveLength(0);
});
});

describe("Edge Cases", () => {
it("handles empty content-type header gracefully", async () => {
const findings = await collectFindings({
"content-type": [],
});

expect(findings).toHaveLength(0);
});

it("handles whitespace in comma-separated values", async () => {
const findings = await collectFindings({
"content-type": ["text/html , application/json "],
});

expect(findings).toHaveLength(1);
});

it("includes guidance about returning single Content-Type", async () => {
const findings = await collectFindings({
"content-type": ["text/html", "application/json"],
});

expect(findings[0].description).toContain("single, unambiguous");
expect(findings[0].description).toContain("MIME confusion");
});
});
});
100 changes: 100 additions & 0 deletions packages/backend/src/checks/multiple-content-types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { defineCheck, done, Severity } from "engine";

import { Tags } from "../../types";
import { keyStrategy } from "../../utils";

const splitContentTypes = (values: Array<string>): string[] => {
const tokens: string[] = [];

for (const raw of values) {
const parts = raw
.split(",")
.map((part) => part.trim())
.filter((part) => part.length > 0);

tokens.push(...parts);
}

return tokens;
};

const buildDescription = (types: string[]): string => {
const list = types.map((type) => `- \`${type}\``).join("\n");

return [
"The response specifies multiple `Content-Type` values, making it unclear which media type the browser should honor.",
"",
"Conflicting content types can be abused to trigger MIME confusion in browsers or caching layers, potentially enabling content sniffing or script execution in contexts where it should be blocked.",
"",
"Observed header values:",
list,
"",
"Return only a single, unambiguous `Content-Type` header for each response.",
].join("\n");
};

export default defineCheck<Record<never, never>>(({ step }) => {
step("detectMultipleContentTypes", (state, context) => {
const { response } = context.target;

if (response === undefined) {
return done({ state });
}

const contentTypeHeader = response.getHeader("content-type");
if (contentTypeHeader === undefined || contentTypeHeader.length === 0) {
return done({ state });
}

const contentTypes = splitContentTypes(contentTypeHeader);
const uniqueTypes = Array.from(
new Set(contentTypes.map((value) => value.toLowerCase())),
);

if (uniqueTypes.length <= 1) {
return done({ state });
}

const originalOrder: string[] = [];
for (const value of contentTypes) {
const lower = value.toLowerCase();
if (!originalOrder.some((existing) => existing.toLowerCase() === lower)) {
originalOrder.push(value);
}
}

return done({
state,
findings: [
{
name: "Multiple Content-Type headers detected",
description: buildDescription(originalOrder),
severity: Severity.MEDIUM,
correlation: {
requestID: context.target.request.getId(),
locations: [],
},
},
],
});
});

return {
metadata: {
id: "multiple-content-types",
name: "Multiple Content-Type headers",
description:
"Detects responses that declare more than one Content-Type value, which can lead to MIME confusion vulnerabilities.",
type: "passive",
tags: [Tags.INFORMATION_DISCLOSURE, Tags.SECURITY_HEADERS],
severities: [Severity.MEDIUM],
aggressivity: {
minRequests: 0,
maxRequests: 0,
},
},
initState: () => ({}),
dedupeKey: keyStrategy().withHost().withPath().withQuery().build(),
when: (target) => target.response !== undefined,
};
});
4 changes: 4 additions & 0 deletions packages/backend/src/stores/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ export class ConfigStore {
checkID: Checks.MISSING_CONTENT_TYPE,
enabled: true,
},
{
checkID: Checks.MULTIPLE_CONTENT_TYPES,
enabled: true,
},
],
},
{
Expand Down
Loading