Added autofillPasskeys feature that intercepts navigator.credentials.get() on Windows - #2571
Conversation
Fixed magic strings. Added passthrough flow if passkey autofill is disabled on an user preference level.
[Beta] Generated file diffTime updated: Tue, 02 Jun 2026 12:03:29 GMT Android
File has changed Apple
File has changed Chrome-mv3File has changed FirefoxFile has changed Integration
File has changed Windows
File has changed |
|
This PR requires a manual review and approval from a member of one of the following teams:
|
Build Branch
Static preview entry points
QR codes (mobile preview)
Integration commandsnpm (Android / Extension): Swift Package Manager (Apple): .package(url: "https://github.com/duckduckgo/content-scope-scripts.git", branch: "pr-releases/balint/feature/passkey-autofill")git submodule (Windows): git -C submodules/content-scope-scripts fetch origin pr-releases/balint/feature/passkey-autofill
git -C submodules/content-scope-scripts checkout origin/pr-releases/balint/feature/passkey-autofillPin to exact commitnpm (Android / Extension): Swift Package Manager (Apple): .package(url: "https://github.com/duckduckgo/content-scope-scripts.git", revision: "672040ec7473e74f4ed7244f5623c19830cc3e95")git submodule (Windows): git -C submodules/content-scope-scripts fetch origin pr-releases/balint/feature/passkey-autofill
git -C submodules/content-scope-scripts checkout 672040ec7473e74f4ed7244f5623c19830cc3e95 |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Caller's options object mutated by event handler
- The passkey-selected path now builds a shallow-copied options object (including
publicKey) before modifyingallowCredentialsand removingmediation, avoiding mutation of the caller’s input.
- The passkey-selected path now builds a shallow-copied options object (including
- ✅ Fixed: Repeated conditional calls silently orphan pending promises
- Before storing a new conditional request, any existing pending request is now rejected with an
AbortErrorand cleared so no promise is orphaned.
- Before storing a new conditional request, any existing pending request is now rejected with an
Or push these changes by commenting:
@cursor push e63efb89aa
Preview (e63efb89aa)
diff --git a/injected/src/features/autofill-passkeys.js b/injected/src/features/autofill-passkeys.js
--- a/injected/src/features/autofill-passkeys.js
+++ b/injected/src/features/autofill-passkeys.js
@@ -29,17 +29,20 @@
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
const options = /** @type {CredentialRequestOptions} */ (pendingOptions);
- if (options.publicKey) {
- options.publicKey.allowCredentials = [{ type: CREDENTIAL_TYPE_PUBLIC_KEY, id: arr.buffer }];
- }
- delete options.mediation;
+ const passkeyOptions = {
+ ...options,
+ publicKey: options.publicKey
+ ? { ...options.publicKey, allowCredentials: [{ type: CREDENTIAL_TYPE_PUBLIC_KEY, id: arr.buffer }] }
+ : options.publicKey,
+ };
+ delete passkeyOptions.mediation;
const resolve = pendingResolve;
const reject = /** @type {(reason?: unknown) => void} */ (pendingReject);
pendingResolve = pendingReject = pendingOptions = null;
try {
- const credential = await savedOriginalGet(options);
+ const credential = await savedOriginalGet(passkeyOptions);
resolve(credential);
} catch (e) {
reject(e);
@@ -47,25 +50,38 @@
}
});
- this.wrapMethod(CredentialsContainer.prototype, 'get', /** @this {CredentialsContainer} */ function (originalGet, options) {
- if (options?.mediation !== MEDIATION_CONDITIONAL) {
- return originalGet.call(this, options);
- }
+ this.wrapMethod(
+ CredentialsContainer.prototype,
+ 'get',
+ /** @this {CredentialsContainer} */ function (originalGet, options) {
+ if (options?.mediation !== MEDIATION_CONDITIONAL) {
+ return originalGet.call(this, options);
+ }
- const rpId = options?.publicKey?.rpId || location.hostname;
+ if (pendingReject) {
+ const abortError =
+ typeof DOMException === 'function'
+ ? new DOMException('The operation was aborted.', 'AbortError')
+ : Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' });
+ pendingReject(abortError);
+ pendingResolve = pendingReject = pendingOptions = null;
+ }
- // @ts-expect-error windowsInteropPostMessage is a Windows-specific global
- windowsInteropPostMessage({
- Feature: MSG_OUTBOUND_FEATURE,
- Name: MSG_OUTBOUND_NAME,
- Data: { rpId },
- });
+ const rpId = options?.publicKey?.rpId || location.hostname;
- return new Promise(function (resolve, reject) {
- pendingResolve = resolve;
- pendingReject = reject;
- pendingOptions = options;
- });
- });
+ // @ts-expect-error windowsInteropPostMessage is a Windows-specific global
+ windowsInteropPostMessage({
+ Feature: MSG_OUTBOUND_FEATURE,
+ Name: MSG_OUTBOUND_NAME,
+ Data: { rpId },
+ });
+
+ return new Promise(function (resolve, reject) {
+ pendingResolve = resolve;
+ pendingReject = reject;
+ pendingOptions = options;
+ });
+ },
+ );
}
}There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Errors before try/catch leave pending promise hanging forever
- I moved pending callback capture/cleanup before risky parsing and wrapped base64 decode plus option preparation in the existing try/catch so malformed credential IDs now reject the pending promise instead of leaving it unresolved.
Or push these changes by commenting:
@cursor push 181b559127
Preview (181b559127)
diff --git a/injected/src/features/autofill-passkeys.js b/injected/src/features/autofill-passkeys.js
--- a/injected/src/features/autofill-passkeys.js
+++ b/injected/src/features/autofill-passkeys.js
@@ -24,21 +24,21 @@
if (!pendingResolve) return;
if (event.data?.type === MSG_INBOUND_PASSKEY_SELECTED) {
- const raw = atob(event.data.credentialId);
- const arr = new Uint8Array(raw.length);
- for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
-
- const options = /** @type {CredentialRequestOptions} */ (pendingOptions);
- if (options.publicKey) {
- options.publicKey.allowCredentials = [{ type: CREDENTIAL_TYPE_PUBLIC_KEY, id: arr.buffer }];
- }
- delete options.mediation;
-
const resolve = pendingResolve;
const reject = /** @type {(reason?: unknown) => void} */ (pendingReject);
+ const options = /** @type {CredentialRequestOptions} */ (pendingOptions);
pendingResolve = pendingReject = pendingOptions = null;
try {
+ const raw = atob(event.data.credentialId);
+ const arr = new Uint8Array(raw.length);
+ for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
+
+ if (options.publicKey) {
+ options.publicKey.allowCredentials = [{ type: CREDENTIAL_TYPE_PUBLIC_KEY, id: arr.buffer }];
+ }
+ delete options.mediation;
+
const credential = await savedOriginalGet(options);
resolve(credential);
} catch (e) {
@@ -47,25 +47,29 @@
}
});
- this.wrapMethod(CredentialsContainer.prototype, 'get', /** @this {CredentialsContainer} */ function (originalGet, options) {
- if (options?.mediation !== MEDIATION_CONDITIONAL) {
- return originalGet.call(this, options);
- }
+ this.wrapMethod(
+ CredentialsContainer.prototype,
+ 'get',
+ /** @this {CredentialsContainer} */ function (originalGet, options) {
+ if (options?.mediation !== MEDIATION_CONDITIONAL) {
+ return originalGet.call(this, options);
+ }
- const rpId = options?.publicKey?.rpId || location.hostname;
+ const rpId = options?.publicKey?.rpId || location.hostname;
- // @ts-expect-error windowsInteropPostMessage is a Windows-specific global
- windowsInteropPostMessage({
- Feature: MSG_OUTBOUND_FEATURE,
- Name: MSG_OUTBOUND_NAME,
- Data: { rpId },
- });
+ // @ts-expect-error windowsInteropPostMessage is a Windows-specific global
+ windowsInteropPostMessage({
+ Feature: MSG_OUTBOUND_FEATURE,
+ Name: MSG_OUTBOUND_NAME,
+ Data: { rpId },
+ });
- return new Promise(function (resolve, reject) {
- pendingResolve = resolve;
- pendingReject = reject;
- pendingOptions = options;
- });
- });
+ return new Promise(function (resolve, reject) {
+ pendingResolve = resolve;
+ pendingReject = reject;
+ pendingOptions = options;
+ });
+ },
+ );
}
}There was a problem hiding this comment.
Stale comment
Web Compatibility Assessment
injected/src/features/autofill-passkeys.jsL50-L69error: The implementation stores only one global pending request (pendingResolve/pendingReject/pendingOptions). Concurrentnavigator.credentials.get({ mediation: 'conditional' })calls overwrite each other, which can leave earlier Promises unresolved or resolve the wrong caller.injected/src/features/autofill-passkeys.jsL23-L47andL62-L69error: The wrapped method has no completion path exceptpasskeySelected. If native sends no message, sends a different message (e.g., cancel), or payload processing throws, the page Promise never settles, causing a hard hang in auth flows.injected/src/features/autofill-passkeys.jsL57-L65andL62-L69warning:windowsInteropPostMessageis called before pending handlers are registered. If the response can arrive synchronously/very early, the listener drops it (!pendingResolve) and the Promise remains pending.injected/src/features/autofill-passkeys.jsL31-L35warning: The code mutates caller-providedoptionsin place (allowCredentialsassignment +delete options.mediation). If the app passes frozen/immutable options objects, this can throw and break credential flows.Security Assessment
injected/src/features/autofill-passkeys.jsL23-L47error: Inbound interop messages are trusted based only onevent.data?.type === 'passkeySelected'with no request ID / nonce correlation. A stale or spoofed message can be applied to the current pending request, violating request integrity.injected/src/features/autofill-passkeys.jsL27-L31warning:credentialIdis decoded without schema/type validation and without a guarded failure path. Malformed payloads can throw before state cleanup, creating a persistent DoS condition (stuck pending request).Risk Level
High Risk: This PR introduces a new
CredentialsContainer.getoverride on Windows plus a native-message-driven control path; current request lifecycle/correlation gaps can cause both auth-flow breakage and message-integrity issues.Recommendations
- Replace single pending state with per-request tracking (
Map<requestId, {resolve,reject,options}>) and includerequestIdin both outboundregisterPasskeyRequestand inboundpasskeySelectedpayloads.- Add explicit settle paths for cancel/timeout/error and always clear pending state in
finallyto avoid unresolved Promises.- Register pending request state before posting to native, or buffer inbound events until registration is complete.
- Validate inbound payload shape (
type,credentialIdtype/size/base64) before processing; reject the specific request on validation failure.- Avoid mutating caller options; create a defensive cloned options object for the
savedOriginalGetcall.- Add integration tests covering: concurrent conditional gets, user-cancel/no-response, malformed payload, and immutable options objects.
Sent by Cursor Automation: Web compat and sec
…icKey to be truthy
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 4 total unresolved issues (including 3 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Intercepted promise ignores AbortSignal during pending phase
- The conditional mediation interceptor now rejects immediately on pre-aborted signals and listens for
abortwhile pending to reject and clean up instead of hanging or forwarding an already-aborted signal to native get.
- The conditional mediation interceptor now rejects immediately on pre-aborted signals and listens for
Or push these changes by commenting:
@cursor push 98e385c5ef
Preview (98e385c5ef)
diff --git a/injected/src/features/autofill-passkeys.js b/injected/src/features/autofill-passkeys.js
--- a/injected/src/features/autofill-passkeys.js
+++ b/injected/src/features/autofill-passkeys.js
@@ -16,6 +16,10 @@
let pendingReject = null;
/** @type {CredentialRequestOptions | null} */
let pendingOptions = null;
+ /** @type {AbortSignal | null} */
+ let pendingSignal = null;
+ /** @type {(() => void) | null} */
+ let pendingAbortHandler = null;
const savedOriginalGet = navigator.credentials.get.bind(navigator.credentials);
@@ -34,6 +38,11 @@
}
delete options.mediation;
+ if (pendingSignal && pendingAbortHandler) {
+ pendingSignal.removeEventListener('abort', pendingAbortHandler);
+ }
+ pendingSignal = pendingAbortHandler = null;
+
const resolve = pendingResolve;
const reject = /** @type {(reason?: unknown) => void} */ (pendingReject);
pendingResolve = pendingReject = pendingOptions = null;
@@ -47,25 +56,50 @@
}
});
- this.wrapMethod(CredentialsContainer.prototype, 'get', /** @this {CredentialsContainer} */ function (originalGet, options) {
- if (options?.mediation !== MEDIATION_CONDITIONAL || !options?.publicKey) {
- return originalGet.call(this, options);
- }
+ this.wrapMethod(
+ CredentialsContainer.prototype,
+ 'get',
+ /** @this {CredentialsContainer} */ function (originalGet, options) {
+ if (options?.mediation !== MEDIATION_CONDITIONAL || !options?.publicKey) {
+ return originalGet.call(this, options);
+ }
- const rpId = options?.publicKey?.rpId || location.hostname;
+ if (options.signal?.aborted) {
+ return Promise.reject(options.signal.reason || new DOMException('The operation was aborted.', 'AbortError'));
+ }
- // @ts-expect-error windowsInteropPostMessage is a Windows-specific global
- windowsInteropPostMessage({
- Feature: MSG_OUTBOUND_FEATURE,
- Name: MSG_OUTBOUND_NAME,
- Data: { rpId },
- });
+ const rpId = options?.publicKey?.rpId || location.hostname;
- return new Promise(function (resolve, reject) {
- pendingResolve = resolve;
- pendingReject = reject;
- pendingOptions = options;
- });
- });
+ // @ts-expect-error windowsInteropPostMessage is a Windows-specific global
+ windowsInteropPostMessage({
+ Feature: MSG_OUTBOUND_FEATURE,
+ Name: MSG_OUTBOUND_NAME,
+ Data: { rpId },
+ });
+
+ return new Promise(function (resolve, reject) {
+ pendingResolve = resolve;
+ pendingReject = reject;
+ pendingOptions = options;
+ pendingSignal = options.signal || null;
+
+ if (pendingSignal) {
+ pendingAbortHandler = function () {
+ if (pendingSignal && pendingAbortHandler) {
+ pendingSignal.removeEventListener('abort', pendingAbortHandler);
+ }
+ pendingSignal = pendingAbortHandler = null;
+
+ if (!pendingReject) return;
+ const pendingAbortReject = pendingReject;
+ pendingResolve = pendingReject = pendingOptions = null;
+ pendingAbortReject(options.signal?.reason || new DOMException('The operation was aborted.', 'AbortError'));
+ };
+
+ pendingSignal.addEventListener('abort', pendingAbortHandler, { once: true });
+ }
+ });
+ },
+ );
}
}
|
The suffix check cannot account for Related Origin Requests (e.g., facebook.com requesting passkeys for meta.com via .well-known/webauthn) or other valid cross-domain scenarios. Native has the full context for RP ID validation including public suffix and ROR checks.
If crypto.randomUUID is missing and no requestId can be generated, delegate to the original credentials.get() instead of proceeding without request correlation.
Add isSecureContext check to the pass-through guard. WebAuthn requires a secure context, so there's no point intercepting and sending registerPasskeyRequest to native when it can never succeed.
|
@gkerenyi @BalintFarkas Is this ready for another review by a human? |
@GioSensation Yes - requested it on the Asana PR for easier inbox management. @dbajpeyi says he'll get to it today. |
There was a problem hiding this comment.
Web Compatibility Assessment
No new non-duplicate web-compat finding from the latest sync. Routing passkeySelected through the typed messaging API removes the earlier raw Windows interop concern and adds request correlation via requestId.
Previously-posted API-fidelity concerns around synchronous WebAuthn dictionary reads and delayed/shallow option snapshotting still look like the key remaining compat risks; I’m not re-posting those inline.
Security Assessment
No new non-duplicate security finding from the latest sync. The outbound message now sends only explicit { rpId, requestId } fields, so there is no nativeData spread, and inbound handling checks credentialId plus matching requestId before narrowing allowCredentials.
Risk Level
Critical Risk under the review rubric because this PR adds a new navigator.credentials.get() API override and changes captured-globals.js.
Recommendations
Keep the messaging route. Before merge, add/verify focused coverage for rejected-Promise semantics when option getters throw, immutable call-time snapshots for BufferSource fields such as challenge, and mismatched requestId subscription messages being ignored.
Verification: npm run build-surrogates --workspace=injected && npm run tsc-strict-core passes.
Sent by Cursor Automation: Web compat and sec
navigator.credentials.get() is a WebIDL promise-returning operation, so exceptions thrown while reading the options dictionary (e.g. a throwing publicKey/mediation getter) surface as a rejected promise rather than a synchronous throw. The interception wrapper read those getters synchronously, so a throwing getter propagated out of get() synchronously and broke callers relying on .catch()/await. Read the discriminator getters under try/catch in the wrapper and snapshot the publicKey fields inside the returned promise so all such throws reject instead. Also exclude the Windows-only autofillPasskeys feature from the integration entry point's enabled features (alongside autofillImport/brokerProtection): it globally wraps navigator.credentials.get and isn't meaningfully exercised in the mocked-messaging integration context. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Web Compatibility Assessment
injected/src/features/autofill-passkeys.jslines 51-60 and 82-96, severity: info. The latest sync resolves the previously posted synchronous-throw compatibility concern:options.mediation/options.publicKeygetter failures are now caught and returned asCapturedPromise.reject(...), andpublicKeyfield reads happen inside the returnedPromiseexecutor so throwing getters reject the promise instead of escaping synchronously.
No new non-duplicate web-compat finding from this sync. The older delayed/shallow WebAuthn snapshotting concern remains the main residual API-fidelity risk, but I’m not re-posting it as a duplicate.
Security Assessment
No new non-duplicate security finding from this sync. The outbound notify path still sends explicit { rpId, requestId } fields only, and inbound passkeySelected handling keeps the request-id match before invoking the narrowed native credentials.get() call.
Risk Level
Critical Risk under the review rubric because this PR adds a new CredentialsContainer.prototype.get override and changes captured-globals.js, even though production enablement is Windows-scoped.
Recommendations
- Add/keep focused tests for rejected-Promise semantics when getters throw on
mediation,publicKey,publicKey.rpId, andpublicKey.challenge. - Add/keep coverage for immutable call-time snapshots of WebAuthn
BufferSourcefields such aschallenge, plus mismatchedrequestIdsubscription messages being ignored.
Verification: npm run build-surrogates --workspace=injected && npm run tsc-strict-core passes.
Sent by Cursor Automation: Web compat and sec
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d1829c5. Configure here.
Add Jasmine tests for the Windows passkey interception flow merged in #2571, covering request registration, native message correlation, abort handling, supersede behavior, and credentials.get pass-through guards. Fix ESM import paths in autofill-passkeys.js so the feature can be loaded by the unit test runner (no behavior change). Co-authored-by: Jonathan Kingston <jonathanKingston@users.noreply.github.com>




