fix(10.1-04): refuse percent-encoded dot segments in plugin asset URLs

The URL parser resolves %2e%2e like .., so /{base}/assets/%2e%2e/api/...
passed assetAllowed and would load from outside the asset prefix. A
segment is now a dot segment after decoding %2e, in any case.

- tests/app/pluginAssets.test.ts covers the URL check, loadScript,
  loadStyles, activateStyles and loadControllerAssets
- modules/boardwalk/dist rebuilt
This commit is contained in:
Jakub Zych
2026-09-29 02:52:14 +02:00
parent 7eed4acd87
commit 6b0ac15086
4 changed files with 211 additions and 4 deletions

View File

@@ -25,7 +25,9 @@ export function assetPrefix(): string {
/**
* Whether a URL may load as a plugin asset: a same-origin path under
* {base}/assets/ with no dot segment, backslash, whitespace or control
* character (browsers strip or rewrite those before resolving).
* character (browsers strip or rewrite those before resolving). A
* percent-encoded dot counts as a dot: the URL parser resolves %2e%2e like
* .., which would climb out of the asset prefix.
*/
export function assetAllowed(url: string): boolean {
if (!url.startsWith(assetPrefix()) || url.includes('\\')) {
@@ -38,7 +40,10 @@ export function assetAllowed(url: string): boolean {
}
}
const path = url.split(/[?#]/, 1)[0] ?? ''
return !path.split('/').some((segment) => segment === '.' || segment === '..')
return !path.split('/').some((segment) => {
const dots = segment.toLowerCase().replaceAll('%2e', '.')
return dots === '.' || dots === '..'
})
}
/** Loads one module script; the same URL always yields the same promise. */

View File

@@ -0,0 +1,202 @@
// Plugin asset loader (D-14, D-16; T-10.1-13, T-10.1-16). Files never load in
// happy-dom: appended scripts and links go into a detached sandbox the test
// inspects, and the test fires each script's load or error event itself.
// Module state (loaded URLs, owned links) lives for the whole file, so every
// test uses its own URLs and controller ids.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
activateStyles,
assetAllowed,
assetPrefix,
loadControllerAssets,
loadScript,
loadStyles,
OWNER_ATTRIBUTE,
} from '../../src/app/pluginAssets'
let sandbox: HTMLDivElement
/** Redirects head.appendChild of scripts and links into the sandbox. */
function captureHead(): void {
sandbox = document.createElement('div')
const head = document.head
const append = head.appendChild.bind(head)
vi.spyOn(head, 'appendChild').mockImplementation(<T extends Node>(node: T): T => {
if (node instanceof HTMLScriptElement || node instanceof HTMLLinkElement) {
return sandbox.appendChild(node)
}
return append(node)
})
}
function scripts(src: string): HTMLScriptElement[] {
return Array.from(sandbox.querySelectorAll('script')).filter((script) => script.getAttribute('src') === src)
}
function links(owner: string): HTMLLinkElement[] {
return Array.from(sandbox.querySelectorAll('link')).filter((link) => link.getAttribute(OWNER_ATTRIBUTE) === owner)
}
function fire(script: HTMLScriptElement | undefined, type: 'load' | 'error'): void {
if (!script) {
throw new Error('script was not appended')
}
script.dispatchEvent(new Event(type))
}
const asset = (file: string) => `/admin-test/assets/acme/demo/${file}`
beforeEach(() => {
captureHead()
})
afterEach(() => {
sandbox.remove()
})
describe('asset URL check', () => {
it('uses the runtime base', () => {
expect(assetPrefix()).toBe('/admin-test/assets/')
})
it.each([
asset('js/lookup.js?v=0123456789ab'),
asset('css/gadgets.css'),
asset('js/nested/deep.mjs#frag'),
])('accepts %s', (url) => {
expect(assetAllowed(url)).toBe(true)
})
it.each([
['another origin', 'https://cdn.example.test/admin-test/assets/x.js'],
['protocol-relative', '//cdn.example.test/admin-test/assets/x.js'],
['another prefix', '/admin-test/api/v1/acme/demo/widgets'],
['the prefix as a substring', '/admin-testassets/x.js'],
['a sibling base', '/admin-test2/assets/x.js'],
['a relative path', 'assets/x.js'],
['a scheme', 'javascript:alert(1)'],
['a dot-dot segment', '/admin-test/assets/../api/v1/x.js'],
['a dot segment', '/admin-test/assets/./x.js'],
['an encoded dot-dot segment', '/admin-test/assets/%2e%2e/api/v1/x.js'],
['a mixed-case encoded dot-dot segment', '/admin-test/assets/.%2E/api/v1/x.js'],
['an encoded dot segment', '/admin-test/assets/%2E/x.js'],
['a backslash', '/admin-test/assets/a\\..\\x.js'],
['a space', '/admin-test/assets/a b.js'],
['a tab', '/admin-test/assets/\t/x.js'],
['a newline', '/admin-test/assets/x.js\n'],
['a DEL', '/admin-test/assets/x\u007f.js'],
['empty', ''],
])('refuses %s', (_, url) => {
expect(assetAllowed(url)).toBe(false)
})
})
describe('loadScript', () => {
it('refuses a URL outside the prefix without touching the document', async () => {
await expect(loadScript('https://cdn.example.test/x.js')).rejects.toThrow('outside /admin-test/assets/')
await expect(loadScript('/admin-test/assets/%2e%2e/api/v1/x.js')).rejects.toThrow()
expect(sandbox.children).toHaveLength(0)
})
it('appends one module script per URL and shares its promise', async () => {
const url = asset('js/once.js?v=1')
const first = loadScript(url)
const second = loadScript(url)
expect(second).toBe(first)
const [script, ...extra] = scripts(url)
expect(extra).toHaveLength(0)
expect(script!.type).toBe('module')
expect(script!.getAttribute('src')).toBe(url)
fire(script, 'load')
await expect(first).resolves.toBeUndefined()
// A loaded URL stays loaded: no second element, the same promise.
expect(loadScript(url)).toBe(first)
expect(scripts(url)).toHaveLength(1)
})
it('rejects on an error, forgets the URL and appends a new element on retry', async () => {
const url = asset('js/flaky.js?v=1')
const failed = loadScript(url)
const [broken] = scripts(url)
fire(broken, 'error')
await expect(failed).rejects.toThrow(`plugin script failed to load: ${url}`)
expect(scripts(url)).toHaveLength(0)
const retry = loadScript(url)
expect(retry).not.toBe(failed)
const [again] = scripts(url)
expect(again).toBeDefined()
expect(again).not.toBe(broken)
fire(again, 'load')
await expect(retry).resolves.toBeUndefined()
})
})
describe('controller stylesheets', () => {
it('tags each link with its controller, once per controller and URL', () => {
const css = asset('css/p1.css?v=1')
loadStyles('acme.demo.p1', [css, css, 'https://cdn.example.test/x.css', '/admin-test/assets/../x.css'])
loadStyles('acme.demo.p1', [css])
const [link, ...extra] = links('acme.demo.p1')
expect(extra).toHaveLength(0)
expect(link!.rel).toBe('stylesheet')
expect(link!.getAttribute('href')).toBe(css)
expect(OWNER_ATTRIBUTE).toBe('data-summer-controller')
// Another controller of the same plugin gets its own link to the file.
loadStyles('acme.demo.p2', [css])
expect(links('acme.demo.p2')).toHaveLength(1)
expect(links('acme.demo.p2')[0]).not.toBe(link)
})
it('enables the opened controller and disables every other one', () => {
loadStyles('acme.demo.s1', [asset('css/s1.css'), asset('css/shared.css')])
loadStyles('acme.demo.s2', [asset('css/shared.css')])
activateStyles('acme.demo.s2')
expect(links('acme.demo.s1').map((link) => link.disabled)).toEqual([true, true])
expect(links('acme.demo.s2').map((link) => link.disabled)).toEqual([false])
activateStyles('acme.demo.s1')
expect(links('acme.demo.s1').map((link) => link.disabled)).toEqual([false, false])
expect(links('acme.demo.s2').map((link) => link.disabled)).toEqual([true])
activateStyles('acme.demo.none')
expect([...links('acme.demo.s1'), ...links('acme.demo.s2')].every((link) => link.disabled)).toBe(true)
})
})
describe('loadControllerAssets', () => {
it('creates and activates the styles, then resolves once the scripts have loaded', async () => {
loadStyles('acme.demo.other', [asset('css/other.css')])
const js = asset('js/c1.js')
let settled = false
const done = loadControllerAssets('acme.demo.c1', { scripts: [js], styles: [asset('css/c1.css')] }).then((failed) => {
settled = true
return failed
})
expect(links('acme.demo.c1').map((link) => link.disabled)).toEqual([false])
expect(links('acme.demo.other').map((link) => link.disabled)).toEqual([true])
await Promise.resolve()
await Promise.resolve()
expect(settled).toBe(false)
fire(scripts(js)[0], 'load')
await expect(done).resolves.toEqual([])
expect(settled).toBe(true)
})
it('resolves with the scripts that failed or were refused and never rejects', async () => {
const ok = asset('js/c2-ok.js')
const bad = asset('js/c2-bad.js')
const foreign = 'https://cdn.example.test/x.js'
const done = loadControllerAssets('acme.demo.c2', { scripts: [ok, bad, foreign], styles: [] })
fire(scripts(ok)[0], 'load')
fire(scripts(bad)[0], 'error')
await expect(done).resolves.toEqual([bad, foreign])
})
it('treats missing assets as nothing to load', async () => {
await expect(loadControllerAssets('acme.demo.c3', null)).resolves.toEqual([])
await expect(loadControllerAssets('acme.demo.c3', undefined)).resolves.toEqual([])
expect(sandbox.children).toHaveLength(0)
})
})

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,7 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-DMFEtOWd.js"></script>
<script type="module" crossorigin src="./assets/index-B51qJou6.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CfeX_snf.css">
</head>
<body>