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 passwordGetSubmissionScan from "./password-get-submission";
import pathTraversalScan from "./path-traversal";
import phpinfoScan from "./phpinfo";
import privateIpDisclosureScan from "./private-ip-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",
PASSWORD_GET_SUBMISSION: "password-get-submission",
// 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,
passwordGetSubmissionScan,
// mysqlTimeBased,
] as const;
195 changes: 195 additions & 0 deletions packages/backend/src/checks/password-get-submission/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { createMockRequest, createMockResponse, runCheck } from "engine";
import { describe, expect, it } from "vitest";

import passwordGetCheck from "./index";

const buildTarget = (config: {
method: string;
path?: string;
query?: string;
}): {
request: ReturnType<typeof createMockRequest>;
response: ReturnType<typeof createMockResponse>;
} => {
const request = createMockRequest({
id: `req-${config.method.toLowerCase()}`,
host: "example.com",
method: config.method,
path: config.path ?? "/login",
query: config.query,
headers: { Host: ["example.com"] },
});

const response = createMockResponse({
id: `res-${config.method.toLowerCase()}`,
code: 200,
headers: { "content-type": ["text/html"] },
body: "<html></html>",
});

return { request, response };
};

const extractFindings = async (
target: ReturnType<typeof buildTarget>,
): Promise<unknown[]> => {
const execution = await runCheck(passwordGetCheck, [target]);
return execution[0]?.steps[execution[0].steps.length - 1]?.findings ?? [];
};

describe("Password submitted using GET method check", () => {
describe("Detection", () => {
it("flags GET requests with password query parameter", async () => {
const target = buildTarget({
method: "GET",
query: "username=user&password=secret123",
});

const findings = await extractFindings(target);
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: "high",
name: "Password submitted using GET method",
});
});

it("flags GET requests with derived password parameter names", async () => {
const target = buildTarget({
method: "GET",
query: "userPassword=s3cr3t",
});

const findings = await extractFindings(target);
expect(findings).toHaveLength(1);
});

it("detects passwd keyword in parameter name", async () => {
const target = buildTarget({
method: "GET",
query: "user_passwd=hunter2",
});

const findings = await extractFindings(target);
expect(findings).toHaveLength(1);
});

it("detects pwd keyword in parameter name", async () => {
const target = buildTarget({
method: "GET",
query: "usrpwd=secret",
});

const findings = await extractFindings(target);
expect(findings).toHaveLength(1);
});

it("detects passcode keyword in parameter name", async () => {
const target = buildTarget({
method: "GET",
query: "login-passcode=1234",
});

const findings = await extractFindings(target);
expect(findings).toHaveLength(1);
});

it("detects password keywords with separators", async () => {
const target = buildTarget({
method: "GET",
query: "user-password=val1&user_pwd=val2",
});

const findings = await extractFindings(target);
expect(findings).toHaveLength(1);
expect(findings[0].description).toContain("user-password");
expect(findings[0].description).toContain("user_pwd");
});

it("detects password keyword in brackets", async () => {
const target = buildTarget({
method: "GET",
query: "data[password]=secret",
});

const findings = await extractFindings(target);
expect(findings).toHaveLength(1);
});
});

describe("False Positive Prevention", () => {
it("does not flag when password-like parameters are absent", async () => {
const target = buildTarget({
method: "GET",
query: "username=user&token=abc",
});

const findings = await extractFindings(target);
expect(findings).toHaveLength(0);
});

it("does not flag non-GET requests", async () => {
const target = buildTarget({
method: "POST",
query: "password=secret",
});

const findings = await extractFindings(target);
expect(findings).toHaveLength(0);
});

it("does not flag PUT requests with password parameters", async () => {
const target = buildTarget({
method: "PUT",
query: "password=secret",
});

const findings = await extractFindings(target);
expect(findings).toHaveLength(0);
});

it("does not flag bypass or passport keywords", async () => {
const target = buildTarget({
method: "GET",
query: "bypass=1&passport_id=ABC123",
});

const findings = await extractFindings(target);
expect(findings).toHaveLength(0);
});
});

