Skip to content

Commit dd55c06

Browse files
Jonathan KingstonJonathan Kingston
authored andcommitted
Preserve static preview isolation across loopback aliases
1 parent 0de5be1 commit dd55c06

3 files changed

Lines changed: 46 additions & 3 deletions

File tree

‎docs/browser-network-policy.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Copse-owned static prototypes send a CSP from their preview server. It allows
2222
resources from the page's own origin, inline scripts/styles and embedded data/blob
2323
images, but denies external origins, frames, workers, plugins and base URL changes.
2424
A network approval does not relax this prototype CSP: bundle assets locally.
25+
The prototype's loopback IP aliases retain its same-origin navigation restriction.
2526

2627
Ordinary HTTP(S) pages, including local development servers opened through the
2728
address bar or `browser_navigate`, retain only the CSP supplied by their server.

‎src/main/services/browser/static-preview-server.test.ts‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
33
import { tmpdir } from 'node:os'
44
import { join } from 'node:path'
55
import { afterEach, describe, it } from 'node:test'
6-
import { isBrowserRequestAllowed } from './browser-network-policy.ts'
6+
import {
7+
isBrowserPageNavigationAllowed,
8+
isBrowserRequestAllowed,
9+
} from './browser-network-policy.ts'
710
import {
811
flushPreviewStaleForTest,
912
getStaticPreviewServer,
@@ -41,7 +44,6 @@ describe('static browser preview server', () => {
4144
assert.equal(second.url, first.url)
4245
assert.match(first.url, /^http:\/\/localhost:\d+\/$/)
4346
assert.equal(isStaticPreviewUrl(first.url), true)
44-
assert.equal(isStaticPreviewUrl(first.url.replace('localhost', '127.0.0.1')), false)
4547
assert.equal(isStaticPreviewUrl('http://localhost:9/'), false)
4648
assert.equal(
4749
isBrowserRequestAllowed({
@@ -71,6 +73,33 @@ describe('static browser preview server', () => {
7173
assert.equal(await stylesheet.text(), 'body { color: plum; }')
7274
})
7375

76+
it('keeps prototype isolation when its listener is opened through an IP alias', async () => {
77+
const root = await temporaryRoot('copse-static-preview-alias-')
78+
await writeFile(join(root, 'index.html'), '<h1>Prototype</h1>')
79+
const preview = await getStaticPreviewServer(root)
80+
const alias = preview.url.replace('localhost', '127.0.0.1')
81+
assert.equal(await (await fetch(alias)).text(), '<h1>Prototype</h1>')
82+
for (const host of ['127.0.0.1', '[::ffff:127.0.0.1]', 'localhost.', 'preview.localhost']) {
83+
const url = preview.url.replace('localhost', host)
84+
assert.equal(isStaticPreviewUrl(url), true)
85+
assert.equal(isBrowserPageNavigationAllowed(url, 'https://example.com/leak'), false)
86+
assert.equal(isBrowserPageNavigationAllowed(url, new URL('next.html', url).href), true)
87+
assert.equal(
88+
isBrowserRequestAllowed({
89+
documentUrl: url,
90+
url: 'https://example.com/theme.css',
91+
resourceType: 'stylesheet',
92+
allowedOrigins: ['https://example.com'],
93+
}),
94+
false,
95+
)
96+
}
97+
assert.equal(isStaticPreviewUrl(preview.url.replace('http:', 'https:')), false)
98+
assert.equal(isStaticPreviewUrl(preview.url.replace('localhost', 'example.com')), false)
99+
await shutdownStaticPreviewServers()
100+
assert.equal(isStaticPreviewUrl(alias), false)
101+
})
102+
74103
it('reports a preview stale for any file it served, not just the entry page', async () => {
75104
const root = await temporaryRoot('copse-static-preview-served-')
76105
await mkdir(join(root, 'assets'))

‎src/main/services/browser/static-preview-server.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createReadStream, watch, type FSWatcher } from 'node:fs'
22
import { realpath, stat } from 'node:fs/promises'
33
import { createServer, type Server } from 'node:http'
44
import { extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
5+
import { embeddedIpv4, normalizeHostname } from '@copse/llm/credential-url.ts'
56

67
const LOOPBACK_HOST = '127.0.0.1'
78

@@ -80,7 +81,19 @@ const servers = new Map<string, PreviewServerEntry>()
8081
/** Whether this URL belongs to a Copse-owned static prototype server. */
8182
export function isStaticPreviewUrl(url: string): boolean {
8283
if (!URL.canParse(url)) return false
83-
const origin = new URL(url).origin
84+
const parsed = new URL(url)
85+
const host = normalizeHostname(parsed.hostname)
86+
// The listener binds 127.0.0.1 but advertises localhost. Opening its IP alias
87+
// must not turn an untrusted prototype into an unrestricted ordinary page.
88+
if (
89+
host === 'localhost' ||
90+
host.endsWith('.localhost') ||
91+
host === LOOPBACK_HOST ||
92+
embeddedIpv4(host) === LOOPBACK_HOST
93+
) {
94+
parsed.hostname = 'localhost'
95+
}
96+
const origin = parsed.origin
8497
return [...servers.values()].some((entry) => new URL(entry.url).origin === origin)
8598
}
8699

0 commit comments

Comments
 (0)