发布 0.2.1 并完善工具边界与安全性
This commit is contained in:
+28
-7
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user