describe("Edge Cases", () => {
it("handles empty password parameter value", async () => {
const target = buildTarget({
method: "GET",
query: "password=",
});

const findings = await extractFindings(target);
expect(findings).toHaveLength(1);
expect(findings[0].description).toContain("empty value");
});

it("handles GET requests with no query string", async () => {
const target = buildTarget({
method: "GET",
query: undefined,
});

const findings = await extractFindings(target);
expect(findings).toHaveLength(0);
});

it("includes security guidance about POST and HTTPS", async () => {
const target = buildTarget({
method: "GET",
query: "password=secret",
});

const findings = await extractFindings(target);
expect(findings[0].description).toContain("POST-based submission");
expect(findings[0].description).toContain("HTTPS");
expect(findings[0].description).toContain("browser history");
});
});
});
122 changes: 122 additions & 0 deletions packages/backend/src/checks/password-get-submission/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { defineCheck, done, Severity } from "engine";

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

type FindingParam = {
name: string;
length: number;
};

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

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

const isPasswordParameter = (name: string): boolean => {
const lowerName = name.toLowerCase();

if (lowerName.includes("password")) {
return true;
}

const normalized = sanitizeName(name);
return PASSWORD_KEYWORDS.some((keyword) => normalized.includes(keyword));
};

const buildFindingDescription = (params: FindingParam[]): string => {
const details = params
.map((param) => {
const lengthText =
param.length === 0
? "empty value"
: param.length === 1
? "1 character"
: `${param.length} characters`;
return `- Query parameter \`${param.name}\` appears to contain a password submitted via GET (${lengthText}).`;
})
.join("\n");

return [
"A password-like parameter was detected in the URL query string of a `GET` request.",
"",
details,
"",
"Transmitting credentials in the URL exposes them to browser history, intermediary logs, and referrer headers. Switch to a POST-based submission and ensure the connection is protected with HTTPS.",
].join("\n");
};

export default defineCheck<unknown>(({ step }) => {
step("inspectQueryParameters", (state, context) => {
const request = context.target.request;

if (request.getMethod().toUpperCase() !== "GET") {
return done({ state });
}

const query = request.getQuery();
if (query === undefined || query.length === 0) {
return done({ state });
}

const urlParams = new URLSearchParams(query);
const passwordParams: FindingParam[] = [];

for (const [name, value] of urlParams.entries()) {
if (value === undefined) {
continue;
}

if (isPasswordParameter(name)) {
passwordParams.push({ name, length: value.length });
}
}

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

return done({
state,
findings: [
{
name: "Password submitted using GET method",
description: buildFindingDescription(passwordParams),
severity: Severity.HIGH,
correlation: {
requestID: request.getId(),
locations: [],
},
},
],
});
});

return {
metadata: {
id: "password-get-submission",
name: "Password submitted using GET method",
description:
"Detects GET requests where password-like parameters are transmitted in the URL query string.",
type: "passive",
tags: [Tags.PASSWORD, Tags.INFORMATION_DISCLOSURE],
severities: [Severity.HIGH],
aggressivity: {
minRequests: 0,
maxRequests: 0,
},
},
initState: () => ({}),
dedupeKey: keyStrategy().withHost().withPath().withQueryKeys().build(),
when: (target) => target.request.getMethod().toUpperCase() === "GET",
};
});
4 changes: 4 additions & 0 deletions packages/backend/src/stores/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,10 @@ export class ConfigStore {
checkID: Checks.DEBUG_ERRORS,
enabled: true,
},
{
checkID: Checks.PASSWORD_GET_SUBMISSION,
enabled: true,
},
{
checkID: Checks.CREDIT_CARD_DISCLOSURE,
enabled: true,
Expand Down
Loading