refactor(server): extract ZIP security checks into reusable safe-zip module

This commit is contained in:
2026-03-06 16:51:33 +08:00
parent 1110edc974
commit 8be32bf15b
2 changed files with 103 additions and 100 deletions

View File

@@ -9,18 +9,13 @@ import {
sha256Hex, sha256Hex,
} from '@furtherverse/crypto' } from '@furtherverse/crypto'
import { ORPCError } from '@orpc/server' import { ORPCError } from '@orpc/server'
import type { JSZipObject } from 'jszip'
import JSZip from 'jszip' import JSZip from 'jszip'
import { z } from 'zod' import { z } from 'zod'
import { extractSafeZipFiles, ZipValidationError } from '@/server/safe-zip'
import { getUxConfig } from '@/server/ux-config' import { getUxConfig } from '@/server/ux-config'
import { db } from '../middlewares' import { db } from '../middlewares'
import { os } from '../server' import { os } from '../server'
interface ZipFileItem {
name: string
bytes: Uint8Array
}
const summaryPayloadSchema = z const summaryPayloadSchema = z
.object({ .object({
taskId: z.string().min(1, 'summary.json must contain a non-empty taskId'), taskId: z.string().min(1, 'summary.json must contain a non-empty taskId'),
@@ -29,84 +24,6 @@ const summaryPayloadSchema = z
}) })
.loose() .loose()
const MAX_RAW_ZIP_BYTES = 50 * 1024 * 1024
const MAX_SINGLE_FILE_BYTES = 20 * 1024 * 1024
const MAX_TOTAL_UNCOMPRESSED_BYTES = 60 * 1024 * 1024
const MAX_ZIP_ENTRIES = 64
const normalizePath = (name: string): string => name.replaceAll('\\', '/')
const isUnsafePath = (name: string): boolean => {
const normalized = normalizePath(name)
const segments = normalized.split('/')
return (
normalized.startsWith('/') ||
normalized.includes('\u0000') ||
segments.some((segment) => segment === '..' || segment.trim().length === 0)
)
}
const listSafeZipFiles = async (rawZip: JSZip): Promise<ZipFileItem[]> => {
const entries = Object.values(rawZip.files) as JSZipObject[]
if (entries.length > MAX_ZIP_ENTRIES) {
throw new ORPCError('BAD_REQUEST', {
message: `Zip contains too many entries: ${entries.length}`,
})
}
let totalUncompressedBytes = 0
const files: ZipFileItem[] = []
const seen = new Set<string>()
for (const entry of entries) {
if (entry.dir) {
continue
}
if (isUnsafePath(entry.name)) {
throw new ORPCError('BAD_REQUEST', {
message: `Zip contains unsafe entry path: ${entry.name}`,
})
}
const normalizedName = normalizePath(entry.name)
if (seen.has(normalizedName)) {
throw new ORPCError('BAD_REQUEST', {
message: `Zip contains duplicate entry: ${normalizedName}`,
})
}
seen.add(normalizedName)
const content = await entry.async('uint8array')
if (content.byteLength > MAX_SINGLE_FILE_BYTES) {
throw new ORPCError('BAD_REQUEST', {
message: `Zip entry too large: ${normalizedName}`,
})
}
totalUncompressedBytes += content.byteLength
if (totalUncompressedBytes > MAX_TOTAL_UNCOMPRESSED_BYTES) {
throw new ORPCError('BAD_REQUEST', {
message: 'Zip total uncompressed content exceeds max size limit',
})
}
files.push({
name: normalizedName,
bytes: content,
})
}
if (files.length === 0) {
throw new ORPCError('BAD_REQUEST', {
message: 'Zip has no file entries',
})
}
return files
}
const requireIdentity = async (dbInstance: Parameters<typeof getUxConfig>[0]) => { const requireIdentity = async (dbInstance: Parameters<typeof getUxConfig>[0]) => {
const config = await getUxConfig(dbInstance) const config = await getUxConfig(dbInstance)
if (!config || !config.licence) { if (!config || !config.licence) {
@@ -155,25 +72,15 @@ export const signAndPackReport = os.crypto.signAndPackReport.use(db).handler(asy
}) })
} }
const rawZipArrayBuffer = await input.rawZip.arrayBuffer() const rawZipBytes = Buffer.from(await input.rawZip.arrayBuffer())
const rawZipBytes = Buffer.from(rawZipArrayBuffer)
if (rawZipBytes.byteLength === 0 || rawZipBytes.byteLength > MAX_RAW_ZIP_BYTES) { const zipFiles = await extractSafeZipFiles(rawZipBytes).catch((error) => {
throw new ORPCError('BAD_REQUEST', { if (error instanceof ZipValidationError) {
message: 'rawZip is empty or exceeds max size limit', throw new ORPCError('BAD_REQUEST', { message: error.message })
}) }
} throw error
const rawZip = await JSZip.loadAsync(rawZipBytes, {
checkCRC32: true,
}).catch(() => {
throw new ORPCError('BAD_REQUEST', {
message: 'rawZip is not a valid zip file',
})
}) })
const zipFiles = await listSafeZipFiles(rawZip)
// Extract and validate summary.json from the ZIP // Extract and validate summary.json from the ZIP
const summaryFile = zipFiles.find((f) => f.name === 'summary.json') const summaryFile = zipFiles.find((f) => f.name === 'summary.json')
if (!summaryFile) { if (!summaryFile) {

View File

@@ -0,0 +1,96 @@
import type { JSZipObject } from 'jszip'
import JSZip from 'jszip'
export class ZipValidationError extends Error {
override name = 'ZipValidationError'
}
export interface ZipFileItem {
name: string
bytes: Uint8Array
}
export interface SafeZipOptions {
maxRawBytes?: number
maxEntries?: number
maxSingleFileBytes?: number
maxTotalUncompressedBytes?: number
}
const DEFAULTS = {
maxRawBytes: 50 * 1024 * 1024,
maxEntries: 64,
maxSingleFileBytes: 20 * 1024 * 1024,
maxTotalUncompressedBytes: 60 * 1024 * 1024,
} satisfies Required<SafeZipOptions>
const normalizePath = (name: string): string => name.replaceAll('\\', '/')
const isUnsafePath = (name: string): boolean => {
const normalized = normalizePath(name)
const segments = normalized.split('/')
return (
normalized.startsWith('/') ||
normalized.includes('\0') ||
segments.some((segment) => segment === '..' || segment.trim().length === 0)
)
}
export const extractSafeZipFiles = async (
rawBytes: Uint8Array | Buffer,
options?: SafeZipOptions,
): Promise<ZipFileItem[]> => {
const opts = { ...DEFAULTS, ...options }
if (rawBytes.byteLength === 0 || rawBytes.byteLength > opts.maxRawBytes) {
throw new ZipValidationError('ZIP is empty or exceeds max size limit')
}
const zip = await JSZip.loadAsync(rawBytes, { checkCRC32: true }).catch(() => {
throw new ZipValidationError('Not a valid ZIP file')
})
const entries = Object.values(zip.files) as JSZipObject[]
if (entries.length > opts.maxEntries) {
throw new ZipValidationError(`ZIP contains too many entries: ${entries.length}`)
}
let totalUncompressedBytes = 0
const files: ZipFileItem[] = []
const seen = new Set<string>()
for (const entry of entries) {
if (entry.dir) {
continue
}
if (isUnsafePath(entry.name)) {
throw new ZipValidationError(`ZIP contains unsafe entry path: ${entry.name}`)
}
const normalizedName = normalizePath(entry.name)
if (seen.has(normalizedName)) {
throw new ZipValidationError(`ZIP contains duplicate entry: ${normalizedName}`)
}
seen.add(normalizedName)
const content = await entry.async('uint8array')
if (content.byteLength > opts.maxSingleFileBytes) {
throw new ZipValidationError(`ZIP entry too large: ${normalizedName}`)
}
totalUncompressedBytes += content.byteLength
if (totalUncompressedBytes > opts.maxTotalUncompressedBytes) {
throw new ZipValidationError('ZIP total uncompressed content exceeds max size limit')
}
files.push({ name: normalizedName, bytes: content })
}
if (files.length === 0) {
throw new ZipValidationError('ZIP has no file entries')
}
return files
}