diff --git a/packages/backend/src/checks/index.ts b/packages/backend/src/checks/index.ts index 1c28c38..77612fd 100644 --- a/packages/backend/src/checks/index.ts +++ b/packages/backend/src/checks/index.ts @@ -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"; @@ -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", @@ -99,6 +101,7 @@ export const checks = [ hashDisclosureScan, jsonHtmlResponseScan, missingContentTypeScan, + multipleContentTypesScan, openRedirectScan, pathTraversalScan, phpinfoScan, diff --git a/packages/backend/src/checks/multiple-content-types/index.spec.ts b/packages/backend/src/checks/multiple-content-types/index.spec.ts new file mode 100644 index 0000000..3b4781d --- /dev/null +++ b/packages/backend/src/checks/multiple-content-types/index.spec.ts @@ -0,0 +1,149 @@ +import { createMockRequest, createMockResponse, runCheck } from "engine"; +import { describe, expect, it } from "vitest"; + +import multipleContentTypesCheck from "./index"; + +const buildTarget = (headers: Record) => { + 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: "", + }); + + return { request, response }; +}; + +const collectFindings = async ( + headers: Record, +): Promise => { + 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"); + }); + }); +}); diff --git a/packages/backend/src/checks/multiple-content-types/index.ts b/packages/backend/src/checks/multiple-content-types/index.ts new file mode 100644 index 0000000..651f06e --- /dev/null +++ b/packages/backend/src/checks/multiple-content-types/index.ts @@ -0,0 +1,100 @@ +import { defineCheck, done, Severity } from "engine"; + +import { Tags } from "../../types"; +import { keyStrategy } from "../../utils"; + +const splitContentTypes = (values: Array): 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>(({ 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, + }; +}); diff --git a/packages/backend/src/stores/config.ts b/packages/backend/src/stores/config.ts index 6b716b7..80bb1a6 100644 --- a/packages/backend/src/stores/config.ts +++ b/packages/backend/src/stores/config.ts @@ -188,6 +188,10 @@ export class ConfigStore { checkID: Checks.MISSING_CONTENT_TYPE, enabled: true, }, + { + checkID: Checks.MULTIPLE_CONTENT_TYPES, + enabled: true, + }, ], }, {