[Bug]: macOS passkey origin validation accepts IP-address relying parties #35960
Send to my agentSummary#
The macOS browser passkey bridge accepts IP-address origins at its origin and relying-party validation boundary. These requests should be rejected before reaching an authenticator because WebAuthn requires a domain. Pure calls to the actual validation functions reproduced acceptance of loopback HTTP and non-loopback HTTPS addresses, with valid-domain and rejection controls passing.
Before submitting#
- Related issues: searched open and closed issues and PRs; distinct related coverage is listed under Additional context.
- Removed credentials, tokens, private paths, and sensitive data.
Area#
apps/desktop
Actual behavior#
Calling the actual origin gate from Passkeys.ts and the pinned native RP validator with http://127.0.0.1:8000 / RP ID 127.0.0.1, or https://192.0.2.1 / RP ID 192.0.2.1, returns accepted. The same acceptance occurs with the latest native package, 1.4.0. This reproduction establishes validation behavior only; it does not claim that macOS creates or signs an IP-scoped credential.
Steps to reproduce#
- Use a clean checkout at
59e64eef881632a23f89c4138bcf37f23d92bf93with Node supportingnode:module.stripTypeScriptTypes(the observed run used Node v26.12.1) and Git on PATH. - Save the script below outside the checkout and run
node repro-origin.mjs /path/to/t3code. No workspace dependency installation is needed. Network access to unpkg.com is required to fetch the exact published source files; these are evaluated only as pure functions, with native operations replaced. - Inspect the controls and mismatch output below.
The recorded run reproduced the mismatch deterministically. The script creates disposable state directories with mkdtemp, redirects HOME, USERPROFILE, APPDATA, LOCALAPPDATA, XDG directories, TMPDIR, and T3CODE_HOME into them, and removes them on exit. It reads the checkout without changing it, opens no native passkey sheet, and uses no credentials. Exit 1 means defect reproduced, 0 means expected behavior, and 2 means setup or a control failed.
Reproduction script: repro-origin.mjs
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import vm from 'node:vm';
import { stripTypeScriptTypes, createRequire } from 'node:module';
import net from 'node:net';
import { domainToASCII } from 'node:url';
import { execFileSync } from 'node:child_process';
const checkout = path.resolve(process.argv[2] ?? '.');
const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'passkey-repro-'));
for (const key of ['HOME','USERPROFILE','APPDATA','LOCALAPPDATA','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','T3CODE_HOME','TMPDIR']) {
process.env[key] = path.join(scratch, key);
await fs.mkdir(process.env[key]);
}
const read = (name) => fs.readFile(path.join(checkout, 'apps/desktop/src/preview', name), 'utf8');
const strip = (text) => stripTypeScriptTypes(text, {mode:'strip'});
const fn = (text, name) => {
const start = text.indexOf(`function ${name}(`);
const end = text.indexOf('\n}', start);
if (start < 0 || end < 0) throw Error(`Missing function ${name}`);
return text.slice(start, end + 2);
};
const download = async (name, version, file) => {
const url = `https://unpkg.com/${name}@${version}/${file}`;
const response = await fetch(url);
if (!response.ok) throw Error(`Package download failed: ${response.status}`);
return response.text();
};
let status = 2;
try {
console.log(`Node ${process.version}; ${process.platform} ${process.arch}`);
console.log(execFileSync('git', ['--version'], {encoding:'utf8'}).trim());
console.log(`T3 commit ${execFileSync('git', ['-C', checkout, 'rev-parse', 'HEAD'], {encoding:'utf8'}).trim()}`);
const suffixFile = path.join(scratch, 'tldts.cjs');
await fs.writeFile(suffixFile, await download('tldts','7.4.2','dist/index.cjs.min.js'));
const { getPublicSuffix } = createRequire(import.meta.url)(suffixFile);
console.log('tldts 7.4.2');
const adapterText = await read('PasskeyBackendMac.ts');
const suffixStart = adapterText.indexOf('const isPublicSuffix =');
const suffixEnd = adapterText.indexOf(';', suffixStart);
const isPublicSuffix = new Function('getPublicSuffix', strip(adapterText.slice(suffixStart, suffixEnd + 1)) + '; return isPublicSuffix;')(getPublicSuffix);
const originText = await read('Passkeys.ts');
const start = originText.indexOf('const isTrustworthyOrigin =');
const end = originText.indexOf('\n};', start);
const trusted = new Function(strip(originText.slice(start, end + 3)) + '; return isTrustworthyOrigin;')();
let failures = 0;
for (const version of ['1.3.1','1.4.0']) {
const nativeText = await download('@electron-webauthn/macos', version, 'dist/index.js');
console.log(`@electron-webauthn/macos ${version}`);
const validate = new Function('net','domainToASCII', ['isRpIdAllowedForOrigin','normalizeHost','normalizeRpId'].map(name=>fn(nativeText,name)).join('\n') + '; return isRpIdAllowedForOrigin;')(net, domainToASCII);
const accepted = (origin, rp) => trusted(origin) && validate(origin, rp, {isPublicSuffix}).ok;
const controls = [['https://login.example.com','example.com',true],['http://localhost:8000','localhost',true],['https://example.com','other.example',false],['https://example.com','com',false],['https://a.github.io','github.io',false],['http://example.com','example.com',false],['null','example.com',false]];
for (const [origin,rp,expected] of controls) {
if (accepted(origin,rp) !== expected) throw Error(`Origin control failed: ${origin} / ${rp}`);
}
console.log('Controls: domain/suffix and localhost accepted; unrelated RP, public/private suffix, non-HTTPS and opaque origins rejected');
for (const [origin,rp] of [['http://127.0.0.1:8000','127.0.0.1'],['https://192.0.2.1','192.0.2.1']]) {
const actual = accepted(origin,rp);
console.log(`${origin} / ${rp}: accepted=${actual}, expected=false`);
if (actual) failures++;
}
}
status = failures ? 1 : 0;
} catch (error) {
console.log(`Setup/control failure: ${error.message}`);
status = 2;
} finally {
await fs.rm(scratch, {recursive:true, force:true});
}
console.log(status === 0 ? 'RESULT: expected behavior' : status === 1 ? 'RESULT: defect reproduced' : 'RESULT: inconclusive');
process.exitCode = status;
Expected behavior#
WebAuthn creation §5.1.3 and assertion §5.1.4 require a domain-form effective domain; IP addresses are excluded. Reject these inputs before invoking an authenticator, while retaining support for http://localhost.
Impact#
Minor bug or occasional failure
Impact: P2 - The browser bridge admits requests outside the documented WebAuthn origin contract. This is a bounded validation defect, with no observed credential issuance, cross-domain access, or data loss.
The tested boundary is the browser-passkey-enabled macOS path, not the Touch ID-only Chromium path. Ordinary domain requests pass the controls. NOT_ENOUGH_INFO: downstream AuthenticationServices behavior was intentionally not exercised; a native sheet or real credential operation would be needed to establish it.
Version#
T3 Code source commit 59e64eef881632a23f89c4138bcf37f23d92bf93. electron-webauthn 1.3.1 and @electron-webauthn/macos 1.3.1 are repository pins. The reproduction additionally fetches the published @electron-webauthn/macos 1.4.0 implementation for comparison.
Runtime and platform#
Observed on macOS 27.2, arm64, Node v26.12.1, Git 2.56.0. Electron 44.10.1 is the repository pin; Electron was not launched. The origin reproduction uses the repository-pinned tldts 7.4.2 public-suffix implementation.
Interface#
Library: desktop preview WebAuthn origin and RP validation, reached by navigator.credentials create/get through the browser bridge.
Earliest failure stage#
Input parsing or validation
Evidence#
The application gate permits HTTP loopback IPs and arbitrary HTTPS hosts at apps/desktop/src/preview/Passkeys.ts#L100-L110; the backend supplies that origin to the dependency at apps/desktop/src/preview/PasskeyBackendMac.ts#L14-L22. The published macOS 1.3.1 implementation explicitly accepts an IP RP ID when it equals the IP origin host. The reproduction executes that function and the actual application gate and public-suffix predicate without loading Objective-C.
Node v26.12.1; darwin arm64
git version 2.56.0
T3 commit 59e64eef881632a23f89c4138bcf37f23d92bf93
tldts 7.4.2
@electron-webauthn/macos 1.3.1
Controls: domain/suffix and localhost accepted; unrelated RP, public/private suffix, non-HTTPS and opaque origins rejected
http://127.0.0.1:8000 / 127.0.0.1: accepted=true, expected=false
https://192.0.2.1 / 192.0.2.1: accepted=true, expected=false
@electron-webauthn/macos 1.4.0
Controls: domain/suffix and localhost accepted; unrelated RP, public/private suffix, non-HTTPS and opaque origins rejected
http://127.0.0.1:8000 / 127.0.0.1: accepted=true, expected=false
https://192.0.2.1 / 192.0.2.1: accepted=true, expected=false
RESULT: defect reproduced
The public output omits Node's process-specific experimental-warning lines; the functional output is unchanged.
Restoration check#
Both IP cases must report accepted=false while every control still passes. The script should print RESULT: expected behavior and exit 0.
Additional context#
The original package pins were preserved. A separate source-only package inspection and the pure reproduction found the same validator behavior in published native 1.4.0. No OS Keychain, entitlements, installed app, or browser settings were accessed.
- https://github.com/pingdotgg/t3code/pull/16952 — introduces the macOS bridge; this report concerns a specific request-semantics mismatch rather than adding the bridge.
- https://github.com/pingdotgg/t3code/issues/34661 — tracks enabling the bridge in signed releases; the reproduction here exercises its source with synthetic inputs, independently of release capability approval.
tanstack-source added labelsbug
juliusmarminge closed this
Sign in to join this conversation.
I tried to reproduce this on the current build and could not.
Commit 67d91a0390 (origin/main). A real run of the bridge was not possible: it is only enabled in a packaged macOS build whose package.json sets t3codeWebAuthn.browserPasskeys and that carries Apple's managed-browser passkey entitlement. Instead I drove the real PreviewPasskeys.attachGuest gate in apps/desktop/src/preview/Passkeys.test.ts, with electron-webauthn mocked. Guests with origins http://127.0.0.1:8000 (RP ID 127.0.0.1), http://[::1]:8000 (RP ID ::1) and https://192.0.2.1 (RP ID 192.0.2.1) each sent a get and a create request. On main the 127.0.0.1 create request passed isTrustworthyOrigin and reached the native createCredential mock, which returned success:true where NotAllowedError was expected. The file passes 6/6 without the new lines, and also passes 6/6 when isTrustworthyOrigin rejects IP hosts (a temporary patch, not committed).
Closing as not planned. If it still happens for you, reply with the exact steps and the version you are on and it will be looked at again.