发布 0.2.1 并完善工具边界与安全性

This commit is contained in:
mid
2026-08-10 14:49:43 +08:00
parent aca004c513
commit 32b7cc827c
47 changed files with 681 additions and 172 deletions
+66 -2
View File
@@ -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)
})
})