From 2f0f539185e74fa3b50ad454f68b76597ae52a3a Mon Sep 17 00:00:00 2001 From: shni Date: Wed, 8 Oct 2025 12:19:09 -0500 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20a=C3=B1adir=20soporte=20para=20comp?= =?UTF-8?q?resi=C3=B3n=20Brotli=20en=20el=20servidor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++++ src/server/server.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 83970f4..8316eaa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ node_modules .env .env.test +qodana.yaml +/.github +/.vscode +/.idea /src/generated/prisma diff --git a/src/server/server.ts b/src/server/server.ts index cdb6365..48bb4ec 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -2,7 +2,7 @@ import { createServer, IncomingMessage, ServerResponse } from "node:http"; import { promises as fs } from "node:fs"; import { readFileSync } from "node:fs"; import { createHash } from "node:crypto"; -import { gzipSync } from "node:zlib"; +import { gzipSync, brotliCompressSync } from "node:zlib"; import path from "node:path"; import ejs from "ejs"; From d8611d8740d388d2fa5b9d5f1af5ed437c71f0bb Mon Sep 17 00:00:00 2001 From: shni Date: Wed, 8 Oct 2025 12:20:52 -0500 Subject: [PATCH 2/4] fix: corregir la sintaxis de las rutas en .gitignore --- .gitignore | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 8316eaa..a4479db 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,8 @@ node_modules .env.test qodana.yaml -/.github -/.vscode -/.idea +.github +.vscode +.idea /src/generated/prisma From 96f7067193f439426e825606e258706eb34906c2 Mon Sep 17 00:00:00 2001 From: shni Date: Wed, 8 Oct 2025 13:01:35 -0500 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20implementar=20encabezados=20de=20se?= =?UTF-8?q?guridad=20mejorados=20y=20pol=C3=ADtica=20de=20contenido=20para?= =?UTF-8?q?=20solicitudes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/server.ts | 64 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/src/server/server.ts b/src/server/server.ts index 48bb4ec..b132a7e 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -114,6 +114,45 @@ function applySecurityHeaders(base: Record = {}) { ...base, }; } +function buildBaseCsp(frameAncestors: string = "'self'") { + // Use a mild CSP; add frame-ancestors dynamically per request. + return ( + "default-src 'self'; " + + "img-src 'self' data: https:; " + + "style-src 'self' 'unsafe-inline' https:; " + + "script-src 'self' 'unsafe-inline' https:; " + + "font-src 'self' https: data:; " + + "frame-src 'self' https://ko-fi.com https://*.ko-fi.com; " + + "child-src 'self' https://ko-fi.com https://*.ko-fi.com; " + + `frame-ancestors ${frameAncestors}` + ); +} + +function applySecurityHeadersForRequest( + req: IncomingMessage, + base: Record = {} +) { + const host = ((req.headers.host as string) || "").toLowerCase(); + const isDocsHost = + host === "docs.amayo.dev" || host.endsWith(".docs.amayo.dev"); + + // Allow embedding only from https://top.gg for docs.amayo.dev; otherwise, self only and keep XFO deny. + const csp = isDocsHost + ? buildBaseCsp("'self' https://top.gg") + : buildBaseCsp("'self'"); + + const headers: Record = { + "Strict-Transport-Security": "max-age=15552000; includeSubDomains; preload", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + // X-Frame-Options is omitted for docs.amayo.dev to rely on CSP frame-ancestors allowing only top.gg + ...(isDocsHost ? {} : { "X-Frame-Options": "DENY" }), + "Content-Security-Policy": csp, + ...base, + }; + + return headers; +} function computeEtag(buf: Buffer): string { // Weak ETag derived from content sha1 and length @@ -174,7 +213,7 @@ const sendResponse = async ( if (inm && inm === etag) { res.writeHead( 304, - applySecurityHeaders({ + applySecurityHeadersForRequest(req, { ETag: etag, "Cache-Control": cacheControl, ...(stat ? { "Last-Modified": stat.mtime.toUTCString() } : {}), @@ -202,7 +241,7 @@ const sendResponse = async ( } } - res.writeHead(statusCode, applySecurityHeaders(headers)); + res.writeHead(statusCode, applySecurityHeadersForRequest(req, headers)); res.end(body); }; @@ -238,7 +277,10 @@ const renderTemplate = async ( if (inm && inm === etag) { res.writeHead( 304, - applySecurityHeaders({ ETag: etag, "Cache-Control": "no-cache" }) + applySecurityHeadersForRequest(req, { + ETag: etag, + "Cache-Control": "no-cache", + }) ); res.end(); return; @@ -261,7 +303,7 @@ const renderTemplate = async ( } } - res.writeHead(statusCode, applySecurityHeaders(headers)); + res.writeHead(statusCode, applySecurityHeadersForRequest(req, headers)); res.end(respBody); }; @@ -289,7 +331,7 @@ export const server = createServer( const robots = "User-agent: *\nAllow: /\n"; // change to Disallow: / if you want to discourage polite crawlers res.writeHead( 200, - applySecurityHeaders({ + applySecurityHeadersForRequest(req, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "public, max-age=86400", }) @@ -301,7 +343,7 @@ export const server = createServer( if (isSuspiciousPath(url.pathname)) { // Hard block known-bad keyword probes if (BLOCKED_PATTERNS.some((re) => re.test(url.pathname))) { - const headers = applySecurityHeaders({ + const headers = applySecurityHeadersForRequest(req, { "Content-Type": "text/plain; charset=utf-8", }); res.writeHead(403, headers); @@ -310,7 +352,7 @@ export const server = createServer( // Rate limit repetitive suspicious hits per IP const rate = hitSuspicious(clientIp); if (!rate.allowed) { - const headers = applySecurityHeaders({ + const headers = applySecurityHeadersForRequest(req, { "Content-Type": "text/plain; charset=utf-8", "Retry-After": String(Math.ceil(rate.resetIn / 1000)), "X-RateLimit-Limit": String(RATE_MAX_SUSPICIOUS), @@ -360,7 +402,7 @@ export const server = createServer( } catch { res.writeHead( 404, - applySecurityHeaders({ + applySecurityHeadersForRequest(req, { "Content-Type": "text/plain; charset=utf-8", }) ); @@ -373,7 +415,7 @@ export const server = createServer( console.error("[Server] Error al servir archivo:", error); res.writeHead( 500, - applySecurityHeaders({ + applySecurityHeadersForRequest(req, { "Content-Type": "text/plain; charset=utf-8", }) ); @@ -384,7 +426,9 @@ export const server = createServer( console.error("[Server] Error inesperado:", error); res.writeHead( 500, - applySecurityHeaders({ "Content-Type": "text/plain; charset=utf-8" }) + applySecurityHeadersForRequest(req, { + "Content-Type": "text/plain; charset=utf-8", + }) ); res.end("500 - Error interno"); } From 67b187c3c055de72f325aee2b0e3fb78bdc3cb14 Mon Sep 17 00:00:00 2001 From: shni Date: Wed, 8 Oct 2025 13:04:03 -0500 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20a=C3=B1adir=20soporte=20para=20comp?= =?UTF-8?q?resi=C3=B3n=20Brotli=20y=20mejorar=20la=20selecci=C3=B3n=20de?= =?UTF-8?q?=20codificaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/server.ts | 86 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 9 deletions(-) diff --git a/src/server/server.ts b/src/server/server.ts index b132a7e..2e289e9 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -2,7 +2,11 @@ import { createServer, IncomingMessage, ServerResponse } from "node:http"; import { promises as fs } from "node:fs"; import { readFileSync } from "node:fs"; import { createHash } from "node:crypto"; -import { gzipSync, brotliCompressSync } from "node:zlib"; +import { + gzipSync, + brotliCompressSync, + constants as zlibConstants, +} from "node:zlib"; import path from "node:path"; import ejs from "ejs"; @@ -168,6 +172,49 @@ function acceptsEncoding(req: IncomingMessage, enc: string): boolean { .includes(enc); } +// Parse Accept-Encoding with q-values and return a map of encoding -> q +function parseAcceptEncoding(req: IncomingMessage): Map { + const header = (req.headers["accept-encoding"] as string) || ""; + const map = new Map(); + if (!header) { + // If header missing, identity is acceptable + map.set("identity", 1); + return map; + } + const parts = header.split(","); + for (const raw of parts) { + const part = raw.trim(); + if (!part) continue; + const [name, ...params] = part.split(";"); + let q = 1; + for (const p of params) { + const [k, v] = p.split("=").map((s) => s.trim()); + if (k === "q" && v) { + const n = Number(v); + if (!Number.isNaN(n)) q = n; + } + } + map.set(name.toLowerCase(), q); + } + // Ensure identity exists unless explicitly disabled (q=0) + if (!map.has("identity")) map.set("identity", 1); + return map; +} + +// Choose the best compression given the request and mime type. +function pickEncoding( + req: IncomingMessage, + mime: string +): "br" | "gzip" | "identity" { + if (!shouldCompress(mime)) return "identity"; + const encs = parseAcceptEncoding(req); + const qBr = encs.get("br") ?? 0; + const qGzip = encs.get("gzip") ?? 0; + if (qBr > 0) return "br"; + if (qGzip > 0) return "gzip"; + return "identity"; +} + function shouldCompress(mime: string): boolean { return ( mime.startsWith("text/") || @@ -231,14 +278,25 @@ const sendResponse = async ( ...(stat ? { "Last-Modified": stat.mtime.toUTCString() } : {}), }; - if (shouldCompress(mimeType) && acceptsEncoding(req, "gzip")) { - try { + // Prefer Brotli over Gzip when supported + const chosen = pickEncoding(req, mimeType); + try { + if (chosen === "br") { + body = brotliCompressSync(data, { + params: { + [zlibConstants.BROTLI_PARAM_QUALITY]: 4, // fast, good ratio for text + [zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT, + }, + }); + headers["Content-Encoding"] = "br"; + headers["Vary"] = "Accept-Encoding"; + } else if (chosen === "gzip") { body = gzipSync(data); headers["Content-Encoding"] = "gzip"; headers["Vary"] = "Accept-Encoding"; - } catch { - // Si falla compresión, enviar sin comprimir } + } catch { + // Si falla compresión, enviar sin comprimir } res.writeHead(statusCode, applySecurityHeadersForRequest(req, headers)); @@ -293,14 +351,24 @@ const renderTemplate = async ( ETag: etag, }; - if (acceptsEncoding(req, "gzip")) { - try { + const chosenDyn = pickEncoding(req, "text/html; charset=utf-8"); + try { + if (chosenDyn === "br") { + respBody = brotliCompressSync(htmlBuffer, { + params: { + [zlibConstants.BROTLI_PARAM_QUALITY]: 4, + [zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT, + }, + }); + headers["Content-Encoding"] = "br"; + headers["Vary"] = "Accept-Encoding"; + } else if (chosenDyn === "gzip") { respBody = gzipSync(htmlBuffer); headers["Content-Encoding"] = "gzip"; headers["Vary"] = "Accept-Encoding"; - } catch { - // continuar sin comprimir } + } catch { + // continuar sin comprimir } res.writeHead(statusCode, applySecurityHeadersForRequest(req, headers));