/**
* Heuristic auth-form detection. Strategy:
*
* 1. Find every visible `input[type=password]` on the page.
* 2. For each, walk up to the nearest `form` (or document body if
* formless) and look for sibling/descendant fields: a
* username/email-style input + a submit affordance.
* 3. Classify login vs signup by:
* - URL hint (`/signup`, `/register`, `/join` → signup;
* `/login`, `/signin` → login)
* - presence of a confirm-password input (signup)
* - presence of additional fields like `name` (signup)
* - default: login (more common on a generic `/auth` URL)
*
* We deliberately don't try to be clever about i18n. If the page has
* `` it's something we can attack. If we can't
* find a submit button it isn't.
*/
import type { Locator, Page } from "playwright";
import type { AuthFormType, DetectedAuthForm } from "./types.js";
const USERNAME_HINT_RE = /user(name)?|email|login/i;
const SIGNUP_URL_RE = /signup|sign[-_]?up|register|join|create[-_]?account/i;
const LOGIN_URL_RE = /(^|\/)log[-_]?in|signin|sign[-_]?in/i;
const SIGNUP_TEXT_RE = /sign\s*up|create\s+account|register/i;
const LOGIN_TEXT_RE = /log\s*in|sign\s*in/i;
const RESET_URL_RE = /forgot|password[-_]?reset|reset[-_]?password|recover/i;
const RESET_TEXT_RE = /forgot.+password|reset.+password|password.+recovery/i;
export async function detectAuthForm(page: Page): Promise {
// Password-reset forms typically have NO password input — just an
// email + submit. Try that path first when URL / page text hints at
// a reset flow.
const url = page.url();
if (RESET_URL_RE.test(url)) {
const reset = await detectPasswordResetForm(page);
if (reset) return reset;
}
const passwordInputs = await page.locator('input[type="password"]:visible').all();
if (passwordInputs.length === 0) {
// No password input AND no URL hint. Try one more time: maybe the
// page text gives the hint we need.
const bodyText = await page.locator("body").innerText({ timeout: 500 }).catch(() => "");
if (RESET_TEXT_RE.test(bodyText.slice(0, 4000))) {
const reset = await detectPasswordResetForm(page);
if (reset) return reset;
}
return null;
}
// Use the first visible password field. The classifier below decides
// whether a SECOND password field hints at signup.
const primaryPassword = passwordInputs[0]!;
const formHandle = await primaryPassword.evaluateHandle((el) => {
let cur: Element | null = el;
while (cur && cur.tagName !== "FORM") cur = cur.parentElement;
return cur ?? document.body;
});
const formId = await formHandle.evaluate((el: Element) => {
if (el.id) return `#${cssEscape(el.id)}`;
return null;
function cssEscape(s: string) {
return s.replace(/["\\]/g, "\\$&");
}
});
// Scope subsequent queries to the form container. We use a Locator
// for ergonomic chaining; if the form has no id we fall back to a
// page-wide search (which is still scoped to visible elements).
const formLocator = formId ? page.locator(formId).first() : page.locator("body");
// Username/email field: first visible text/email input whose
// name/id/placeholder/aria-label/autocomplete looks user-shaped.
const candidateInputs = await formLocator
.locator('input:visible:not([type="password"]):not([type="hidden"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"]):not([type="button"])')
.all();
let usernameField: Locator | null = null;
let emailField: Locator | null = null;
for (const input of candidateInputs) {
const info = await input.evaluate((el: Element) => {
if (!(el instanceof HTMLInputElement)) return null;
return {
type: (el.type || "text").toLowerCase(),
name: el.name,
id: el.id,
placeholder: el.placeholder,
ariaLabel: el.getAttribute("aria-label") ?? "",
autocomplete: el.autocomplete,
};
});
if (!info) continue;
const haystack = `${info.name} ${info.id} ${info.placeholder} ${info.ariaLabel} ${info.autocomplete}`;
if (info.type === "email" && !emailField) emailField = input;
if (USERNAME_HINT_RE.test(haystack) && !usernameField) usernameField = input;
}
// Fall back to the first text-like input we found.
if (!usernameField && candidateInputs.length > 0) usernameField = candidateInputs[0]!;
if (!usernameField) return null;
const submitButton = await findSubmit(page, formLocator);
if (!submitButton) return null;
const confirmPasswordField = passwordInputs.length > 1 ? passwordInputs[1] : undefined;
const type = classify({
url: page.url(),
confirmPasswordPresent: confirmPasswordField !== undefined,
page,
});
return {
type: await type,
form: formLocator,
usernameField,
passwordField: primaryPassword,
submitButton,
...(confirmPasswordField ? { confirmPasswordField } : {}),
...(emailField && emailField !== usernameField ? { emailField } : {}),
};
}
async function findSubmit(page: Page, form: Locator): Promise {
// Priority 1: explicit submit affordance inside the form.
const explicit = form.locator(
'button[type="submit"]:visible, input[type="submit"]:visible',
);
if ((await explicit.count()) > 0) return explicit.first();
// Priority 2: any button inside the form (some apps omit type=submit).
const anyButton = form.locator("button:visible");
if ((await anyButton.count()) > 0) return anyButton.first();
// Priority 3: a button with login/signup-ish text anywhere on the page.
const anywhere = page.getByRole("button", {
name: /log\s*in|sign\s*in|sign\s*up|register|create\s+account/i,
});
if ((await anywhere.count()) > 0) return anywhere.first();
return null;
}
async function classify(input: {
url: string;
confirmPasswordPresent: boolean;
page: Page;
}): Promise {
if (SIGNUP_URL_RE.test(input.url)) return "signup";
if (LOGIN_URL_RE.test(input.url)) return "login";
if (input.confirmPasswordPresent) return "signup";
// Inspect page text for hints. Cap at 4KB to avoid pulling huge docs.
const bodyText = await input.page
.locator("body")
.innerText({ timeout: 500 })
.catch(() => "");
const snippet = bodyText.slice(0, 4000);
// Prefer signup when both phrases appear — login pages rarely say
// "create account" but signup pages often link back to "log in".
if (SIGNUP_TEXT_RE.test(snippet)) return "signup";
if (LOGIN_TEXT_RE.test(snippet)) return "login";
return "login";
}
/**
* Password-reset form detection — used for the
* `password-reset-token-entropy` attack (issue #93). Reset flows
* typically have NO password input — just an email + submit — so the
* primary detector won't find them.
*/
async function detectPasswordResetForm(page: Page): Promise {
const emailInput = page.locator('input[type="email"]:visible, input[name*="email" i]:visible').first();
if ((await emailInput.count()) === 0) return null;
const formLocator = page.locator("body");
const submit = await findSubmit(page, formLocator);
if (!submit) return null;
// The driver expects passwordField to be defined. For reset forms
// the field doesn't exist — use the email locator as a stand-in so
// attack code that conditions on form.type doesn't have to special-case.
return {
type: "password-reset",
form: formLocator,
usernameField: emailInput,
emailField: emailInput,
passwordField: emailInput,
submitButton: submit,
};
}