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
3 changes: 3 additions & 0 deletions packages/backend/src/checks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import hashDisclosureScan from "./hash-disclosure";
import jsonHtmlResponseScan from "./json-html-response";
import missingContentTypeScan from "./missing-content-type";
import openRedirectScan from "./open-redirect";
import passwordInCookieScan from "./password-in-cookie";
import pathTraversalScan from "./path-traversal";
import phpinfoScan from "./phpinfo";
import privateIpDisclosureScan from "./private-ip-disclosure";
Expand Down Expand Up @@ -59,6 +60,7 @@ export const Checks = {
HASH_DISCLOSURE: "hash-disclosure",
JSON_HTML_RESPONSE: "json-html-response",
MISSING_CONTENT_TYPE: "missing-content-type",
PASSWORD_IN_COOKIE: "password-in-cookie",
OPEN_REDIRECT: "open-redirect",
PATH_TRAVERSAL: "path-traversal",
PHPINFO: "phpinfo",
Expand Down Expand Up @@ -99,6 +101,7 @@ export const checks = [
hashDisclosureScan,
jsonHtmlResponseScan,
missingContentTypeScan,
passwordInCookieScan,
openRedirectScan,
pathTraversalScan,
phpinfoScan,
Expand Down
149 changes: 149 additions & 0 deletions packages/backend/src/checks/password-in-cookie/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { createMockRequest, createMockResponse, runCheck } from "engine";
import { describe, expect, it } from "vitest";

import passwordCookieCheck from "./index";

const runPasswordCookieCheck = async (
setCookieHeaders: string[],
): Promise<unknown[]> => {
const request = createMockRequest({
id: "req-cookie",
host: "example.com",
method: "GET",
path: "/",
headers: { Host: ["example.com"] },
});

const response = createMockResponse({
id: "res-cookie",
code: 200,
headers: { "set-cookie": setCookieHeaders },
body: "",
});

const execution = await runCheck(passwordCookieCheck, [
{ request, response },
]);

return execution[0]?.steps[execution[0].steps.length - 1]?.findings ?? [];
};

describe("Password value stored in cookie check", () => {
describe("Detection", () => {
it("flags cookies whose name indicates a password", async () => {
const findings = await runPasswordCookieCheck([
"password=SuperSecret123; Path=/; HttpOnly",
]);

expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
name: "Password value stored in cookie",
severity: "high",
});
});

it("flags cookies whose value indicates a password", async () => {
const findings = await runPasswordCookieCheck([
"auth=Pwd%3DPlainText; Path=/; HttpOnly",
]);

expect(findings).toHaveLength(1);
});

it("detects passwd keyword in cookie name", async () => {
const findings = await runPasswordCookieCheck([
"user_passwd=hashed123; Path=/",
]);

expect(findings).toHaveLength(1);
});

it("detects pwd keyword in cookie name", async () => {
const findings = await runPasswordCookieCheck([
"usrpwd=secret; Path=/; Secure",
]);

expect(findings).toHaveLength(1);
});

it("detects passcode keyword in cookie name", async () => {
const findings = await runPasswordCookieCheck([
"access-passcode=1234; Path=/",
]);

expect(findings).toHaveLength(1);
});

it("detects password keywords with separators", async () => {
const findings = await runPasswordCookieCheck([
"user-password=value1; Path=/",
"user_pwd=value2; Path=/",
"user.passwd=value3; Path=/",
]);

expect(findings).toHaveLength(1);
expect(findings[0].description).toContain("user-password");
expect(findings[0].description).toContain("user_pwd");
expect(findings[0].description).toContain("user.passwd");
});

it("detects password keyword in brackets", async () => {
const findings = await runPasswordCookieCheck([
"data[password]=secret; Path=/",
]);

expect(findings).toHaveLength(1);
});
});

describe("False Positive Prevention", () => {
it("does not flag unrelated cookies", async () => {
const findings = await runPasswordCookieCheck([
"sessionid=abcdef123456; Path=/; HttpOnly; Secure",
]);

expect(findings).toHaveLength(0);
});

it("does not flag bypass keyword", async () => {
const findings = await runPasswordCookieCheck([
"can_bypass=true; Path=/",
]);

expect(findings).toHaveLength(0);
});

it("does not flag passport keyword", async () => {
const findings = await runPasswordCookieCheck([
"passport_session=abc123; Path=/",
]);

expect(findings).toHaveLength(0);
});
});

describe("Edge Cases", () => {
it("handles empty password cookie value", async () => {
const findings = await runPasswordCookieCheck(["password=; Path=/"]);

expect(findings).toHaveLength(1);
expect(findings[0].description).toContain("empty value");
});

it("includes security flags in description", async () => {
const findings = await runPasswordCookieCheck([
"pwd=secret; Path=/; HttpOnly; Secure",
]);

expect(findings).toHaveLength(1);
expect(findings[0].description).toContain("HttpOnly");
expect(findings[0].description).toContain("Secure");
});

it("returns no findings when no cookies are set", async () => {
const findings = await runPasswordCookieCheck([]);

expect(findings).toHaveLength(0);
});
});
});
152 changes: 152 additions & 0 deletions packages/backend/src/checks/password-in-cookie/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { defineCheck, done, Severity } from "engine";

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

