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
130 changes: 130 additions & 0 deletions packages/backend/src/checks/http-trace-enabled/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
88 changes: 88 additions & 0 deletions packages/backend/src/checks/http-trace-enabled/index.ts
Original file line number Diff line number Diff line change
@@ -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<State>(({ 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,
};
});
3 changes: 3 additions & 0 deletions packages/backend/src/checks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -111,5 +113,6 @@ export const checks = [
sqlStatementInParams,
ssnDisclosureScan,
suspectTransformScan,
httpTraceEnabledScan,
// mysqlTimeBased,
] as const;
4 changes: 4 additions & 0 deletions packages/backend/src/stores/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,10 @@ export class ConfigStore {
checkID: Checks.SUSPECT_TRANSFORM,
enabled: true,
},
{
checkID: Checks.HTTP_TRACE_ENABLED,
enabled: false,
},
],
passive: [
{
Expand Down