Skip to content

Commit

Permalink
[PM-6413] Add http loophole for localhost (#9236)
Browse files Browse the repository at this point in the history
* [PM-6413] feat: add http loophole for localhost

Fixes #6882

* feat: add sanity check

* feat: change fido2 filters to allow scripts on localhost

* [PM-6413] fix: injection tests
  • Loading branch information
coroiu authored Jun 25, 2024
1 parent 591f444 commit dce5c0f
Show file tree
Hide file tree
Showing 7 changed files with 79 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const contentScriptDetails = {
...sharedScriptInjectionDetails,
};
const sharedRegistrationOptions = {
matches: ["https://*/*"],
matches: ["https://*/*", "http://localhost/*"],
excludeMatches: ["https://*/*.xml*"],
allFrames: true,
...sharedExecuteScriptOptions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export class Fido2Background implements Fido2BackgroundInterface {
runAt: "document_start",
};
private readonly sharedRegistrationOptions: SharedFido2ScriptRegistrationOptions = {
matches: ["https://*/*"],
matches: ["https://*/*", "http://localhost/*"],
excludeMatches: ["https://*/*.xml*"],
allFrames: true,
...this.sharedInjectionDetails,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import { MessageWithMetadata, Messenger } from "./messaging/messenger";
(function (globalContext) {
const shouldExecuteContentScript =
globalContext.document.contentType === "text/html" &&
globalContext.document.location.protocol === "https:";
(globalContext.document.location.protocol === "https:" ||
(globalContext.document.location.protocol === "http:" &&
globalContext.document.location.hostname === "localhost"));

if (!shouldExecuteContentScript) {
return;
Expand Down
4 changes: 3 additions & 1 deletion apps/browser/src/autofill/fido2/content/fido2-page-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { Messenger } from "./messaging/messenger";
(function (globalContext) {
const shouldExecuteContentScript =
globalContext.document.contentType === "text/html" &&
globalContext.document.location.protocol === "https:";
(globalContext.document.location.protocol === "https:" ||
(globalContext.document.location.protocol === "http:" &&
globalContext.document.location.hostname === "localhost"));

if (!shouldExecuteContentScript) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ const mockGlobalThisDocument = {
contentType: "text/html",
location: {
...originalGlobalThis.document.location,
href: "https://localhost",
origin: "https://localhost",
href: "https://bitwarden.com",
origin: "https://bitwarden.com",
hostname: "bitwarden.com",
protocol: "https:",
},
};
Expand Down Expand Up @@ -166,8 +167,8 @@ describe("Fido2 page script with native WebAuthn support", () => {
...mockGlobalThisDocument,
location: {
...mockGlobalThisDocument.location,
href: "http://localhost",
origin: "http://localhost",
href: "http://bitwarden.com",
origin: "http://bitwarden.com",
protocol: "http:",
},
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "../../abstractions/fido2/fido2-client.service.abstraction";
import { Utils } from "../../misc/utils";

import * as DomainUtils from "./domain-utils";
import { Fido2AuthenticatorService } from "./fido2-authenticator.service";
import { Fido2ClientService } from "./fido2-client.service";
import { Fido2Utils } from "./fido2-utils";
Expand All @@ -36,6 +37,7 @@ describe("FidoAuthenticatorService", () => {
let domainSettingsService: MockProxy<DomainSettingsService>;
let client!: Fido2ClientService;
let tab!: chrome.tabs.Tab;
let isValidRpId!: jest.SpyInstance;

beforeEach(async () => {
authenticator = mock<Fido2AuthenticatorService>();
Expand All @@ -44,6 +46,8 @@ describe("FidoAuthenticatorService", () => {
vaultSettingsService = mock<VaultSettingsService>();
domainSettingsService = mock<DomainSettingsService>();

isValidRpId = jest.spyOn(DomainUtils, "isValidRpId");

client = new Fido2ClientService(
authenticator,
configService,
Expand All @@ -58,6 +62,10 @@ describe("FidoAuthenticatorService", () => {
tab = { id: 123, windowId: 456 } as chrome.tabs.Tab;
});

afterEach(() => {
isValidRpId.mockRestore();
});

describe("createCredential", () => {
describe("input parameters validation", () => {
// Spec: If sameOriginWithAncestors is false, return a "NotAllowedError" DOMException.
Expand Down Expand Up @@ -113,6 +121,7 @@ describe("FidoAuthenticatorService", () => {
});

// 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.
// This is actually checked by `isValidRpId` function, but we'll test it here as well
it("should throw error if rp.id is not valid for this origin", async () => {
const params = createParams({
origin: "https://passwordless.dev",
Expand All @@ -126,6 +135,20 @@ describe("FidoAuthenticatorService", () => {
await rejects.toBeInstanceOf(DOMException);
});

// Sanity check to make sure that we use `isValidRpId` to validate the rp.id
it("should throw if isValidRpId returns false", async () => {
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);

const result = async () => await client.createCredential(params, tab);

const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "SecurityError" });
await rejects.toBeInstanceOf(DOMException);
});

it("should fallback if origin hostname is found in neverDomains", async () => {
const params = createParams({
origin: "https://bitwarden.com",
Expand All @@ -151,6 +174,16 @@ describe("FidoAuthenticatorService", () => {
await rejects.toBeInstanceOf(DOMException);
});

it("should not throw error if localhost is http", async () => {
const params = createParams({
origin: "http://localhost",
rp: { id: undefined, name: "localhost" },
});
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());

await client.createCredential(params, tab);
});

// Spec: If credTypesAndPubKeyAlgs is empty, return a DOMException whose name is "NotSupportedError", and terminate this algorithm.
it("should throw error if no support key algorithms were found", async () => {
const params = createParams({
Expand Down Expand Up @@ -360,6 +393,7 @@ describe("FidoAuthenticatorService", () => {
});

// 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.
// This is actually checked by `isValidRpId` function, but we'll test it here as well
it("should throw error if rp.id is not valid for this origin", async () => {
const params = createParams({
origin: "https://passwordless.dev",
Expand All @@ -373,6 +407,20 @@ describe("FidoAuthenticatorService", () => {
await rejects.toBeInstanceOf(DOMException);
});

// Sanity check to make sure that we use `isValidRpId` to validate the rp.id
it("should throw if isValidRpId returns false", async () => {
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);

const result = async () => await client.assertCredential(params, tab);

const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "SecurityError" });
await rejects.toBeInstanceOf(DOMException);
});

it("should fallback if origin hostname is found in neverDomains", async () => {
const params = createParams({
origin: "https://bitwarden.com",
Expand Down Expand Up @@ -506,6 +554,16 @@ describe("FidoAuthenticatorService", () => {
expect.anything(),
);
});

it("should not throw error if localhost is http", async () => {
const params = createParams({
origin: "http://localhost",
});
params.rpId = undefined;
authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult());

await client.assertCredential(params, tab);
});
});

describe("assert discoverable credential", () => {
Expand Down
10 changes: 8 additions & 2 deletions libs/common/src/platform/services/fido2/fido2-client.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,10 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
}

params.rp.id = params.rp.id ?? parsedOrigin.hostname;
if (parsedOrigin.hostname == undefined || !params.origin.startsWith("https://")) {
if (
parsedOrigin.hostname == undefined ||
(!params.origin.startsWith("https://") && parsedOrigin.hostname !== "localhost")
) {
this.logService?.warning(`[Fido2Client] Invalid https origin: ${params.origin}`);
throw new DOMException("'origin' is not a valid https origin", "SecurityError");
}
Expand Down Expand Up @@ -238,7 +241,10 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {

params.rpId = params.rpId ?? parsedOrigin.hostname;

if (parsedOrigin.hostname == undefined || !params.origin.startsWith("https://")) {
if (
parsedOrigin.hostname == undefined ||
(!params.origin.startsWith("https://") && parsedOrigin.hostname !== "localhost")
) {
this.logService?.warning(`[Fido2Client] Invalid https origin: ${params.origin}`);
throw new DOMException("'origin' is not a valid https origin", "SecurityError");
}
Expand Down

0 comments on commit dce5c0f

Please sign in to comment.