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
+323 -882
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -1 +1,12 @@
# CLAUDE.md
@AGENTS.md @AGENTS.md
@agy.md
## Todo agente que trabaje en este repo debe:
- Leer **AGENTS.md** (documentación viva de la arquitectura, schema, API, bugs) y **agy.md** (post-mortem y plan original) **antes de tocar código**.
- Trabajar SOLO sobre el repo actual (Next.js web). El backend/pipeline Python vive en otro repo — **no editar ni asumir que está acá**.
- Escribir SQL solo dentro de `src/lib/db.ts` (nunca raw en rutas).
- No exponer secrets en texto plano (ni en scripts ni en `data/settings.json`).
- Correr `npm run build` para verificar cambios antes de dar por terminado.
+3 -1
View File
@@ -18,8 +18,10 @@ err() { printf "${RED}✗${NC} %s\n" "$1"; }
info() { printf "${CYAN}${NC} %s\n" "$1"; } info() { printf "${CYAN}${NC} %s\n" "$1"; }
# ── Sudo ────────────────────────────────────── # ── Sudo ──────────────────────────────────────
# Valida credenciales una vez; las siguientes llamadas usan el sudo cacheado,
# sin exponer la contraseña en texto plano en el script.
sudo -v || (err "sudo required" && exit 1) sudo -v || (err "sudo required" && exit 1)
SU() { echo 'Wlillidan1.' | sudo -S "$@"; } SU() { sudo "$@"; }
# ── Swap (prevent OOM during compile) ───────── # ── Swap (prevent OOM during compile) ─────────
if [ "$(free -m | awk '/^Swap:/{print $2}')" -lt 512 ]; then if [ "$(free -m | awk '/^Swap:/{print $2}')" -lt 512 ]; then
+2 -3
View File
@@ -13,10 +13,9 @@ if (!fs.existsSync(standaloneDir)) {
process.exit(1) process.exit(1)
} }
if (!fs.existsSync(staticDst)) { fs.rmSync(staticDst, { recursive: true, force: true })
console.log("Copying static files to standalone output...")
fs.cpSync(staticSrc, staticDst, { recursive: true }) fs.cpSync(staticSrc, staticDst, { recursive: true })
} console.log("Synced static files to standalone output...")
const server = path.join(standaloneDir, "server.js") const server = path.join(standaloneDir, "server.js")
if (!fs.existsSync(server)) { if (!fs.existsSync(server)) {
+1 -8
View File
@@ -1,5 +1,5 @@
import Link from "next/link" import Link from "next/link"
import { Image, Search, Shield } from "lucide-react" import { Image, Search } from "lucide-react"
export default function PublicLayout({ children }: { children: React.ReactNode }) { export default function PublicLayout({ children }: { children: React.ReactNode }) {
return ( return (
@@ -20,13 +20,6 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
<span className="hidden sm:inline">Buscar</span> <span className="hidden sm:inline">Buscar</span>
</Link> </Link>
<Link
href="/feed"
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-[var(--muted)] hover:bg-[var(--surface)] hover:text-[var(--foreground)] transition-colors"
>
<Shield className="h-4 w-4" />
<span className="hidden sm:inline">Admin</span>
</Link>
</nav> </nav>
</div> </div>
</header> </header>
+1 -2
View File
@@ -12,8 +12,7 @@ export async function GET(
if (!coverPath) { if (!coverPath) {
try { try {
const proxyUrl = `http://127.0.0.1:${process.env.PORT || 3000}/api/proxy/galleries/${gid}/cover` coverPath = await cacheCover(gid)
coverPath = await cacheCover(gid, proxyUrl)
} catch { } catch {
} }
} }
+1 -3
View File
@@ -75,9 +75,7 @@ export async function POST(req: NextRequest) {
})! })!
} }
const port = process.env.PORT || "3000" await cacheCover(gid)
const proxyUrl = `http://127.0.0.1:${port}/api/proxy/galleries/${gid}/cover`
await cacheCover(gid, proxyUrl)
return Response.json({ return Response.json({
success: true, success: true,
+5 -1
View File
@@ -1,7 +1,11 @@
import { SignJWT, jwtVerify } from "jose" 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( 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" const COOKIE_NAME = "session"
+14 -2
View File
@@ -1,5 +1,6 @@
import fs from "fs" import fs from "fs"
import path from "path" import path from "path"
import { get } from "./settings"
const COVERS_DIR = process.env.COVERS_DIR || path.join(process.cwd(), "data", "covers") 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" 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() ensureDir()
try { 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 if (!res.ok) return null
const buffer = Buffer.from(await res.arrayBuffer()) const buffer = Buffer.from(await res.arrayBuffer())
+18 -3
View File
@@ -145,13 +145,29 @@ export function createPost(input: PostInput): Post {
return getPostById(result.lastInsertRowid as number)! 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 d = getDb()
const fields: string[] = [] const fields: string[] = []
const values: Record<string, unknown> = { id } const values: Record<string, unknown> = { id }
for (const [k, v] of Object.entries(updates)) { for (const [k, v] of Object.entries(updates)) {
if (v !== undefined) { if (v === undefined || !UPDATE_FIELDS.has(k)) continue
if (k === "tags") { if (k === "tags") {
fields.push("tags = @tags") fields.push("tags = @tags")
values.tags = JSON.stringify(v) values.tags = JSON.stringify(v)
@@ -160,7 +176,6 @@ export function updatePost(id: number, updates: Partial<PostInput & { published:
values[k] = v values[k] = v
} }
} }
}
if (fields.length === 0) return getPostById(id) if (fields.length === 0) return getPostById(id)
+1 -1
View File
@@ -89,7 +89,7 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
} }
const port = process.env.PORT || "3000" const port = process.env.PORT || "3000"
await cacheCover(gid, `http://127.0.0.1:${port}/api/proxy/galleries/${gid}/cover`) await cacheCover(gid)
} catch { } catch {
continue continue
} }
+30 -13
View File
@@ -1,30 +1,47 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { verifySession } from "@/lib/auth" import { verifySession } from "@/lib/auth"
const publicPaths = [ // Rutas públicas EXACTAS (coincidencia por segmento, no por prefijo).
const publicExact = new Set([
"/login", "/login",
"/api/auth/login", "/api/auth/login",
"/api/auth/logout", "/api/auth/logout",
"/api/health", "/api/health",
"/api/proxy/health", "/api/proxy/health",
"/api/posts",
"/api/cover",
"/api/cron",
"/api/setup",
"/setup", "/setup",
"/_next", ])
"/favicon.ico",
"/fonts", // Prefijos públicos: /api/cover (imágenes de portada para el sitio público),
// assets de Next y fuentes. Coinciden a nivel de segmento: "/api/cover" NO
// destapa "/api/cover/evil" ni otras rutas.
const publicPrefixes = [
"/api/cover/",
"/_next/",
"/fonts/",
] ]
// Páginas públicas del sitio (server-side, sin login).
const publicPages = ["/", "/p/", "/tag/"]
function isPublicPath(pathname: string, method: string): boolean {
if (publicExact.has(pathname)) return true
if (publicPrefixes.some((p) => pathname.startsWith(p))) return true
if (publicPages.some((p) => pathname.startsWith(p))) return true
// /api/posts: GET de listado público; mutaciones requieren sesión.
if (pathname === "/api/posts" && method === "GET") return true
// /api/setup: GET (chequeo de config) público; POST (cambiar secrets) NO.
if (pathname === "/api/setup" && method === "GET") return true
return false
}
export async function middleware(req: NextRequest) { export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl const { pathname } = req.nextUrl
const method = req.method
const isPublic = publicPaths.some((p) => pathname.startsWith(p)) if (isPublicPath(pathname, method)) return NextResponse.next()
if (isPublic) return NextResponse.next()
const isPublicPage = pathname === "/" || pathname.startsWith("/p/") || pathname.startsWith("/tag/")
if (isPublicPage) return NextResponse.next()
const webPassword = process.env.WEB_PASSWORD const webPassword = process.env.WEB_PASSWORD
if (!webPassword) return NextResponse.next() if (!webPassword) return NextResponse.next()