mirror of
https://github.com/bitwarden/browser
synced 2026-02-11 22:13:32 +00:00
[PM-30529][PM-31279] Webauthn: Support Related Origin Requests (#18242)
* Webauthn: Support Related Origin Requests * review changes * PM-31279 Add feature flag to guard executing ROR checks * Fix fido2-client.service tests * Set ROR_MAX_LABELS to 5 --------- Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com> Co-authored-by: Addison Beck <github@addisonbeck.com>
This commit is contained in:
committed by
GitHub
parent
d18ddd3480
commit
29e2be0d2b
@@ -76,6 +76,7 @@ export enum FeatureFlag {
|
||||
|
||||
/* Platform */
|
||||
ContentScriptIpcChannelFramework = "content-script-ipc-channel-framework",
|
||||
WebAuthnRelatedOrigins = "pm-30529-webauthn-related-origins",
|
||||
|
||||
/* Innovation */
|
||||
PM19148_InnovationArchive = "pm-19148-innovation-archive",
|
||||
@@ -171,6 +172,7 @@ export const DefaultFeatureFlagValue = {
|
||||
|
||||
/* Platform */
|
||||
[FeatureFlag.ContentScriptIpcChannelFramework]: FALSE,
|
||||
[FeatureFlag.WebAuthnRelatedOrigins]: FALSE,
|
||||
|
||||
/* Innovation */
|
||||
[FeatureFlag.PM19148_InnovationArchive]: FALSE,
|
||||
|
||||
@@ -2,101 +2,377 @@ import { isValidRpId } from "./domain-utils";
|
||||
|
||||
// Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm.
|
||||
describe("validateRpId", () => {
|
||||
it("should not be valid when rpId is null", () => {
|
||||
const origin = "example.com";
|
||||
let mockFetch: jest.Mock;
|
||||
let webAuthnRelatedOriginsFeatureFlag = false;
|
||||
|
||||
expect(isValidRpId(null, origin)).toBe(false);
|
||||
beforeEach(() => {
|
||||
mockFetch = jest.fn();
|
||||
// Default: ROR requests fail (no .well-known/webauthn endpoint)
|
||||
mockFetch.mockRejectedValue(new Error("Network error"));
|
||||
});
|
||||
|
||||
it("should not be valid when origin is null", () => {
|
||||
const rpId = "example.com";
|
||||
describe("classic domain validation", () => {
|
||||
it("should not be valid when rpId is null", async () => {
|
||||
const origin = "example.com";
|
||||
|
||||
expect(isValidRpId(rpId, null)).toBe(false);
|
||||
expect(await isValidRpId(null, origin, webAuthnRelatedOriginsFeatureFlag)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not be valid when origin is null", async () => {
|
||||
const rpId = "example.com";
|
||||
|
||||
expect(await isValidRpId(rpId, null, webAuthnRelatedOriginsFeatureFlag)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not be valid when rpId is more specific than origin", async () => {
|
||||
const rpId = "sub.login.bitwarden.com";
|
||||
const origin = "https://login.bitwarden.com:1337";
|
||||
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not be valid when effective domains of rpId and origin do not match", async () => {
|
||||
const rpId = "passwordless.dev";
|
||||
const origin = "https://login.bitwarden.com:1337";
|
||||
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not be valid when subdomains are the same but effective domains of rpId and origin do not match", async () => {
|
||||
const rpId = "login.passwordless.dev";
|
||||
const origin = "https://login.bitwarden.com:1337";
|
||||
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not be valid when rpId and origin are both different TLD", async () => {
|
||||
const rpId = "bitwarden";
|
||||
const origin = "localhost";
|
||||
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// Only allow localhost for rpId, need to properly investigate the implications of
|
||||
// adding support for ip-addresses and other TLDs
|
||||
it("should not be valid when rpId and origin are both the same TLD", async () => {
|
||||
const rpId = "bitwarden";
|
||||
const origin = "bitwarden";
|
||||
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not be valid when rpId and origin are ip-addresses", async () => {
|
||||
const rpId = "127.0.0.1";
|
||||
const origin = "127.0.0.1";
|
||||
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should be valid when domains of rpId and origin are localhost", async () => {
|
||||
const rpId = "localhost";
|
||||
const origin = "https://localhost:8080";
|
||||
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should be valid when domains of rpId and origin are the same", async () => {
|
||||
const rpId = "bitwarden.com";
|
||||
const origin = "https://bitwarden.com";
|
||||
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should be valid when origin is a subdomain of rpId", async () => {
|
||||
const rpId = "bitwarden.com";
|
||||
const origin = "https://login.bitwarden.com:1337";
|
||||
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should be valid when domains of rpId and origin are the same and they are both subdomains", async () => {
|
||||
const rpId = "login.bitwarden.com";
|
||||
const origin = "https://login.bitwarden.com:1337";
|
||||
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should be valid when origin is a subdomain of rpId and they are both subdomains", async () => {
|
||||
const rpId = "login.bitwarden.com";
|
||||
const origin = "https://sub.login.bitwarden.com:1337";
|
||||
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not be valid for a partial match of a subdomain", async () => {
|
||||
const rpId = "accounts.example.com";
|
||||
const origin = "https://evilaccounts.example.com";
|
||||
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not be valid when rpId is more specific than origin", () => {
|
||||
const rpId = "sub.login.bitwarden.com";
|
||||
const origin = "https://login.bitwarden.com:1337";
|
||||
describe("Related Origin Requests (ROR)", () => {
|
||||
// Helper to create a mock fetch response
|
||||
function mockRorResponse(origins: string[], status = 200, contentType = "application/json") {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: new Headers({ "content-type": contentType }),
|
||||
json: async () => ({ origins }),
|
||||
});
|
||||
}
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(false);
|
||||
});
|
||||
it("should not proceed with ROR check when valid when feature flag disabled", async () => {
|
||||
const rpId = "accounts.meta.com";
|
||||
const origin = "https://accountscenter.facebook.com";
|
||||
|
||||
it("should not be valid when effective domains of rpId and origin do not match", () => {
|
||||
const rpId = "passwordless.dev";
|
||||
const origin = "https://login.bitwarden.com:1337";
|
||||
mockRorResponse([origin, "https://www.facebook.com", "https://www.instagram.com"]);
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(false);
|
||||
});
|
||||
expect(await isValidRpId(rpId, origin, false, mockFetch)).toBe(false);
|
||||
expect(mockFetch).not.toHaveBeenCalledWith(
|
||||
`https://${rpId}/.well-known/webauthn`,
|
||||
expect.objectContaining({
|
||||
credentials: "omit",
|
||||
referrerPolicy: "no-referrer",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not be valid when subdomains are the same but effective domains of rpId and origin do not match", () => {
|
||||
const rpId = "login.passwordless.dev";
|
||||
const origin = "https://login.bitwarden.com:1337";
|
||||
webAuthnRelatedOriginsFeatureFlag = true;
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(false);
|
||||
});
|
||||
it("should be valid when origin is listed in .well-known/webauthn", async () => {
|
||||
const rpId = "accounts.meta.com";
|
||||
const origin = "https://accountscenter.facebook.com";
|
||||
|
||||
it("should not be valid when rpId and origin are both different TLD", () => {
|
||||
const rpId = "bitwarden";
|
||||
const origin = "https://localhost";
|
||||
mockRorResponse([origin, "https://www.facebook.com", "https://www.instagram.com"]);
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(false);
|
||||
});
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
`https://${rpId}/.well-known/webauthn`,
|
||||
expect.objectContaining({
|
||||
credentials: "omit",
|
||||
referrerPolicy: "no-referrer",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Only allow localhost for rpId, need to properly investigate the implications of
|
||||
// adding support for ip-addresses and other TLDs
|
||||
it("should not be valid when rpId and origin are both the same TLD", () => {
|
||||
const rpId = "bitwarden";
|
||||
const origin = "https://bitwarden";
|
||||
it("should not be valid when origin is not listed in .well-known/webauthn", async () => {
|
||||
const rpId = "accounts.meta.com";
|
||||
const origin = "https://evil.com";
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(false);
|
||||
});
|
||||
mockRorResponse(["https://www.facebook.com", "https://www.instagram.com"]);
|
||||
|
||||
it("should not be valid when rpId and origin are ip-addresses", () => {
|
||||
const rpId = "127.0.0.1";
|
||||
const origin = "https://127.0.0.1";
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(false);
|
||||
});
|
||||
it("should not be valid when .well-known/webauthn returns non-200 status", async () => {
|
||||
const rpId = "accounts.meta.com";
|
||||
const origin = "https://accountscenter.facebook.com";
|
||||
|
||||
it("should be valid when domains of rpId and origin are localhost", () => {
|
||||
const rpId = "localhost";
|
||||
const origin = "https://localhost:8080";
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
});
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(true);
|
||||
});
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should be valid when domains of rpId and origin are the same", () => {
|
||||
const rpId = "bitwarden.com";
|
||||
const origin = "https://bitwarden.com";
|
||||
it("should not be valid when .well-known/webauthn returns non-JSON content-type", async () => {
|
||||
const rpId = "accounts.meta.com";
|
||||
const origin = "https://accountscenter.facebook.com";
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(true);
|
||||
});
|
||||
mockRorResponse([origin], 200, "text/html");
|
||||
|
||||
it("should be valid when origin is a subdomain of rpId", () => {
|
||||
const rpId = "bitwarden.com";
|
||||
const origin = "https://login.bitwarden.com:1337";
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(true);
|
||||
});
|
||||
it("should not be valid when .well-known/webauthn response has no origins array", async () => {
|
||||
const rpId = "accounts.meta.com";
|
||||
const origin = "https://accountscenter.facebook.com";
|
||||
|
||||
it("should be valid when domains of rpId and origin are the same and they are both subdomains", () => {
|
||||
const rpId = "login.bitwarden.com";
|
||||
const origin = "https://login.bitwarden.com:1337";
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ notOrigins: "invalid" }),
|
||||
});
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(true);
|
||||
});
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should be valid when origin is a subdomain of rpId and they are both subdomains", () => {
|
||||
const rpId = "login.bitwarden.com";
|
||||
const origin = "https://sub.login.bitwarden.com:1337";
|
||||
it("should not be valid when .well-known/webauthn response has empty origins array", async () => {
|
||||
const rpId = "accounts.meta.com";
|
||||
const origin = "https://accountscenter.facebook.com";
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(true);
|
||||
});
|
||||
mockRorResponse([]);
|
||||
|
||||
it("should not be valid for a partial match of a subdomain", () => {
|
||||
const rpId = "accounts.example.com";
|
||||
const origin = "https://evilaccounts.example.com";
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
expect(isValidRpId(rpId, origin)).toBe(false);
|
||||
it("should not be valid when .well-known/webauthn response has non-string origins", async () => {
|
||||
const rpId = "accounts.meta.com";
|
||||
const origin = "https://accountscenter.facebook.com";
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ origins: [123, { url: origin }] }),
|
||||
});
|
||||
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not be valid when fetch throws an error", async () => {
|
||||
const rpId = "accounts.meta.com";
|
||||
const origin = "https://accountscenter.facebook.com";
|
||||
|
||||
mockFetch.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not be valid when fetch times out", async () => {
|
||||
const rpId = "accounts.meta.com";
|
||||
const origin = "https://accountscenter.facebook.com";
|
||||
|
||||
mockFetch.mockRejectedValue(new DOMException("The operation was aborted.", "AbortError"));
|
||||
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should skip classic validation and use ROR when domains do not match", async () => {
|
||||
// This is the Facebook/Meta use case
|
||||
const rpId = "accounts.meta.com";
|
||||
const origin = "https://accountscenter.facebook.com";
|
||||
|
||||
mockRorResponse([origin]);
|
||||
|
||||
// Classic validation would fail (different domains), but ROR should succeed
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not call ROR endpoint when classic validation succeeds", async () => {
|
||||
const rpId = "bitwarden.com";
|
||||
const origin = "https://bitwarden.com";
|
||||
|
||||
// Classic validation succeeds, so ROR should not be called
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should require exact origin match (including port)", async () => {
|
||||
const rpId = "accounts.meta.com";
|
||||
const origin = "https://accountscenter.facebook.com:8443";
|
||||
|
||||
// Only the non-port version is listed
|
||||
mockRorResponse(["https://accountscenter.facebook.com"]);
|
||||
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle invalid URLs in origins array gracefully", async () => {
|
||||
const rpId = "accounts.meta.com";
|
||||
const origin = "https://accountscenter.facebook.com";
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({
|
||||
origins: ["not-a-valid-url", "://also-invalid", origin],
|
||||
}),
|
||||
});
|
||||
|
||||
// Should still find the valid origin despite invalid entries
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should enforce max labels limit", async () => {
|
||||
const rpId = "example.com";
|
||||
const origin = "https://site6.com";
|
||||
|
||||
// Create origins from 6 different eTLD+1 labels
|
||||
// Only the first 5 should be processed
|
||||
mockRorResponse([
|
||||
"https://site1.com",
|
||||
"https://site2.com",
|
||||
"https://site3.com",
|
||||
"https://site4.com",
|
||||
"https://site5.com",
|
||||
"https://site6.com", // This is the 6th label, should be skipped
|
||||
]);
|
||||
|
||||
// The origin is in the list but should be skipped due to max labels limit
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow multiple origins from the same eTLD+1", async () => {
|
||||
const rpId = "example.com";
|
||||
const origin = "https://sub2.facebook.com";
|
||||
|
||||
// All these are from facebook.com (same eTLD+1), so they count as 1 label
|
||||
mockRorResponse([
|
||||
"https://www.facebook.com",
|
||||
"https://sub1.facebook.com",
|
||||
"https://sub2.facebook.com",
|
||||
"https://sub3.facebook.com",
|
||||
]);
|
||||
|
||||
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
import { parse } from "tldts";
|
||||
|
||||
/**
|
||||
* Validates whether a Relying Party ID (rpId) is valid for a given origin according to WebAuthn specifications.
|
||||
* Maximum number of unique eTLD+1 labels to process when checking Related Origin Requests.
|
||||
* This limit prevents malicious servers from causing excessive processing.
|
||||
* Per WebAuthn spec recommendation.
|
||||
*/
|
||||
const ROR_MAX_LABELS = 5;
|
||||
|
||||
/**
|
||||
* Timeout in milliseconds for fetching the .well-known/webauthn endpoint.
|
||||
*/
|
||||
const ROR_FETCH_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* Validates whether a Relying Party ID (rpId) is valid for a given origin according to classic
|
||||
* WebAuthn specifications (before Related Origin Requests extension).
|
||||
*
|
||||
* The validation enforces the following rules:
|
||||
* - The origin must use the HTTPS scheme
|
||||
* This implements the core WebAuthn RP ID validation logic:
|
||||
* - The origin must use the HTTPS scheme (except localhost)
|
||||
* - Both rpId and origin must be valid domain names (not IP addresses)
|
||||
* - Both must have the same registrable domain (e.g., example.com)
|
||||
* - Both must have the same registrable domain (eTLD+1)
|
||||
* - The origin must either exactly match the rpId or be a subdomain of it
|
||||
* - Single-label domains are rejected unless they are 'localhost'
|
||||
* - Localhost is always valid when both rpId and origin are localhost
|
||||
*
|
||||
* This is used internally as the first validation step before falling back to
|
||||
* Related Origin Requests (ROR) validation.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-2/#rp-id
|
||||
*
|
||||
* @param rpId - The Relying Party identifier to validate
|
||||
* @param origin - The origin URL to validate against (must start with https://)
|
||||
* @returns `true` if the rpId is valid for the given origin, `false` otherwise
|
||||
*
|
||||
*/
|
||||
export function isValidRpId(rpId: string, origin: string) {
|
||||
function isValidRpIdInternal(rpId: string, origin: string) {
|
||||
if (!rpId || !origin) {
|
||||
return false;
|
||||
}
|
||||
@@ -73,6 +90,148 @@ export function isValidRpId(rpId: string, origin: string) {
|
||||
if (parsedOrigin.hostname != null && parsedOrigin.hostname.endsWith("." + rpId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the origin is allowed to use the given rpId via Related Origin Requests (ROR).
|
||||
* This implements the WebAuthn Related Origin Requests spec which allows an RP to
|
||||
* authorize origins from different domains to use its rpId.
|
||||
*
|
||||
* @see https://w3c.github.io/webauthn/#sctn-related-origins
|
||||
*
|
||||
* @param rpId - The relying party ID being requested
|
||||
* @param origin - The origin making the WebAuthn request
|
||||
* @param fetchFn - Optional fetch function for testing, defaults to global fetch
|
||||
* @returns Promise that resolves to true if the origin is allowed via ROR, false otherwise
|
||||
*/
|
||||
async function isAllowedByRor(
|
||||
rpId: string,
|
||||
origin: string,
|
||||
fetchFn?: typeof fetch,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const fetchImpl = fetchFn ?? globalThis.fetch;
|
||||
|
||||
// Create abort signal with timeout - use AbortSignal.timeout if available, otherwise use AbortController
|
||||
let signal: AbortSignal;
|
||||
if (typeof AbortSignal.timeout === "function") {
|
||||
signal = AbortSignal.timeout(ROR_FETCH_TIMEOUT_MS);
|
||||
} else {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ROR_FETCH_TIMEOUT_MS);
|
||||
signal = controller.signal;
|
||||
}
|
||||
|
||||
const response = await fetchImpl(`https://${rpId}/.well-known/webauthn`, {
|
||||
credentials: "omit",
|
||||
referrerPolicy: "no-referrer",
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { origins?: unknown };
|
||||
|
||||
if (
|
||||
!data ||
|
||||
!Array.isArray(data.origins) ||
|
||||
!data.origins.every((o) => typeof o === "string") ||
|
||||
data.origins.length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Track unique labels (eTLD+1) to enforce the max labels limit
|
||||
const labelsSeen = new Set<string>();
|
||||
|
||||
for (const allowedOrigin of data.origins as string[]) {
|
||||
try {
|
||||
const url = new URL(allowedOrigin);
|
||||
const hostname = url.hostname;
|
||||
if (!hostname) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parse(hostname, { allowPrivateDomains: true });
|
||||
if (!parsed.domain || !parsed.publicSuffix) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract the label (the part before the public suffix)
|
||||
const label = parsed.domain.slice(0, parsed.domain.length - parsed.publicSuffix.length - 1);
|
||||
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if we've already seen max labels and this is a new one
|
||||
if (labelsSeen.size >= ROR_MAX_LABELS && !labelsSeen.has(label)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for exact origin match
|
||||
if (origin === allowedOrigin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Track the label if we haven't hit the limit
|
||||
if (labelsSeen.size < ROR_MAX_LABELS) {
|
||||
labelsSeen.add(label);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL, skip this entry
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
// Network error, timeout, or other failure - fail closed
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* Validates whether a Relying Party ID (rpId) is valid for a given origin according to WebAuthn specifications.
|
||||
* If that fails, checks if the origin is authorized via Related Origin Requests (ROR).
|
||||
*
|
||||
* The validation enforces the following rules:
|
||||
* - The origin must use the HTTPS scheme
|
||||
* - Both rpId and origin must be valid domain names (not IP addresses)
|
||||
* - Both must have the same registrable domain (e.g., example.com)
|
||||
* - The origin must either exactly match the rpId or be a subdomain of it
|
||||
* - Single-label domains are rejected unless they are 'localhost'
|
||||
* - Localhost is always valid when both rpId and origin are localhost
|
||||
*
|
||||
* @param rpId - The Relying Party identifier to validate
|
||||
* @param origin - The origin URL to validate against (must start with https://)
|
||||
* @param fetchFn - Optional fetch function for testing, defaults to global fetch
|
||||
* @returns `true` if the rpId is valid for the given origin, `false` otherwise
|
||||
*
|
||||
*/
|
||||
export async function isValidRpId(
|
||||
rpId: string,
|
||||
origin: string,
|
||||
relatedOriginChecksEnabled: boolean,
|
||||
fetchFn?: typeof fetch,
|
||||
): Promise<boolean> {
|
||||
// Classic WebAuthn validation: rpId must be a registrable domain suffix of the origin
|
||||
const classicMatch = isValidRpIdInternal(rpId, origin);
|
||||
|
||||
if (classicMatch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!relatedOriginChecksEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fall back to Related Origin Requests (ROR) validation
|
||||
return await isAllowedByRor(rpId, origin, fetchFn);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@ describe("FidoAuthenticatorService", () => {
|
||||
|
||||
isValidRpId = jest.spyOn(DomainUtils, "isValidRpId");
|
||||
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
|
||||
client = new Fido2ClientService(
|
||||
authenticator,
|
||||
configService,
|
||||
@@ -186,7 +188,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
const params = createParams();
|
||||
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());
|
||||
// `params` actually has a valid rp.id, but we're mocking the function to return false
|
||||
isValidRpId.mockReturnValue(false);
|
||||
isValidRpId.mockResolvedValue(false);
|
||||
|
||||
const result = async () => await client.createCredential(params, windowReference);
|
||||
|
||||
@@ -459,7 +461,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
const params = createParams();
|
||||
authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult());
|
||||
// `params` actually has a valid rp.id, but we're mocking the function to return false
|
||||
isValidRpId.mockReturnValue(false);
|
||||
isValidRpId.mockResolvedValue(false);
|
||||
|
||||
const result = async () => await client.assertCredential(params, windowReference);
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { firstValueFrom, Subscription } from "rxjs";
|
||||
import { parse } from "tldts";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
|
||||
@@ -62,6 +64,9 @@ export class Fido2ClientService<
|
||||
MAX: 600000,
|
||||
},
|
||||
};
|
||||
protected readonly relatedOriginChecksEnabled$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.WebAuthnRelatedOrigins,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private authenticator: Fido2AuthenticatorService<ParentWindowReference>,
|
||||
@@ -142,7 +147,13 @@ export class Fido2ClientService<
|
||||
throw new DOMException("'origin' is not a valid https origin", "SecurityError");
|
||||
}
|
||||
|
||||
if (!isValidRpId(params.rp.id, params.origin)) {
|
||||
if (
|
||||
!(await isValidRpId(
|
||||
params.rp.id,
|
||||
params.origin,
|
||||
await firstValueFrom(this.relatedOriginChecksEnabled$),
|
||||
))
|
||||
) {
|
||||
this.logService?.warning(
|
||||
`[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rp.id}; origin = ${params.origin}`,
|
||||
);
|
||||
@@ -281,7 +292,13 @@ export class Fido2ClientService<
|
||||
throw new DOMException("'origin' is not a valid https origin", "SecurityError");
|
||||
}
|
||||
|
||||
if (!isValidRpId(params.rpId, params.origin)) {
|
||||
if (
|
||||
!(await isValidRpId(
|
||||
params.rpId,
|
||||
params.origin,
|
||||
await firstValueFrom(this.relatedOriginChecksEnabled$),
|
||||
))
|
||||
) {
|
||||
this.logService?.warning(
|
||||
`[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rpId}; origin = ${params.origin}`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user