diff --git a/api/package.json b/api/package.json index 74ecba9b..9e6bc048 100644 --- a/api/package.json +++ b/api/package.json @@ -36,12 +36,14 @@ "bcryptjs": "^3.0.2", "express": "^5.1.0", "pg": "^8.16.3", + "pngjs": "7.0.0", "zod": "^4.1.11" }, "devDependencies": { "@types/express": "^5.0.3", "@types/node": "^25.8.0", "@types/pg": "^8.15.5", + "@types/pngjs": "6.0.5", "tsx": "^4.22.1", "typescript": "^6.0.3", "vitest": "^4.1.6" diff --git a/api/pnpm-lock.yaml b/api/pnpm-lock.yaml index 8c23005c..25cb994d 100644 --- a/api/pnpm-lock.yaml +++ b/api/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: pg: specifier: ^8.16.3 version: 8.20.0 + pngjs: + specifier: 7.0.0 + version: 7.0.0 zod: specifier: ^4.1.11 version: 4.4.3 @@ -33,6 +36,9 @@ importers: '@types/pg': specifier: ^8.15.5 version: 8.20.0 + '@types/pngjs': + specifier: 6.0.5 + version: 6.0.5 tsx: specifier: ^4.22.1 version: 4.22.1 @@ -69,6 +75,7 @@ packages: '@aws-sdk/core@3.974.11': resolution: {integrity: sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==} engines: {node: '>=20.0.0'} + deprecated: Deprecated due to an error deserialization bug in JSON 1.0 protocol services, see https://github.com/aws/aws-sdk-js-v3/pull/8031. Newer version available. '@aws-sdk/credential-provider-env@3.972.37': resolution: {integrity: sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==} @@ -665,6 +672,9 @@ packages: '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/pngjs@6.0.5': + resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -1005,6 +1015,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + postcss@8.5.14: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} @@ -1746,7 +1760,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 24.12.3 + '@types/node': 25.8.0 '@types/deep-eql@4.0.2': {} @@ -1783,13 +1797,17 @@ snapshots: pg-protocol: 1.13.0 pg-types: 2.2.0 + '@types/pngjs@6.0.5': + dependencies: + '@types/node': 25.8.0 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} '@types/send@1.2.1': dependencies: - '@types/node': 24.12.3 + '@types/node': 25.8.0 '@types/serve-static@2.2.0': dependencies: @@ -2174,6 +2192,8 @@ snapshots: picomatch@4.0.4: {} + pngjs@7.0.0: {} + postcss@8.5.14: dependencies: nanoid: 3.3.12 diff --git a/api/src/lib/pngValidation.ts b/api/src/lib/pngValidation.ts index 1ce7c3f7..cc5569fd 100644 --- a/api/src/lib/pngValidation.ts +++ b/api/src/lib/pngValidation.ts @@ -1,3 +1,5 @@ +import { PNG } from "pngjs"; + /** * Validacion de PNG subidos por administradores. * @@ -21,6 +23,8 @@ export type PngValidationOk = { width: number; height: number; byteSize: number; + /** PNG decodificado y vuelto a codificar; nunca se guarda el archivo original. */ + content: Buffer; }; export type PngValidationError = { @@ -57,6 +61,30 @@ function readIhdrDimensions( }; } +/** Devuelve el final exacto del chunk IEND o null si la cadena esta truncada. */ +function findPngEnd(buffer: Buffer): number | null { + let offset = PNG_SIGNATURE.length; + + while (offset + 12 <= buffer.length) { + const dataLength = buffer.readUInt32BE(offset); + const chunkEnd = offset + 12 + dataLength; + + if (chunkEnd > buffer.length) { + return null; + } + + const chunkType = buffer.subarray(offset + 4, offset + 8).toString("ascii"); + + if (chunkType === "IEND") { + return dataLength === 0 ? chunkEnd : null; + } + + offset = chunkEnd; + } + + return null; +} + export function validatePngUpload(buffer: Buffer): PngValidationResult { if (buffer.length === 0) { return { ok: false, reason: "El archivo esta vacio." }; @@ -80,25 +108,83 @@ export function validatePngUpload(buffer: Buffer): PngValidationResult { return { ok: false, reason: "No se pudo leer la cabecera del PNG." }; } - const { width, height } = dimensions; + const { width: headerWidth, height: headerHeight } = dimensions; - if (width === 0 || height === 0) { + if (headerWidth === 0 || headerHeight === 0) { return { ok: false, reason: "El PNG tiene dimensiones invalidas." }; } - if (width > MAX_PNG_DIMENSION || height > MAX_PNG_DIMENSION) { + // Se controla la cabecera antes de descomprimir para que un archivo hostil no + // pueda reservar una imagen enorme y agotar la memoria del proceso. + if ( + headerWidth > MAX_PNG_DIMENSION || + headerHeight > MAX_PNG_DIMENSION + ) { return { ok: false, reason: `Las dimensiones superan el maximo de ${MAX_PNG_DIMENSION}px por lado.`, }; } - if (width % TILE_SIZE !== 0 || height % TILE_SIZE !== 0) { + if (headerWidth % TILE_SIZE !== 0 || headerHeight % TILE_SIZE !== 0) { return { ok: false, - reason: `El PNG debe medir multiplos de ${TILE_SIZE}px por lado. Recibido: ${width}x${height}.`, + reason: `El PNG debe medir multiplos de ${TILE_SIZE}px por lado. Recibido: ${headerWidth}x${headerHeight}.`, }; } - return { ok: true, width, height, byteSize: buffer.length }; + try { + // La firma y el IHDR no prueban que el resto del archivo sea un PNG. + // pngjs valida la estructura, los CRC y el flujo comprimido completo. + const pngEnd = findPngEnd(buffer); + if (pngEnd === null) { + throw new Error("PNG sin IEND completo"); + } + + const decoded = PNG.sync.read(buffer.subarray(0, pngEnd), { + checkCRC: true, + }); + + if ( + decoded.width !== headerWidth || + decoded.height !== headerHeight + ) { + return { + ok: false, + reason: "La cabecera y los datos del PNG no coinciden.", + }; + } + + // Escribir desde pixeles decodificados elimina chunks auxiliares, datos + // anexados despues de IEND y cualquier payload que viniera en el archivo. + // El resultado es el unico contenido que debe llegar a la base de datos. + decoded.gamma = 0; + const content = PNG.sync.write(decoded, { + colorType: 6, + inputColorType: 6, + inputHasAlpha: true, + bitDepth: 8, + deflateLevel: 9, + }); + + if (content.length > MAX_PNG_BYTES) { + return { + ok: false, + reason: `El PNG normalizado supera el maximo de ${Math.floor(MAX_PNG_BYTES / 1024)} KB.`, + }; + } + + return { + ok: true, + width: decoded.width, + height: decoded.height, + byteSize: content.length, + content, + }; + } catch { + return { + ok: false, + reason: "El archivo no contiene un PNG completo y valido.", + }; + } } diff --git a/api/src/repositories/worldBuilder.ts b/api/src/repositories/worldBuilder.ts index ff6cdae1..669a02f4 100644 --- a/api/src/repositories/worldBuilder.ts +++ b/api/src/repositories/worldBuilder.ts @@ -61,7 +61,10 @@ export async function uploadGraphic( return { ok: false, reason: validation.reason }; } - const checksum = computeChecksum(buffer); + // El checksum y el blob se calculan sobre la salida re-encodeada, no sobre + // bytes controlados por el usuario. Asi metadatos o datos anexados no crean + // assets distintos y nunca llegan al almacenamiento. + const checksum = computeChecksum(validation.content); const existing = await pool.query<{ grh_index: number; @@ -127,7 +130,7 @@ export async function uploadGraphic( validation.width, validation.height, validation.byteSize, - buffer, + validation.content, accountId, ], ); diff --git a/api/src/tests/png-validation.test.ts b/api/src/tests/png-validation.test.ts new file mode 100644 index 00000000..4cce2c7c --- /dev/null +++ b/api/src/tests/png-validation.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { PNG } from "pngjs"; +import { + MAX_PNG_BYTES, + validatePngUpload, +} from "../lib/pngValidation"; + +function makePng(width = 32, height = 32): Buffer { + const image = new PNG({ width, height }); + + for (let offset = 0; offset < image.data.length; offset += 4) { + image.data[offset] = 48; + image.data[offset + 1] = 121; + image.data[offset + 2] = 201; + image.data[offset + 3] = 255; + } + + return PNG.sync.write(image); +} + +describe("validatePngUpload", () => { + it("decodes and re-encodes a complete PNG", () => { + const result = validatePngUpload(makePng()); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.width).toBe(32); + expect(result.height).toBe(32); + expect(result.byteSize).toBe(result.content.length); + expect(() => PNG.sync.read(result.content)).not.toThrow(); + }); + + it("strips bytes appended after IEND and deduplicates by pixels", () => { + const clean = makePng(); + const tainted = Buffer.concat([ + clean, + Buffer.from(""), + ]); + + const cleanResult = validatePngUpload(clean); + const taintedResult = validatePngUpload(tainted); + + expect(cleanResult.ok).toBe(true); + expect(taintedResult.ok).toBe(true); + if (!cleanResult.ok || !taintedResult.ok) return; + + expect(taintedResult.content).toEqual(cleanResult.content); + expect(taintedResult.content.includes("payload-after-iend")).toBe( + false, + ); + }); + + it("rejects a fake file that only carries the PNG signature", () => { + const fake = Buffer.alloc(32); + makePng().subarray(0, 24).copy(fake); + + expect(validatePngUpload(fake)).toEqual({ + ok: false, + reason: "El archivo no contiene un PNG completo y valido.", + }); + }); + + it("rejects truncated and corrupt PNG data", () => { + const png = makePng(); + + expect(validatePngUpload(png.subarray(0, png.length - 20)).ok).toBe( + false, + ); + }); + + it("enforces tile multiples, dimensions and input size limits", () => { + expect(validatePngUpload(makePng(33, 32)).ok).toBe(false); + expect(validatePngUpload(makePng(1056, 32)).ok).toBe(false); + expect(validatePngUpload(Buffer.alloc(MAX_PNG_BYTES + 1)).ok).toBe( + false, + ); + }); +}); diff --git a/api/src/tests/world-builder-upload.test.ts b/api/src/tests/world-builder-upload.test.ts new file mode 100644 index 00000000..924831df --- /dev/null +++ b/api/src/tests/world-builder-upload.test.ts @@ -0,0 +1,81 @@ +import crypto from "crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PNG } from "pngjs"; + +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + clientQuery: vi.fn(), + release: vi.fn(), + connect: vi.fn(), +})); + +vi.mock("../db", () => ({ + default: { query: mocks.query, connect: mocks.connect }, +})); + +import { uploadGraphic } from "../repositories/worldBuilder"; + +function makePng(): Buffer { + const image = new PNG({ width: 32, height: 32 }); + image.data.fill(255); + return PNG.sync.write(image); +} + +describe("uploadGraphic", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.query.mockResolvedValue({ rows: [] }); + mocks.connect.mockResolvedValue({ + query: mocks.clientQuery, + release: mocks.release, + }); + mocks.clientQuery.mockImplementation(async (sql: string) => { + if (sql.includes("COALESCE(MAX")) { + return { rows: [{ next_index: 1_000_000 }] }; + } + if (sql.includes("RETURNING created_at")) { + return { + rows: [{ created_at: new Date("2026-08-17T00:00:00Z") }], + }; + } + return { rows: [] }; + }); + }); + + it("stores and hashes only canonical PNG bytes", async () => { + const input = Buffer.concat([ + makePng(), + Buffer.from("hidden-payload-after-iend"), + ]); + + const result = await uploadGraphic(input, "account-123"); + + expect(result.ok).toBe(true); + const insert = mocks.clientQuery.mock.calls.find(([sql]) => + String(sql).includes("INSERT INTO game_uploaded_graphics"), + ); + expect(insert).toBeDefined(); + + const params = insert?.[1] as unknown[]; + const stored = params[5] as Buffer; + const checksum = crypto + .createHash("sha256") + .update(stored) + .digest("hex"); + + expect(stored.includes("hidden-payload-after-iend")).toBe(false); + expect(() => PNG.sync.read(stored)).not.toThrow(); + expect(params[1]).toBe(checksum); + expect(params[4]).toBe(stored.length); + expect(params[6]).toBe("account-123"); + expect(mocks.release).toHaveBeenCalledOnce(); + }); + + it("rejects invalid bytes before touching the database", async () => { + const result = await uploadGraphic(Buffer.from("not-a-png"), "acct"); + + expect(result.ok).toBe(false); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.connect).not.toHaveBeenCalled(); + }); +});