security: fix rate limiting, error message leaks, decrypt crash, key preview

- H1: Add checkRateLimit to OAuth authorize/callback/disconnect + admin AI settings
- H2: Fix decrypt("") crash in provider.ts onTokenRefresh by using decrypt() directly
- H3: Truncate error messages in codex-oauth, codex-client, openai-client to prevent leaking upstream response bodies
- M5: Fix keyPreview to show last 4 chars of decrypted key instead of ciphertext prefix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.6 committed 2026-04-06 00:47:05 +02:00
1 parent 50911ad801
commit d3149170cd
8 files changed
+33 -20

No files matched your search

+9 -5
View File
@@ -3,7 +3,8 @@ import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
import { encrypt } from "@/lib/crypto";
import { encrypt, decrypt } from "@/lib/crypto";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
@@ -22,8 +23,8 @@ export const GET = apiHandler(async () => {
return apiSuccess({
hasKey,
keyPreview: hasKey && settings?.adminAiKeyEncrypted
? `${settings.adminAiKeyEncrypted.slice(0, 7)}...`
keyPreview: settings?.adminAiKeyEncrypted
? `...${decrypt(settings.adminAiKeyEncrypted).slice(-4)}`
: null,
model,
baseUrl,
@@ -34,6 +35,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.ai-settings.update" } });
const rl = await checkRateLimit("admin-ai-settings", 10, 60_000);
if (!rl.allowed) throw new HttpError(429, "Too many requests");
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
@@ -94,8 +98,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
return apiSuccess({
hasKey,
keyPreview: hasKey && settings.adminAiKeyEncrypted
? `${settings.adminAiKeyEncrypted.slice(0, 7)}...`
keyPreview: settings.adminAiKeyEncrypted
? `...${decrypt(settings.adminAiKeyEncrypted).slice(-4)}`
: null,
model: settings.adminAiModel,
baseUrl: settings.adminAiBaseUrl,
+6 -2
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import {
generatePKCE,
generateState,
@@ -9,7 +10,10 @@ import { cookies } from "next/headers";
import { annotate } from "@/lib/logging/context";
export const GET = apiHandler(async (_request: NextRequest) => {
await requireAuth();
const { user } = await requireAuth();
const rl = await checkRateLimit(`codex-authorize:${user.id}`, 5, 60_000);
if (!rl.allowed) throw new HttpError(429, "Too many requests");
const { verifier, challenge } = generatePKCE();
const state = generateState();
+8
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { exchangeCodeForTokens, encryptTokens } from "@/lib/ai/codex-oauth";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
@@ -9,6 +10,13 @@ import { annotate } from "@/lib/logging/context";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(`codex-callback:${user.id}`, 10, 60_000);
if (!rl.allowed) {
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
return NextResponse.redirect(`${appUrl}/settings?codex_error=rate_limited`);
}
const url = new URL(request.url);
const code = url.searchParams.get("code");
+5 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, getClientIp } from "@/lib/api-response";
@@ -8,6 +9,9 @@ import { annotate } from "@/lib/logging/context";
export const DELETE = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(`codex-disconnect:${user.id}`, 5, 60_000);
if (!rl.allowed) throw new HttpError(429, "Too many requests");
await prisma.user.update({
where: { id: user.id },
data: {
+1 -2
View File
@@ -31,8 +31,7 @@ export class CodexClient implements AIProvider {
}
if (!firstAttempt.ok) {
const body = await firstAttempt.text();
throw new Error(`Codex request failed (${firstAttempt.status}): ${body}`);
throw new Error(`Codex request failed (${firstAttempt.status})`);
}
return this.parseResponse(firstAttempt);
+2 -4
View File
@@ -53,8 +53,7 @@ export async function exchangeCodeForTokens(params: {
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Codex token exchange failed (${res.status}): ${body}`);
throw new Error(`Codex token exchange failed (${res.status})`);
}
return res.json();
@@ -71,8 +70,7 @@ export async function refreshAccessToken(refreshToken: string): Promise<TokenRes
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Codex token refresh failed (${res.status}): ${body}`);
throw new Error(`Codex token refresh failed (${res.status})`);
}
return res.json();
+1 -2
View File
@@ -36,8 +36,7 @@ export class OpenAIClient implements AIProvider {
});
if (!res.ok) {
const body = await res.text();
throw new Error(`OpenAI request failed (${res.status}): ${body}`);
throw new Error(`OpenAI request failed (${res.status})`);
}
const json = await res.json();
+1 -4
View File
@@ -96,10 +96,7 @@ export async function resolveProvider(userId: string): Promise<AIProvider> {
throw new Error("No refresh token available");
}
const { refreshToken: currentRefreshToken } = decryptTokens({
accessEncrypted: "",
refreshEncrypted: freshUser.codexRefreshTokenEncrypted,
});
const currentRefreshToken = decrypt(freshUser.codexRefreshTokenEncrypted);
const newTokens = await refreshAccessToken(currentRefreshToken);
const encrypted = encryptTokens({