发布 0.2.1 并完善工具边界与安全性
This commit is contained in:
+66
-2
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { assertProductionBuild, createWebToolsServer, MAX_JSON_BODY_SIZE } from '../server/index.mjs'
|
||||
import { assertProductionBuild, createWebToolsServer, CSP_NONCE_BYTES, MAX_JSON_BODY_SIZE } from '../server/index.mjs'
|
||||
|
||||
const temporaryDirectories = []
|
||||
const servers = []
|
||||
@@ -34,8 +34,11 @@ async function createFixtureServer() {
|
||||
const dataDir = path.join(directory, 'data')
|
||||
await writeFile(path.join(directory, 'placeholder'), '')
|
||||
await mkdir(staticRoot, { recursive: true })
|
||||
await mkdir(path.join(staticRoot, 'assets'), { recursive: true })
|
||||
await writeFile(path.join(staticRoot, 'index.html'), '<!doctype html><title>Web Tools</title>')
|
||||
await writeFile(path.join(staticRoot, 'app.js'), 'console.log("ok")')
|
||||
await writeFile(path.join(staticRoot, 'assets', 'app-ABCD1234.js'), 'console.log("hashed")')
|
||||
await writeFile(path.join(staticRoot, 'assets', 'unversioned.js'), 'console.log("stable")')
|
||||
await writeFile(path.join(staticRoot, 'site.webmanifest'), '{}')
|
||||
const server = await createWebToolsServer({ staticRoot, dataDir, catalog, defaults, logger: { error: () => undefined } })
|
||||
servers.push(server)
|
||||
@@ -68,6 +71,26 @@ describe('HTTP server', () => {
|
||||
expect(extensionlessAsset.status).toBe(404)
|
||||
})
|
||||
|
||||
it('uses a unique cryptographic CSP nonce without allowing arbitrary inline scripts', async () => {
|
||||
const { baseUrl } = await createFixtureServer()
|
||||
const first = await fetch(`${baseUrl}/`)
|
||||
const second = await fetch(`${baseUrl}/tools/base64`, { headers: { Accept: 'text/html' } })
|
||||
const firstPolicy = first.headers.get('content-security-policy') ?? ''
|
||||
const secondPolicy = second.headers.get('content-security-policy') ?? ''
|
||||
const noncePattern = /script-src 'self' 'nonce-([A-Za-z0-9+/]+={0,2})'/
|
||||
const firstNonce = firstPolicy.match(noncePattern)?.[1]
|
||||
const secondNonce = secondPolicy.match(noncePattern)?.[1]
|
||||
|
||||
expect(firstNonce).toBeTruthy()
|
||||
expect(secondNonce).toBeTruthy()
|
||||
expect(Buffer.from(firstNonce, 'base64')).toHaveLength(CSP_NONCE_BYTES)
|
||||
expect(Buffer.from(secondNonce, 'base64')).toHaveLength(CSP_NONCE_BYTES)
|
||||
expect(firstNonce).not.toBe(secondNonce)
|
||||
expect(firstPolicy).not.toContain("'unsafe-inline'")
|
||||
expect(firstPolicy).toContain("object-src 'none'")
|
||||
expect(firstPolicy).toContain("frame-ancestors 'none'")
|
||||
})
|
||||
|
||||
it('returns no-store JSON health and API responses', async () => {
|
||||
const { baseUrl } = await createFixtureServer()
|
||||
const health = await fetch(`${baseUrl}/healthz`)
|
||||
@@ -85,6 +108,18 @@ describe('HTTP server', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toBe('application/manifest+json; charset=utf-8')
|
||||
expect(response.headers.get('cache-control')).toBe('no-cache')
|
||||
})
|
||||
|
||||
it('only gives immutable caching to fingerprinted build assets', async () => {
|
||||
const { baseUrl } = await createFixtureServer()
|
||||
const stableAsset = await fetch(`${baseUrl}/app.js`)
|
||||
const nestedStableAsset = await fetch(`${baseUrl}/assets/unversioned.js`)
|
||||
const fingerprintedAsset = await fetch(`${baseUrl}/assets/app-ABCD1234.js`)
|
||||
|
||||
expect(stableAsset.headers.get('cache-control')).toBe('no-cache')
|
||||
expect(nestedStableAsset.headers.get('cache-control')).toBe('no-cache')
|
||||
expect(fingerprintedAsset.headers.get('cache-control')).toBe('public, max-age=31536000, immutable')
|
||||
})
|
||||
|
||||
it('creates, reads and updates navigation through the API', async () => {
|
||||
@@ -103,6 +138,21 @@ describe('HTTP server', () => {
|
||||
expect(JSON.parse(await readFile(path.join(dataDir, 'navigation.json'), 'utf8'))).toEqual(updated)
|
||||
})
|
||||
|
||||
it('keeps concurrent navigation writes valid and complete', async () => {
|
||||
const { baseUrl, dataDir } = await createFixtureServer()
|
||||
const first = { version: 1, categories: defaults.categories }
|
||||
const second = { version: 1, categories: [...defaults.categories].reverse() }
|
||||
const responses = await Promise.all([first, second].map((navigation) => fetch(`${baseUrl}/api/navigation`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(navigation),
|
||||
})))
|
||||
|
||||
expect(responses.map((response) => response.status)).toEqual([200, 200])
|
||||
const stored = JSON.parse(await readFile(path.join(dataDir, 'navigation.json'), 'utf8'))
|
||||
expect([first, second]).toContainEqual(stored)
|
||||
})
|
||||
|
||||
it('normalizes an existing navigation file when a later release adds a tool', async () => {
|
||||
const { baseUrl, dataDir } = await createFixtureServer()
|
||||
const legacy = { version: 1, categories: [{ id: 'encoding', tools: ['hex'] }, { id: 'crypto', tools: ['cmac'] }] }
|
||||
@@ -144,7 +194,7 @@ describe('HTTP server', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid, oversized and cross-site navigation writes', async () => {
|
||||
it('rejects invalid, oversized and cross-origin browser navigation writes', async () => {
|
||||
const { baseUrl } = await createFixtureServer()
|
||||
const invalid = await fetch(`${baseUrl}/api/navigation`, {
|
||||
method: 'PUT',
|
||||
@@ -167,5 +217,19 @@ describe('HTTP server', () => {
|
||||
body: JSON.stringify(defaults),
|
||||
})
|
||||
expect(crossSite.status).toBe(403)
|
||||
|
||||
const sameSite = await fetch(`${baseUrl}/api/navigation`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-site' },
|
||||
body: JSON.stringify(defaults),
|
||||
})
|
||||
expect(sameSite.status).toBe(403)
|
||||
|
||||
const sameOrigin = await fetch(`${baseUrl}/api/navigation`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
body: JSON.stringify(defaults),
|
||||
})
|
||||
expect(sameOrigin.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user