Asana Task/Github Issue: https://app.asana.com/1/137249556945/project/72649045549333/task/1213359471378866?focus=true
Description
Add a new
autofillPasskeysfeature that interceptsnavigator.credentials.get()calls withmediation: 'conditional'on Windows, enabling passkey autofill via Windows Hello.When a site requests conditional mediation, the feature notifies the host (
{ Feature: 'Autofill', Name: 'getPasskeys', Data: { rpId } }), which populates the autofill dropdown with OS-managed passkeys. When the user picks one, the host replies withpasskeySelectedand the feature calls the realnavigator.credentials.get()with the selected credential, triggering Windows Hello. Non-conditional calls pass through unmodified.Key design decisions:
this.wrapMethod()onCredentialsContainer.prototypefor API wrapping (automatictoStringspoofing and debug flag integration)exceptionsin privacy-configplatformSupport.windows, not bundled into other platformsTesting Steps
npm run buildand verifyautofillPasskeysclass appears inbuild/windows/contentScope.jsclass AutofillPasskeysdoes NOT appear in non-Windows bundles (e.g.build/android/contentScope.js)Checklist
Note
High Risk
Intercepts WebAuthn credential retrieval on Windows, which is authentication-critical; incorrect handling could break logins or alter conditional passkey behavior even though non-conditional paths pass through.
Overview
Adds
autofillPasskeyson Windows so sites usingnavigator.credentials.get()withmediation: 'conditional'can register pending requests with native autofill and complete sign-in after the user picks a passkey.The feature wraps
CredentialsContainer.prototype.get(singleton only) viawrapMethod. Secure-context conditionalpublicKeycalls notify the host withregisterPasskeyRequest(rpId,requestId);passkeySelectedsupplies a base64 credential ID, which is decoded and passed to the realget()withallowCredentials. Other calls, missing APIs, or failedrandomUUIDpass through unchanged. Pending requests honorAbortSignal, supersede each other, and mirror native promise semantics for throwing option getters.platformSupport.windowsenables the feature; integration tests exclude it alongside other host-dependent features.captured-globalsgainsatob,DOMException, andcharCodeAtfor decoding; JSON schemas document the notify/subscribe payloads; strict-core coverage includes the new module.Reviewed by Cursor Bugbot for commit 726e5fd. Bugbot is set up for automated code reviews on this repo. Configure here.