diff --git a/packages/backend/src/checks/index.ts b/packages/backend/src/checks/index.ts index 1c28c38..381294e 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 passwordAutocompleteScan from "./password-autocomplete"; 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_AUTOCOMPLETE: "password-autocomplete", OPEN_REDIRECT: "open-redirect", PATH_TRAVERSAL: "path-traversal", PHPINFO: "phpinfo", @@ -100,6 +102,7 @@ export const checks = [ jsonHtmlResponseScan, missingContentTypeScan, openRedirectScan, + passwordAutocompleteScan, pathTraversalScan, phpinfoScan, privateIpDisclosureScan, diff --git a/packages/backend/src/checks/password-autocomplete/index.spec.ts b/packages/backend/src/checks/password-autocomplete/index.spec.ts new file mode 100644 index 0000000..9a28c80 --- /dev/null +++ b/packages/backend/src/checks/password-autocomplete/index.spec.ts @@ -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 => { + 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( + '', + ); + + 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( + '', + ); + + expect(findings).toHaveLength(1); + }); + + it("does not flag password inputs with autocomplete off", async () => { + const findings = await runAutocompleteCheck( + '', + ); + + expect(findings).toHaveLength(0); + }); + + it("does not flag password inputs with autocomplete new-password", async () => { + const findings = await runAutocompleteCheck( + '', + ); + + expect(findings).toHaveLength(0); + }); +}); diff --git a/packages/backend/src/checks/password-autocomplete/index.ts b/packages/backend/src/checks/password-autocomplete/index.ts new file mode 100644 index 0000000..9481de6 --- /dev/null +++ b/packages/backend/src/checks/password-autocomplete/index.ts @@ -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 = /]*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>(({ 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, + }; +}); diff --git a/packages/backend/src/stores/config.ts b/packages/backend/src/stores/config.ts index 6b716b7..ef6aa9b 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_AUTOCOMPLETE, + enabled: true, + }, ], }, {