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 passwordReturnedInUrlScan from "./password-returned-in-url";
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_RETURNED_IN_URL: "password-returned-in-url",
OPEN_REDIRECT: "open-redirect",
PATH_TRAVERSAL: "path-traversal",
PHPINFO: "phpinfo",
Expand Down Expand Up @@ -99,6 +101,7 @@ export const checks = [
hashDisclosureScan,
jsonHtmlResponseScan,
missingContentTypeScan,
passwordReturnedInUrlScan,
openRedirectScan,
pathTraversalScan,
phpinfoScan,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { createMockRequest, createMockResponse, runCheck } from "engine";
import { describe, expect, it } from "vitest";

import passwordUrlCheck from "./index";

const executeCheck = async (config: {
body?: string;
location?: string[];
}): Promise<unknown[]> => {
const request = createMockRequest({
id: "req-password-url",
host: "example.com",
method: "GET",
path: "/",
headers: { Host: ["example.com"] },
});

const response = createMockResponse({
id: "res-password-url",
code: 200,
headers: {
"content-type": ["text/html"],
...(config.location !== undefined ? { location: config.location } : {}),
},
body: config.body ?? "",
});

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

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

describe("Password returned in URL query string check", () => {
it("flags password parameter in response body URL", async () => {
const findings = await executeCheck({
body: '<a href="https://example.com/reset?password=Secret123">link</a>',
});

expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: "high",
name: "Password returned in URL query string",
});
});

it("flags password parameter in Location header", async () => {
const findings = await executeCheck({
location: ["https://example.com/callback?pwd=%50%40ssw0rd"],
});

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

it("does not flag when no password indicators exist", async () => {
const findings = await executeCheck({
body: '<a href="https://example.com/reset?token=abc123">link</a>',
});

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

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

type FlaggedParam = {
name: string;
valueLength: number;
context: "body" | "header";
};

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

const QUERY_PARAM_REGEX = /[?&]([^&?#"'<>\s=]+)=([^&?#"'<>\s]*)/g;

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 = (value: string): boolean => {
const normalized = sanitize(value);
return PASSWORD_KEYWORDS.some((keyword) => normalized.includes(keyword));
};

const extractPasswordParams = (
text: string,
context: FlaggedParam["context"],
): FlaggedParam[] => {
const flagged: FlaggedParam[] = [];

for (const match of text.matchAll(QUERY_PARAM_REGEX)) {
const paramName = match[1];
const rawValue = match[2];

if (paramName === undefined || rawValue === undefined) {
continue;
}

const decodedValue = decodeValue(rawValue);

if (isPasswordIndicator(paramName) || isPasswordIndicator(decodedValue)) {
flagged.push({
name: paramName,
valueLength: decodedValue.length,
context,
});
}
}

return flagged;
};

const buildDescription = (parameters: FlaggedParam[]): string => {
const details = parameters
.map((param) => {
const lengthText =
param.valueLength === 0
? "empty value"
: param.valueLength === 1
? "1 character"
: `${param.valueLength} characters`;
const contextText =
param.context === "body" ? "response body" : "Location header";

return `- Parameter \`${param.name}\` appears in the ${contextText} with password-like content (${lengthText}).`;
})
.join("\n");

return [
"The response exposes a URL query parameter containing password-like data.",
"",
details,
"",
"Passwords must never be returned to the client. Remove password material from responses and ensure sensitive data is only transmitted during authentication over secure channels.",
].join("\n");
};

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

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

const flaggedParams: FlaggedParam[] = [];

const bodyText = response.getBody()?.toText();
if (bodyText !== undefined && bodyText.length > 0) {
flaggedParams.push(...extractPasswordParams(bodyText, "body"));
}

const locationHeader = response.getHeader("location");
if (locationHeader !== undefined) {
for (const headerValue of locationHeader) {
if (headerValue === undefined || headerValue.length === 0) {
continue;
}
flaggedParams.push(...extractPasswordParams(headerValue, "header"));
}
}

if (flaggedParams.length === 0) {
return done({ state });
}

return done({
state,
findings: [
{
name: "Password returned in URL query string",
description: buildDescription(flaggedParams),
severity: Severity.HIGH,
correlation: {
requestID: context.target.request.getId(),
locations: [],
},
},
],
});
});

return {
metadata: {
id: "password-returned-in-url",
name: "Password returned in URL query string",
description:
"Detects responses that include URLs containing password parameters in the query string.",
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_RETURNED_IN_URL,
enabled: true,
},
],
},
{
Expand Down