发布 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
+28 -7
View File
@@ -1,3 +1,4 @@
import { randomBytes } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { createServer } from 'node:http'
@@ -17,6 +18,7 @@ const serverDir = path.dirname(serverFile)
const projectRoot = path.resolve(serverDir, '..')
export const MAX_JSON_BODY_SIZE = 64 * 1024
export const DEFAULT_PORT = 9000
export const CSP_NONCE_BYTES = 16
const mimeTypes = new Map([
['.css', 'text/css; charset=utf-8'],
@@ -32,8 +34,13 @@ const mimeTypes = new Map([
['.woff2', 'font/woff2'],
])
export function createContentSecurityPolicy(nonce) {
return `default-src 'self'; img-src 'self' blob: data:; style-src 'self'; script-src 'self' 'nonce-${nonce}'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`
}
function applySecurityHeaders(response) {
response.setHeader('Content-Security-Policy', "default-src 'self'; img-src 'self' blob: data:; style-src 'self'; script-src 'self'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
const nonce = randomBytes(CSP_NONCE_BYTES).toString('base64')
response.setHeader('Content-Security-Policy', createContentSecurityPolicy(nonce))
response.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
response.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
response.setHeader('Referrer-Policy', 'no-referrer')
@@ -96,6 +103,12 @@ function acceptsHtmlNavigation(request, pathname) {
return String(request.headers.accept || '').split(',').some((value) => value.trim().toLowerCase().startsWith('text/html'))
}
function isFingerprintedAsset(filePath, staticRoot) {
const relativePath = path.relative(staticRoot, filePath)
return relativePath.split(path.sep)[0] === 'assets'
&& /-[A-Za-z0-9_-]{8,}(?:\.[^.]+)+$/.test(path.basename(relativePath))
}
async function serveStatic(request, response, staticRoot) {
const url = new URL(request.url, 'http://localhost')
let pathname
@@ -133,8 +146,8 @@ async function serveStatic(request, response, staticRoot) {
response.statusCode = 200
response.setHeader('Content-Type', mimeTypes.get(path.extname(filePath).toLowerCase()) || 'application/octet-stream')
response.setHeader('Content-Length', details.size)
if (path.basename(filePath) === 'index.html') response.setHeader('Cache-Control', 'no-cache')
else response.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
if (isFingerprintedAsset(filePath, staticRoot)) response.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
else response.setHeader('Cache-Control', 'no-cache')
if (request.method === 'HEAD') {
response.end()
return
@@ -149,6 +162,13 @@ export async function createWebToolsServer(options = {}) {
const catalog = options.catalog || await loadCatalog()
const defaults = options.defaults || await loadDefaultNavigation()
const logger = options.logger || console
let navigationOperation = Promise.resolve()
function withNavigationLock(operation) {
const result = navigationOperation.then(operation, operation)
navigationOperation = result.catch(() => undefined)
return result
}
return createServer(async (request, response) => {
applySecurityHeaders(response)
@@ -160,12 +180,13 @@ export async function createWebToolsServer(options = {}) {
}
if (url.pathname === '/api/navigation') {
if (request.method === 'GET') {
sendJson(response, 200, await readOrCreateNavigation(navigationPath, catalog, defaults))
sendJson(response, 200, await withNavigationLock(() => readOrCreateNavigation(navigationPath, catalog, defaults)))
return
}
if (request.method === 'PUT') {
if (request.headers['sec-fetch-site'] === 'cross-site') {
sendJson(response, 403, { error: '不允许跨站写入配置' })
const fetchSite = request.headers['sec-fetch-site']
if (fetchSite && fetchSite !== 'same-origin') {
sendJson(response, 403, { error: '不允许跨源写入配置' })
return
}
let navigation
@@ -175,7 +196,7 @@ export async function createWebToolsServer(options = {}) {
sendJson(response, 400, { error: error instanceof Error ? error.message : '配置格式无效' })
return
}
await writeJsonAtomic(navigationPath, navigation)
await withNavigationLock(() => writeJsonAtomic(navigationPath, navigation))
sendJson(response, 200, navigation)
return
}