diff --git a/apps/server/src/server/api/routers/crypto.router.ts b/apps/server/src/server/api/routers/crypto.router.ts index 74a8c48..259bc97 100644 --- a/apps/server/src/server/api/routers/crypto.router.ts +++ b/apps/server/src/server/api/routers/crypto.router.ts @@ -9,18 +9,13 @@ import { sha256Hex, } from '@furtherverse/crypto' import { ORPCError } from '@orpc/server' -import type { JSZipObject } from 'jszip' import JSZip from 'jszip' import { z } from 'zod' +import { extractSafeZipFiles, ZipValidationError } from '@/server/safe-zip' import { getUxConfig } from '@/server/ux-config' import { db } from '../middlewares' import { os } from '../server' -interface ZipFileItem { - name: string - bytes: Uint8Array -} - const summaryPayloadSchema = z .object({ taskId: z.string().min(1, 'summary.json must contain a non-empty taskId'), @@ -29,84 +24,6 @@ const summaryPayloadSchema = z }) .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 => { - 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() - - 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[0]) => { const config = await getUxConfig(dbInstance) 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(rawZipArrayBuffer) + const rawZipBytes = Buffer.from(await input.rawZip.arrayBuffer()) - if (rawZipBytes.byteLength === 0 || rawZipBytes.byteLength > MAX_RAW_ZIP_BYTES) { - throw new ORPCError('BAD_REQUEST', { - message: 'rawZip is empty or exceeds max size limit', - }) - } - - 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 extractSafeZipFiles(rawZipBytes).catch((error) => { + if (error instanceof ZipValidationError) { + throw new ORPCError('BAD_REQUEST', { message: error.message }) + } + throw error }) - const zipFiles = await listSafeZipFiles(rawZip) - // Extract and validate summary.json from the ZIP const summaryFile = zipFiles.find((f) => f.name === 'summary.json') if (!summaryFile) { diff --git a/apps/server/src/server/safe-zip.ts b/apps/server/src/server/safe-zip.ts new file mode 100644 index 0000000..f2ee2aa --- /dev/null +++ b/apps/server/src/server/safe-zip.ts @@ -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 + +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 => { + 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() + + 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 +}