From 5a7202324e56295fd0c1b1dd12b0e0de1e206631 Mon Sep 17 00:00:00 2001 From: Joseph Thacker Date: Thu, 23 Oct 2025 09:02:26 -0400 Subject: [PATCH] feat: detect http trace method (#109) --- .../checks/http-trace-enabled/index.spec.ts | 130 ++++++++++++++++++ .../src/checks/http-trace-enabled/index.ts | 88 ++++++++++++ packages/backend/src/checks/index.ts | 3 + packages/backend/src/stores/config.ts | 4 + 4 files changed, 225 insertions(+) create mode 100644 packages/backend/src/checks/http-trace-enabled/index.spec.ts create mode 100644 packages/backend/src/checks/http-trace-enabled/index.ts diff --git a/packages/backend/src/checks/http-trace-enabled/index.spec.ts b/packages/backend/src/checks/http-trace-enabled/index.spec.ts new file mode 100644 index 0000000..8621e6d --- /dev/null +++ b/packages/backend/src/checks/http-trace-enabled/index.spec.ts @@ -0,0 +1,130 @@ +import { + createMockRequest, + createMockResponse, + runCheck, + type SendHandler, +} from "engine"; +import { describe, expect, it } from "vitest"; + +import traceCheck from "./index"; + +const TRACE_HEADER = "X-Trace-Detection"; + +type HandlerConfig = { + status: number; + echoMarker?: boolean; +}; + +const buildSendHandler = (config: HandlerConfig): SendHandler => { + return (spec) => { + const body = + config.echoMarker === true + ? [ + `TRACE ${spec.getPath()} HTTP/1.1`, + `${TRACE_HEADER}: ${spec.getHeader(TRACE_HEADER)?.[0] ?? ""}`, + ].join("\r\n") + : "TRACE disabled"; + + const mockRequest = createMockRequest({ + id: "trace-probe", + host: spec.getHost(), + method: spec.getMethod(), + path: spec.getPath(), + query: spec.getQuery(), + headers: spec.getHeaders(), + }); + + const mockResponse = createMockResponse({ + id: "trace-response", + code: config.status, + headers: { "content-type": ["message/http"] }, + body, + }); + + return Promise.resolve({ request: mockRequest, response: mockResponse }); + }; +}; + +describe("HTTP TRACE enabled check", () => { + it("reports when TRACE is enabled and echoes headers", async () => { + const targetRequest = createMockRequest({ + id: "target-1", + host: "example.com", + method: "GET", + path: "/trace", + headers: { Host: ["example.com"] }, + }); + + const targetResponse = createMockResponse({ + id: "target-resp-1", + code: 200, + headers: { "content-type": ["text/html"] }, + body: "", + }); + + const execution = await runCheck( + traceCheck, + [{ request: targetRequest, response: targetResponse }], + { sendHandler: buildSendHandler({ status: 200, echoMarker: true }) }, + ); + + const findings = + execution[0]?.steps[execution[0].steps.length - 1]?.findings ?? []; + expect(findings).toHaveLength(1); + expect(findings[0]?.severity).toBe("medium"); + }); + + it("does not report when TRACE returns 405", async () => { + const targetRequest = createMockRequest({ + id: "target-2", + host: "example.com", + method: "GET", + path: "/trace", + headers: { Host: ["example.com"] }, + }); + + const targetResponse = createMockResponse({ + id: "target-resp-2", + code: 200, + headers: { "content-type": ["text/html"] }, + body: "", + }); + + const execution = await runCheck( + traceCheck, + [{ request: targetRequest, response: targetResponse }], + { sendHandler: buildSendHandler({ status: 405, echoMarker: false }) }, + ); + + const findings = + execution[0]?.steps[execution[0].steps.length - 1]?.findings ?? []; + expect(findings).toHaveLength(0); + }); + + it("does not report when headers are not echoed", async () => { + const targetRequest = createMockRequest({ + id: "target-3", + host: "example.com", + method: "GET", + path: "/trace", + headers: { Host: ["example.com"] }, + }); + + const targetResponse = createMockResponse({ + id: "target-resp-3", + code: 200, + headers: { "content-type": ["text/html"] }, + body: "", + }); + + const execution = await runCheck( + traceCheck, + [{ request: targetRequest, response: targetResponse }], + { sendHandler: buildSendHandler({ status: 200, echoMarker: false }) }, + ); + + const findings = + execution[0]?.steps[execution[0].steps.length - 1]?.findings ?? []; + expect(findings).toHaveLength(0); + }); +}); diff --git a/packages/backend/src/checks/http-trace-enabled/index.ts b/packages/backend/src/checks/http-trace-enabled/index.ts new file mode 100644 index 0000000..e49723c --- /dev/null +++ b/packages/backend/src/checks/http-trace-enabled/index.ts @@ -0,0 +1,88 @@ +import { defineCheck, done, Severity } from "engine"; + +import { Tags } from "../../types"; +import { keyStrategy } from "../../utils"; + +const TRACE_MARKER_HEADER = "X-Trace-Detection"; +const TRACE_MARKER_VALUE = "caido-trace-check"; + +type State = { + probeSent: boolean; +}; + +const hasEchoedMarker = (bodyText: string): boolean => { + const normalized = bodyText.toLowerCase(); + return ( + normalized.includes(TRACE_MARKER_HEADER.toLowerCase()) && + normalized.includes(TRACE_MARKER_VALUE.toLowerCase()) + ); +}; + +export default defineCheck(({ step }) => { + step("sendTraceProbe", async (_, context) => { + const spec = context.target.request.toSpec(); + spec.setMethod("TRACE"); + spec.setHeader(TRACE_MARKER_HEADER, TRACE_MARKER_VALUE); + spec.setBody(""); + + const result = await context.sdk.requests.send(spec); + const response = result.response; + + if (response === undefined) { + return done({ + state: { probeSent: true }, + }); + } + + const code = response.getCode(); + const bodyText = response.getBody()?.toText() ?? ""; + + if (code === 200 && hasEchoedMarker(bodyText)) { + const findingDescription = [ + "The server responded to an HTTP `TRACE` request with a 200 status code and echoed back custom headers.", + "", + "This behaviour indicates that the TRACE method is enabled, which can expose user cookies and authentication headers via cross-site tracing (XST) attacks.", + "", + `The echoed payload included the header \`${TRACE_MARKER_HEADER}: ${TRACE_MARKER_VALUE}\`.`, + ].join("\n"); + + return done({ + state: { probeSent: true }, + findings: [ + { + name: "HTTP TRACE method enabled", + description: findingDescription, + severity: Severity.MEDIUM, + correlation: { + requestID: result.request.getId(), + locations: [], + }, + }, + ], + }); + } + + return done({ + state: { probeSent: true }, + }); + }); + + return { + metadata: { + id: "http-trace-enabled", + name: "HTTP TRACE Method Enabled", + description: + "Detects servers that accept HTTP TRACE requests and echo request headers, enabling cross-site tracing attacks.", + type: "active", + tags: [Tags.INFORMATION_DISCLOSURE], + severities: [Severity.MEDIUM], + aggressivity: { + minRequests: 1, + maxRequests: 1, + }, + }, + initState: () => ({ probeSent: false }), + dedupeKey: keyStrategy().withHost().withPath().build(), + when: () => true, + }; +}); diff --git a/packages/backend/src/checks/index.ts b/packages/backend/src/checks/index.ts index 1c28c38..d729b53 100644 --- a/packages/backend/src/checks/index.ts +++ b/packages/backend/src/checks/index.ts @@ -18,6 +18,7 @@ import emailDisclosureScan from "./email-disclosure"; import exposedEnvScan from "./exposed-env"; import gitConfigScan from "./git-config"; import hashDisclosureScan from "./hash-disclosure"; +import httpTraceEnabledScan from "./http-trace-enabled"; import jsonHtmlResponseScan from "./json-html-response"; import missingContentTypeScan from "./missing-content-type"; import openRedirectScan from "./open-redirect"; @@ -71,6 +72,7 @@ export const Checks = { SQL_STATEMENT_IN_PARAMS: "sql-statement-in-params", SSN_DISCLOSURE: "ssn-disclosure", SUSPECT_TRANSFORM: "suspect-transform", + HTTP_TRACE_ENABLED: "http-trace-enabled", // MYSQL_TIME_BASED_SQLI: "mysql-time-based-sqli" - TODO: fix false positives } as const; @@ -111,5 +113,6 @@ export const checks = [ sqlStatementInParams, ssnDisclosureScan, suspectTransformScan, + httpTraceEnabledScan, // mysqlTimeBased, ] as const; diff --git a/packages/backend/src/stores/config.ts b/packages/backend/src/stores/config.ts index 6b716b7..df8417a 100644 --- a/packages/backend/src/stores/config.ts +++ b/packages/backend/src/stores/config.ts @@ -253,6 +253,10 @@ export class ConfigStore { checkID: Checks.SUSPECT_TRANSFORM, enabled: true, }, + { + checkID: Checks.HTTP_TRACE_ENABLED, + enabled: false, + }, ], passive: [ {