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 passwordAutocompleteScan from "./password-autocomplete";
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_AUTOCOMPLETE: "password-autocomplete",
OPEN_REDIRECT: "open-redirect",
PATH_TRAVERSAL: "path-traversal",
PHPINFO: "phpinfo",
Expand Down Expand Up @@ -100,6 +102,7 @@ export const checks = [
jsonHtmlResponseScan,
missingContentTypeScan,
openRedirectScan,
passwordAutocompleteScan,
pathTraversalScan,
phpinfoScan,
privateIpDisclosureScan,
Expand Down
65 changes: 65 additions & 0 deletions packages/backend/src/checks/password-autocomplete/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { createMockRequest, createMockResponse, runCheck } from "engine";
import { describe, expect, it } from "vitest";

import passwordAutocompleteCheck from "./index";

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

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

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

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

describe("Password autocomplete check", () => {
it("flags password inputs without autocomplete attribute", async () => {
const findings = await runAutocompleteCheck(
'<input type="password" name="password">',
);

expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
name: "Password field with autocomplete enabled",
severity: "low",
});
});

it("flags password inputs with autocomplete set to on", async () => {
const findings = await runAutocompleteCheck(
'<input type="password" name="password" autocomplete="on">',
);

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

it("does not flag password inputs with autocomplete off", async () => {
const findings = await runAutocompleteCheck(
'<input type="password" name="password" autocomplete="off">',
);

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

it("does not flag password inputs with autocomplete new-password", async () => {
const findings = await runAutocompleteCheck(
'<input type="password" name="password" autocomplete="new-password">',
);

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

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

type FlaggedField = {
hasAttribute: boolean;
attributeValue?: string;
};

const PASSWORD_INPUT_REGEX = /<input\b[^>]*type=(["'])password\1[^>]*>/gi;
const AUTOCOMPLETE_REGEX = /autocomplete=(["'])([^"']+)\1/i;

const isAutocompleteDisabled = (value: string): boolean => {
const normalized = value.trim().toLowerCase();
return normalized === "off" || normalized === "new-password";
};

const collectFlaggedFields = (body: string): FlaggedField[] => {
const fields: FlaggedField[] = [];

for (const match of body.matchAll(PASSWORD_INPUT_REGEX)) {
const inputTag = match[0];
if (inputTag === undefined) {
continue;
}

const autocompleteMatch = inputTag.match(AUTOCOMPLETE_REGEX);
const autocompleteValue = autocompleteMatch?.[2];

if (autocompleteValue === undefined) {
fields.push({ hasAttribute: false });
continue;
}

if (!isAutocompleteDisabled(autocompleteValue)) {
fields.push({
hasAttribute: true,
attributeValue: autocompleteValue,
});
}
}

return fields;
};

const buildDescription = (fields: FlaggedField[]): string => {
const details = fields
.map((field, index) => {
if (field.hasAttribute) {
return `- Password input #${index + 1} sets \`autocomplete="${field.attributeValue ?? ""}"\`.`;
}
return `- Password input #${index + 1} omits the \`autocomplete\` attribute, allowing browsers to autofill credentials.`;
})
.join("\n");

return [
"The response contains password fields that permit browser autocomplete.",
"",
details,
"",
'Autocomplete should be disabled for password inputs using `autocomplete="off"` or `autocomplete="new-password"` to reduce the risk of credential theft on shared machines and shoulder-surfing attacks.',
].join("\n");
};

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

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

const body = response.getBody()?.toText();
if (body === undefined || body.length === 0) {
return done({ state });
}

const flaggedFields = collectFlaggedFields(body);
if (flaggedFields.length === 0) {
return done({ state });
}

return done({
state,
findings: [
{
name: "Password field with autocomplete enabled",
description: buildDescription(flaggedFields),
severity: Severity.LOW,
correlation: {
requestID: context.target.request.getId(),
locations: [],
},
},
],
});
});

return {
metadata: {
id: "password-autocomplete",
name: "Password field with autocomplete enabled",
description:
"Detects password inputs that allow browser autocomplete instead of explicitly disabling it.",
type: "passive",
tags: [Tags.PASSWORD, Tags.INFORMATION_DISCLOSURE],
severities: [Severity.LOW],
aggressivity: {
minRequests: 0,
maxRequests: 0,
},
},
initState: () => ({}),
dedupeKey: keyStrategy().withHost().withPath().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_AUTOCOMPLETE,
enabled: true,
},
],
},
{
Expand Down