From 27746f85fe68098aa1e71121b854a32c795c8854 Mon Sep 17 00:00:00 2001 From: Joseph Thacker Date: Tue, 21 Oct 2025 12:32:41 -0400 Subject: [PATCH] Warn when X-XSS-Protection disables filter (#29) --- packages/backend/src/checks/index.ts | 3 + .../checks/xss-filter-disabled/index.spec.ts | 61 ++++++++++++++++ .../src/checks/xss-filter-disabled/index.ts | 69 +++++++++++++++++++ packages/backend/src/stores/config.ts | 8 +++ 4 files changed, 141 insertions(+) create mode 100644 packages/backend/src/checks/xss-filter-disabled/index.spec.ts create mode 100644 packages/backend/src/checks/xss-filter-disabled/index.ts diff --git a/packages/backend/src/checks/index.ts b/packages/backend/src/checks/index.ts index 1c28c38..7fda3e9 100644 --- a/packages/backend/src/checks/index.ts +++ b/packages/backend/src/checks/index.ts @@ -32,6 +32,7 @@ import sqlStatementInParams from "./sql-statement-in-params"; import ssnDisclosureScan from "./ssn-disclosure"; import sstiScan from "./ssti"; import suspectTransformScan from "./suspect-transform"; +import xssFilterDisabledScan from "./xss-filter-disabled"; export type CheckID = (typeof Checks)[keyof typeof Checks]; export const Checks = { @@ -71,6 +72,7 @@ export const Checks = { SQL_STATEMENT_IN_PARAMS: "sql-statement-in-params", SSN_DISCLOSURE: "ssn-disclosure", SUSPECT_TRANSFORM: "suspect-transform", + XSS_FILTER_DISABLED: "xss-filter-disabled", // MYSQL_TIME_BASED_SQLI: "mysql-time-based-sqli" - TODO: fix false positives } as const; @@ -111,5 +113,6 @@ export const checks = [ sqlStatementInParams, ssnDisclosureScan, suspectTransformScan, + xssFilterDisabledScan, // mysqlTimeBased, ] as const; diff --git a/packages/backend/src/checks/xss-filter-disabled/index.spec.ts b/packages/backend/src/checks/xss-filter-disabled/index.spec.ts new file mode 100644 index 0000000..fd4ab1b --- /dev/null +++ b/packages/backend/src/checks/xss-filter-disabled/index.spec.ts @@ -0,0 +1,61 @@ +import { createMockRequest, createMockResponse, runCheck } from "engine"; +import { describe, expect, it } from "vitest"; + +import xssFilterDisabledCheck from "./index"; + +describe("X-XSS-Protection disabled check", () => { + it("flags header disabling filter", async () => { + const request = createMockRequest({ + id: "req-1", + host: "example.com", + method: "GET", + path: "/", + }); + + const response = createMockResponse({ + id: "res-1", + code: 200, + headers: { + "x-xss-protection": ["0"], + }, + body: "OK", + }); + + const executionHistory = await runCheck(xssFilterDisabledCheck, [ + { request, response }, + ]); + + const findings = + executionHistory[0]?.steps[executionHistory[0].steps.length - 1] + ?.findings ?? []; + expect(findings).toHaveLength(1); + expect(findings[0]?.name).toBe("Browser XSS filter disabled"); + }); + + it("ignores safe header values", async () => { + const request = createMockRequest({ + id: "req-2", + host: "example.com", + method: "GET", + path: "/", + }); + + const response = createMockResponse({ + id: "res-2", + code: 200, + headers: { + "x-xss-protection": ["1; mode=block"], + }, + body: "OK", + }); + + const executionHistory = await runCheck(xssFilterDisabledCheck, [ + { request, response }, + ]); + + const findings = + executionHistory[0]?.steps[executionHistory[0].steps.length - 1] + ?.findings ?? []; + expect(findings).toHaveLength(0); + }); +}); diff --git a/packages/backend/src/checks/xss-filter-disabled/index.ts b/packages/backend/src/checks/xss-filter-disabled/index.ts new file mode 100644 index 0000000..b40403a --- /dev/null +++ b/packages/backend/src/checks/xss-filter-disabled/index.ts @@ -0,0 +1,69 @@ +import { defineCheck, done, Severity } from "engine"; + +import { Tags } from "../../types"; +import { keyStrategy } from "../../utils"; + +const isFilterDisabled = (values: Array | undefined): boolean => { + if (!values) return false; + + return values.some((value) => { + const normalized = value.trim().toLowerCase(); + if (normalized === "0") return true; + if (normalized.startsWith("0;")) return true; + return normalized.includes("mode=0"); + }); +}; + +export default defineCheck(({ step }) => { + step("detectDisabledFilter", (state, context) => { + const { response, request } = context.target; + if (!response) { + return done({ state }); + } + + const headerValues = response.getHeader("x-xss-protection"); + if (!isFilterDisabled(headerValues)) { + return done({ state }); + } + + const description = [ + "The response disables the legacy browser XSS filter via the `X-XSS-Protection` header.", + "", + `**Header value:** \`${headerValues?.join(", ") ?? ""}\``, + "", + "While modern browsers ignore this header, disabling the filter can expose older clients to reflected XSS.", + "**Recommendation:** Remove the header or set it to `1; mode=block` for legacy compatibility.", + ].join("\n"); + + return done({ + state, + findings: [ + { + name: "Browser XSS filter disabled", + description, + severity: Severity.LOW, + correlation: { + requestID: request.getId(), + locations: [], + }, + }, + ], + }); + }); + + return { + metadata: { + id: "xss-filter-disabled", + name: "Browser XSS filter disabled", + description: + "Detects responses that disable the legacy XSS filter using the X-XSS-Protection header.", + type: "passive", + tags: [Tags.SECURITY_HEADERS, Tags.XSS], + severities: [Severity.LOW], + aggressivity: { minRequests: 0, maxRequests: 0 }, + }, + initState: () => ({}), + dedupeKey: keyStrategy().withHost().withPort().withPath().build(), + when: () => true, + }; +}); diff --git a/packages/backend/src/stores/config.ts b/packages/backend/src/stores/config.ts index 6b716b7..01e925f 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.XSS_FILTER_DISABLED, + enabled: false, + }, ], }, { @@ -371,6 +375,10 @@ export class ConfigStore { checkID: Checks.MISSING_CONTENT_TYPE, enabled: true, }, + { + checkID: Checks.XSS_FILTER_DISABLED, + enabled: true, + }, ], }, {