diff --git a/packages/backend/src/checks/index.ts b/packages/backend/src/checks/index.ts index 1c28c38..925c902 100644 --- a/packages/backend/src/checks/index.ts +++ b/packages/backend/src/checks/index.ts @@ -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"; @@ -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", @@ -99,6 +101,7 @@ export const checks = [ hashDisclosureScan, jsonHtmlResponseScan, missingContentTypeScan, + passwordReturnedInUrlScan, openRedirectScan, pathTraversalScan, phpinfoScan, diff --git a/packages/backend/src/checks/password-returned-in-url/index.spec.ts b/packages/backend/src/checks/password-returned-in-url/index.spec.ts new file mode 100644 index 0000000..9353862 --- /dev/null +++ b/packages/backend/src/checks/password-returned-in-url/index.spec.ts @@ -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 => { + 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: 'link', + }); + + 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: 'link', + }); + + expect(findings).toHaveLength(0); + }); +}); diff --git a/packages/backend/src/checks/password-returned-in-url/index.ts b/packages/backend/src/checks/password-returned-in-url/index.ts new file mode 100644 index 0000000..2d5870d --- /dev/null +++ b/packages/backend/src/checks/password-returned-in-url/index.ts @@ -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>(({ 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, + }; +}); diff --git a/packages/backend/src/stores/config.ts b/packages/backend/src/stores/config.ts index 6b716b7..76f22b2 100644 --- a/packages/backend/src/stores/config.ts +++ b/packages/backend/src/stores/config.ts @@ -188,6 +188,10 @@ export class ConfigStore { checkID: Checks.MISSING_CONTENT_TYPE, enabled: true, }, + { + checkID: Checks.PASSWORD_RETURNED_IN_URL, + enabled: true, + }, ], }, {