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:
@@ -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. */
|
||||
|
||||
202
admin/tests/app/pluginAssets.test.ts
Normal file
202
admin/tests/app/pluginAssets.test.ts
Normal 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
2
modules/boardwalk/dist/index.html
vendored
2
modules/boardwalk/dist/index.html
vendored
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user