Seguridad y docs: fix SQLi en updatePost, middleware por segmentos exactos, cover-cache directo al upstream, JWT sin fallback hardcodeado, password sudo fuera de deploy/setup.sh; reescritura AGENTS.md y CLAUDE.md

This commit is contained in:
Renato
2026-08-04 11:13:57 +08:00
parent 5d36c635cb
commit 451518004d
12 changed files with 419 additions and 928 deletions
+5 -1
View File
@@ -1,7 +1,11 @@
import { SignJWT, jwtVerify } from "jose"
import crypto from "crypto"
// Secreto de firma: JWT_SECRET explícito > WEB_PASSWORD > secreto efímero
// aleatorio (modo dev sin contraseña — las sesiones no sobreviven restart,
// pero no hay nada hardcodeado ni predecible en el código).
const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || process.env.WEB_PASSWORD || "worst-scan-web-dev-secret",
process.env.JWT_SECRET || process.env.WEB_PASSWORD || crypto.randomBytes(32).toString("hex"),
)
const COOKIE_NAME = "session"
+15 -3
View File
@@ -1,5 +1,6 @@
import fs from "fs"
import path from "path"
import { get } from "./settings"
const COVERS_DIR = process.env.COVERS_DIR || path.join(process.cwd(), "data", "covers")
@@ -20,10 +21,21 @@ function extFromContentType(ct: string | null): string {
return exts[m[1]] || ".jpg"
}
export async function cacheCover(gid: string, proxyUrl: string): Promise<string | null> {
// Descarga la portada DIRECTAMENTE del upstream (API_BASE_URL), con la misma
// API key que usa el resto de la app. NO pasa por el proxy /api/proxy/* (que el
// middleware protege), evitando el redirect a /login que guardaba HTML como portada.
export async function cacheCover(gid: string): Promise<string | null> {
ensureDir()
try {
const res = await fetch(proxyUrl, { signal: AbortSignal.timeout(15000) })
const apiBase = get("API_BASE_URL", "http://127.0.0.1:8080/api/v1")
const apiKey = get("API_KEY")
const headers: Record<string, string> = {}
if (apiKey) headers["X-API-Key"] = apiKey
const res = await fetch(`${apiBase}/galleries/${gid}/cover`, {
headers,
signal: AbortSignal.timeout(15000),
})
if (!res.ok) return null
const buffer = Buffer.from(await res.arrayBuffer())
@@ -61,4 +73,4 @@ export function getCoverContentType(gid: string): string {
export function deleteCover(gid: string): void {
const fp = getCoverPath(gid)
if (fp) fs.unlinkSync(fp)
}
}
+24 -9
View File
@@ -145,20 +145,35 @@ export function createPost(input: PostInput): Post {
return getPostById(result.lastInsertRowid as number)!
}
export function updatePost(id: number, updates: Partial<PostInput & { published: number }>): Post | null {
// Columnas permitidas en PATCH — whitelist estricta para impedir SQLi
// por nombres de columna y el bypass de `published`/columnas internas.
const UPDATE_FIELDS = new Set([
"title",
"title_jpn",
"artist",
"parody",
"tags",
"num_pages",
"source",
"cover_url",
"url",
"summary",
"slug",
])
export function updatePost(id: number, updates: Partial<PostInput>): Post | null {
const d = getDb()
const fields: string[] = []
const values: Record<string, unknown> = { id }
for (const [k, v] of Object.entries(updates)) {
if (v !== undefined) {
if (k === "tags") {
fields.push("tags = @tags")
values.tags = JSON.stringify(v)
} else {
fields.push(`${k} = @${k}`)
values[k] = v
}
if (v === undefined || !UPDATE_FIELDS.has(k)) continue
if (k === "tags") {
fields.push("tags = @tags")
values.tags = JSON.stringify(v)
} else {
fields.push(`${k} = @${k}`)
values[k] = v
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
}
const port = process.env.PORT || "3000"
await cacheCover(gid, `http://127.0.0.1:${port}/api/proxy/galleries/${gid}/cover`)
await cacheCover(gid)
} catch {
continue
}