mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
50911ad801
commit
d3149170cd
8 files changed
+33
-20
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user