const PASSWORD_KEYWORDS = [
"password",
"passwd",
"passcode",
"passphrase",
"passwrd",
"pwd",
];

type FlaggedCookie = {
name: string;
valueLength: number;
httpOnly: boolean;
secure: boolean;
};

const sanitize = (value: string): string => {
return value
.toLowerCase()
.replace(/\[|\]/g, "")
.replace(/[.\-_]/g, "");
};

const decodeValue = (value: string): string => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};

const isPasswordIndicator = (text: string): boolean => {
const normalized = sanitize(text);
return PASSWORD_KEYWORDS.some((keyword) => normalized.includes(keyword));
};

const collectFlaggedCookies = (
cookies: ReturnType<typeof getSetCookieHeaders>,
): FlaggedCookie[] => {
const flagged: FlaggedCookie[] = [];

for (const cookie of cookies) {
const cookieName = cookie.key;
const cookieValue = cookie.value;

if (cookieName === undefined || cookieValue === undefined) {
continue;
}

const decodedValue = decodeValue(cookieValue);

if (isPasswordIndicator(cookieName) || isPasswordIndicator(decodedValue)) {
flagged.push({
name: cookieName,
valueLength: decodedValue.length,
httpOnly: cookie.isHttpOnly,
secure: cookie.isSecure,
});
}
}

return flagged;
};

const buildDescription = (cookies: FlaggedCookie[]): string => {
const details = cookies
.map((cookie) => {
const lengthText =
cookie.valueLength === 0
? "empty value"
: cookie.valueLength === 1
? "1 character"
: `${cookie.valueLength} characters`;

const flags: string[] = [];
flags.push(cookie.httpOnly ? "HttpOnly" : "no HttpOnly");
flags.push(cookie.secure ? "Secure" : "no Secure");

return `- Cookie \`${cookie.name}\` appears to store a password-like value (${lengthText}; ${flags.join(
", ",
)}).`;
})
.join("\n");

return [
"The application sets cookies that appear to contain password values.",
"",
details,
"",
"Password material must never be stored client-side. Use short-lived session identifiers or cryptographic tokens instead and keep raw credentials on the server.",
].join("\n");
};

export default defineCheck<Record<never, never>>(({ step }) => {
step("inspectCookies", (state, context) => {
const { response } = context.target;

if (response === undefined) {
return done({ state });
}

const cookies = getSetCookieHeaders(response);
if (cookies.length === 0) {
return done({ state });
}

const flaggedCookies = collectFlaggedCookies(cookies);
if (flaggedCookies.length === 0) {
return done({ state });
}

return done({
state,
findings: [
{
name: "Password value stored in cookie",
description: buildDescription(flaggedCookies),
severity: Severity.HIGH,
correlation: {
requestID: context.target.request.getId(),
locations: [],
},
},
],
});
});

return {
metadata: {
id: "password-in-cookie",
name: "Password value stored in cookie",
description:
"Detects Set-Cookie headers that store password-like values, indicating insecure credential handling.",
type: "passive",
tags: [Tags.PASSWORD, Tags.INFORMATION_DISCLOSURE],
severities: [Severity.HIGH],
aggressivity: {
minRequests: 0,
maxRequests: 0,
},
},
initState: () => ({}),
dedupeKey: keyStrategy().withHost().withPath().withQuery().build(),
when: (target) => target.response !== undefined,
};
});
4 changes: 4 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.PASSWORD_IN_COOKIE,
enabled: true,
},
],
},
{
Expand Down
Loading