refactor(server): extract ZIP security checks into reusable safe-zip module
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
96
apps/server/src/server/safe-zip.ts
Normal file
96
apps/server/src/server/safe-zip.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user