Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 22 additions & 2 deletions api/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

98 changes: 92 additions & 6 deletions api/src/lib/pngValidation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { PNG } from "pngjs";

/**
* Validacion de PNG subidos por administradores.
*
Expand All @@ -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 = {
Expand Down Expand Up @@ -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." };
Expand All @@ -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.",
};
}
}
7 changes: 5 additions & 2 deletions api/src/repositories/worldBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -127,7 +130,7 @@ export async function uploadGraphic(
validation.width,
validation.height,
validation.byteSize,
buffer,
validation.content,
accountId,
],
);
Expand Down
79 changes: 79 additions & 0 deletions api/src/tests/png-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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("<script>payload-after-iend</script>"),
]);

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,
);
});
});
Loading
Loading