fix: add Ollama Cloud provider + security hardening (#255 #258)

feat(#255): add Ollama Cloud provider (api.ollama.com/v1)
- New registry entry 'ollama-cloud' (alias: ollamacloud)
- OpenAI-compatible, Bearer API key auth
- 7 pre-populated models: Gemma 3 27B, Llama 3.3 70B, Qwen3 72B,
  Devstral 24B, DeepSeek R2 671B, Phi 4 14B, Mistral Small 3.2 24B
- passthroughModels: true for dynamic model names

fix(#258): security hardening — all 9 findings addressed
- [CRITICAL] db-backups/export: add isAuthRequired + isAuthenticated guard
- [CRITICAL] db-backups/import: add isAuthRequired + isAuthenticated guard
- [HIGH] oauth/cursor/auto-import: add isAuthRequired + isAuthenticated guard
- [HIGH] oauth/kiro/auto-import: add isAuthRequired + isAuthenticated guard
- [LOW x4] oauth/[provider]/[action]: replace === with safeEqual()
  using crypto.timingSafeEqual() for CWE-208 (timing-oracle attacks)
  on email and workspaceId comparisons across all 3 handler branches
- [FALSE POSITIVE] Finding #1 (package.json 'reset-password' binary name)
  is not a hardcoded credential — no change needed

Tests: 590/594 pass (4 pre-existing failures in combo-circuit-breaker)
This commit is contained in:
diegosouzapw
2026-03-09 12:42:17 -03:00
parent 9aaebbcd2a
commit 74a0585683
6 changed files with 84 additions and 11 deletions

View File

@@ -769,6 +769,29 @@ export const REGISTRY: Record<string, RegistryEntry> = {
],
},
"ollama-cloud": {
id: "ollama-cloud",
alias: "ollamacloud",
format: "openai",
executor: "default",
baseUrl: "https://api.ollama.com/v1/chat/completions",
modelsUrl: "https://api.ollama.com/v1/models",
authType: "apikey",
authHeader: "bearer",
// Note: rate limits vary by plan (free = "Light usage", Pro = more, Max = 5x Pro).
// Users can generate API keys at https://ollama.com/settings/api-keys
models: [
{ id: "gemma3:27b", name: "Gemma 3 27B" },
{ id: "llama3.3:70b", name: "Llama 3.3 70B" },
{ id: "qwen3:72b", name: "Qwen3 72B" },
{ id: "devstral:24b", name: "Devstral 24B" },
{ id: "deepseek-r2:671b", name: "DeepSeek R2 671B" },
{ id: "phi4:14b", name: "Phi 4 14B" },
{ id: "mistral-small3.2:24b", name: "Mistral Small 3.2 24B" },
],
passthroughModels: true,
},
cohere: {
id: "cohere",
alias: "cohere",

View File

@@ -3,14 +3,22 @@ import path from "node:path";
import fs from "node:fs";
import os from "node:os";
import { getDbInstance, SQLITE_FILE } from "@/lib/db/core";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
/**
* GET /api/db-backups/export — Download the current database as a .sqlite file.
*
* Uses SQLite's native backup API to create a consistent snapshot,
* then streams it as a downloadable attachment.
*
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-2).
*/
export async function GET() {
export async function GET(request: Request) {
if (await isAuthRequired()) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
try {
if (!SQLITE_FILE || !fs.existsSync(SQLITE_FILE)) {
return NextResponse.json({ error: "Database file not found" }, { status: 404 });

View File

@@ -5,6 +5,7 @@ import fs from "node:fs";
import os from "node:os";
import { getDbInstance, resetDbInstance, SQLITE_FILE } from "@/lib/db/core";
import { backupDbFile } from "@/lib/db/backup";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
const MAX_UPLOAD_SIZE = 100 * 1024 * 1024; // 100 MB
@@ -16,8 +17,15 @@ const REQUIRED_TABLES = ["provider_connections", "provider_nodes", "combos", "ap
*
* Accepts multipart/form-data with a single "file" field containing the .sqlite backup.
* Validates integrity, schema, and required tables before replacing the active database.
*
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-3).
*/
export async function POST(request: Request) {
if (await isAuthRequired()) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
let tmpPath: string | null = null;
try {

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
import {
getProvider,
generateAuthData,
@@ -29,6 +30,18 @@ if (!globalThis.__codexCallbackState) {
globalThis.__codexCallbackState = null;
}
/**
* Constant-time string comparison to prevent timing-oracle attacks (CWE-208).
* Handles null/undefined safely and different-length strings.
*/
function safeEqual(a: string | null | undefined, b: string | null | undefined): boolean {
if (a == null || b == null) return a === b;
const ba = Buffer.from(String(a));
const bb = Buffer.from(String(b));
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
/**
* Dynamic OAuth API Route
* Handles: authorize, exchange, device-code, poll, start-callback-server, poll-callback
@@ -222,11 +235,12 @@ export async function POST(
if (tokenData.email) {
const existing = await getProviderConnections({ provider });
const match = existing.find((c: any) => {
if (c.email !== tokenData.email || c.authType !== "oauth") return false;
// safeEqual: constant-time comparison to prevent timing attacks (CWE-208, finding #258-6/7)
if (!safeEqual(c.email, tokenData.email) || c.authType !== "oauth") return false;
// For Codex, also check workspaceId to avoid overwriting different workspace connections
if (provider === "codex" && tokenData.providerSpecificData?.workspaceId) {
const existingWorkspace = c.providerSpecificData?.workspaceId;
return existingWorkspace === tokenData.providerSpecificData.workspaceId;
return safeEqual(existingWorkspace, tokenData.providerSpecificData.workspaceId);
}
return true;
});
@@ -292,11 +306,12 @@ export async function POST(
if (result.tokens.email) {
const existing = await getProviderConnections({ provider });
const match = existing.find((c: any) => {
if (c.email !== result.tokens.email || c.authType !== "oauth") return false;
// safeEqual: constant-time comparison to prevent timing attacks (CWE-208, finding #258-8/9)
if (!safeEqual(c.email, result.tokens.email) || c.authType !== "oauth") return false;
// For Codex, also check workspaceId to avoid overwriting different workspace connections
if (provider === "codex" && result.tokens.providerSpecificData?.workspaceId) {
const existingWorkspace = c.providerSpecificData?.workspaceId;
return existingWorkspace === result.tokens.providerSpecificData.workspaceId;
return safeEqual(existingWorkspace, result.tokens.providerSpecificData.workspaceId);
}
return true;
});
@@ -412,11 +427,12 @@ export async function POST(
if (tokenData.email) {
const existing = await getProviderConnections({ provider });
const match = existing.find((c: any) => {
if (c.email !== tokenData.email || c.authType !== "oauth") return false;
// safeEqual: constant-time comparison to prevent timing attacks (CWE-208, finding #258-6/7)
if (!safeEqual(c.email, tokenData.email) || c.authType !== "oauth") return false;
// For Codex, also check workspaceId to avoid overwriting different workspace connections
if (provider === "codex" && tokenData.providerSpecificData?.workspaceId) {
const existingWorkspace = c.providerSpecificData?.workspaceId;
return existingWorkspace === tokenData.providerSpecificData.workspaceId;
return safeEqual(existingWorkspace, tokenData.providerSpecificData.workspaceId);
}
return true;
});

View File

@@ -2,12 +2,21 @@ import { NextResponse } from "next/server";
import { homedir } from "os";
import { join } from "path";
import Database from "better-sqlite3";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
/**
* GET /api/oauth/cursor/auto-import
* Auto-detect and extract Cursor tokens from local SQLite database
* Auto-detect and extract Cursor tokens from local SQLite database.
*
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-4).
*/
export async function GET() {
export async function GET(request: Request) {
if (await isAuthRequired()) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
try {
const platform = process.platform;
let dbPath;

View File

@@ -2,12 +2,21 @@ import { NextResponse } from "next/server";
import { readFile, readdir } from "fs/promises";
import { homedir } from "os";
import { join } from "path";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
/**
* GET /api/oauth/kiro/auto-import
* Auto-detect and extract Kiro refresh token from AWS SSO cache
* Auto-detect and extract Kiro refresh token from AWS SSO cache.
*
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-5).
*/
export async function GET() {
export async function GET(request: Request) {
if (await isAuthRequired()) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
try {
const cachePath = join(homedir(), ".aws/sso/cache");