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
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { createMockRequest, createMockResponse, runCheck } from "engine";
import { describe, expect, it } from "vitest";

import cacheableHttpsCheck from "./index";

describe("Cacheable HTTPS response check", () => {
it("flags HTTPS response without cache directives", async () => {
const request = createMockRequest({
id: "req-1",
host: "example.com",
method: "GET",
path: "/",
tls: true,
});

const response = createMockResponse({
id: "res-1",
code: 200,
headers: {
"content-type": ["text/html"],
},
body: "OK",
});

const executionHistory = await runCheck(cacheableHttpsCheck, [
{ request, response },
]);

const findings =
executionHistory[0]?.steps[executionHistory[0].steps.length - 1]
?.findings ?? [];
expect(findings).toHaveLength(1);
expect(findings[0]?.name).toBe("Cacheable HTTPS response");
});

it("ignores responses with protective cache-control", async () => {
const request = createMockRequest({
id: "req-2",
host: "example.com",
method: "GET",
path: "/",
tls: true,
});

const response = createMockResponse({
id: "res-2",
code: 200,
headers: {
"cache-control": ["no-store, private"],
},
body: "OK",
});

const executionHistory = await runCheck(cacheableHttpsCheck, [
{ request, response },
]);

const findings =
executionHistory[0]?.steps[executionHistory[0].steps.length - 1]
?.findings ?? [];
expect(findings).toHaveLength(0);
});

it("ignores HTTP responses", async () => {
const request = createMockRequest({
id: "req-3",
host: "example.com",
method: "GET",
path: "/",
tls: false,
});

const response = createMockResponse({
id: "res-3",
code: 200,
headers: {},
body: "OK",
});

const executionHistory = await runCheck(cacheableHttpsCheck, [
{ request, response },
]);

const findings =
executionHistory[0]?.steps[executionHistory[0].steps.length - 1]
?.findings ?? [];
expect(findings).toHaveLength(0);
});
});
102 changes: 102 additions & 0 deletions packages/backend/src/checks/cacheable-https-response/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { defineCheck, done, Severity } from "engine";

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

const hasProtectiveDirectives = (cacheControl: string | undefined): boolean => {
if (cacheControl === undefined || cacheControl.length === 0) {
return false;
}
const normalized = cacheControl.toLowerCase();
return (
normalized.includes("no-store") ||
normalized.includes("no-cache") ||
normalized.includes("private") ||
normalized.includes("must-revalidate") ||
normalized.includes("max-age=0") ||
normalized.includes("s-maxage=0")
);
};

const isExplicitlyPublic = (cacheControl: string | undefined): boolean => {
if (cacheControl === undefined || cacheControl.length === 0) {
return false;
}
return cacheControl.toLowerCase().includes("public");
};

const hasPragmaNoCache = (pragma: string | undefined): boolean => {
if (pragma === undefined || pragma.length === 0) {
return false;
}
return pragma.toLowerCase().includes("no-cache");
};

export default defineCheck(({ step }) => {
step("detectCacheableHttpsResponse", (state, context) => {
const { request, response } = context.target;

if (!response || !request.getTls()) {
return done({ state });
}

const cacheControlHeader = response.getHeader("cache-control")?.[0];
const pragmaHeader = response.getHeader("pragma")?.[0];

const hasProtection =
hasProtectiveDirectives(cacheControlHeader) ||
hasPragmaNoCache(pragmaHeader);

if (hasProtection) {
return done({ state });
}

const isCacheable =
cacheControlHeader === undefined ||
isExplicitlyPublic(cacheControlHeader);

if (!isCacheable) {
return done({ state });
}

const description = [
"A response delivered over HTTPS appears cacheable by shared intermediaries.",
"",
`**Cache-Control:** \`${cacheControlHeader ?? "<not set>"}\``,
`**Pragma:** \`${pragmaHeader ?? "<not set>"}\``,
"",
"Sensitive HTTPS responses should include `Cache-Control: no-store` (or similar directives) to prevent storage by proxies or browsers.",
].join("\n");

return done({
state,
findings: [
{
name: "Cacheable HTTPS response",
description,
severity: Severity.MEDIUM,
correlation: {
requestID: request.getId(),
locations: [],
},
},
],
});
});

return {
metadata: {
id: "cacheable-https-response",
name: "Cacheable HTTPS response",
description:
"Identifies HTTPS responses missing cache-control directives that prevent caching.",
type: "passive",
tags: [Tags.CACHE, Tags.SECURE],
severities: [Severity.MEDIUM],
aggressivity: { minRequests: 0, maxRequests: 0 },
},
initState: () => ({}),
dedupeKey: keyStrategy().withHost().withPort().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
@@ -1,6 +1,7 @@
import antiClickjackingScan from "./anti-clickjacking";
import applicationErrorsScan from "./application-errors";
import bigRedirectsScan from "./big-redirects";
import cacheableHttpsResponseScan from "./cacheable-https-response";
import commandInjectionScan from "./command-injection";
import corsMisconfigScan from "./cors-misconfig";
import creditCardDisclosureScan from "./credit-card-disclosure";
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",
CACHEABLE_HTTPS_RESPONSE: "cacheable-https-response",
// 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,
cacheableHttpsResponseScan,
// mysqlTimeBased,
] as const;
8 changes: 8 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.CACHEABLE_HTTPS_RESPONSE,
enabled: false,
},
],
},
{
Expand Down Expand Up @@ -371,6 +375,10 @@ export class ConfigStore {
checkID: Checks.MISSING_CONTENT_TYPE,
enabled: true,
},
{
checkID: Checks.CACHEABLE_HTTPS_RESPONSE,
enabled: true,
},
],
},
{
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const Tags = {
COOKIES: "cookies",
CORS: "cors",
CRYPTOGRAPHY: "cryptography",
CACHE: "cache",
CSP: "csp",
CSRF: "csrf",
CSS_INJECTION: "css-injection",
Expand Down