1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-24 16:43:27 +00:00

Extract urlOriginsMatch utility and refactor senderIsInternal (#19076)

Adds urlOriginsMatch to @bitwarden/platform, which compares two URLs by
scheme, host, and port. Uses `protocol + "//" + host` rather than
`URL.origin` because non-special schemes (e.g. chrome-extension://)
return the opaque string "null" from .origin, making equality comparison
unreliable. URLs without a host (file:, data:) are explicitly rejected
to prevent hostless schemes from comparing equal.

Refactors senderIsInternal to delegate to urlOriginsMatch and to derive
the extension URL via BrowserApi.getRuntimeURL("") rather than inline
chrome/browser API detection. Adds full test coverage for
senderIsInternal.

The previous string-based comparison used startsWith after stripping
trailing slashes, which was safe in senderIsInternal where inputs are
tightly constrained. As a general utility accepting arbitrary URLs,
startsWith can produce false positives (e.g. "https://example.com"
matching "https://example.com.evil.com"). Structural host comparison
is the correct contract for unrestricted input.
This commit is contained in:
✨ Audrey ✨
2026-02-19 08:45:24 -05:00
committed by GitHub
parent 6498ec42f8
commit e66a1f37b5
5 changed files with 227 additions and 17 deletions

View File

@@ -1,2 +1,3 @@
export * from "./services/browser-service";
export * from "./background-sync";
export * from "./util";

View File

@@ -0,0 +1,54 @@
import { urlOriginsMatch } from "./util";
describe("urlOriginsMatch", () => {
it.each([
["string/string, same origin", "https://example.com", "https://example.com"],
["URL/URL, same origin", new URL("https://example.com"), new URL("https://example.com")],
["string canonical, URL suspect", "https://example.com", new URL("https://example.com/path")],
["URL canonical, string suspect", new URL("https://example.com/path"), "https://example.com"],
[
"paths and query differ but origin same",
"https://example.com/foo",
"https://example.com/bar?baz=1",
],
["explicit default port matches implicit", "https://example.com", "https://example.com:443"],
[
"non-special scheme with matching host",
"chrome-extension://abc123/popup.html",
"chrome-extension://abc123/bg.js",
],
])("returns true when %s", (_, canonical, suspect) => {
expect(urlOriginsMatch(canonical as string | URL, suspect as string | URL)).toBe(true);
});
it.each([
["hosts differ", "https://example.com", "https://evil.com"],
["schemes differ", "https://example.com", "http://example.com"],
["ports differ", "https://example.com:8080", "https://example.com:9090"],
[
"suspect is a subdomain of the canonical host",
"https://example.com",
"https://sub.example.com",
],
["non-special scheme hosts differ", "chrome-extension://abc123/", "chrome-extension://xyz789/"],
])("returns false when %s", (_, canonical, suspect) => {
expect(urlOriginsMatch(canonical, suspect)).toBe(false);
});
it.each([
["canonical is an invalid string", "not a url", "https://example.com"],
["suspect is an invalid string", "https://example.com", "not a url"],
])("returns false when %s", (_, canonical, suspect) => {
expect(urlOriginsMatch(canonical, suspect)).toBe(false);
});
it.each([
["canonical is a file: URL", "file:///home/user/a.txt", "https://example.com"],
["suspect is a file: URL", "https://example.com", "file:///home/user/a.txt"],
["both are file: URLs", "file:///home/user/a.txt", "file:///home/other/b.txt"],
["canonical is a data: URL", "data:text/plain,foo", "https://example.com"],
["suspect is a data: URL", "https://example.com", "data:text/plain,foo"],
])("returns false when %s (no host)", (_, canonical, suspect) => {
expect(urlOriginsMatch(canonical, suspect)).toBe(false);
});
});

53
libs/platform/src/util.ts Normal file
View File

@@ -0,0 +1,53 @@
function toURL(input: string | URL): URL | null {
if (input instanceof URL) {
return input;
}
try {
return new URL(input);
} catch {
return null;
}
}
function effectiveOrigin(url: URL): string | null {
// The URL spec returns "null" for .origin on non-special schemes
// (e.g. chrome-extension://) so we build the origin from protocol + host instead.
// An empty host means no meaningful origin can be compared (file:, data:, etc.).
if (!url.host) {
return null;
}
return `${url.protocol}//${url.host}`;
}
/**
* Compares two URLs to determine whether the suspect URL originates from the
* same host as the canonical URL.
*
* Both arguments accept either a string or an existing {@link URL} object.
*
* Returns `false` when:
* - Either argument cannot be parsed as a valid URL
* - Either URL has no host (e.g. `file:`, `data:` schemes), since URLs without
* a meaningful host cannot be distinguished by origin
*
* @param canonical - The reference URL whose origin acts as the baseline.
* @param suspect - The URL being tested against the canonical origin.
* @returns `true` if both URLs share the same scheme, host, and port; `false` otherwise.
*/
export function urlOriginsMatch(canonical: string | URL, suspect: string | URL): boolean {
const canonicalUrl = toURL(canonical);
const suspectUrl = toURL(suspect);
if (!canonicalUrl || !suspectUrl) {
return false;
}
const canonicalOrigin = effectiveOrigin(canonicalUrl);
const suspectOrigin = effectiveOrigin(suspectUrl);
if (!canonicalOrigin || !suspectOrigin) {
return false;
}
return canonicalOrigin === suspectOrigin;
}