feat: ZIP 경로 가드 추가 및 무결성 size 검증 도입 (TDD)
- buildZip에 경로 하드닝 추가: ../, 절대경로(/), 역슬래시(\) 금지 - assets.integrity.index.json에 size(byte) 필드 추가 - verifyIntegrity에서 size 불일치 검증 추가 - 경로/사이즈 검증 테스트 추가: zip.pathGuard.test.ts - 전체 테스트 그린 유지
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import type { AssetManager } from '@/lib/assets/assetManager'
|
||||
|
||||
export type AssetsIntegrityIndex = {
|
||||
entries: Array<{ path: string; integrity: string }>
|
||||
entries: Array<{ path: string; integrity: string; size: number }>
|
||||
bundleHash: string
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export function buildAssetsIntegrityIndex(am: AssetManager): Uint8Array {
|
||||
const entries = am.list().map(ref => ({
|
||||
path: ref.path,
|
||||
integrity: `sha256-${sha256Base64(ref.bytes)}`,
|
||||
size: ref.bytes.length,
|
||||
}))
|
||||
// 2) Sort entries by path for deterministic output
|
||||
entries.sort((a, b) => a.path.localeCompare(b.path))
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
import { buildAssetsIntegrityIndex } from '@/lib/exporter/assets.integrity.index'
|
||||
import { verifyIntegrity } from '@/lib/exporter/verify'
|
||||
|
||||
function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i<n;i++) a[i]=i&0xff; return a }
|
||||
|
||||
function td(b: Uint8Array){ return new TextDecoder().decode(b) }
|
||||
function te(s: string){ return new TextEncoder().encode(s) }
|
||||
|
||||
describe('verifyIntegrity', () => {
|
||||
it('returns ok=true for valid assets and integrity index', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt'] })
|
||||
const a = await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) })
|
||||
const b = await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(7) })
|
||||
const assets = am.toZipStructure()
|
||||
const idx = buildAssetsIntegrityIndex(am)
|
||||
const extras: Record<string, Uint8Array> = { 'assets.integrity.index.json': idx }
|
||||
|
||||
const res = verifyIntegrity(extras, assets)
|
||||
expect(res.ok).toBe(true)
|
||||
expect(res.errors.length).toBe(0)
|
||||
// sanity: index contains both paths
|
||||
const parsed = JSON.parse(td(idx)) as { entries: Array<{ path: string }> }
|
||||
const paths = parsed.entries.map(e => e.path)
|
||||
expect(paths).toContain(a.path)
|
||||
expect(paths).toContain(b.path)
|
||||
})
|
||||
|
||||
it('detects byte tampering in an asset', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt'] })
|
||||
await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) })
|
||||
await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(7) })
|
||||
const assets = am.toZipStructure()
|
||||
const idx = buildAssetsIntegrityIndex(am)
|
||||
// tamper one asset byte
|
||||
const somePath = Object.keys(assets)[0]
|
||||
const tampered = assets[somePath].slice()
|
||||
tampered[0] = (tampered[0] ^ 0xff) & 0xff
|
||||
assets[somePath] = tampered
|
||||
|
||||
const res = verifyIntegrity({ 'assets.integrity.index.json': idx }, assets)
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.errors.some((e: string) => e.includes(somePath) && e.toLowerCase().includes('mismatch'))).toBe(true)
|
||||
})
|
||||
|
||||
it('detects missing entry or missing asset referenced by index', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt'] })
|
||||
await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) })
|
||||
await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(7) })
|
||||
const assets = am.toZipStructure()
|
||||
const idxRaw = buildAssetsIntegrityIndex(am)
|
||||
const obj = JSON.parse(td(idxRaw)) as { entries: Array<{ path: string; integrity: string }>, bundleHash: string }
|
||||
// remove one entry
|
||||
const removed = obj.entries.shift()!
|
||||
const idx2 = te(JSON.stringify(obj))
|
||||
|
||||
const res1 = verifyIntegrity({ 'assets.integrity.index.json': idx2 }, assets)
|
||||
expect(res1.ok).toBe(false)
|
||||
expect(res1.errors.some((e: string) => e.toLowerCase().includes('bundlehash'))).toBe(true)
|
||||
|
||||
// reference a missing path in index
|
||||
obj.entries.unshift({ path: 'assets/missing.bin', integrity: 'sha256-AAAA' })
|
||||
const idx3 = te(JSON.stringify(obj))
|
||||
const res2 = verifyIntegrity({ 'assets.integrity.index.json': idx3 }, assets)
|
||||
expect(res2.ok).toBe(false)
|
||||
expect(res2.errors.some((e: string) => e.includes('assets/missing.bin') && e.toLowerCase().includes('missing'))).toBe(true)
|
||||
// keep lints happy
|
||||
expect(removed).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
export type VerifyResult = { ok: boolean; errors: string[] }
|
||||
|
||||
// Minimal synchronous SHA-256 implementation returning base64 string.
|
||||
function sha256Base64(bytes: Uint8Array): string {
|
||||
const K = new Uint32Array([
|
||||
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
||||
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
||||
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
||||
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
||||
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
||||
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
||||
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
||||
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
|
||||
])
|
||||
const H = new Uint32Array([
|
||||
0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19
|
||||
])
|
||||
const l = bytes.length
|
||||
const bitLenHi = Math.floor((l >>> 29))
|
||||
const bitLenLo = (l << 3) >>> 0
|
||||
const withOne = l + 1
|
||||
const padLen = ((withOne % 64) <= 56) ? (56 - (withOne % 64)) : (56 + 64 - (withOne % 64))
|
||||
const totalLen = withOne + padLen + 8
|
||||
const m = new Uint8Array(totalLen)
|
||||
m.set(bytes)
|
||||
m[l] = 0x80
|
||||
m[totalLen - 8] = (bitLenHi >>> 24) & 0xff
|
||||
m[totalLen - 7] = (bitLenHi >>> 16) & 0xff
|
||||
m[totalLen - 6] = (bitLenHi >>> 8) & 0xff
|
||||
m[totalLen - 5] = (bitLenHi >>> 0) & 0xff
|
||||
m[totalLen - 4] = (bitLenLo >>> 24) & 0xff
|
||||
m[totalLen - 3] = (bitLenLo >>> 16) & 0xff
|
||||
m[totalLen - 2] = (bitLenLo >>> 8) & 0xff
|
||||
m[totalLen - 1] = (bitLenLo >>> 0) & 0xff
|
||||
const W = new Uint32Array(64)
|
||||
for (let i = 0; i < totalLen; i += 64) {
|
||||
for (let t = 0; t < 16; t++) {
|
||||
const j = i + t * 4
|
||||
W[t] = (m[j] << 24) | (m[j + 1] << 16) | (m[j + 2] << 8) | (m[j + 3])
|
||||
}
|
||||
for (let t = 16; t < 64; t++) {
|
||||
const s0 = (rotr(W[t - 15], 7) ^ rotr(W[t - 15], 18) ^ (W[t - 15] >>> 3)) >>> 0
|
||||
const s1 = (rotr(W[t - 2], 17) ^ rotr(W[t - 2], 19) ^ (W[t - 2] >>> 10)) >>> 0
|
||||
W[t] = (W[t - 16] + s0 + W[t - 7] + s1) >>> 0
|
||||
}
|
||||
let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7]
|
||||
for (let t = 0; t < 64; t++) {
|
||||
const S1 = (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) >>> 0
|
||||
const ch = ((e & f) ^ (~e & g)) >>> 0
|
||||
const temp1 = (h + S1 + ch + K[t] + W[t]) >>> 0
|
||||
const S0 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) >>> 0
|
||||
const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0
|
||||
const temp2 = (S0 + maj) >>> 0
|
||||
h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 0
|
||||
}
|
||||
H[0] = (H[0] + a) >>> 0
|
||||
H[1] = (H[1] + b) >>> 0
|
||||
H[2] = (H[2] + c) >>> 0
|
||||
H[3] = (H[3] + d) >>> 0
|
||||
H[4] = (H[4] + e) >>> 0
|
||||
H[5] = (H[5] + f) >>> 0
|
||||
H[6] = (H[6] + g) >>> 0
|
||||
H[7] = (H[7] + h) >>> 0
|
||||
}
|
||||
const out = new Uint8Array(32)
|
||||
for (let i = 0; i < 8; i++) {
|
||||
out[i*4+0]=(H[i]>>>24)&0xff; out[i*4+1]=(H[i]>>>16)&0xff; out[i*4+2]=(H[i]>>>8)&0xff; out[i*4+3]=(H[i]>>>0)&0xff
|
||||
}
|
||||
if (typeof Buffer !== 'undefined') return Buffer.from(out).toString('base64')
|
||||
let s = ''; for (let i=0;i<out.length;i++) s += String.fromCharCode(out[i])
|
||||
const btoaFn = (globalThis as unknown as { btoa?: (s: string) => string }).btoa
|
||||
if (!btoaFn) throw new Error('btoa not available')
|
||||
return btoaFn(s)
|
||||
function rotr(x: number, n: number) { return (x >>> n) | (x << (32 - n)) }
|
||||
}
|
||||
|
||||
export function verifyIntegrity(
|
||||
extras: Record<string, Uint8Array>,
|
||||
assets: Record<string, Uint8Array>
|
||||
): VerifyResult {
|
||||
const errors: string[] = []
|
||||
const key = 'assets.integrity.index.json'
|
||||
if (!Object.prototype.hasOwnProperty.call(extras, key)) {
|
||||
return { ok: false, errors: ['Missing assets.integrity.index.json in extras'] }
|
||||
}
|
||||
let parsed: { entries: Array<{ path: string; integrity: string; size?: number }>; bundleHash: string }
|
||||
try {
|
||||
parsed = JSON.parse(new TextDecoder().decode(extras[key]))
|
||||
} catch {
|
||||
return { ok: false, errors: ['Invalid assets.integrity.index.json JSON'] }
|
||||
}
|
||||
// verify each entry exists and integrity matches
|
||||
const recomputedLines: string[] = []
|
||||
for (const e of parsed.entries) {
|
||||
const bytes = assets[e.path]
|
||||
if (!bytes) {
|
||||
errors.push(`Missing asset for path in integrity index: ${e.path}`)
|
||||
continue
|
||||
}
|
||||
// optional size check when present in index
|
||||
if (typeof e.size === 'number' && e.size !== bytes.length) {
|
||||
errors.push(`Size mismatch for ${e.path}: expected ${e.size}, got ${bytes.length}`)
|
||||
}
|
||||
const b64 = sha256Base64(bytes)
|
||||
const integ = `sha256-${b64}`
|
||||
if (integ !== e.integrity) {
|
||||
errors.push(`Integrity mismatch for ${e.path}`)
|
||||
}
|
||||
recomputedLines.push(e.path)
|
||||
recomputedLines.push(integ)
|
||||
}
|
||||
// recompute bundle hash
|
||||
const joined = new TextEncoder().encode(recomputedLines.join('\n'))
|
||||
const bundle = `sha256-${sha256Base64(joined)}`
|
||||
if (bundle !== parsed.bundleHash) {
|
||||
errors.push('BundleHash mismatch')
|
||||
}
|
||||
return { ok: errors.length === 0, errors }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildZip } from '@/lib/exporter/zip'
|
||||
|
||||
function u8(s: string): Uint8Array {
|
||||
return new TextEncoder().encode(s)
|
||||
}
|
||||
|
||||
describe('buildZip path guard', () => {
|
||||
it('rejects dangerous asset paths with .. segments', async () => {
|
||||
await expect(
|
||||
buildZip({ html: '', css: '', js: '', assets: { '../evil.txt': u8('x') } })
|
||||
).rejects.toThrow(/invalid asset path/i)
|
||||
})
|
||||
|
||||
it('rejects dangerous extras with absolute path and backslashes', async () => {
|
||||
await expect(
|
||||
buildZip({ html: '', css: '', js: '', extras: { '/abs.txt': u8('x') } })
|
||||
).rejects.toThrow(/invalid extra path/i)
|
||||
|
||||
await expect(
|
||||
buildZip({ html: '', css: '', js: '', extras: { 'assets\\x.bin': u8('x') } })
|
||||
).rejects.toThrow(/invalid extra path/i)
|
||||
})
|
||||
|
||||
it('accepts safe relative paths under assets/', async () => {
|
||||
const zip = await buildZip({ html: '', css: '', js: '', assets: { 'assets/a.bin': u8('ok') } })
|
||||
expect(zip.byteLength).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -14,8 +14,19 @@ export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
|
||||
zip.file('index.html', input.html)
|
||||
zip.file('styles.css', input.css)
|
||||
zip.file('app.js', input.js)
|
||||
|
||||
// guard: basic path sanitizer to avoid directory traversal or absolute paths
|
||||
const isSafePath = (p: string) => {
|
||||
if (!p || typeof p !== 'string') return false
|
||||
if (p.startsWith('/')) return false
|
||||
if (p.includes('..')) return false
|
||||
if (p.includes('\\')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
if (input.assets) {
|
||||
for (const [path, bytes] of Object.entries(input.assets)) {
|
||||
if (!isSafePath(path)) throw new Error(`Invalid asset path: ${path}`)
|
||||
// In Node/Vitest, use Buffer for robust binary handling
|
||||
const buf = Buffer.from(bytes)
|
||||
zip.file(path, buf as unknown as Buffer)
|
||||
@@ -33,6 +44,7 @@ export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
|
||||
}
|
||||
}
|
||||
for (const [path, bytes] of Object.entries(input.extras)) {
|
||||
if (!isSafePath(path)) throw new Error(`Invalid extra path: ${path}`)
|
||||
const buf = Buffer.from(bytes)
|
||||
zip.file(path, buf as unknown as Buffer)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user