diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts
index a58a72b1b..97790ec4c 100644
--- a/src/backend/controllers/auth/AuthController.test.ts
+++ b/src/backend/controllers/auth/AuthController.test.ts
@@ -3119,6 +3119,27 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
expect(bootstrapped).toBeTruthy();
expect(bootstrapped?.owner_user_id).toBe(owner!.id);
});
+
+ it('supports browser extension origins in handleGetUserAppToken', async () => {
+ // Random id: a pre-existing row for this origin would resolve through
+ // the canonical lookup and never exercise the bootstrap path.
+ const origin = `chrome-extension://${uuidv4()}`;
+ const res = makeRes();
+ await inCtx(actor, () =>
+ controller.handleGetUserAppToken(
+ makeReq({ origin }, { actor }),
+ res,
+ ),
+ );
+ const body = res.body as { token: string; app_uid: string };
+ expect(body.app_uid).toBe(
+ `app-${uuidv5(origin, APP_ORIGIN_UUID_NAMESPACE)}`,
+ );
+ const bootstrapped = await server.stores.app.getByUid(body.app_uid);
+ expect(bootstrapped?.index_url).toBe(origin);
+ // Proves the row came from the bootstrap path, not an earlier test.
+ expect(bootstrapped?.description).toMatch(/^App created from origin /);
+ });
});
// ── Access tokens: create + revoke ─────────────────────────────────
diff --git a/src/backend/drivers/apps/AppDriver.js b/src/backend/drivers/apps/AppDriver.js
index f2d88470e..1e4134487 100644
--- a/src/backend/drivers/apps/AppDriver.js
+++ b/src/backend/drivers/apps/AppDriver.js
@@ -48,6 +48,7 @@ import {
validateJsonObject,
validateString,
validateUrl,
+ WEB_AND_EXTENSION_PROTOCOLS,
} from '../../util/validation.js';
import { PuterDriver } from '../types.js';
@@ -632,6 +633,7 @@ export class AppDriver extends PuterDriver {
key: 'index_url',
maxLen: 3000,
required: isCreate,
+ protocols: WEB_AND_EXTENSION_PROTOCOLS,
});
// Only enforce on new/changed values so rows that already
// carry a reserved host (migration-seeded builtins) can still
diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts
index aede3e864..9f67606c4 100644
--- a/src/backend/services/auth/AuthService.test.ts
+++ b/src/backend/services/auth/AuthService.test.ts
@@ -18,7 +18,7 @@
*/
import jwt from 'jsonwebtoken';
-import { v4 as uuidv4 } from 'uuid';
+import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { makeActor, type Actor } from '../../core/actor.js';
import { PuterServer } from '../../server.js';
@@ -1631,14 +1631,81 @@ describe('AuthService (integration)', () => {
'data:text/html,',
'file:///etc/passwd',
'vbscript:msgbox(1)',
- ])('throws 400 for non-http(s) scheme %s', async (origin) => {
- // These parse fine via `new URL()` but must never become a
- // bootstrap app `index_url` — that would be a stored XSS /
- // code-execution vector when launched as `iframe.src`.
- await expect(
- authService.appUidFromOrigin(origin),
- ).rejects.toMatchObject({ statusCode: 400 });
+ ])(
+ 'throws 400 for unsafe non-web/non-extension scheme %s',
+ async (origin) => {
+ // These parse fine via `new URL()` but must never become a
+ // bootstrap app `index_url` — that would be a stored XSS /
+ // code-execution vector when launched as `iframe.src`.
+ await expect(
+ authService.appUidFromOrigin(origin),
+ ).rejects.toMatchObject({ statusCode: 400 });
+ },
+ );
+
+ // Mirrors APP_ORIGIN_UUID_NAMESPACE in AuthService: changing it
+ // changes every origin-derived app uid in the fleet.
+ const APP_ORIGIN_UUID_NAMESPACE =
+ '33de3768-8ee0-43e9-9e73-db192b97a5d8';
+ // Unique per run — a real `apps` row carrying the same origin as its
+ // `index_url` resolves ahead of the uuidv5 fallback.
+ const extensionId = uuidv4();
+
+ it.each([
+ `chrome-extension://${extensionId}`,
+ `moz-extension://${extensionId}`,
+ `safari-extension://${extensionId}`,
+ `safari-web-extension://${extensionId}`,
+ ])('derives the app uid for extension origin %s', async (origin) => {
+ const uid = await authService.appUidFromOrigin(origin);
+ expect(uid).toBe(
+ `app-${uuidv5(origin, APP_ORIGIN_UUID_NAMESPACE)}`,
+ );
+ expect(await authService.appUidFromOrigin(origin)).toBe(uid);
});
+
+ it('gives two extensions two different app uids', async () => {
+ const a = await authService.appUidFromOrigin(
+ `chrome-extension://${uuidv4()}`,
+ );
+ const b = await authService.appUidFromOrigin(
+ `chrome-extension://${uuidv4()}`,
+ );
+ expect(a).not.toBe(b);
+ });
+
+ it('resolves an extension id case-insensitively', async () => {
+ // `new URL()` lowercases http(s) hosts but leaves opaque ones
+ // alone, so without normalization one extension would get two
+ // uids, two AppData trees and two permission sets.
+ const id = uuidv4();
+ expect(
+ await authService.appUidFromOrigin(
+ `chrome-extension://${id.toUpperCase()}`,
+ ),
+ ).toBe(
+ await authService.appUidFromOrigin(`chrome-extension://${id}`),
+ );
+ });
+
+ it.each([
+ // Extension schemes are not "special", so `new URL()` accepts them
+ // with no authority — which would slip past every host-based guard.
+ 'chrome-extension:',
+ 'moz-extension:',
+ // Near-misses: no browser emits any of these.
+ 'extension://my-extension-id',
+ 'web-extension://my-extension-id',
+ 'ms-browser-extension://my-extension-id',
+ 'chrome-extensions://my-extension-id',
+ ])(
+ 'throws 400 for host-less or non-browser scheme %s',
+ async (origin) => {
+ await expect(
+ authService.appUidFromOrigin(origin),
+ ).rejects.toMatchObject({ statusCode: 400 });
+ },
+ );
});
describe('subdomainOwnerIdFromOrigin', () => {
diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts
index e4a7aecab..f0df2a8a7 100644
--- a/src/backend/services/auth/AuthService.ts
+++ b/src/backend/services/auth/AuthService.ts
@@ -26,6 +26,7 @@ import {
type Actor,
} from '../../core/actor';
import { HttpError } from '../../core/http/HttpError.js';
+import { WEB_AND_EXTENSION_PROTOCOLS } from '../../util/validation.js';
import {
ASSET_WINDOW_SECONDS,
WEB_WINDOW_SECONDS,
@@ -915,10 +916,13 @@ export class AuthService extends PuterService {
return this.#normalizedOrigin(parsed);
}
- /** Scheme + host + explicit port, with no trailing separator. */
+ /** Scheme + lowercased host + explicit port, with no trailing separator. */
#normalizedOrigin(parsed: URL): string {
const port = parsed.port ? `:${parsed.port}` : '';
- return `${parsed.protocol}//${parsed.hostname}${port}`;
+ // `new URL()` lowercases http(s) hosts but leaves opaque ones alone, so
+ // without this an extension id in two spellings hashes to two app uids
+ // (and misses the blocklist, which matches on a lowercased host).
+ return `${parsed.protocol}//${parsed.hostname.toLowerCase()}${port}`;
}
/**
@@ -1786,14 +1790,15 @@ export class AuthService extends PuterService {
#originFromUrl(url: string): string | null {
try {
const parsed = new URL(url);
- // A real web origin is always http(s). `new URL()` happily parses
- // `javascript:`, `data:`, `file:`, `vbscript:`, etc.; if one of
- // those slips through it ends up persisted as an app `index_url`
- // (see AppStore.createFromOrigin) and later loaded as `iframe.src`
- // — an XSS/code-execution primitive. Reject anything that isn't
- // http(s) so the bootstrap path matches AppDriver's validateUrl
- // allow-list.
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ // This gets persisted as an app `index_url` and later loaded as
+ // `iframe.src` (see AppStore.createFromOrigin), so anything outside
+ // the allow-list is a stored code-execution vector.
+ if (!WEB_AND_EXTENSION_PROTOCOLS.includes(parsed.protocol)) {
+ return null;
+ }
+ // Extension schemes aren't "special", so `new URL()` accepts them
+ // with no authority at all (`chrome-extension:`).
+ if (!parsed.hostname) {
return null;
}
return this.#normalizedOrigin(parsed);
diff --git a/src/backend/stores/app/AppStore.js b/src/backend/stores/app/AppStore.js
index ada84646d..46ed2da5d 100644
--- a/src/backend/stores/app/AppStore.js
+++ b/src/backend/stores/app/AppStore.js
@@ -21,7 +21,10 @@ import { v4 as uuidv4 } from 'uuid';
import { PuterStore } from '../types';
import { HttpError } from '../../core/http/HttpError.js';
import { isUniqueViolation } from '../../util/dbError.js';
-import { validateUrl } from '../../util/validation.js';
+import {
+ validateUrl,
+ WEB_AND_EXTENSION_PROTOCOLS,
+} from '../../util/validation.js';
/**
* Persistence + cache for the `apps` table.
@@ -470,11 +473,14 @@ export class AppStore extends PuterStore {
{ ownerUserId = null } = {},
) {
// Bootstrap apps persist `origin` straight into `index_url`, which is
- // later loaded as `iframe.src`. Enforce the same http(s) scheme
- // allow-list as the AppDriver create/update path so a caller-supplied
+ // later loaded as `iframe.src`. Enforce the same scheme allow-list
+ // as the AppDriver create/update path so a caller-supplied
// `javascript:`/`data:`/`file:` origin can never become a stored,
// launchable code-execution vector.
- validateUrl(origin, { key: 'origin' });
+ validateUrl(origin, {
+ key: 'origin',
+ protocols: WEB_AND_EXTENSION_PROTOCOLS,
+ });
const fields = {
name: uid,
diff --git a/src/backend/stores/app/AppStore.test.js b/src/backend/stores/app/AppStore.test.js
index 4019f5043..40c0a7199 100644
--- a/src/backend/stores/app/AppStore.test.js
+++ b/src/backend/stores/app/AppStore.test.js
@@ -776,6 +776,10 @@ describe('AppStore CRUD and cache invalidation', () => {
'javascript:alert(1)',
'data:text/html,',
'file:///etc/passwd',
+ // Allow-listed scheme, but `new URL()` accepts it with no authority.
+ 'chrome-extension:',
+ // Not a scheme any browser emits.
+ 'extension://my-extension-id',
])('refuses to bootstrap an app from the %s origin', async (origin) => {
await expect(
appStore.createFromOrigin(
@@ -785,6 +789,16 @@ describe('AppStore CRUD and cache invalidation', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
+ it('creates an origin-bootstrap app for a browser extension origin', async () => {
+ const uid = `app-ext-${Math.random().toString(36).slice(2, 10)}`;
+ const origin = `chrome-extension://${uid}`;
+
+ const app = await appStore.createFromOrigin(uid, origin);
+
+ expect(app.uid).toBe(uid);
+ expect(app.index_url).toBe(origin);
+ });
+
it('returns the existing row when the deterministic uid was already inserted', async () => {
const uid = `app-origin-${Math.random().toString(36).slice(2, 10)}`;
const origin = `https://${uid}.example.com`;
diff --git a/src/backend/util/validation.js b/src/backend/util/validation.js
index 359043bcf..a98da504e 100644
--- a/src/backend/util/validation.js
+++ b/src/backend/util/validation.js
@@ -24,6 +24,31 @@ import { HttpError } from '../core/http/HttpError.js';
* ...) on failure. Returns the value on success.
*/
+/**
+ * Real web origins. Anything outside this list is an XSS/SSRF primitive once
+ * the value is consumed as `iframe.src`, `window.location`, a server-side
+ * fetch, etc. — `new URL()` alone happily parses `javascript:alert(1)`,
+ * `data:text/html,…`, `file:///etc/passwd`, and `vbscript:`.
+ */
+export const WEB_PROTOCOLS = Object.freeze(['http:', 'https:']);
+
+/**
+ * Web origins plus the browser-extension schemes, for app origins and
+ * `index_url` values. Shared by AuthService.#originFromUrl,
+ * AppStore.createFromOrigin and AppDriver.#validateInput.
+ *
+ * Only Chrome derives the extension host from the signing key. Firefox and
+ * Safari generate it per install, so one extension is a distinct origin — and
+ * therefore a distinct app row — for each user who installs it.
+ */
+export const WEB_AND_EXTENSION_PROTOCOLS = Object.freeze([
+ ...WEB_PROTOCOLS,
+ 'chrome-extension:',
+ 'moz-extension:',
+ 'safari-extension:',
+ 'safari-web-extension:',
+]);
+
export function validateString(
value,
{ key, maxLen, regex, required = true, allowEmpty = false } = {},
@@ -66,13 +91,9 @@ export function validateUrl(
key,
maxLen = 3000,
required = true,
- // Default allowlist is http(s) only — anything else is an XSS/SSRF
- // primitive when the value is later consumed as `iframe.src`,
- // `window.location`, a server-side fetch, etc. `new URL()` alone
- // happily parses `javascript:alert(1)`, `data:text/html,…`,
- // `file:///etc/passwd`, and `vbscript:`; callers that need
- // something exotic must opt in explicitly.
- protocols = ['http:', 'https:'],
+ // http(s) by default (see WEB_PROTOCOLS); callers that need something
+ // exotic must opt in explicitly.
+ protocols = WEB_PROTOCOLS,
} = {},
) {
if (value === undefined || value === null) {
@@ -98,6 +119,14 @@ export function validateUrl(
{ legacyCode: 'bad_request' },
);
}
+ // Only "special" schemes (http:, https:, ws:, …) require an authority.
+ // `new URL()` accepts `chrome-extension:` and `extension:javascript:alert(1)`
+ // with an empty host, which slips past every host-based guard downstream.
+ if (!parsed.hostname) {
+ throw new HttpError(400, `\`${key}\` must include a host`, {
+ legacyCode: 'bad_request',
+ });
+ }
return value;
}
diff --git a/src/backend/util/validation.test.js b/src/backend/util/validation.test.js
index f4f23b340..dc9398d55 100644
--- a/src/backend/util/validation.test.js
+++ b/src/backend/util/validation.test.js
@@ -24,6 +24,7 @@ import {
validateJsonObject,
validateString,
validateUrl,
+ WEB_AND_EXTENSION_PROTOCOLS,
} from './validation.js';
/**
@@ -163,6 +164,56 @@ describe('validateUrl', () => {
);
});
+ it.each([
+ 'chrome-extension://cafneielldmiliebnkhaeaaibinihgpb',
+ 'moz-extension://f4b30177-3e5e-49b4-bb50-32df6ff09033',
+ 'safari-extension://f4b30177-3e5e-49b4-bb50-32df6ff09033',
+ 'safari-web-extension://f4b30177-3e5e-49b4-bb50-32df6ff09033',
+ ])('accepts the browser extension origin %s when opted in', (value) => {
+ expect(
+ validateUrl(value, {
+ key: 'index_url',
+ protocols: WEB_AND_EXTENSION_PROTOCOLS,
+ }),
+ ).toBe(value);
+ });
+
+ it.each([
+ 'chrome-extension:',
+ 'moz-extension:',
+ 'extension:javascript:alert(1)',
+ ])('rejects the host-less value %s', (value) => {
+ // Only "special" schemes require an authority, so these parse with an
+ // empty host and would slip past every host-based guard downstream.
+ expectBadRequest(
+ () =>
+ validateUrl(value, {
+ key: 'index_url',
+ protocols: [...WEB_AND_EXTENSION_PROTOCOLS, 'extension:'],
+ }),
+ '`index_url` must include a host',
+ );
+ });
+
+ it.each([
+ 'extension://my-extension-id',
+ 'web-extension://my-extension-id',
+ 'ms-browser-extension://my-extension-id',
+ ])('keeps rejecting the non-browser scheme %s', (value) => {
+ expectBadRequest(
+ () =>
+ validateUrl(value, {
+ key: 'index_url',
+ protocols: WEB_AND_EXTENSION_PROTOCOLS,
+ }),
+ 'must use one of the following protocols',
+ );
+ });
+
+ it('exposes the extension allow-list as a frozen value', () => {
+ expect(Object.isFrozen(WEB_AND_EXTENSION_PROTOCOLS)).toBe(true);
+ });
+
it('rejects a missing value when required, passes it through otherwise', () => {
expectBadRequest(
() => validateUrl(undefined, { key: 'url' }),