From 10afef9fec9fefae166678a0c4c0706c2ba9f105 Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Sat, 4 Apr 2026 02:47:05 +0000 Subject: [PATCH] =?UTF-8?q?fix(seguran=C3=A7a):=20corrigir=2010=20vulnerab?= =?UTF-8?q?ilidades=20do=20relat=C3=B3rio=20de=20seguran=C3=A7a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tokens: remover aceite de expiresAt NULL e forçar TTL de 1 ano - tokens: corrigir refresh que invalidava access token anterior - xlsx: desabilitar parsing de fórmulas (CVE-2024-44294) - csp: expandir Content-Security-Policy com origens explícitas - headers: adicionar Referrer-Policy e X-Permitted-Cross-Domain-Policies - api: retornar 401 JSON em vez de redirect 302 em rotas autenticadas - health: remover version disclosure do /api/health - robots.txt: simplificar para não expor rotas internas - sitemap: corrigir URL com protocolo duplicado - criar security.txt (RFC 9116) Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 15 ++++++++++++++ next.config.ts | 18 ++++++++++++++++- package.json | 2 +- public/.well-known/security.txt | 4 ++++ .../[attachmentId]/presign/route.ts | 16 +++++++++++++-- src/app/api/auth/device/refresh/route.ts | 5 ++--- src/app/api/auth/device/verify/route.ts | 20 +++++++++---------- src/app/api/health/route.ts | 3 --- src/app/api/inbox/batch/route.ts | 4 ++-- src/app/api/inbox/route.ts | 4 ++-- src/app/api/insights/saved/route.ts | 16 ++++++++++++--- .../[transactionId]/attachments/route.ts | 16 +++++++++++++-- .../[seriesId]/anticipations/route.ts | 16 +++++++++++++-- src/app/robots.ts | 20 +------------------ src/app/sitemap.ts | 2 +- src/features/settings/actions.ts | 2 +- src/shared/lib/import/xls-parser.ts | 2 ++ 17 files changed, 113 insertions(+), 52 deletions(-) create mode 100644 public/.well-known/security.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a29326..dc69b0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ## [Unreleased] +## [2.3.2] - 2026-04-04 + +### Segurança + +- Tokens: removido aceite de tokens sem expiração (`expiresAt NULL`); tokens criados via settings agora expiram em 1 ano +- Tokens: corrigido refresh que sobrescrevia hash e invalidava access token anterior; verify agora valida JWT por assinatura +- xlsx: desabilitado parsing de fórmulas (`cellFormula: false`) para mitigar CVE-2024-44294 +- CSP: expandida Content-Security-Policy com `default-src`, `script-src`, `style-src`, `img-src`, `font-src` e `connect-src` +- Headers: adicionados `Referrer-Policy` e `X-Permitted-Cross-Domain-Policies` +- API: rotas autenticadas agora retornam `401 JSON` em vez de redirect `302` para clientes não autenticados +- Health: removido campo `version` da resposta do `/api/health` +- robots.txt: simplificado para não expor mapa de rotas internas +- Sitemap: corrigida URL com protocolo duplicado (`https://https://`) +- Criado `security.txt` (RFC 9116) + ## [2.3.1] - 2026-04-03 ### Corrigido diff --git a/next.config.ts b/next.config.ts index 909eedb..4ea4df7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -44,7 +44,23 @@ const nextConfig: NextConfig = { }, { key: "Content-Security-Policy", - value: "frame-ancestors 'none';", + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' https://umami.felipecoutinho.com", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' https://lh3.googleusercontent.com data: blob:", + "font-src 'self'", + "connect-src 'self' https://umami.felipecoutinho.com", + "frame-ancestors 'none'", + ].join("; "), + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "X-Permitted-Cross-Domain-Policies", + value: "none", }, { key: "Permissions-Policy", diff --git a/package.json b/package.json index 61179d2..45d4a8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openmonetis", - "version": "2.3.1", + "version": "2.3.2", "private": true, "packageManager": "pnpm@10.33.0", "scripts": { diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt new file mode 100644 index 0000000..781333b --- /dev/null +++ b/public/.well-known/security.txt @@ -0,0 +1,4 @@ +Contact: https://github.com/felipegcoutinho/openmonetis/security/advisories +Expires: 2027-04-04T00:00:00.000Z +Preferred-Languages: pt-BR, en +Canonical: https://openmonetis.com/.well-known/security.txt diff --git a/src/app/api/attachments/[attachmentId]/presign/route.ts b/src/app/api/attachments/[attachmentId]/presign/route.ts index 794aded..473464c 100644 --- a/src/app/api/attachments/[attachmentId]/presign/route.ts +++ b/src/app/api/attachments/[attachmentId]/presign/route.ts @@ -1,7 +1,7 @@ import { and, eq } from "drizzle-orm"; import { NextResponse } from "next/server"; import { attachments } from "@/db/schema"; -import { getUserId } from "@/shared/lib/auth/server"; +import { getOptionalUserSession } from "@/shared/lib/auth/server"; import { db } from "@/shared/lib/db"; import { createPresignedGetUrl } from "@/shared/lib/storage/presign"; @@ -13,7 +13,19 @@ export async function GET( _request: Request, { params }: { params: Promise<{ attachmentId: string }> }, ) { - const [userId, { attachmentId }] = await Promise.all([getUserId(), params]); + const [session, { attachmentId }] = await Promise.all([ + getOptionalUserSession(), + params, + ]); + + if (!session?.user) { + return NextResponse.json( + { error: "Não autenticado" }, + { status: 401, headers: PRIVATE_RESPONSE_HEADERS }, + ); + } + + const userId = session.user.id; const [row] = await db .select({ fileKey: attachments.fileKey }) diff --git a/src/app/api/auth/device/refresh/route.ts b/src/app/api/auth/device/refresh/route.ts index 9733c87..e2954e9 100644 --- a/src/app/api/auth/device/refresh/route.ts +++ b/src/app/api/auth/device/refresh/route.ts @@ -3,7 +3,6 @@ import { NextResponse } from "next/server"; import { apiTokens } from "@/db/schema"; import { extractBearerToken, - hashToken, refreshAccessToken, verifyJwt, } from "@/shared/lib/auth/api-token"; @@ -59,11 +58,11 @@ export async function POST(request: Request) { ); } - // Atualizar hash do token e último uso + // Atualizar último uso e expiração (sem sobrescrever tokenHash, + // pois o JWT é auto-verificável por assinatura) await db .update(apiTokens) .set({ - tokenHash: hashToken(result.accessToken), lastUsedAt: new Date(), lastUsedIp: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || diff --git a/src/app/api/auth/device/verify/route.ts b/src/app/api/auth/device/verify/route.ts index 3032512..c40329b 100644 --- a/src/app/api/auth/device/verify/route.ts +++ b/src/app/api/auth/device/verify/route.ts @@ -1,7 +1,7 @@ import { and, eq, gt, isNull } from "drizzle-orm"; import { NextResponse } from "next/server"; import { apiTokens } from "@/db/schema"; -import { extractBearerToken, hashToken } from "@/shared/lib/auth/api-token"; +import { extractBearerToken, verifyJwt } from "@/shared/lib/auth/api-token"; import { db } from "@/shared/lib/db"; export async function POST(request: Request) { @@ -17,21 +17,21 @@ export async function POST(request: Request) { ); } - // Validar token os_xxx via hash lookup - if (!token.startsWith("os_")) { + // Verificar JWT (assinatura + expiração) + const payload = verifyJwt(token); + + if (!payload || payload.type !== "api_access") { return NextResponse.json( - { valid: false, error: "Formato de token inválido" }, + { valid: false, error: "Token inválido ou expirado" }, { status: 401 }, ); } - // Hash do token para buscar no DB - const tokenHash = hashToken(token); - - // Buscar token no banco + // Buscar token no banco por tokenId para checar revogação const tokenRecord = await db.query.apiTokens.findFirst({ where: and( - eq(apiTokens.tokenHash, tokenHash), + eq(apiTokens.id, payload.tokenId), + eq(apiTokens.userId, payload.sub), isNull(apiTokens.revokedAt), gt(apiTokens.expiresAt, new Date()), ), @@ -39,7 +39,7 @@ export async function POST(request: Request) { if (!tokenRecord) { return NextResponse.json( - { valid: false, error: "Token inválido ou revogado" }, + { valid: false, error: "Token revogado ou não encontrado" }, { status: 401 }, ); } diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index b3d4c3c..885393f 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,5 +1,4 @@ import { NextResponse } from "next/server"; -import { version as APP_VERSION } from "@/package.json"; import { db } from "@/shared/lib/db"; /** @@ -20,7 +19,6 @@ export async function GET() { { status: "ok", name: "OpenMonetis", - version: APP_VERSION, timestamp: new Date().toISOString(), }, { status: 200 }, @@ -33,7 +31,6 @@ export async function GET() { { status: "error", name: "OpenMonetis", - version: APP_VERSION, timestamp: new Date().toISOString(), message: "Database connection failed", }, diff --git a/src/app/api/inbox/batch/route.ts b/src/app/api/inbox/batch/route.ts index 3940384..3c065f0 100644 --- a/src/app/api/inbox/batch/route.ts +++ b/src/app/api/inbox/batch/route.ts @@ -1,4 +1,4 @@ -import { and, eq, gt, isNull, or } from "drizzle-orm"; +import { and, eq, gt, isNull } from "drizzle-orm"; import { NextResponse } from "next/server"; import { z } from "zod"; import { apiTokens, inboxItems } from "@/db/schema"; @@ -63,7 +63,7 @@ export async function POST(request: Request) { where: and( eq(apiTokens.tokenHash, tokenHash), isNull(apiTokens.revokedAt), - or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())), + gt(apiTokens.expiresAt, new Date()), ), }); diff --git a/src/app/api/inbox/route.ts b/src/app/api/inbox/route.ts index 8bfb7d1..bfd9240 100644 --- a/src/app/api/inbox/route.ts +++ b/src/app/api/inbox/route.ts @@ -1,4 +1,4 @@ -import { and, eq, gt, isNull, or } from "drizzle-orm"; +import { and, eq, gt, isNull } from "drizzle-orm"; import { NextResponse } from "next/server"; import { z } from "zod"; import { apiTokens, inboxItems } from "@/db/schema"; @@ -56,7 +56,7 @@ export async function POST(request: Request) { where: and( eq(apiTokens.tokenHash, tokenHash), isNull(apiTokens.revokedAt), - or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())), + gt(apiTokens.expiresAt, new Date()), ), }); diff --git a/src/app/api/insights/saved/route.ts b/src/app/api/insights/saved/route.ts index bf49b87..1b84d40 100644 --- a/src/app/api/insights/saved/route.ts +++ b/src/app/api/insights/saved/route.ts @@ -3,7 +3,7 @@ import { fetchSavedInsights, savedInsightsPeriodSchema, } from "@/features/insights/queries"; -import { getUserId } from "@/shared/lib/auth/server"; +import { getOptionalUserSession } from "@/shared/lib/auth/server"; const PRIVATE_RESPONSE_HEADERS = { "Cache-Control": "private, no-store", @@ -25,8 +25,18 @@ export async function GET(request: Request) { ); } - const userId = await getUserId(); - const insights = await fetchSavedInsights(userId, validatedPeriod.data); + const session = await getOptionalUserSession(); + if (!session?.user) { + return NextResponse.json( + { error: "Não autenticado" }, + { status: 401, headers: PRIVATE_RESPONSE_HEADERS }, + ); + } + + const insights = await fetchSavedInsights( + session.user.id, + validatedPeriod.data, + ); return NextResponse.json(insights, { headers: PRIVATE_RESPONSE_HEADERS, diff --git a/src/app/api/transactions/[transactionId]/attachments/route.ts b/src/app/api/transactions/[transactionId]/attachments/route.ts index cee34f4..7a2a3c8 100644 --- a/src/app/api/transactions/[transactionId]/attachments/route.ts +++ b/src/app/api/transactions/[transactionId]/attachments/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { fetchTransactionAttachments } from "@/features/transactions/attachment-queries"; -import { getUserId } from "@/shared/lib/auth/server"; +import { getOptionalUserSession } from "@/shared/lib/auth/server"; const PRIVATE_RESPONSE_HEADERS = { "Cache-Control": "private, no-store", @@ -10,7 +10,19 @@ export async function GET( _request: Request, { params }: { params: Promise<{ transactionId: string }> }, ) { - const [userId, { transactionId }] = await Promise.all([getUserId(), params]); + const [session, { transactionId }] = await Promise.all([ + getOptionalUserSession(), + params, + ]); + + if (!session?.user) { + return NextResponse.json( + { error: "Não autenticado" }, + { status: 401, headers: PRIVATE_RESPONSE_HEADERS }, + ); + } + + const userId = session.user.id; const attachments = await fetchTransactionAttachments(userId, transactionId); return NextResponse.json(attachments, { diff --git a/src/app/api/transactions/installments/[seriesId]/anticipations/route.ts b/src/app/api/transactions/installments/[seriesId]/anticipations/route.ts index adae96b..3187c71 100644 --- a/src/app/api/transactions/installments/[seriesId]/anticipations/route.ts +++ b/src/app/api/transactions/installments/[seriesId]/anticipations/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { fetchInstallmentAnticipations } from "@/features/transactions/anticipation-queries"; -import { getUserId } from "@/shared/lib/auth/server"; +import { getOptionalUserSession } from "@/shared/lib/auth/server"; const PRIVATE_RESPONSE_HEADERS = { "Cache-Control": "private, no-store", @@ -11,7 +11,19 @@ export async function GET( { params }: { params: Promise<{ seriesId: string }> }, ) { try { - const [userId, { seriesId }] = await Promise.all([getUserId(), params]); + const [session, { seriesId }] = await Promise.all([ + getOptionalUserSession(), + params, + ]); + + if (!session?.user) { + return NextResponse.json( + { error: "Não autenticado" }, + { status: 401, headers: PRIVATE_RESPONSE_HEADERS }, + ); + } + + const userId = session.user.id; const anticipations = await fetchInstallmentAnticipations(userId, seriesId); return NextResponse.json(anticipations, { diff --git a/src/app/robots.ts b/src/app/robots.ts index 11325c8..f564e22 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -6,25 +6,7 @@ export default function robots(): MetadataRoute.Robots { { userAgent: "*", allow: "/", - disallow: [ - "/dashboard", - "/transactions", - "/accounts", - "/cards", - "/categories", - "/budgets", - "/payers", - "/notes", - "/insights", - "/calendar", - "/attachments", - "/settings", - "/reports", - "/inbox", - "/login", - "/signup", - "/api/", - ], + disallow: "/api/", }, ], }; diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index fb6ea0c..8a97798 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,7 +1,7 @@ import type { MetadataRoute } from "next"; const BASE_URL = process.env.PUBLIC_DOMAIN - ? `https://${process.env.PUBLIC_DOMAIN}` + ? `https://${process.env.PUBLIC_DOMAIN.replace(/^https?:\/\//, "")}` : "https://openmonetis.com"; export default function sitemap(): MetadataRoute.Sitemap { diff --git a/src/features/settings/actions.ts b/src/features/settings/actions.ts index 3899263..9942dd8 100644 --- a/src/features/settings/actions.ts +++ b/src/features/settings/actions.ts @@ -649,7 +649,7 @@ export async function createApiTokenAction( name: validated.name, tokenHash, tokenPrefix, - expiresAt: null, // No expiration for now + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 ano }) .returning({ id: apiTokens.id }); diff --git a/src/shared/lib/import/xls-parser.ts b/src/shared/lib/import/xls-parser.ts index ed57a86..bd2c504 100644 --- a/src/shared/lib/import/xls-parser.ts +++ b/src/shared/lib/import/xls-parser.ts @@ -47,6 +47,8 @@ export function parseXls(buffer: ArrayBuffer): ImportStatement { const workbook = XLSX.read(new Uint8Array(buffer), { type: "array", cellDates: false, + cellFormula: false, + cellNF: false, }); if (!workbook.SheetNames.length) {