fix(segurança): corrigir 10 vulnerabilidades do relatório de segurança

- 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) <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-04-04 02:47:05 +00:00
parent fd4d90a53e
commit 10afef9fec
17 changed files with 113 additions and 52 deletions

View File

@@ -7,6 +7,21 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [Unreleased] ## [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 ## [2.3.1] - 2026-04-03
### Corrigido ### Corrigido

View File

@@ -44,7 +44,23 @@ const nextConfig: NextConfig = {
}, },
{ {
key: "Content-Security-Policy", 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", key: "Permissions-Policy",

View File

@@ -1,6 +1,6 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.3.1", "version": "2.3.2",
"private": true, "private": true,
"packageManager": "pnpm@10.33.0", "packageManager": "pnpm@10.33.0",
"scripts": { "scripts": {

View File

@@ -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

View File

@@ -1,7 +1,7 @@
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { attachments } from "@/db/schema"; 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 { db } from "@/shared/lib/db";
import { createPresignedGetUrl } from "@/shared/lib/storage/presign"; import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
@@ -13,7 +13,19 @@ export async function GET(
_request: Request, _request: Request,
{ params }: { params: Promise<{ attachmentId: string }> }, { 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 const [row] = await db
.select({ fileKey: attachments.fileKey }) .select({ fileKey: attachments.fileKey })

View File

@@ -3,7 +3,6 @@ import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema"; import { apiTokens } from "@/db/schema";
import { import {
extractBearerToken, extractBearerToken,
hashToken,
refreshAccessToken, refreshAccessToken,
verifyJwt, verifyJwt,
} from "@/shared/lib/auth/api-token"; } 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 await db
.update(apiTokens) .update(apiTokens)
.set({ .set({
tokenHash: hashToken(result.accessToken),
lastUsedAt: new Date(), lastUsedAt: new Date(),
lastUsedIp: lastUsedIp:
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||

View File

@@ -1,7 +1,7 @@
import { and, eq, gt, isNull } from "drizzle-orm"; import { and, eq, gt, isNull } from "drizzle-orm";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema"; 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"; import { db } from "@/shared/lib/db";
export async function POST(request: Request) { export async function POST(request: Request) {
@@ -17,21 +17,21 @@ export async function POST(request: Request) {
); );
} }
// Validar token os_xxx via hash lookup // Verificar JWT (assinatura + expiração)
if (!token.startsWith("os_")) { const payload = verifyJwt(token);
if (!payload || payload.type !== "api_access") {
return NextResponse.json( return NextResponse.json(
{ valid: false, error: "Formato de token inválido" }, { valid: false, error: "Token inválido ou expirado" },
{ status: 401 }, { status: 401 },
); );
} }
// Hash do token para buscar no DB // Buscar token no banco por tokenId para checar revogação
const tokenHash = hashToken(token);
// Buscar token no banco
const tokenRecord = await db.query.apiTokens.findFirst({ const tokenRecord = await db.query.apiTokens.findFirst({
where: and( where: and(
eq(apiTokens.tokenHash, tokenHash), eq(apiTokens.id, payload.tokenId),
eq(apiTokens.userId, payload.sub),
isNull(apiTokens.revokedAt), isNull(apiTokens.revokedAt),
gt(apiTokens.expiresAt, new Date()), gt(apiTokens.expiresAt, new Date()),
), ),
@@ -39,7 +39,7 @@ export async function POST(request: Request) {
if (!tokenRecord) { if (!tokenRecord) {
return NextResponse.json( return NextResponse.json(
{ valid: false, error: "Token inválido ou revogado" }, { valid: false, error: "Token revogado ou não encontrado" },
{ status: 401 }, { status: 401 },
); );
} }

View File

@@ -1,5 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { version as APP_VERSION } from "@/package.json";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
/** /**
@@ -20,7 +19,6 @@ export async function GET() {
{ {
status: "ok", status: "ok",
name: "OpenMonetis", name: "OpenMonetis",
version: APP_VERSION,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}, },
{ status: 200 }, { status: 200 },
@@ -33,7 +31,6 @@ export async function GET() {
{ {
status: "error", status: "error",
name: "OpenMonetis", name: "OpenMonetis",
version: APP_VERSION,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
message: "Database connection failed", message: "Database connection failed",
}, },

View File

@@ -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 { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema"; import { apiTokens, inboxItems } from "@/db/schema";
@@ -63,7 +63,7 @@ export async function POST(request: Request) {
where: and( where: and(
eq(apiTokens.tokenHash, tokenHash), eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt), isNull(apiTokens.revokedAt),
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())), gt(apiTokens.expiresAt, new Date()),
), ),
}); });

View File

@@ -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 { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema"; import { apiTokens, inboxItems } from "@/db/schema";
@@ -56,7 +56,7 @@ export async function POST(request: Request) {
where: and( where: and(
eq(apiTokens.tokenHash, tokenHash), eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt), isNull(apiTokens.revokedAt),
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())), gt(apiTokens.expiresAt, new Date()),
), ),
}); });

View File

@@ -3,7 +3,7 @@ import {
fetchSavedInsights, fetchSavedInsights,
savedInsightsPeriodSchema, savedInsightsPeriodSchema,
} from "@/features/insights/queries"; } from "@/features/insights/queries";
import { getUserId } from "@/shared/lib/auth/server"; import { getOptionalUserSession } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = { const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store", "Cache-Control": "private, no-store",
@@ -25,8 +25,18 @@ export async function GET(request: Request) {
); );
} }
const userId = await getUserId(); const session = await getOptionalUserSession();
const insights = await fetchSavedInsights(userId, validatedPeriod.data); 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, { return NextResponse.json(insights, {
headers: PRIVATE_RESPONSE_HEADERS, headers: PRIVATE_RESPONSE_HEADERS,

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { fetchTransactionAttachments } from "@/features/transactions/attachment-queries"; 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 = { const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store", "Cache-Control": "private, no-store",
@@ -10,7 +10,19 @@ export async function GET(
_request: Request, _request: Request,
{ params }: { params: Promise<{ transactionId: string }> }, { 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); const attachments = await fetchTransactionAttachments(userId, transactionId);
return NextResponse.json(attachments, { return NextResponse.json(attachments, {

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { fetchInstallmentAnticipations } from "@/features/transactions/anticipation-queries"; 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 = { const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store", "Cache-Control": "private, no-store",
@@ -11,7 +11,19 @@ export async function GET(
{ params }: { params: Promise<{ seriesId: string }> }, { params }: { params: Promise<{ seriesId: string }> },
) { ) {
try { 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); const anticipations = await fetchInstallmentAnticipations(userId, seriesId);
return NextResponse.json(anticipations, { return NextResponse.json(anticipations, {

View File

@@ -6,25 +6,7 @@ export default function robots(): MetadataRoute.Robots {
{ {
userAgent: "*", userAgent: "*",
allow: "/", allow: "/",
disallow: [ disallow: "/api/",
"/dashboard",
"/transactions",
"/accounts",
"/cards",
"/categories",
"/budgets",
"/payers",
"/notes",
"/insights",
"/calendar",
"/attachments",
"/settings",
"/reports",
"/inbox",
"/login",
"/signup",
"/api/",
],
}, },
], ],
}; };

View File

@@ -1,7 +1,7 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
const BASE_URL = process.env.PUBLIC_DOMAIN const BASE_URL = process.env.PUBLIC_DOMAIN
? `https://${process.env.PUBLIC_DOMAIN}` ? `https://${process.env.PUBLIC_DOMAIN.replace(/^https?:\/\//, "")}`
: "https://openmonetis.com"; : "https://openmonetis.com";
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {

View File

@@ -649,7 +649,7 @@ export async function createApiTokenAction(
name: validated.name, name: validated.name,
tokenHash, tokenHash,
tokenPrefix, tokenPrefix,
expiresAt: null, // No expiration for now expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 ano
}) })
.returning({ id: apiTokens.id }); .returning({ id: apiTokens.id });

View File

@@ -47,6 +47,8 @@ export function parseXls(buffer: ArrayBuffer): ImportStatement {
const workbook = XLSX.read(new Uint8Array(buffer), { const workbook = XLSX.read(new Uint8Array(buffer), {
type: "array", type: "array",
cellDates: false, cellDates: false,
cellFormula: false,
cellNF: false,
}); });
if (!workbook.SheetNames.length) { if (!workbook.SheetNames.length) {