Skip to content
Merged
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
49 changes: 38 additions & 11 deletions apps/extension/entrypoints/autofill.content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ import {
fillLoginFields,
fillProfileField,
fillRegistrationPasswordFields,
filterCredentialsForUsername,
generateAdaptiveRegistrationPassword,
isCredentialField,
isLoginAction,
isProfileOrRegistrationEmailField,
isRegistrationEmailField,
isRegistrationPasswordField,
isUsernameField,
Expand Down Expand Up @@ -60,6 +62,7 @@ export default defineContentScript({
let lastFilledCredential: { readonly password: string; readonly username: string } | null =
null;
const suppressedUsernameInputs = new WeakSet<HTMLInputElement>();
const knownUsernamesByInput = new WeakMap<HTMLInputElement, readonly string[]>();
const generatedPasswords = new WeakMap<object, Map<RegistrationPasswordStyle, string>>();
const closePrompt = () => {
promptCleanup?.();
Expand Down Expand Up @@ -350,6 +353,23 @@ export default defineContentScript({
return;
}
if (response?.status !== "suggestions") return;
const currentUsernameField = usernameFieldForCredentialAnchor(document, anchor);
if (currentUsernameField !== null) {
knownUsernamesByInput.set(
currentUsernameField,
response.credentials.map((credential) => credential.username),
);
}
const credentials = filterCredentialsForUsername(
currentUsernameField?.value ?? "",
response.credentials,
);
if (credentials.length === 0) {
if (currentUsernameField !== null) suppressedUsernameInputs.add(currentUsernameField);
closePrompt();
return;
}
if (currentUsernameField !== null) suppressedUsernameInputs.delete(currentUsernameField);
const authenticateAndFill = (credentialId: string) => {
closePrompt();
void sendMessage({
Expand All @@ -362,14 +382,14 @@ export default defineContentScript({
version: 1,
});
};
const options = response.credentials.map((credential) => ({
const options = credentials.map((credential) => ({
detail: "Verify and fill",
icon: response.deviceSlots.length > 0 ? "◎" : "●",
label: credential.username || "Saved login",
run: () => authenticateAndFill(credential.id),
}));
prompt("suggestions", "Passwords", response.displayHost, options, anchor);
promptUsernames = response.credentials.map((credential) => credential.username);
promptUsernames = credentials.map((credential) => credential.username);
})
.finally(() => {
if (!extensionContextActive) return;
Expand Down Expand Up @@ -681,16 +701,23 @@ export default defineContentScript({
(event) => {
if (
!event.isTrusted ||
promptKind !== "suggestions" ||
!(event.target instanceof HTMLInputElement) ||
!isUsernameField(event.target)
) {
return;
}
if (shouldDismissSuggestionsForUsername(event.target.value, promptUsernames)) {
const storedUsernames =
knownUsernamesByInput.get(event.target) ??
(promptKind === "suggestions" ? promptUsernames : null);
if (storedUsernames === null) return;
if (shouldDismissSuggestionsForUsername(event.target.value, storedUsernames)) {
suppressedUsernameInputs.add(event.target);
closePrompt();
if (promptKind === "suggestions") closePrompt();
return;
}
suppressedUsernameInputs.delete(event.target);
if (promptKind === "suggestions") closePrompt();
requestSuggestions(event.target);
},
true,
);
Expand All @@ -716,7 +743,7 @@ export default defineContentScript({
event.isTrusted &&
window.top === window &&
target instanceof HTMLInputElement &&
profileFieldKind(target) !== null
isProfileOrRegistrationEmailField(target)
) {
requestProfileOrPrivateEmail(target);
} else if (event.isTrusted && window.top === window && isCredentialField(target)) {
Expand Down Expand Up @@ -756,7 +783,7 @@ export default defineContentScript({
requestCardSuggestions(event.target);
} else if (
event.target instanceof HTMLInputElement &&
profileFieldKind(event.target) !== null
isProfileOrRegistrationEmailField(event.target)
) {
requestProfileOrPrivateEmail(event.target);
} else if (isCredentialField(event.target)) {
Expand Down Expand Up @@ -817,7 +844,7 @@ export default defineContentScript({
return (
isRegistrationPasswordField(input) ||
cardFieldKind(input) !== null ||
profileFieldKind(input) !== null ||
isProfileOrRegistrationEmailField(input) ||
isCredentialField(input)
);
}) ?? null);
Expand All @@ -827,7 +854,7 @@ export default defineContentScript({
requestStrongPassword(anchor);
} else if (cardFieldKind(anchor) !== null) {
requestCardSuggestions(anchor);
} else if (profileFieldKind(anchor) !== null) {
} else if (isProfileOrRegistrationEmailField(anchor)) {
requestProfileOrPrivateEmail(anchor);
} else {
requestSuggestions(anchor);
Expand Down Expand Up @@ -878,14 +905,14 @@ export default defineContentScript({
document.activeElement instanceof HTMLInputElement &&
(isCredentialField(document.activeElement) ||
cardFieldKind(document.activeElement) !== null ||
profileFieldKind(document.activeElement) !== null)
isProfileOrRegistrationEmailField(document.activeElement))
) {
const active = document.activeElement;
if (isRegistrationPasswordField(active)) {
requestStrongPassword(active);
} else if (cardFieldKind(active) !== null) {
requestCardSuggestions(active);
} else if (profileFieldKind(active) !== null) {
} else if (isProfileOrRegistrationEmailField(active)) {
requestProfileOrPrivateEmail(active);
} else {
requestSuggestions(active);
Expand Down
50 changes: 50 additions & 0 deletions apps/extension/src/autofill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ import {
fillLoginFields,
fillProfileField,
fillRegistrationPasswordFields,
filterCredentialsForUsername,
generateAdaptiveRegistrationPassword,
generateStrongRegistrationPassword,
isCredentialField,
isLoginAction,
isProfileOrRegistrationEmailField,
isRegistrationEmailField,
isRegistrationPasswordField,
isUsernameField,
Expand Down Expand Up @@ -137,6 +139,16 @@ describe("extension automatic autofill", () => {
expect(shouldDismissSuggestionsForUsername("typed", null)).toBe(true);
});

it("filters delayed credential suggestions against the current username", () => {
const credentials = [
{ id: "student", username: "Student" },
{ id: "person", username: "person@example.test" },
];
expect(filterCredentialsForUsername("", credentials)).toEqual(credentials);
expect(filterCredentialsForUsername("STU", credentials)).toEqual([credentials[0]]);
expect(filterCredentialsForUsername("pk", credentials)).toEqual([]);
});

it("associates password-field suggestion requests with their username field", () => {
document.body.innerHTML = `
<form>
Expand Down Expand Up @@ -318,6 +330,26 @@ describe("extension automatic autofill", () => {
expect(password?.value).toBe("secret");
});

it("completes a case-insensitive username prefix before filling the password", () => {
document.body.innerHTML = `
<form>
<input autocomplete="username" value="S">
<input type="password" autocomplete="current-password">
</form>
`;
const username = document.querySelector<HTMLInputElement>("[autocomplete=username]");
const password = document.querySelector<HTMLInputElement>("[type=password]");
let inputEvents = 0;
document.addEventListener("input", () => {
inputEvents += 1;
});

expect(fillLoginFields(document, { password: "Password123", username: "student" })).toBe(true);
expect(username?.value).toBe("student");
expect(password?.value).toBe("Password123");
expect(inputEvents).toBe(2);
});

it("fills a login form whose site incorrectly marks its password as new-password", () => {
document.body.innerHTML = `
<form id="login">
Expand Down Expand Up @@ -830,6 +862,24 @@ describe("extension automatic autofill", () => {
expect(isRegistrationEmailField(document.querySelector("#login-email"))).toBe(false);
});

it("routes a legacy metadata-free registration email field to private email", () => {
document.body.innerHTML = `
<h2>Register</h2>
<form id="basicBootstrapForm">
<label>Email address*</label>
<input type="email" required>
<input id="firstpassword" type="password" required>
<input id="secondpassword" type="password" required>
<button type="submit" name="signup" value="sign up">Submit</button>
</form>
`;
const email = document.querySelector<HTMLInputElement>('input[type="email"]');

expect(email === null ? null : profileFieldKind(email)).toBeNull();
expect(isRegistrationEmailField(email)).toBe(true);
expect(isProfileOrRegistrationEmailField(email)).toBe(true);
});

it("supports passwordless multi-step signup but rejects ambiguous standalone email fields", () => {
document.body.innerHTML = `
<form id="registration-step-one" action="/accounts/register">
Expand Down
40 changes: 34 additions & 6 deletions apps/extension/src/autofill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,17 @@ export function shouldDismissSuggestionsForUsername(
return !storedUsernames.some((username) => normalizedUsername(username).startsWith(typed));
}

export function filterCredentialsForUsername<T extends { readonly username: string }>(
value: string,
credentials: readonly T[],
): readonly T[] {
const typed = normalizedUsername(value);
if (typed.length === 0) return credentials;
return credentials.filter((credential) =>
normalizedUsername(credential.username).startsWith(typed),
);
}

function isInvalidatedExtensionContext(error: unknown): boolean {
return error instanceof Error && /extension context invalidated/iu.test(error.message);
}
Expand Down Expand Up @@ -788,6 +799,15 @@ export function isRegistrationEmailField(element: Element | null): element is HT
return evidence.positive && !evidence.negative;
}

export function isProfileOrRegistrationEmailField(
element: Element | null,
): element is HTMLInputElement {
return (
element instanceof HTMLInputElement &&
(isRegistrationEmailField(element) || profileFieldKind(element) !== null)
);
}

export function isRegistrationPasswordField(element: Element | null): element is HTMLInputElement {
if (
!(element instanceof HTMLInputElement) ||
Expand Down Expand Up @@ -1153,10 +1173,11 @@ function selectLoginFields(
if (!renderedForCredentialUse(password)) return false;
if (credential === undefined) return true;
if (password.value.length > 0 && password.value !== credential.password) return false;
return !(
username !== undefined &&
username.value.length > 0 &&
normalizedUsername(username.value) !== normalizedUsername(credential.username)
if (username === undefined || username.value.length === 0) return true;
const currentUsername = normalizedUsername(username.value);
const credentialUsername = normalizedUsername(credential.username);
return (
currentUsername === credentialUsername || credentialUsername.startsWith(currentUsername)
);
})
.map((fields) => {
Expand Down Expand Up @@ -1217,8 +1238,15 @@ export function fillLoginFields(
input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" }));
input.dispatchEvent(new Event("change", { bubbles: true }));
};
if (fields.username !== undefined && fields.username.value.length === 0) {
setValue(fields.username, credential.username);
if (fields.username !== undefined) {
const currentUsername = normalizedUsername(fields.username.value);
const credentialUsername = normalizedUsername(credential.username);
if (
currentUsername.length === 0 ||
(currentUsername !== credentialUsername && credentialUsername.startsWith(currentUsername))
) {
setValue(fields.username, credential.username);
}
}
if (fields.password.value.length === 0) setValue(fields.password, credential.password);
return (
Expand Down
Loading