Serve mounted images publicly #66
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@directory/web': patch
|
||||
---
|
||||
|
||||
Serve mounted images as public static files without authentication, configuration allowlisting, or content sniffing.
|
||||
@@ -130,7 +130,7 @@ async function stopOidc(server: TestServer, issuer: TestIssuer) {
|
||||
}
|
||||
}
|
||||
|
||||
test('enabled authentication gates HTML, actual Kit data, HEAD, unknown routes and future assets', async () => {
|
||||
test('enabled authentication gates HTML, actual Kit data, HEAD and unknown routes', async () => {
|
||||
const server = await startServer({ configuration: configuration() });
|
||||
try {
|
||||
await server.ready();
|
||||
@@ -145,7 +145,6 @@ test('enabled authentication gates HTML, actual Kit data, HEAD, unknown routes a
|
||||
'/',
|
||||
'/__data.json?x-sveltekit-invalidated=11',
|
||||
'/unknown',
|
||||
'/public/private.svg',
|
||||
'/healthz/extra',
|
||||
'/signin/extra',
|
||||
'/favicon-missing.svg'
|
||||
@@ -203,9 +202,11 @@ test('enabled authentication gates HTML, actual Kit data, HEAD, unknown routes a
|
||||
expectPublic(body);
|
||||
}
|
||||
}
|
||||
const missingScript = await server.fetch('/_app/immutable/missing.js');
|
||||
expect(missingScript.status).toBe(404);
|
||||
expectPublic(await missingScript.text());
|
||||
for (const path of ['/_app/immutable/missing.js', '/public/private.svg']) {
|
||||
const missing = await server.fetch(path);
|
||||
expect(missing.status).toBe(404);
|
||||
expectPublic(await missing.text());
|
||||
}
|
||||
expectSafeLogs(server);
|
||||
} finally {
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
@@ -241,7 +242,8 @@ test('native routes reject unsupported verbs instead of serving public or privat
|
||||
method,
|
||||
headers: { origin: server.origin, cookie: session }
|
||||
});
|
||||
expect(response.status).toBe(session ? 405 : 401);
|
||||
const expectedStatus = session || path.startsWith('/public/') ? 405 : 401;
|
||||
expect(response.status).toBe(expectedStatus);
|
||||
expectPublic(await response.text());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,11 +179,11 @@ test('source changes retry primary URLs in place, including a repaired previousl
|
||||
await expect
|
||||
.poll(async () => (await server.fetch('/__data.json')).text())
|
||||
.toContain('/public/primary-c.svg');
|
||||
const failedPrimary = page.waitForResponse(
|
||||
const primaryResponse = page.waitForResponse(
|
||||
(response) => new URL(response.url()).pathname === '/public/primary-c.svg'
|
||||
);
|
||||
await navigateQuery(page, '/?link=valid');
|
||||
expect((await failedPrimary).status()).toBe(404);
|
||||
await primaryResponse;
|
||||
await expectDecoded(image, '/bookmarks/broken/logo');
|
||||
expect(await page.evaluate(() => Reflect.get(window, 'bookmarkNavigationMarker'))).toBe(true);
|
||||
expect(
|
||||
|
||||
@@ -6,8 +6,15 @@ import { credentialsAuth, password, username } from './support/configuration.js'
|
||||
import { startServer, type TestServer } from './support/server.js';
|
||||
|
||||
const siteDescription = 'Useful services with familiar images.';
|
||||
const privateLogo =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><text>PRIVATE_IMAGE_BYTES</text></svg>';
|
||||
const privateLogo = `<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Illustrator export -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
|
||||
<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
|
||||
]>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:i="&ns_ai;" viewBox="0 0 128 128">
|
||||
<g i:extraneous="self"><rect width="128" height="128" fill="#91bdce"/><text>PRIVATE_IMAGE_BYTES</text></g>
|
||||
<i:aipgf><![CDATA[opaque Illustrator editing metadata]]></i:aipgf>
|
||||
</svg>`;
|
||||
const privateBrand =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><text>PRIVATE_BRAND_BYTES</text></svg>';
|
||||
const replacementImage =
|
||||
@@ -57,6 +64,9 @@ async function fixtures() {
|
||||
await writeFile(join(publicDir, 'brand logo.svg'), privateBrand);
|
||||
await writeFile(join(publicDir, 'photo.svg'), privateLogo);
|
||||
await writeFile(join(publicDir, 'photo.png'), png);
|
||||
await writeFile(join(publicDir, 'unreferenced.svg'), privateLogo);
|
||||
await mkdir(join(publicDir, 'images', 'bookmarks'), { recursive: true });
|
||||
await writeFile(join(publicDir, 'images', 'bookmarks', 'test-ling.svg'), privateLogo);
|
||||
return { directory, publicDir };
|
||||
} catch (error) {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
@@ -99,7 +109,7 @@ async function stop(server: TestServer, directory: string) {
|
||||
}
|
||||
}
|
||||
|
||||
test('mounted files and generated bookmark logos require a real session, including encoded HTML requests', async () => {
|
||||
test('mounted files are public while generated bookmark logos require a session, including encoded HTML requests', async () => {
|
||||
const fixture = await fixtures();
|
||||
const server = await startServer({
|
||||
configuration: configuration(),
|
||||
@@ -109,14 +119,28 @@ test('mounted files and generated bookmark logos require a real session, includi
|
||||
await server.ready();
|
||||
for (const method of ['GET', 'HEAD']) {
|
||||
for (const cookie of ['', 'authjs.session-token=invalid-session']) {
|
||||
for (const [path, contentType, bytes] of [
|
||||
['/public/photo.svg', 'image/svg+xml', Buffer.from(privateLogo)],
|
||||
['/public/photo.png', 'image/png', png],
|
||||
['/public/brand%20logo.svg', 'image/svg+xml', Buffer.from(privateBrand)],
|
||||
['/%70ublic/photo.svg', 'image/svg+xml', Buffer.from(privateLogo)],
|
||||
['/public/photo%2Esvg', 'image/svg+xml', Buffer.from(privateLogo)],
|
||||
['/public/unreferenced.svg', 'image/svg+xml', Buffer.from(privateLogo)],
|
||||
['/public/images/bookmarks/test-ling.svg', 'image/svg+xml', Buffer.from(privateLogo)]
|
||||
] as const) {
|
||||
const image = await server.fetch(path, {
|
||||
method,
|
||||
headers: { accept: 'text/html', cookie }
|
||||
});
|
||||
expect(image.status).toBe(200);
|
||||
expectPrivateHeaders(image);
|
||||
expect(image.headers.get('content-type')).toBe(contentType);
|
||||
expect(image.headers.get('content-length')).toBe(String(bytes.length));
|
||||
expect(Buffer.from(await image.arrayBuffer())).toEqual(
|
||||
method === 'HEAD' ? Buffer.alloc(0) : bytes
|
||||
);
|
||||
}
|
||||
for (const path of [
|
||||
'/public/photo.svg',
|
||||
'/public/photo.png',
|
||||
'/public/brand%20logo.svg',
|
||||
'/%70ublic/photo.svg',
|
||||
'/public/photo%2Esvg',
|
||||
'/public/unknown.svg',
|
||||
'/public',
|
||||
'/bookmarks/photos/logo',
|
||||
'/bookmarks/missing/logo',
|
||||
'/%62ookmarks/photos/logo',
|
||||
@@ -140,11 +164,12 @@ test('mounted files and generated bookmark logos require a real session, includi
|
||||
expect(response.status).toBe(200);
|
||||
await expectNoPrivateData(response);
|
||||
}
|
||||
for (const path of ['/__data.json', '/public/photo.svg/__data.json']) {
|
||||
const denied = await server.fetch(path);
|
||||
expect(denied.status).toBe(401);
|
||||
await expectNoPrivateData(denied);
|
||||
}
|
||||
const denied = await server.fetch('/__data.json');
|
||||
expect(denied.status).toBe(401);
|
||||
await expectNoPrivateData(denied);
|
||||
const imageData = await server.fetch('/public/photo.svg/__data.json');
|
||||
expect(imageData.status).toBe(404);
|
||||
await expectNoPrivateData(imageData);
|
||||
|
||||
const cookie = await signIn(server);
|
||||
for (const path of [
|
||||
@@ -180,23 +205,18 @@ test('mounted files and generated bookmark logos require a real session, includi
|
||||
}
|
||||
});
|
||||
|
||||
test('open mode serves only exact contained image references, with MIME and bodyless HEAD responses', async () => {
|
||||
test('open mode serves contained images regardless of references, with MIME and bodyless HEAD responses', async () => {
|
||||
const fixture = await fixtures();
|
||||
await writeFile(join(fixture.directory, 'outside.svg'), privateLogo);
|
||||
await symlink(join(fixture.directory, 'outside.svg'), join(fixture.publicDir, 'escape.svg'));
|
||||
await symlink(join(fixture.publicDir, 'photo.svg'), join(fixture.publicDir, 'inside.svg'));
|
||||
await writeFile(join(fixture.publicDir, 'not-image.png'), '<html>PRIVATE_IMAGE_BYTES</html>');
|
||||
await writeFile(join(fixture.publicDir, 'unreferenced.svg'), privateLogo);
|
||||
await writeFile(join(fixture.publicDir, 'unsupported.txt'), 'PRIVATE_IMAGE_BYTES');
|
||||
await writeFile(join(fixture.publicDir, '家 icon.svg'), replacementImage);
|
||||
const extra = `
|
||||
extra:
|
||||
title: Extra
|
||||
url: https://extra.example.test
|
||||
logo_path: /public/escape.svg
|
||||
invalid-image:
|
||||
title: Invalid image
|
||||
url: https://invalid-image.example.test
|
||||
logo_path: /public/not-image.png
|
||||
inside:
|
||||
title: Inside
|
||||
url: https://inside.example.test
|
||||
@@ -215,6 +235,7 @@ test('open mode serves only exact contained image references, with MIME and body
|
||||
for (const [path, bytes, contentType] of [
|
||||
['/public/photo.svg', Buffer.from(privateLogo), 'image/svg+xml'],
|
||||
['/public/photo.png', png, 'image/png'],
|
||||
['/public/unreferenced.svg', Buffer.from(privateLogo), 'image/svg+xml'],
|
||||
['/public/inside.svg', Buffer.from(privateLogo), 'image/svg+xml'],
|
||||
['/public/%E5%AE%B6%20icon.svg', Buffer.from(replacementImage), 'image/svg+xml']
|
||||
] as const) {
|
||||
@@ -242,9 +263,9 @@ test('open mode serves only exact contained image references, with MIME and body
|
||||
}
|
||||
for (const path of [
|
||||
'/public/escape.svg',
|
||||
'/public/not-image.png',
|
||||
'/public/unreferenced.svg',
|
||||
'/public/unsupported.txt',
|
||||
'/public/missing.svg',
|
||||
'/public',
|
||||
'/public/PHOTO.svg',
|
||||
'/bookmarks/missing/logo',
|
||||
'/bookmarks/constructor/logo',
|
||||
@@ -274,65 +295,52 @@ test('open mode serves only exact contained image references, with MIME and body
|
||||
}
|
||||
});
|
||||
|
||||
test('accepted reloads replace exact references; invalid and auth-changing candidates keep last-good images', async () => {
|
||||
test('config reloads update generated logos but never gate mounted files on references', async () => {
|
||||
const fixture = await fixtures();
|
||||
await writeFile(join(fixture.publicDir, 'replacement.svg'), replacementImage);
|
||||
const server = await startServer({
|
||||
configuration: configuration(),
|
||||
publicDir: fixture.publicDir
|
||||
});
|
||||
async function expectPublicFiles() {
|
||||
for (const [path, bytes] of [
|
||||
['/public/photo.svg', privateLogo],
|
||||
['/public/replacement.svg', replacementImage]
|
||||
] as const) {
|
||||
const response = await server.fetch(path);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(bytes);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await server.ready();
|
||||
const cookie = await signIn(server);
|
||||
const original = await (
|
||||
await server.fetch('/bookmarks/photos/logo', { headers: { cookie } })
|
||||
).text();
|
||||
const bookmarkLogo = async () =>
|
||||
(await server.fetch('/bookmarks/photos/logo', { headers: { cookie } })).text();
|
||||
const original = await bookmarkLogo();
|
||||
await expectPublicFiles();
|
||||
const replacement = configuration()
|
||||
.replace('/public/photo.svg', '/public/replacement.svg')
|
||||
.replace('title: Photos', 'title: Updated Photos');
|
||||
await server.replace(replacement);
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await server.fetch('/public/replacement.svg', { headers: { cookie } })).status
|
||||
)
|
||||
.toBe(200);
|
||||
expect((await server.fetch('/public/photo.svg', { headers: { cookie } })).status).toBe(404);
|
||||
const changed = await (
|
||||
await server.fetch('/bookmarks/photos/logo', { headers: { cookie } })
|
||||
).text();
|
||||
await expect.poll(bookmarkLogo).toContain('>UP</text>');
|
||||
const changed = await bookmarkLogo();
|
||||
expect(changed).not.toBe(original);
|
||||
expect(changed).toContain('>UP</text>');
|
||||
await expectPublicFiles();
|
||||
|
||||
await server.replace('version: [invalid');
|
||||
await expect.poll(() => server.output()).toContain('load_failed');
|
||||
expect(
|
||||
await (await server.fetch('/public/replacement.svg', { headers: { cookie } })).text()
|
||||
).toBe(replacementImage);
|
||||
expect((await server.fetch('/public/photo.svg', { headers: { cookie } })).status).toBe(404);
|
||||
expect(
|
||||
await (await server.fetch('/bookmarks/photos/logo', { headers: { cookie } })).text()
|
||||
).toBe(changed);
|
||||
await expectPublicFiles();
|
||||
expect(await bookmarkLogo()).toBe(changed);
|
||||
|
||||
await server.replace(configuration().replace(password, 'CHANGED_PASSWORD'));
|
||||
await expect.poll(() => server.output()).toContain('restart_required');
|
||||
expect(
|
||||
await (await server.fetch('/public/replacement.svg', { headers: { cookie } })).text()
|
||||
).toBe(replacementImage);
|
||||
expect((await server.fetch('/public/photo.svg', { headers: { cookie } })).status).toBe(404);
|
||||
expect(
|
||||
await (await server.fetch('/bookmarks/photos/logo', { headers: { cookie } })).text()
|
||||
).toBe(changed);
|
||||
await expectPublicFiles();
|
||||
expect(await bookmarkLogo()).toBe(changed);
|
||||
|
||||
await server.replace(configuration());
|
||||
await expect
|
||||
.poll(async () => (await server.fetch('/public/photo.svg', { headers: { cookie } })).status)
|
||||
.toBe(200);
|
||||
expect((await server.fetch('/public/replacement.svg', { headers: { cookie } })).status).toBe(
|
||||
404
|
||||
);
|
||||
expect(
|
||||
await (await server.fetch('/bookmarks/photos/logo', { headers: { cookie } })).text()
|
||||
).toBe(original);
|
||||
await expect.poll(bookmarkLogo).toBe(original);
|
||||
await expectPublicFiles();
|
||||
} finally {
|
||||
await stop(server, fixture.directory);
|
||||
}
|
||||
@@ -363,7 +371,7 @@ test('missing and atomically replaced bytes recover without changing the prepare
|
||||
}
|
||||
});
|
||||
|
||||
test('a missing public directory is valid at startup and recovers when its referenced file appears', async () => {
|
||||
test('a missing public directory is valid at startup and recovers when an image appears', async () => {
|
||||
const fixture = await fixtures();
|
||||
await rm(fixture.publicDir, { recursive: true });
|
||||
const server = await startServer({
|
||||
|
||||
@@ -120,34 +120,6 @@ async function expectNotFound(server: TestServer) {
|
||||
}
|
||||
}
|
||||
|
||||
async function expectRawPrivate(server: TestServer) {
|
||||
for (const method of ['GET', 'HEAD']) {
|
||||
for (const cookie of ['', 'authjs.session-token=invalid-favicon-cookie']) {
|
||||
for (const path of [
|
||||
encodeURI(sourceA),
|
||||
sourceB,
|
||||
'/public/private-brand.svg',
|
||||
'/%70ublic/book.png',
|
||||
'/public/book%2Epng',
|
||||
'/public/unlisted.svg',
|
||||
'/bookmarks/book/logo',
|
||||
'/__data.json'
|
||||
]) {
|
||||
const response = await server.fetch(path, {
|
||||
method,
|
||||
headers: { cookie, accept: 'text/html' }
|
||||
});
|
||||
expect(response.status, path).toBe(401);
|
||||
expectPrivateHeaders(response);
|
||||
expect(response.headers.has('location')).toBe(false);
|
||||
const text = await response.text();
|
||||
if (method === 'HEAD') expect(text).toBe('');
|
||||
expectNoPrivateData(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function signIn(server: TestServer) {
|
||||
const response = await server.fetch('/signin', {
|
||||
method: 'POST',
|
||||
@@ -174,7 +146,7 @@ for (const [source, bytes, contentType] of [
|
||||
[sourceA, Buffer.from(svg), 'image/svg+xml'],
|
||||
[sourceB, png, 'image/png']
|
||||
] as const) {
|
||||
test(`selected ${contentType} is public only through the alias, including before sign-in without JS`, async ({
|
||||
test(`selected ${contentType} is public through the alias, including before sign-in without JS`, async ({
|
||||
browser
|
||||
}) => {
|
||||
const fixture = await fixtures();
|
||||
@@ -205,7 +177,6 @@ for (const [source, bytes, contentType] of [
|
||||
for (const session of ['', 'authjs.session-token=invalid-favicon-cookie', cookie]) {
|
||||
await expectImage(server, bytes, contentType, session);
|
||||
}
|
||||
await expectRawPrivate(server);
|
||||
for (const query of [
|
||||
'?file=/public/private-brand.svg&source=/public/book.png',
|
||||
'?path=%2Fpublic%2Finvalid.png&publicDir=%2Foutside',
|
||||
@@ -257,7 +228,6 @@ test('valid reloads keep the alias but change bytes; invalid and auth-changing c
|
||||
.poll(async () => (await server.fetch('/favicon')).headers.get('content-type'))
|
||||
.toBe('image/png');
|
||||
await expectImage(server, png, 'image/png');
|
||||
await expectRawPrivate(server);
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${server.url}/signin`);
|
||||
await expect(page.locator('head link[rel="icon"]')).toHaveAttribute('href', '/favicon');
|
||||
@@ -268,7 +238,6 @@ test('valid reloads keep the alias but change bytes; invalid and auth-changing c
|
||||
await server.replace(configuration(sourceA, true, 'REJECTED_FAVICON_PASSWORD'));
|
||||
await expect.poll(() => server.output()).toContain('restart_required');
|
||||
await expectImage(server, png, 'image/png');
|
||||
await expectRawPrivate(server);
|
||||
expectNoPrivateData(server.output(), ['REJECTED_FAVICON_PASSWORD']);
|
||||
|
||||
await server.replace(configuration());
|
||||
@@ -279,7 +248,6 @@ test('valid reloads keep the alias but change bytes; invalid and auth-changing c
|
||||
const bundled = await server.fetch('/favicon.png');
|
||||
expect(bundled.status).toBe(200);
|
||||
expect(bundled.headers.get('content-type')).toContain('image/png');
|
||||
await expectRawPrivate(server);
|
||||
} finally {
|
||||
await context.close();
|
||||
await stop(server, fixture.directory);
|
||||
|
||||
@@ -14,7 +14,7 @@ const generatedPath = '/images/og.png';
|
||||
const svgPath = '/public/share.svg';
|
||||
const nextPath = '/public/next.svg';
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0h32v32H0z"/></svg>';
|
||||
const privatePaths = [
|
||||
const mountedPaths = [
|
||||
'/public/logo.svg',
|
||||
'/public/link.svg',
|
||||
'/public/tab.svg',
|
||||
@@ -27,16 +27,16 @@ function brandedConfiguration(image: string) {
|
||||
return configuration()
|
||||
.replace(
|
||||
'site:\n',
|
||||
`site:\n og_image_path: ${image}\n logo_path: ${privatePaths[0]}\n favicon_path: ${privatePaths[2]}\n`
|
||||
`site:\n og_image_path: ${image}\n logo_path: ${mountedPaths[0]}\n favicon_path: ${mountedPaths[2]}\n`
|
||||
)
|
||||
.replace(
|
||||
' household:\n',
|
||||
` household:\n group: PRIVATE_OG_GROUP\n logo_path: ${privatePaths[1]}\n`
|
||||
` household:\n group: PRIVATE_OG_GROUP\n logo_path: ${mountedPaths[1]}\n`
|
||||
);
|
||||
}
|
||||
|
||||
function expectNoPrivateData(text: string) {
|
||||
for (const value of [...privateSentinels, 'PRIVATE_OG_GROUP', ...privatePaths, publicDir]) {
|
||||
for (const value of [...privateSentinels, 'PRIVATE_OG_GROUP', ...mountedPaths, publicDir]) {
|
||||
expect(text).not.toContain(value);
|
||||
}
|
||||
}
|
||||
@@ -84,17 +84,13 @@ async function expectStatic(server: TestServer, path: string) {
|
||||
expect(await response.text()).toBe(svg);
|
||||
}
|
||||
|
||||
async function expectPrivateImages(server: TestServer, extra: string[] = []) {
|
||||
for (const path of [...privatePaths, '/bookmarks/household/logo', ...extra]) {
|
||||
for (const method of ['GET', 'HEAD']) {
|
||||
const response = await server.fetch(path, { method });
|
||||
expect(response.status, `${method} ${path}`).toBe(401);
|
||||
expect(response.headers.has('location')).toBe(false);
|
||||
const body = await response.text();
|
||||
if (method === 'HEAD') expect(body).toBe('');
|
||||
expectNoPrivateData(body);
|
||||
}
|
||||
async function expectPublicAssets(server: TestServer) {
|
||||
for (const path of [...mountedPaths, svgPath, nextPath]) {
|
||||
await expectStatic(server, path);
|
||||
}
|
||||
const generatedLogo = await server.fetch('/bookmarks/household/logo');
|
||||
expect(generatedLogo.status).toBe(401);
|
||||
expectNoPrivateData(await generatedLogo.text());
|
||||
}
|
||||
|
||||
test.setTimeout(60_000);
|
||||
@@ -102,7 +98,7 @@ test.beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'directory-open-graph-'));
|
||||
publicDir = join(directory, 'public');
|
||||
await mkdir(publicDir);
|
||||
for (const path of [...privatePaths, nextPath]) {
|
||||
for (const path of [...mountedPaths, nextPath]) {
|
||||
await writeFile(join(publicDir, path.slice('/public/'.length)), svg);
|
||||
}
|
||||
});
|
||||
@@ -146,7 +142,7 @@ test('auth redirects home while sign-in SSR and generated PNG GET/HEAD remain pu
|
||||
}
|
||||
});
|
||||
|
||||
test('missing static image recovers without reload; one reload moves only its public access', async () => {
|
||||
test('missing static image recovers without reload; selection changes metadata, not public file access', async () => {
|
||||
const server = await startServer({ configuration: brandedConfiguration(svgPath), publicDir });
|
||||
try {
|
||||
await server.ready();
|
||||
@@ -160,16 +156,14 @@ test('missing static image recovers without reload; one reload moves only its pu
|
||||
});
|
||||
expect(metadata(html)).not.toHaveProperty('og:image:width');
|
||||
expect(metadata(html)).not.toHaveProperty('og:image:height');
|
||||
await expectStatic(server, svgPath);
|
||||
await expectPrivateImages(server, [nextPath]);
|
||||
await expectPublicAssets(server);
|
||||
|
||||
await server.replace(brandedConfiguration(nextPath));
|
||||
await expect
|
||||
.poll(async () => metadata(await ssr(server))['og:image'])
|
||||
.toBe(`${server.origin}${nextPath}`);
|
||||
await expectStatic(server, nextPath);
|
||||
// Selecting another OG image must not widen the static-image privacy scope.
|
||||
await expectPrivateImages(server, [svgPath]);
|
||||
// Both selected and unselected mounted files remain public.
|
||||
await expectPublicAssets(server);
|
||||
} finally {
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } fr
|
||||
import type { Authentication } from './lib/server/auth/index.js';
|
||||
import { requireSignIn } from './lib/server/auth/access.js';
|
||||
import { readMountedImage } from './lib/server/images/read-mounted-image.js';
|
||||
import { GET as mountedImage } from './routes/(protected)/(endpoints)/public/[...file]/+server.js';
|
||||
import { GET as mountedImage } from './routes/(public)/public/[...file]/+server.js';
|
||||
import { GET as bookmarkLogo } from './routes/(protected)/(endpoints)/bookmarks/[id]/logo/+server.js';
|
||||
import type { Application } from './lib/server/application.js';
|
||||
|
||||
@@ -297,9 +297,13 @@ describe('server hooks', () => {
|
||||
);
|
||||
|
||||
it.each(['GET', 'HEAD'])(
|
||||
'never reaches an image reader before authentication for %s, including the configured favicon',
|
||||
'serves public mounted %s images but authenticates generated bookmark logos',
|
||||
async (method) => {
|
||||
const session = vi.fn<App.Locals['auth']>().mockResolvedValue(null);
|
||||
vi.mocked(readMountedImage).mockResolvedValue({
|
||||
bytes: new TextEncoder().encode('<svg/>'),
|
||||
contentType: 'image/svg+xml'
|
||||
});
|
||||
const authentication: Authentication = {
|
||||
strategy: 'credentials',
|
||||
handle: async (input) => {
|
||||
@@ -317,25 +321,17 @@ describe('server hooks', () => {
|
||||
const hooks = await import('./hooks.server.js');
|
||||
await hooks.init();
|
||||
const resolve = vi.fn<Resolve>().mockImplementation(async (event) => {
|
||||
if (event.route.id === '/(protected)/(endpoints)/public/[...file]') {
|
||||
if (event.route.id === '/(public)/public/[...file]') {
|
||||
return mountedImage(event as Parameters<typeof mountedImage>[0]);
|
||||
}
|
||||
return bookmarkLogo(event as Parameters<typeof bookmarkLogo>[0]);
|
||||
});
|
||||
for (const cookie of ['', 'authjs.session-token=invalid-session']) {
|
||||
for (const [path, routeId, params] of [
|
||||
['/public/photo.svg', '/(protected)/(endpoints)/public/[...file]', { file: 'photo.svg' }],
|
||||
['/public/site.svg', '/(protected)/(endpoints)/public/[...file]', { file: 'site.svg' }],
|
||||
[
|
||||
'/%70ublic/photo.svg',
|
||||
'/(protected)/(endpoints)/public/[...file]',
|
||||
{ file: 'photo.svg' }
|
||||
],
|
||||
[
|
||||
'/public/unknown.svg',
|
||||
'/(protected)/(endpoints)/public/[...file]',
|
||||
{ file: 'unknown.svg' }
|
||||
],
|
||||
['/public/photo.svg', '/(public)/public/[...file]', { file: 'photo.svg' }],
|
||||
['/public/site.svg', '/(public)/public/[...file]', { file: 'site.svg' }],
|
||||
['/%70ublic/photo.svg', '/(public)/public/[...file]', { file: 'photo.svg' }],
|
||||
['/public/unknown.svg', '/(public)/public/[...file]', { file: 'unknown.svg' }],
|
||||
[
|
||||
'/bookmarks/photos/logo',
|
||||
'/(protected)/(endpoints)/bookmarks/[id]/logo',
|
||||
@@ -356,33 +352,28 @@ describe('server hooks', () => {
|
||||
isDataRequest: false,
|
||||
locals: {}
|
||||
} as unknown as RequestEvent;
|
||||
expect((await hooks.handle({ event: request, resolve })).status).toBe(401);
|
||||
expect((await hooks.handle({ event: request, resolve })).status).toBe(
|
||||
routeId === '/(public)/public/[...file]' ? 200 : 401
|
||||
);
|
||||
expect(request.locals.publicDir).toBe(ready.publicDir);
|
||||
}
|
||||
}
|
||||
expect(session).toHaveBeenCalledTimes(12);
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
expect(readMountedImage).not.toHaveBeenCalled();
|
||||
expect(session).toHaveBeenCalledTimes(4);
|
||||
expect(resolve).toHaveBeenCalledTimes(8);
|
||||
expect(readMountedImage).toHaveBeenCalledTimes(8);
|
||||
|
||||
session.mockResolvedValue({ user: { name: 'Directory' }, expires: '2030-01-01' });
|
||||
vi.mocked(readMountedImage).mockResolvedValue({
|
||||
bytes: new TextEncoder().encode('<svg/>'),
|
||||
contentType: 'image/svg+xml'
|
||||
});
|
||||
const url = new URL('https://directory.example/public/photo.svg');
|
||||
const url = new URL('https://directory.example/bookmarks/photos/logo');
|
||||
const authorized = {
|
||||
url,
|
||||
request: new Request(url, { method }),
|
||||
route: { id: '/(protected)/(endpoints)/public/[...file]' },
|
||||
params: { file: 'photo.svg' },
|
||||
route: { id: '/(protected)/(endpoints)/bookmarks/[id]/logo' },
|
||||
params: { id: 'photos' },
|
||||
isDataRequest: false,
|
||||
locals: {}
|
||||
} as unknown as RequestEvent;
|
||||
expect((await hooks.handle({ event: authorized, resolve })).status).toBe(200);
|
||||
expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(
|
||||
ready.publicDir,
|
||||
'/public/photo.svg'
|
||||
);
|
||||
expect(readMountedImage).toHaveBeenCalledTimes(8);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -97,10 +97,6 @@ describe('application startup', () => {
|
||||
expect(snapshot.configuration.auth?.credentials?.password).toBe(sentinel);
|
||||
expect(snapshot.configuration.auth).not.toHaveProperty('secret');
|
||||
expect(snapshot.display.groups[0]?.links[0]?.tags).toEqual(['photos', 'backup']);
|
||||
expect(snapshot.assets.referencedPaths).toEqual([
|
||||
'/public/boot.svg',
|
||||
'/public/boot-photos.svg'
|
||||
]);
|
||||
expectRedacted(snapshot.display);
|
||||
expect(mocks.report).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -141,16 +137,11 @@ describe('application config policy', () => {
|
||||
expect(current.configuration.site.title).toBe('next');
|
||||
expect(current.display.site.title).toBe('next');
|
||||
expect(current.display.groups[0]?.links[0]?.title).toBe('next photos');
|
||||
expect(current.assets.referencedPaths).toEqual(['/public/next.svg', '/public/next-photos.svg']);
|
||||
expect(current.assets.bookmarkLogos.photos).not.toBe(captured.assets.bookmarkLogos.photos);
|
||||
expect(current.display.snapshotId).not.toBe(captured.display.snapshotId);
|
||||
expect(captured.configuration.site.title).toBe('boot');
|
||||
expect(captured.display.site.title).toBe('boot');
|
||||
expect(captured.display.groups[0]?.links[0]?.title).toBe('boot photos');
|
||||
expect(captured.assets.referencedPaths).toEqual([
|
||||
'/public/boot.svg',
|
||||
'/public/boot-photos.svg'
|
||||
]);
|
||||
expectRedacted(current.display);
|
||||
expect(app.authentication).toBe(authentication);
|
||||
expect(mocks.authenticate).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function requireSignIn(
|
||||
event.locals.session = null;
|
||||
const routeId = event.route.id ?? '';
|
||||
const publicRoute = routeId === '/(public)' || routeId.startsWith('/(public)/');
|
||||
if (publicRoute || isConfiguredShareImage(event)) return resolve(event);
|
||||
if (publicRoute) return resolve(event);
|
||||
try {
|
||||
event.locals.session = await event.locals.auth();
|
||||
} catch {
|
||||
@@ -80,15 +80,6 @@ export async function requireSignIn(
|
||||
return denied(401);
|
||||
}
|
||||
|
||||
function isConfiguredShareImage(event: RequestEvent): boolean {
|
||||
if (event.route.id !== '/(protected)/(endpoints)/public/[...file]') return false;
|
||||
if (event.isDataRequest) return false;
|
||||
if (!['GET', 'HEAD'].includes(event.request.method)) return false;
|
||||
const selectedPath = event.locals.snapshot.configuration.site.og_image_path;
|
||||
if (selectedPath === undefined) return false;
|
||||
return selectedPath === `/public/${event.params.file}`;
|
||||
}
|
||||
|
||||
function isUnsafeMethod(event: RequestEvent): boolean {
|
||||
return !['GET', 'HEAD', 'OPTIONS'].includes(event.request.method);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { isHttpError, isRedirect, redirect, type Handle, type RequestEvent } fro
|
||||
import type { Session, SvelteKitAuthConfig } from '@auth/sveltekit';
|
||||
import { validateConfig } from '@directory/config';
|
||||
import { prepareSnapshot } from '../directory/snapshot.js';
|
||||
import { GET as mountedImage } from '../../../routes/(protected)/(endpoints)/public/[...file]/+server.js';
|
||||
import { createAuthentication } from './index.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -163,113 +162,27 @@ describe('bookmark logo API access', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mounted image access', () => {
|
||||
describe('public mounted image access', () => {
|
||||
it.each(['GET', 'HEAD'])(
|
||||
'requires a session for every mounted %s path, even configured site favicons and HTML Accept',
|
||||
'lets configured and unreferenced %s images reach the public route without reading a session',
|
||||
async (method) => {
|
||||
for (const path of [
|
||||
'/public/site.svg',
|
||||
'/public/site-logo.svg',
|
||||
'/public/logos/photos.svg',
|
||||
'/public/bookmark-favicon.svg',
|
||||
'/public/unreferenced.svg',
|
||||
'/public',
|
||||
'/public/'
|
||||
'/%70ublic/site.svg',
|
||||
'/public/site%2Esvg'
|
||||
]) {
|
||||
const response = await responseFor(
|
||||
eventFor(path, { method, routeId: '/(protected)/(endpoints)/public/[...file]' })
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.has('location')).toBe(false);
|
||||
const event = eventFor(path, { method, routeId: '/(public)/public/[...file]' });
|
||||
const response = await responseFor(event);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('cache-control')).toBe('private, no-store');
|
||||
expect(mocks.resolve).toHaveBeenLastCalledWith(event);
|
||||
}
|
||||
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['GET', 'HEAD'])(
|
||||
'uses the matched mounted route for encoded %s aliases',
|
||||
async (method) => {
|
||||
for (const path of ['/%70ublic/site.svg', '/public/site%2Esvg', '/public/caf%C3%A9.svg']) {
|
||||
const event = eventFor(path, { method });
|
||||
event.route.id = '/(protected)/(endpoints)/public/[...file]';
|
||||
expect((await responseFor(event)).status).toBe(401);
|
||||
}
|
||||
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['GET', 'HEAD'])(
|
||||
'keeps an unreferenced former bookmark favicon private, then returns 404 after sign-in for %s',
|
||||
async (method) => {
|
||||
const path = '/public/bookmark-favicon.svg';
|
||||
expect(snapshot.assets.referencedPaths).not.toContain(path);
|
||||
const event = eventFor(path, {
|
||||
method,
|
||||
routeId: '/(protected)/(endpoints)/public/[...file]'
|
||||
});
|
||||
event.params = { file: 'bookmark-favicon.svg' };
|
||||
mocks.resolve.mockImplementation((current: RequestEvent) =>
|
||||
mountedImage(current as Parameters<typeof mountedImage>[0])
|
||||
);
|
||||
expect((await responseFor(event)).status).toBe(401);
|
||||
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||
mocks.session.mockResolvedValueOnce(signedIn);
|
||||
expect((await responseFor(event)).status).toBe(404);
|
||||
expect(mocks.resolve).toHaveBeenCalledExactlyOnceWith(event);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['/public/site.svg', '/public/logos/photos.svg'])(
|
||||
'permits a signed-in request for %s',
|
||||
async (path) => {
|
||||
mocks.session.mockResolvedValueOnce(signedIn);
|
||||
const event = eventFor(path);
|
||||
event.route.id = '/(protected)/(endpoints)/public/[...file]';
|
||||
expect((await responseFor(event)).status).toBe(200);
|
||||
expect(mocks.resolve).toHaveBeenCalledExactlyOnceWith(event);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('configured public share image access', () => {
|
||||
it.each(['GET', 'HEAD'])(
|
||||
'permits the selected static OG image for %s without a session',
|
||||
async (method) => {
|
||||
const event = eventFor('/public/images/share.svg?ignored=1', {
|
||||
method,
|
||||
routeId: '/(protected)/(endpoints)/public/[...file]'
|
||||
});
|
||||
event.params = { file: 'images/share.svg' };
|
||||
event.locals.snapshot = prepareSnapshot(
|
||||
validateConfig(
|
||||
{ site: { og_image_path: '/public/images/share.svg' }, links: {} },
|
||||
{ env: {} }
|
||||
)
|
||||
);
|
||||
expect((await responseFor(event)).status).toBe(200);
|
||||
expect(mocks.resolve).toHaveBeenCalledExactlyOnceWith(event);
|
||||
expect(mocks.session).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['other.svg', 'images/share.svg/other'])(
|
||||
'keeps %s outside the public selection',
|
||||
async (file) => {
|
||||
const event = eventFor(`/public/${file}`, {
|
||||
routeId: '/(protected)/(endpoints)/public/[...file]'
|
||||
});
|
||||
event.params = { file };
|
||||
event.locals.snapshot = prepareSnapshot(
|
||||
validateConfig(
|
||||
{ site: { og_image_path: '/public/images/share.svg' }, links: {} },
|
||||
{ env: {} }
|
||||
)
|
||||
);
|
||||
expect((await responseFor(event)).status).toBe(401);
|
||||
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('boot factory', () => {
|
||||
|
||||
@@ -71,7 +71,6 @@ describe('private prepared snapshots', () => {
|
||||
expect(Object.isFrozen(snapshot.configuration.auth?.credentials)).toBe(true);
|
||||
expect(Object.isFrozen(snapshot.display)).toBe(true);
|
||||
expect(Object.isFrozen(snapshot.assets)).toBe(true);
|
||||
expect(Object.isFrozen(snapshot.assets.referencedPaths)).toBe(true);
|
||||
expect(Object.isFrozen(snapshot.assets.bookmarkLogos)).toBe(true);
|
||||
expect(Reflect.set(snapshot.assets.bookmarkLogos, 'photos', 'Changed')).toBe(false);
|
||||
input.site.title = 'Changed';
|
||||
@@ -84,16 +83,8 @@ describe('private prepared snapshots', () => {
|
||||
expect(Reflect.set(snapshot.configuration.site, 'title', 'Changed')).toBe(false);
|
||||
});
|
||||
|
||||
it('collects only site images and primary logos, deduplicating shared branding references', () => {
|
||||
it('retains configured image paths in display while preparing generated bookmark logos', () => {
|
||||
const snapshot = prepareSnapshot(configuration());
|
||||
expect(snapshot.assets.brandingPaths).toEqual(['/public/home.svg', '/public/site-icon.png']);
|
||||
expect(snapshot.assets.referencedPaths).toEqual([
|
||||
'/public/home.svg',
|
||||
'/public/site-icon.png',
|
||||
'/public/photos.svg',
|
||||
'/public/shared.png',
|
||||
'/public/photos-icon.png'
|
||||
]);
|
||||
const links = snapshot.display.groups.flatMap((group) => group.links);
|
||||
expect(links).toHaveLength(4);
|
||||
expect(Object.keys(snapshot.assets.bookmarkLogos)).toEqual([
|
||||
@@ -102,8 +93,6 @@ describe('private prepared snapshots', () => {
|
||||
'album',
|
||||
'home'
|
||||
]);
|
||||
expect(snapshot.assets.brandingPaths).not.toContain('/public/shared.png');
|
||||
expect(snapshot.assets.brandingPaths).not.toContain('/public/photos-icon.png');
|
||||
for (const link of links) {
|
||||
expect(link.logo_path).toBe(snapshot.configuration.links[link.id]!.logo_path);
|
||||
expect(link).not.toHaveProperty('favicon_path');
|
||||
@@ -111,12 +100,11 @@ describe('private prepared snapshots', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('tracks a configured Open Graph image as branding without adding its path to display data', () => {
|
||||
it('keeps a configured Open Graph image in configuration, not directory display data', () => {
|
||||
const config = configuration();
|
||||
config.site.og_image_path = '/public/share.png';
|
||||
const snapshot = prepareSnapshot(config);
|
||||
expect(snapshot.assets.brandingPaths).toContain('/public/share.png');
|
||||
expect(snapshot.assets.referencedPaths).toContain('/public/share.png');
|
||||
expect(snapshot.configuration.site.og_image_path).toBe('/public/share.png');
|
||||
expect(JSON.stringify(snapshot.display)).not.toContain('/public/share.png');
|
||||
});
|
||||
|
||||
@@ -162,12 +150,10 @@ describe('private prepared snapshots', () => {
|
||||
});
|
||||
|
||||
it.each([{}, { logo_path: '/favicon.png', favicon_path: '/favicon.png' }])(
|
||||
'excludes default and explicit bundled site images from mounted assets: %j',
|
||||
'retains default and explicit bundled site images in display: %j',
|
||||
(site) => {
|
||||
const snapshot = prepareSnapshot(validateConfig({ site, links: {} }, { env: {} }));
|
||||
expect(snapshot.assets).toEqual({
|
||||
referencedPaths: [],
|
||||
brandingPaths: [],
|
||||
bookmarkLogos: {}
|
||||
});
|
||||
expect(snapshot.display.site.logo_path).toBe('/favicon.png');
|
||||
@@ -177,7 +163,7 @@ describe('private prepared snapshots', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('captures only mounted references when site selections mix bundled and mounted images', () => {
|
||||
it('retains selected paths when site images mix bundled and mounted images', () => {
|
||||
const snapshot = prepareSnapshot(
|
||||
validateConfig(
|
||||
{
|
||||
@@ -190,8 +176,6 @@ describe('private prepared snapshots', () => {
|
||||
{ env: {} }
|
||||
)
|
||||
);
|
||||
expect(snapshot.assets.brandingPaths).toEqual(['/public/site.png']);
|
||||
expect(snapshot.assets.referencedPaths).toEqual(['/public/site.png', '/public/photos.png']);
|
||||
expect(snapshot.display.groups[0]?.links[0]?.logo_path).toBe('/public/photos.png');
|
||||
expect(snapshot.display.groups[0]?.links[1]?.logo_path).toBe('/bookmarks/plain/logo');
|
||||
});
|
||||
|
||||
@@ -9,8 +9,6 @@ export interface PreparedSnapshot {
|
||||
readonly configuration: Immutable<DirectoryConfiguration>;
|
||||
readonly display: DirectoryContent;
|
||||
readonly assets: {
|
||||
readonly referencedPaths: readonly string[];
|
||||
readonly brandingPaths: readonly string[];
|
||||
readonly bookmarkLogos: Readonly<Record<string, string>>;
|
||||
};
|
||||
}
|
||||
@@ -19,22 +17,7 @@ export function prepareSnapshot(config: DirectoryConfiguration): PreparedSnapsho
|
||||
const configuration = structuredClone(config);
|
||||
const display = prepareDirectoryContent(configuration, randomUUID());
|
||||
const assets = {
|
||||
...collectAssetPaths(configuration),
|
||||
bookmarkLogos: renderBookmarkLogos(configuration)
|
||||
};
|
||||
return freezeData({ configuration, display, assets });
|
||||
}
|
||||
|
||||
function collectAssetPaths(
|
||||
config: DirectoryConfiguration
|
||||
): Pick<PreparedSnapshot['assets'], 'referencedPaths' | 'brandingPaths'> {
|
||||
const brandingPaths = new Set<string>();
|
||||
for (const path of [config.site.logo_path, config.site.favicon_path, config.site.og_image_path]) {
|
||||
if (path?.startsWith('/public/')) brandingPaths.add(path);
|
||||
}
|
||||
const referencedPaths = new Set(brandingPaths);
|
||||
for (const link of Object.values(config.links)) {
|
||||
if (link.logo_path !== undefined) referencedPaths.add(link.logo_path);
|
||||
}
|
||||
return { referencedPaths: [...referencedPaths], brandingPaths: [...brandingPaths] };
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { matchesImageFormat } from './image-format.js';
|
||||
|
||||
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>');
|
||||
const pngHeader = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
).subarray(0, 33);
|
||||
const jpegHeader = Buffer.from([0xff, 0xd8, 0xff]);
|
||||
const gifHeader = Buffer.concat([Buffer.from('GIF89a'), Buffer.from([1, 0, 1, 0])]);
|
||||
const webpHeader = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBP')]);
|
||||
|
||||
describe('image format recognition', () => {
|
||||
it.each([
|
||||
['.png', svg],
|
||||
['.jpg', gifHeader],
|
||||
['.gif', webpHeader],
|
||||
['.webp', pngHeader],
|
||||
['.png', pngHeader.subarray(0, 30)],
|
||||
['.gif', gifHeader.subarray(0, 9)],
|
||||
['.jpeg', jpegHeader.subarray(0, 2)],
|
||||
['.webp', webpHeader.subarray(0, 11)],
|
||||
['.png', Buffer.from('<html>not an image</html>')]
|
||||
] as const)('rejects mismatched bytes or an incomplete %s header', (extension, bytes) => {
|
||||
expect(matchesImageFormat(bytes, extension)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['.png', pngHeader],
|
||||
['.jpeg', jpegHeader],
|
||||
['.gif', gifHeader],
|
||||
['.webp', webpHeader]
|
||||
] as const)('recognizes %s without requiring complete codec data', (extension, bytes) => {
|
||||
expect(matchesImageFormat(bytes, extension)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'<svg/>',
|
||||
'<!-- first -->\n<!-- second --> <svg/>',
|
||||
'<?xml version="1.0"?>\n<svg/>',
|
||||
"<?xml version='1.0' encoding='utf-8'?><svg/>",
|
||||
'\ufeff<?xml version="1.0" encoding="UTF-8"?>\n<!-- icon -->\n<svg xmlns="http://www.w3.org/2000/svg"><text>Home & family</text></svg>',
|
||||
'<svg><text><![CDATA[<hello> & anything]]></text></svg><!-- end & -->',
|
||||
'<svg viewBox="0 0 1 1"><path d="M0 0" /></svg>'
|
||||
])('recognizes an SVG document with XML prolog/comments', (text) => {
|
||||
expect(matchesImageFormat(Buffer.from(text), '.svg')).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'<html><svg/></html>',
|
||||
'<?xml version="1.0"?><!-- icon --><html><svg/></html>',
|
||||
'<!-- <svg/> -->',
|
||||
'not xml <svg/>',
|
||||
'<svgish/>',
|
||||
'<svg',
|
||||
'<!-- unclosed <svg/>',
|
||||
'<?xml version="1.0" <svg/>',
|
||||
'<!DOCTYPE svg><svg/>',
|
||||
'<!doctype svg><svg/>',
|
||||
'<!DOCTYPE svg [<!ENTITY x SYSTEM "file:///private">]><svg>&x;</svg>',
|
||||
'<svg><!ENTITY x "value"></svg>',
|
||||
'<?xml version="1.0" encoding="ISO-8859-1"?><svg/>',
|
||||
'<svg><script/></svg><!DOCTYPE html>'
|
||||
])('rejects non-SVG roots, unsupported encodings and DTD/entity markup', (text) => {
|
||||
expect(matchesImageFormat(Buffer.from(text), '.svg')).toBe(false);
|
||||
});
|
||||
|
||||
it('recognizes an SVG root without validating descendant XML grammar', () => {
|
||||
expect(matchesImageFormat(Buffer.from('<svg><path bad=value>&unknown;</svg>'), '.svg')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('compares magic bytes without masking their high bits', () => {
|
||||
const disguised = Buffer.from(gifHeader);
|
||||
disguised[0] = disguised[0]! | 0x80;
|
||||
expect(matchesImageFormat(disguised, '.gif')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
export function matchesImageFormat(bytes: Buffer, extension: string): boolean {
|
||||
switch (extension) {
|
||||
case '.png':
|
||||
return (
|
||||
bytes.length >= 33 &&
|
||||
bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) &&
|
||||
bytes.readUInt32BE(8) === 13 &&
|
||||
bytes.toString('latin1', 12, 16) === 'IHDR' &&
|
||||
bytes.readUInt32BE(16) > 0 &&
|
||||
bytes.readUInt32BE(20) > 0
|
||||
);
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||
case '.gif':
|
||||
return (
|
||||
bytes.length >= 10 &&
|
||||
['GIF87a', 'GIF89a'].includes(bytes.toString('latin1', 0, 6)) &&
|
||||
bytes.readUInt16LE(6) > 0 &&
|
||||
bytes.readUInt16LE(8) > 0
|
||||
);
|
||||
case '.webp':
|
||||
return (
|
||||
bytes.length >= 12 &&
|
||||
bytes.toString('latin1', 0, 4) === 'RIFF' &&
|
||||
bytes.toString('latin1', 8, 12) === 'WEBP'
|
||||
);
|
||||
case '.svg':
|
||||
return isSvg(bytes);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Recognize trusted operator SVG, not XML validity or safe active content.
|
||||
function isSvg(bytes: Buffer): boolean {
|
||||
let text = new TextDecoder('utf-8', { fatal: true }).decode(bytes).trimStart();
|
||||
if (/<!\s*(?:DOCTYPE|ENTITY)\b/i.test(text)) return false;
|
||||
if (/^<\?xml[\t\n\r ]/.test(text)) {
|
||||
const end = text.indexOf('?>');
|
||||
if (end === -1) return false;
|
||||
const declaration = text.slice(0, end);
|
||||
const encoding = /\bencoding\s*=\s*(["'])(.*?)\1/i.exec(declaration);
|
||||
if (/\bencoding\s*=/i.test(declaration) && encoding?.[2]?.toLowerCase() !== 'utf-8') {
|
||||
return false;
|
||||
}
|
||||
text = text.slice(end + 2).trimStart();
|
||||
}
|
||||
while (text.startsWith('<!--')) {
|
||||
const end = text.indexOf('-->');
|
||||
if (end === -1) return false;
|
||||
text = text.slice(end + 3).trimStart();
|
||||
}
|
||||
return /^<svg(?:[\t\n\r ][^<>]*|\/?)>/.test(text);
|
||||
}
|
||||
@@ -80,6 +80,14 @@ describe('bounded mounted images', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('returns file bytes unchanged with an extension-based MIME type', async () => {
|
||||
const bytes = Buffer.from('mounted file bytes\xff', 'latin1');
|
||||
await put('icon.svg', bytes);
|
||||
const image = await readMountedImage(root, '/public/icon.svg');
|
||||
expect(image?.contentType).toBe('image/svg+xml');
|
||||
expect(image?.bytes).toEqual(new Uint8Array(bytes));
|
||||
});
|
||||
|
||||
it('accepts decoded Unicode/spaces, and nested paths without decoding again', async () => {
|
||||
await mkdir(join(root, 'family'));
|
||||
await put('family/café icon.svg');
|
||||
@@ -160,6 +168,8 @@ describe('bounded mounted images', () => {
|
||||
'rejects an ordinary concurrent %s on the same opened descriptor',
|
||||
async (change) => {
|
||||
await put('icon.svg');
|
||||
// Same-size writes can share a filesystem clock tick.
|
||||
if (change === 'rewrite') await fs.utimes(join(root, 'icon.svg'), 0, 0);
|
||||
const normalOpen = vi.mocked(open).getMockImplementation()!;
|
||||
vi.mocked(open).mockImplementationOnce(async (...args) => {
|
||||
const file = await normalOpen(...args);
|
||||
@@ -307,26 +317,3 @@ describe('bounded mounted images', () => {
|
||||
expect(await readMountedImage(root, '/public/icon.svg')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('image format checks during file reads', () => {
|
||||
it.each([
|
||||
['icon.png', svg],
|
||||
['icon.svg', png],
|
||||
['icon.svg', Buffer.from('<!DOCTYPE svg><svg/>')]
|
||||
] as const)('rejects mismatched bytes or unsupported SVG markup in %s', async (name, bytes) => {
|
||||
await put(name, bytes);
|
||||
expect(await readMountedImage(root, `/public/${name}`)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects invalid UTF-8 and bounded long non-SVG prefixes', async () => {
|
||||
await put(
|
||||
'icon.svg',
|
||||
Buffer.concat([Buffer.from('<svg>'), Buffer.from([0xff]), Buffer.from('</svg>')])
|
||||
);
|
||||
expect(await readMountedImage(root, '/public/icon.svg')).toBeUndefined();
|
||||
await put('icon.svg', ' '.repeat(MAX_IMAGE_BYTES - 6) + '<html>');
|
||||
expect(await readMountedImage(root, '/public/icon.svg')).toBeUndefined();
|
||||
await put('icon.svg', '<svg' + ' '.repeat(MAX_IMAGE_BYTES - 4));
|
||||
expect(await readMountedImage(root, '/public/icon.svg')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { constants } from 'node:fs';
|
||||
import { open, realpath, stat, type FileHandle } from 'node:fs/promises';
|
||||
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
import type { BigIntStats } from 'node:fs';
|
||||
import { matchesImageFormat } from './image-format.js';
|
||||
|
||||
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
@@ -96,7 +95,6 @@ export async function readMountedImage(
|
||||
return undefined;
|
||||
}
|
||||
const bytes = buffer.subarray(0, length);
|
||||
if (!matchesImageFormat(bytes, extension)) return undefined;
|
||||
image = { bytes: new Uint8Array(bytes), contentType };
|
||||
} catch {
|
||||
return undefined;
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readMountedImage } from '../../../../../lib/server/images/read-mounted-image.js';
|
||||
import { GET, HEAD } from './+server.js';
|
||||
|
||||
vi.mock('../../../../../lib/server/images/read-mounted-image.js', () => ({
|
||||
readMountedImage: vi.fn()
|
||||
}));
|
||||
|
||||
const publicDir = '/mounted/private-images';
|
||||
const image = { bytes: new TextEncoder().encode('<svg/>'), contentType: 'image/svg+xml' };
|
||||
const snapshot = (path = '/public/icon.svg') =>
|
||||
Object.freeze({ assets: Object.freeze({ referencedPaths: Object.freeze([path]) }) });
|
||||
|
||||
function event(file: string, method = 'GET', captured = snapshot()) {
|
||||
return {
|
||||
params: { file },
|
||||
locals: { snapshot: captured, publicDir },
|
||||
request: new Request(`http://localhost/public/${encodeURI(file)}`, { method })
|
||||
} as Parameters<typeof GET>[0];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(readMountedImage).mockReset().mockResolvedValue(image);
|
||||
});
|
||||
|
||||
describe('referenced mounted image route', () => {
|
||||
it.each(['GET', 'HEAD'])('reads eligible images for %s', async (method) => {
|
||||
const response = await (method === 'HEAD' ? HEAD : GET)(event('icon.svg', method));
|
||||
expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(publicDir, '/public/icon.svg');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe(image.contentType);
|
||||
expect(response.headers.get('content-length')).toBe(String(image.bytes.byteLength));
|
||||
expect(await response.text()).toBe(method === 'HEAD' ? '' : '<svg/>');
|
||||
if (method === 'HEAD') expect(response.body).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['GET', 'HEAD'])(
|
||||
'requires exact captured membership before I/O for %s',
|
||||
async (method) => {
|
||||
for (const file of [
|
||||
'unlisted.svg',
|
||||
'ICON.svg',
|
||||
'icon.svg/extra',
|
||||
'../icon.svg',
|
||||
'%2e%2e/icon.svg',
|
||||
'%252e%252e/icon.svg',
|
||||
'icon.svg?x',
|
||||
''
|
||||
]) {
|
||||
const response = await (method === 'HEAD' ? HEAD : GET)(event(file, method));
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe(method === 'HEAD' ? '' : 'Not found');
|
||||
}
|
||||
expect(readMountedImage).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('passes Kit-decoded paths unchanged, without a second decode', async () => {
|
||||
const captured = snapshot('/public/café family.svg');
|
||||
expect((await GET(event('café family.svg', 'GET', captured))).status).toBe(200);
|
||||
expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(publicDir, '/public/café family.svg');
|
||||
vi.mocked(readMountedImage).mockClear();
|
||||
expect((await GET(event('caf%C3%A9%20family.svg', 'GET', captured))).status).toBe(404);
|
||||
expect(readMountedImage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps old captured eligibility while a replacement removes the reference', async () => {
|
||||
const captured = event('icon.svg');
|
||||
const replacement = snapshot('/public/replacement.svg');
|
||||
expect((await GET(captured)).status).toBe(200);
|
||||
expect((await GET(event('icon.svg', 'GET', replacement))).status).toBe(404);
|
||||
expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(publicDir, '/public/icon.svg');
|
||||
});
|
||||
|
||||
it.each(['GET', 'HEAD'])(
|
||||
'maps an unavailable reader result to a generic %s 404',
|
||||
async (method) => {
|
||||
vi.mocked(readMountedImage).mockResolvedValue(undefined);
|
||||
const response = await (method === 'HEAD' ? HEAD : GET)(event('icon.svg', method));
|
||||
expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(publicDir, '/public/icon.svg');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get('content-length')).toBe('9');
|
||||
expect(await response.text()).toBe(method === 'HEAD' ? '' : 'Not found');
|
||||
}
|
||||
);
|
||||
|
||||
it('ignores conditional and range headers', async () => {
|
||||
const request = event('icon.svg');
|
||||
request.request = new Request(request.request, {
|
||||
headers: {
|
||||
'if-none-match': '*',
|
||||
'if-modified-since': new Date().toUTCString(),
|
||||
range: 'bytes=0-2'
|
||||
}
|
||||
});
|
||||
const response = await GET(request);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe('<svg/>');
|
||||
});
|
||||
});
|
||||
@@ -127,8 +127,6 @@ describe('directory page data', () => {
|
||||
for (const field of [
|
||||
'configuration',
|
||||
'assets',
|
||||
'referencedPaths',
|
||||
'brandingPaths',
|
||||
'session',
|
||||
'authentication',
|
||||
'auth',
|
||||
@@ -181,13 +179,11 @@ describe('directory page data', () => {
|
||||
...privateSnapshot,
|
||||
display,
|
||||
assets: {
|
||||
referencedPaths: ['PRIVATE_ASSET_BYTES'],
|
||||
brandingPaths: [],
|
||||
bookmarkLogos: { photos: 'PRIVATE_SVG_BYTES' }
|
||||
}
|
||||
};
|
||||
expect(load(event)).toEqual({ directory: display, selectedLinkId: 'photos', signedIn: false });
|
||||
for (const id of ['hidden', 'PRIVATE_ASSET_BYTES']) {
|
||||
for (const id of ['hidden', 'PRIVATE_SVG_BYTES']) {
|
||||
event.url.search = `?link=${id}`;
|
||||
expect(() => load(event)).toThrowError(notFound);
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('public favicon alias', () => {
|
||||
captured.locals.snapshot = {
|
||||
...captured.locals.snapshot,
|
||||
display: snapshot('/public/display.svg').display,
|
||||
assets: { referencedPaths: [], brandingPaths: [], bookmarkLogos: {} }
|
||||
assets: { bookmarkLogos: {} }
|
||||
};
|
||||
const replacement = event('GET', snapshot('/public/replacement.png'));
|
||||
await GET(captured);
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('public generated Open Graph image', () => {
|
||||
captured.locals.snapshot = {
|
||||
...captured.locals.snapshot,
|
||||
display: replacement.locals.snapshot.display,
|
||||
assets: { referencedPaths: [], brandingPaths: [], bookmarkLogos: {} }
|
||||
assets: { bookmarkLogos: {} }
|
||||
};
|
||||
const replacementFavicon = { bytes: new Uint8Array([1, 2, 3]), contentType: 'image/png' };
|
||||
vi.mocked(readMountedImage).mockImplementationOnce(async () => {
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('public selected-logo alias', () => {
|
||||
captured.locals.snapshot = {
|
||||
...captured.locals.snapshot,
|
||||
display: snapshot('/public/display.svg').display,
|
||||
assets: { referencedPaths: [], brandingPaths: [], bookmarkLogos: {} }
|
||||
assets: { bookmarkLogos: {} }
|
||||
};
|
||||
const replacement = event('GET', snapshot('/public/replacement.png'));
|
||||
await GET(captured);
|
||||
|
||||
+2
-5
@@ -1,12 +1,9 @@
|
||||
import { readMountedImage } from '../../../../../lib/server/images/read-mounted-image.js';
|
||||
import { imageResponse } from '../../../../../lib/server/images/image-response.js';
|
||||
import { readMountedImage } from '../../../../lib/server/images/read-mounted-image.js';
|
||||
import { imageResponse } from '../../../../lib/server/images/image-response.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, params, request }) => {
|
||||
const publicPath = `/public/${params.file}`;
|
||||
if (!locals.snapshot.assets.referencedPaths.includes(publicPath)) {
|
||||
return imageResponse(request.method);
|
||||
}
|
||||
const image = await readMountedImage(locals.publicDir, publicPath);
|
||||
return imageResponse(request.method, image);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readMountedImage } from '../../../../lib/server/images/read-mounted-image.js';
|
||||
import { GET, HEAD } from './+server.js';
|
||||
|
||||
vi.mock('../../../../lib/server/images/read-mounted-image.js', () => ({
|
||||
readMountedImage: vi.fn()
|
||||
}));
|
||||
|
||||
const publicDir = '/mounted/public-images';
|
||||
const image = { bytes: new TextEncoder().encode('<svg/>'), contentType: 'image/svg+xml' };
|
||||
|
||||
function event(file: string, method = 'GET') {
|
||||
return {
|
||||
params: { file },
|
||||
// Mounted files are public independently of configuration and sessions.
|
||||
locals: { publicDir },
|
||||
request: new Request(`http://localhost/public/${encodeURI(file)}`, { method })
|
||||
} as Parameters<typeof GET>[0];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(readMountedImage).mockReset().mockResolvedValue(image);
|
||||
});
|
||||
|
||||
describe('public mounted image route', () => {
|
||||
it.each(['GET', 'HEAD'])(
|
||||
'reads unreferenced images for %s without a snapshot',
|
||||
async (method) => {
|
||||
const response = await (method === 'HEAD' ? HEAD : GET)(event('unlisted.svg', method));
|
||||
expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(publicDir, '/public/unlisted.svg');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe(image.contentType);
|
||||
expect(response.headers.get('content-length')).toBe(String(image.bytes.byteLength));
|
||||
expect(await response.text()).toBe(method === 'HEAD' ? '' : '<svg/>');
|
||||
if (method === 'HEAD') expect(response.body).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['café family.svg', 'caf%C3%A9%20family.svg', '../icon.svg', 'icon.svg?x', ''])(
|
||||
'passes Kit-decoded paths unchanged to the validating reader, without a second decode: %s',
|
||||
async (file) => {
|
||||
await GET(event(file));
|
||||
expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(publicDir, `/public/${file}`);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['GET', 'HEAD'])(
|
||||
'maps an unavailable reader result to a generic %s 404',
|
||||
async (method) => {
|
||||
vi.mocked(readMountedImage).mockResolvedValue(undefined);
|
||||
const response = await (method === 'HEAD' ? HEAD : GET)(event('icon.svg', method));
|
||||
expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(publicDir, '/public/icon.svg');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get('content-length')).toBe('9');
|
||||
expect(await response.text()).toBe(method === 'HEAD' ? '' : 'Not found');
|
||||
}
|
||||
);
|
||||
|
||||
it('ignores conditional and range headers', async () => {
|
||||
const request = event('icon.svg');
|
||||
request.request = new Request(request.request, {
|
||||
headers: {
|
||||
'if-none-match': '*',
|
||||
'if-modified-since': new Date().toUTCString(),
|
||||
range: 'bytes=0-2'
|
||||
}
|
||||
});
|
||||
const response = await GET(request);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe('<svg/>');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user