Skip to content
Merged
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
3 changes: 2 additions & 1 deletion packages/void/src/pages/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type PrefetchOptions,
} from './navigation-engine.ts';
import { ISLAND_FRONTMATTER_ID } from './serialize.ts';
import { isSameOrigin } from './url.ts';

export { getValidationErrors, isModifiedEvent, mergeDataIntoHref } from './adapter-helpers.ts';
export {
Expand Down Expand Up @@ -98,7 +99,7 @@ function normalizeUrl(value: string | URL): string {
return value;
}
const url = toUrl(value);
return url.origin === getUrlOrigin() ? url.pathname + url.search + url.hash : url.href;
return isSameOrigin(url, getUrlOrigin()) ? url.pathname + url.search + url.hash : url.href;
}

function navigationMethod(method: string): NavigationMethod {
Expand Down
14 changes: 10 additions & 4 deletions packages/void/src/pages/navigation-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { PrefetchCache, parseCacheFor } from './prefetch.ts';
import type { CacheForInput, CacheForResult } from './prefetch.ts';
import type { PageObject } from './protocol.ts';
import { createScrollRestoration } from './scroll-restoration.ts';
import { isSameOrigin } from './url.ts';

export type PageComponentModule = {
default: unknown;
Expand Down Expand Up @@ -595,13 +596,18 @@ export async function createPageNavigationEngine(
deferredRegistry: DeferredRegistry,
): Promise<PageNavigationResult | undefined> {
// Page data only comes from this origin. Other http(s) URLs get a full page
// load; other schemes (javascript:, data:, ...) must never reach location.href.
// load. Capacitor's iOS WebView uses capacitor: for local pages.
// Unsafe schemes (javascript:, data:, ...) must never reach location.href.
// Check the scheme first: a blob: URL reports the origin that created it.
const target = new URL(url, window.location.origin);
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
if (
target.protocol !== 'http:' &&
target.protocol !== 'https:' &&
target.protocol !== 'capacitor:'
) {
throw new Error(`Void Router: refusing to navigate to a ${target.protocol} URL.`);
}
if (target.origin !== window.location.origin) {
if (!isSameOrigin(target, window.location.origin)) {
if (method !== 'GET') {
throw new Error('Void Router: non-GET visits must target the current origin.');
}
Expand Down Expand Up @@ -676,7 +682,7 @@ export async function createPageNavigationEngine(

if (response?.redirected) {
const redirectUrl = new URL(response.url, window.location.origin);
if (redirectUrl.origin !== window.location.origin) {
if (!isSameOrigin(redirectUrl, window.location.origin)) {
window.location.href = method === 'GET' ? url : response.url;
return undefined;
}
Expand Down
8 changes: 8 additions & 0 deletions packages/void/src/pages/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function isSameOrigin(url: URL, origin: string): boolean {
// Custom schemes have an opaque URL.origin, even for different hosts.
if (url.protocol === 'capacitor:') {
const current = new URL(origin);
return current.protocol === 'capacitor:' && url.host !== '' && url.host === current.host;
}
return url.origin === origin;
}
66 changes: 62 additions & 4 deletions packages/void/test/unit/pages-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type TestRouterConfig = Partial<Omit<VoidRouterConfig, 'adapter'>> & {
adapter?: Partial<VoidRouterConfig['adapter']>;
};

function stubClientGlobals() {
function stubClientGlobals(origin = 'http://localhost') {
const documentStub = {
createElement: vi.fn(() => ({})),
getElementById: vi.fn((id: string) => {
Expand Down Expand Up @@ -42,7 +42,7 @@ function stubClientGlobals() {
vi.stubGlobal('window', {
addEventListener: vi.fn(),
location: {
origin: 'http://localhost',
origin,
pathname: '/',
search: '',
hash: '',
Expand Down Expand Up @@ -924,8 +924,8 @@ describe('createVoidRouter', () => {
});

describe('createVoidRouter other-origin visits', () => {
async function createRouter() {
stubClientGlobals();
async function createRouter(origin = 'http://localhost') {
stubClientGlobals(origin);
const fetchMock = vi.fn(async () => pageJson({ component: 'users', props: { users: [] } }));
vi.stubGlobal('fetch', fetchMock);
const applyUpdate = vi.fn();
Expand All @@ -937,6 +937,64 @@ describe('createVoidRouter other-origin visits', () => {
return { router, fetchMock, applyUpdate };
}

it.each([
['GET', '/users'],
['GET', 'capacitor://localhost/users'],
['POST', '/users'],
['POST', 'capacitor://localhost/users'],
])('loads Capacitor %s visits to %s in place', async (method, url) => {
const { router, fetchMock, applyUpdate } = await createRouter('capacitor://localhost');
await router.visit(url, { method });
expect(fetchMock).toHaveBeenCalledWith('/users', expect.objectContaining({ method }));
expect(applyUpdate).toHaveBeenCalledTimes(1);
expect(window.location.href).toBeUndefined();
});

it('follows a same-host Capacitor action redirect in place', async () => {
const { router, fetchMock, applyUpdate } = await createRouter('capacitor://localhost');
const response = pageJson({});
Object.defineProperties(response, {
redirected: { value: true },
url: { value: 'capacitor://localhost/users' },
});
fetchMock.mockResolvedValueOnce(response);
await router.visit('/submit', { method: 'POST' });
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/users',
expect.objectContaining({ method: 'GET' }),
);
expect(applyUpdate).toHaveBeenCalledTimes(1);
expect(window.location.href).toBeUndefined();
});

it('does not fetch another Capacitor host for GET navigation', async () => {
const { router, fetchMock, applyUpdate } = await createRouter('capacitor://localhost');
await router.visit('capacitor://other/users');
expect(fetchMock).not.toHaveBeenCalled();
expect(applyUpdate).not.toHaveBeenCalled();
expect(window.location.href).toBe('capacitor://other/users');
});

it('does not treat different Capacitor hosts as the same origin', async () => {
const { router, fetchMock } = await createRouter('capacitor://localhost');
await expect(router.visit('capacitor://other/users', { method: 'POST' })).rejects.toThrow(
'Void Router: non-GET visits must target the current origin.',
);
expect(fetchMock).not.toHaveBeenCalled();
expect(window.location.href).toBeUndefined();
});

it.each(['javascript:alert(1)', 'data:text/html,x', 'file:///users', 'app://localhost/users'])(
'still rejects %s from Capacitor',
async (url) => {
const { router, fetchMock } = await createRouter('capacitor://localhost');
await expect(router.visit(url)).rejects.toThrow(/refusing to navigate/);
expect(fetchMock).not.toHaveBeenCalled();
expect(window.location.href).toBeUndefined();
},
);

it.each(['javascript:alert(1)', ' JaVaScRiPt:alert(1)', 'data:text/html,x'])(
'rejects %j without fetching or assigning location',
async (url) => {
Expand Down