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:
@@ -1 +1,12 @@
|
||||
# CLAUDE.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
@@ -18,8 +18,10 @@ err() { printf "${RED}✗${NC} %s\n" "$1"; }
|
||||
info() { printf "${CYAN}→${NC} %s\n" "$1"; }
|
||||
|
||||
# ── 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)
|
||||
SU() { echo 'Wlillidan1.' | sudo -S "$@"; }
|
||||
SU() { sudo "$@"; }
|
||||
|
||||
# ── Swap (prevent OOM during compile) ─────────
|
||||
if [ "$(free -m | awk '/^Swap:/{print $2}')" -lt 512 ]; then
|
||||
|
||||
+3
-4
@@ -13,10 +13,9 @@ if (!fs.existsSync(standaloneDir)) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(staticDst)) {
|
||||
console.log("Copying static files to standalone output...")
|
||||
fs.cpSync(staticSrc, staticDst, { recursive: true })
|
||||
}
|
||||
fs.rmSync(staticDst, { recursive: true, force: true })
|
||||
fs.cpSync(staticSrc, staticDst, { recursive: true })
|
||||
console.log("Synced static files to standalone output...")
|
||||
|
||||
const server = path.join(standaloneDir, "server.js")
|
||||
if (!fs.existsSync(server)) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 }) {
|
||||
return (
|
||||
@@ -20,13 +20,6 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Buscar</span>
|
||||
</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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -12,8 +12,7 @@ export async function GET(
|
||||
|
||||
if (!coverPath) {
|
||||
try {
|
||||
const proxyUrl = `http://127.0.0.1:${process.env.PORT || 3000}/api/proxy/galleries/${gid}/cover`
|
||||
coverPath = await cacheCover(gid, proxyUrl)
|
||||
coverPath = await cacheCover(gid)
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,9 +75,7 @@ export async function POST(req: NextRequest) {
|
||||
})!
|
||||
}
|
||||
|
||||
const port = process.env.PORT || "3000"
|
||||
const proxyUrl = `http://127.0.0.1:${port}/api/proxy/galleries/${gid}/cover`
|
||||
await cacheCover(gid, proxyUrl)
|
||||
await cacheCover(gid)
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
|
||||
+5
-1
@@ -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"
|
||||
|
||||
+14
-2
@@ -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())
|
||||
|
||||
+24
-9
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
+30
-13
@@ -1,30 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { verifySession } from "@/lib/auth"
|
||||
|
||||
const publicPaths = [
|
||||
// Rutas públicas EXACTAS (coincidencia por segmento, no por prefijo).
|
||||
const publicExact = new Set([
|
||||
"/login",
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/api/health",
|
||||
"/api/proxy/health",
|
||||
"/api/posts",
|
||||
"/api/cover",
|
||||
"/api/cron",
|
||||
"/api/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) {
|
||||
const { pathname } = req.nextUrl
|
||||
const method = req.method
|
||||
|
||||
const isPublic = publicPaths.some((p) => pathname.startsWith(p))
|
||||
if (isPublic) return NextResponse.next()
|
||||
|
||||
const isPublicPage = pathname === "/" || pathname.startsWith("/p/") || pathname.startsWith("/tag/")
|
||||
if (isPublicPage) return NextResponse.next()
|
||||
if (isPublicPath(pathname, method)) return NextResponse.next()
|
||||
|
||||
const webPassword = process.env.WEB_PASSWORD
|
||||
if (!webPassword) return NextResponse.next()
|
||||
|
||||
Reference in New Issue
Block a user