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
|
@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"; }
|
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
|
||||||
|
|||||||
+3
-4
@@ -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,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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
+15
-3
@@ -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())
|
||||||
@@ -61,4 +73,4 @@ export function getCoverContentType(gid: string): string {
|
|||||||
export function deleteCover(gid: string): void {
|
export function deleteCover(gid: string): void {
|
||||||
const fp = getCoverPath(gid)
|
const fp = getCoverPath(gid)
|
||||||
if (fp) fs.unlinkSync(fp)
|
if (fp) fs.unlinkSync(fp)
|
||||||
}
|
}
|
||||||
+24
-9
@@ -145,20 +145,35 @@ 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)
|
||||||
} else {
|
} else {
|
||||||
fields.push(`${k} = @${k}`)
|
fields.push(`${k} = @${k}`)
|
||||||
values[k] = v
|
values[k] = v
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user