Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/playground/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import { ProgressTracker } from '@php-wasm/progress';
import type { MountDescriptor, PlaygroundClient } from '@wp-playground/remote';
import type { PathAlias } from '@php-wasm/universal';
import { additionalRemoteOrigins } from './additional-remote-origins';
import { getRuntimeRemoteOrigins } from './runtime-remote-origins';
export { addRemoteOrigin } from './runtime-remote-origins';
// eslint-disable-next-line @nx/enforce-module-boundaries
import { remoteDevServerHost, remoteDevServerPort } from '../../build-config';
import { BlueprintsV1Handler } from './blueprints-v1-handler';
Expand Down Expand Up @@ -183,7 +185,7 @@ function allowStorageAccessByUserActivation(iframe: HTMLIFrameElement) {

const officialRemoteOrigin = 'https://playground.wordpress.net';
const devRemoteOrigin = `http://${remoteDevServerHost}:${remoteDevServerPort}`;
const validRemoteOrigins = [
const builtInRemoteOrigins = [
officialRemoteOrigin,
devRemoteOrigin,
// An older origin that's still used by some plugins.
Expand Down Expand Up @@ -217,6 +219,10 @@ const remoteOrigin =
*/
function assertLikelyCompatibleRemoteOrigin(remoteHtmlUrl: string) {
const url = new URL(remoteHtmlUrl, remoteOrigin);
const validRemoteOrigins = [
...builtInRemoteOrigins,
...getRuntimeRemoteOrigins(),
];

const validRemote =
validRemoteOrigins.includes(url.origin) &&
Expand Down
163 changes: 163 additions & 0 deletions packages/playground/client/src/runtime-remote-origins.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
describe('addRemoteOrigin', () => {
let addRemoteOrigin: (origin: string) => void;
let getRuntimeRemoteOrigins: () => readonly string[];

beforeEach(async () => {
// Reset the module so each test starts with an empty registry.
vi.resetModules();
({ addRemoteOrigin, getRuntimeRemoteOrigins } =
await import('./runtime-remote-origins'));
});

describe('valid origins', () => {
it('should accept a basic https origin', () => {
addRemoteOrigin('https://example.com');
expect(getRuntimeRemoteOrigins()).toEqual(['https://example.com']);
});

it('should accept an http origin', () => {
addRemoteOrigin('http://example.com');
expect(getRuntimeRemoteOrigins()).toEqual(['http://example.com']);
});

it('should accept an origin with a non-default port', () => {
addRemoteOrigin('http://localhost:5401');
expect(getRuntimeRemoteOrigins()).toEqual([
'http://localhost:5401',
]);
});

it('should accept a bracketed IPv6 origin', () => {
addRemoteOrigin('http://[::1]:8080');
expect(getRuntimeRemoteOrigins()).toEqual(['http://[::1]:8080']);
});

it('should accept a punycode host', () => {
addRemoteOrigin('https://xn--exmple-cua.com');
expect(getRuntimeRemoteOrigins()).toEqual([
'https://xn--exmple-cua.com',
]);
});
});

describe('invalid origins', () => {
it('should reject an empty string', () => {
expect(() => addRemoteOrigin('')).toThrow("Invalid origin: ''");
});

it('should reject an unparseable string', () => {
expect(() => addRemoteOrigin('not a url')).toThrow(
"Invalid origin: 'not a url'"
);
});

it('should reject an origin with a path', () => {
expect(() => addRemoteOrigin('https://example.com/path')).toThrow(
"Invalid origin: 'https://example.com/path'"
);
});

it('should reject an origin with a trailing slash', () => {
expect(() => addRemoteOrigin('https://example.com/')).toThrow(
"Invalid origin: 'https://example.com/'"
);
});

it('should reject an origin with a query string', () => {
expect(() => addRemoteOrigin('https://example.com?x=1')).toThrow(
"Invalid origin: 'https://example.com?x=1'"
);
});

it('should reject an origin with a fragment', () => {
expect(() => addRemoteOrigin('https://example.com#frag')).toThrow(
"Invalid origin: 'https://example.com#frag'"
);
});

it("should reject an origin with the scheme's default port specified", () => {
// `new URL('https://example.com:443').href === 'https://example.com/'`,
// so the round-trip strict-equality check rejects the input.
expect(() => addRemoteOrigin('https://example.com:443')).toThrow(
"Invalid origin: 'https://example.com:443'"
);
});

it('should reject an origin with an uppercase scheme', () => {
// The URL parser normalizes the scheme to lowercase, so the
// round-trip strict-equality check rejects upper-case input.
expect(() => addRemoteOrigin('HTTPS://example.com')).toThrow(
"Invalid origin: 'HTTPS://example.com'"
);
});

it('should reject an origin with a non-ASCII host', () => {
// IDN hosts must be supplied in punycode form; otherwise the
// URL parser normalizes the host and the round-trip check fails.
expect(() => addRemoteOrigin('https://exämple.com')).toThrow(
"Invalid origin: 'https://exämple.com'"
);
});

it('should reject an origin with userinfo', () => {
// `URL.origin` strips userinfo, so an origin with userinfo can
// never match the iframe's `url.origin` at validation time.
expect(() =>
addRemoteOrigin('https://user:pass@example.com')
).toThrow("Invalid origin: 'https://user:pass@example.com'");
});

it('should reject a `ws://` origin', () => {
expect(() => addRemoteOrigin('ws://example.com')).toThrow(
"Invalid origin: 'ws://example.com'"
);
});

it('should reject a `file://` origin', () => {
expect(() => addRemoteOrigin('file:///tmp/x')).toThrow(
"Invalid origin: 'file:///tmp/x'"
);
});

it('should reject a `data:` URL', () => {
expect(() => addRemoteOrigin('data:text/plain,hi')).toThrow(
"Invalid origin: 'data:text/plain,hi'"
);
});
});

describe('accumulation', () => {
it('should accumulate across calls in insertion order', () => {
addRemoteOrigin('https://a.example.com');
addRemoteOrigin('https://b.example.com');
addRemoteOrigin('https://c.example.com');
expect(getRuntimeRemoteOrigins()).toEqual([
'https://a.example.com',
'https://b.example.com',
'https://c.example.com',
]);
});

it('should not deduplicate when the same origin is added twice', () => {
addRemoteOrigin('https://example.com');
addRemoteOrigin('https://example.com');
expect(getRuntimeRemoteOrigins()).toEqual([
'https://example.com',
'https://example.com',
]);
});

it('should not register an origin when validation throws', () => {
expect(() => addRemoteOrigin('not a url')).toThrow();
expect(getRuntimeRemoteOrigins()).toEqual([]);
});

it('should preserve previously registered origins when a later call is rejected', () => {
addRemoteOrigin('https://first.example.com');
expect(() => addRemoteOrigin('not a url')).toThrow();
expect(getRuntimeRemoteOrigins()).toEqual([
'https://first.example.com',
]);
});
});
});
46 changes: 46 additions & 0 deletions packages/playground/client/src/runtime-remote-origins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { validateOrigin } from './validate-origin';

// Origins added at runtime via `addRemoteOrigin`. Kept separate from the
// build-time `additionalRemoteOrigins` array (see `additional-remote-origins.ts`)
// because the vite plugin in this package's `vite.config.ts` rewrites the
// source of that file when `ADDITIONAL_REMOTE_ORIGINS` is set, and would
// otherwise clobber any runtime registrations colocated with it.
const runtimeRemoteOrigins: string[] = [];

/**
* Returns the origins registered at runtime via {@link addRemoteOrigin}.
*
* @internal — exposed for use by the assertion in `index.ts` and by tests.
* Not part of the public package API.
*/
export function getRuntimeRemoteOrigins(): readonly string[] {
return runtimeRemoteOrigins;
}

/**
* Register an additional origin that is a valid host for Playground's
* `remote.html` iframe.
*
* Use this when embedding Playground from an origin that isn't part of
* the built-in allowlist (`playground.wordpress.net`, `wasm.wordpress.net`,
* the embedding page's own origin, or `localhost`/`127.0.0.1` on the
* default dev port). Call this before the `startPlaygroundWeb()`
* invocation that should see the new origin. Multiple calls accumulate:
* each call appends an entry, and duplicate origins are preserved (they
* are not deduplicated or treated as a no-op).
*
* The origin must be in canonical form: an `http://` or `https://` scheme,
* a lowercase ASCII (or punycode) host, an optional non-default port, and
* nothing else. No userinfo, path, query, fragment, or trailing slash.
* Invalid input throws and the registration is rejected.
*
* @example
* import { addRemoteOrigin, startPlaygroundWeb } from '@wp-playground/client';
*
* addRemoteOrigin('https://playground.example.com');
* await startPlaygroundWeb({ ... });
*/
export function addRemoteOrigin(origin: string): void {
validateOrigin(origin);
runtimeRemoteOrigins.push(origin);
}
45 changes: 45 additions & 0 deletions packages/playground/client/src/validate-origin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Assert that a string is a canonical origin: an `http://` or `https://`
* scheme followed by a host (with an optional non-default port) and
* nothing else — no userinfo, no path, no query, no fragment.
*
* Used by both the build-time `ADDITIONAL_REMOTE_ORIGINS` env-var seam
* (see `vite.config.ts`) and the runtime `addRemoteOrigin` API
* (see `runtime-remote-origins.ts`) so the two paths stay in lockstep.
*
* @throws if the input is not a canonical origin. The thrown message
* includes a short reason so build-time misconfigurations are easy to
* diagnose from a CI log.
*
* @internal
*/
export function validateOrigin(origin: string): void {
let url: URL;
try {
url = new URL(origin);
} catch {
throw invalidOriginError(origin, 'not a valid URL');
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw invalidOriginError(origin, 'must use http:// or https://');
}
if (url.username !== '' || url.password !== '') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason to limit this?

throw invalidOriginError(
origin,
'must not include username or password'
);
}
// Round-trip check rejects any input that the URL parser had to
// normalize: paths, queries, fragments, trailing slashes, uppercase
// schemes, and ports that match the scheme's default.
if (url.href !== `${origin}/`) {
throw invalidOriginError(
origin,
'must be a canonical http(s) origin with no path, query, fragment, trailing slash, or default port'
);
}
}

function invalidOriginError(origin: string, reason: string): Error {
return new Error(`Invalid origin: '${origin}' (${reason})`);
}
22 changes: 4 additions & 18 deletions packages/playground/client/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,7 @@ import { viteIgnoreImports } from '../../vite-extensions/vite-ignore-imports';
import { buildVersionPlugin } from '../../vite-extensions/vite-build-version';
// eslint-disable-next-line @nx/enforce-module-boundaries
import viteGlobalExtensions from '../../vite-extensions/vite-global-extensions';

function validateOrigin(origin: string) {
try {
const url = new URL(origin);
if (url.href === `${origin}/`) {
return true;
}
} catch {
// Let exceptions fall through to the error below
}

throw new Error(`Invalid origin: '${origin}'`);
}
import { validateOrigin } from './src/validate-origin';

const additionalRemoteOriginsModulePath = join(
__dirname,
Expand Down Expand Up @@ -67,11 +55,9 @@ export default defineConfig({
return code;
}

const additionalRemoteOrigins = process.env[
'ADDITIONAL_REMOTE_ORIGINS'
]
.split(',')
.filter(validateOrigin);
const additionalRemoteOrigins =
process.env['ADDITIONAL_REMOTE_ORIGINS'].split(',');
additionalRemoteOrigins.forEach(validateOrigin);
return `export const additionalRemoteOrigins = ${JSON.stringify(
additionalRemoteOrigins
)};`;
Expand Down
Loading