Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 0 additions & 54 deletions .agent/workflows/git-workflow.md

This file was deleted.

18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [1.0.8] — 2026-02-21

> ### 🔒 API Security & Windows Support
>
> Adds API Endpoint Protection for `/models`, Windows server startup fixes, and UI improvements.

### ✨ New Features

- **API Endpoint Protection (`/models`)** — New Security Tab settings to optionally require an API key for the `/v1/models` endpoint (returns 404 when unauthorized) and to selectively block specific providers from appearing in the models list ([#100](https://github.com/diegosouzapw/OmniRoute/issues/100), [#96](https://github.com/diegosouzapw/OmniRoute/issues/96))
- **Interactive Provider UI** — Blocked Providers setting features an interactive chip selector with visual badges for all available AI providers

### 🐛 Bug Fixes

- **Windows Server Startup** — Fixed `ERR_INVALID_FILE_URL_PATH` crash on Windows by safely wrapping `import.meta.url` resolution with a fallback to `process.cwd()` for globally installed npm packages ([#98](https://github.com/diegosouzapw/OmniRoute/issues/98))
- **Combo buttons visibility** — Fixed layout overlap and tight spacing for the Quick Action buttons (Clone / Delete / Test) on the Combos page on narrower screens ([#95](https://github.com/diegosouzapw/OmniRoute/issues/95))

---

## [1.0.7] — 2026-02-20

> ### 🐛 Bugfix Release — OpenAI Compatibility, Custom Models & OAuth UX
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
| 🔒 **TLS Fingerprint Spoofing** | Bypass TLS-based bot detection via wreq-js |
| 🌐 **IP Filtering** | Allowlist/blocklist for API access control |
| 📊 **Editable Rate Limits** | Configurable RPM, min gap, and max concurrent at system level |
| 🛡 **API Endpoint Protection** | Auth gating + provider blocking for the `/models` endpoint |

### 📊 Observability & Analytics

Expand Down
1 change: 1 addition & 0 deletions README.pt-BR.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ Acesso via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
| 🔒 **Spoofing de Fingerprint TLS** | Bypass de detecção de bot via TLS com wreq-js |
| 🌐 **Filtragem de IP** | Allowlist/blocklist para controle de acesso à API |
| 📊 **Rate Limits Editáveis** | RPM, gap mínimo e concorrência máxima configuráveis |
| 🛡 **Proteção de Endpoint API** | Gateway de Auth + bloqueio de provedores para o endpoint `/models` |

### 📊 Observabilidade e Analytics

Expand Down
2 changes: 1 addition & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Four modes for debugging API translations: **Playground** (format converter), **

## ⚙️ Settings

General settings, system storage, backup management (export/import database), appearance (dark/light mode), security, routing, resilience, and advanced configuration.
General settings, system storage, backup management (export/import database), appearance (dark/light mode), security (includes API endpoint protection and custom provider blocking), routing, resilience, and advanced configuration.

![Settings Dashboard](screenshots/06-settings.png)

Expand Down
2 changes: 1 addition & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,7 @@ The settings page is organized into 5 tabs for easy navigation:

| Tab | Contents |
| -------------- | ---------------------------------------------------------------------------------------------- |
| **Security** | Login/Password settings and IP Access Control (allowlist/blocklist) |
| **Security** | Login/Password settings, IP Access Control, API auth for `/models`, and Provider Blocking |
| **Routing** | Global routing strategy (6 options), wildcard model aliases, fallback chains, combo defaults |
| **Resilience** | Provider profiles, editable rate limits, circuit breaker status, policies & locked identifiers |
| **AI** | Thinking budget configuration, global system prompt injection, prompt cache stats |
Expand Down
4 changes: 2 additions & 2 deletions src/app/(dashboard)/dashboard/combos/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -409,14 +409,14 @@ function ComboCard({
</div>

{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<div className="flex items-center gap-1.5 shrink-0 ml-2">
<Toggle
size="sm"
checked={!isDisabled}
onChange={onToggle}
title={isDisabled ? "Enable combo" : "Disable combo"}
/>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={onTest}
disabled={testing}
Expand Down
114 changes: 113 additions & 1 deletion src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

import { useState, useEffect } from "react";
import { Card, Button, Input, Toggle } from "@/shared/components";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import IPFilterSection from "./IPFilterSection";
import SessionInfoCard from "./SessionInfoCard";

export default function SecurityTab() {
const [settings, setSettings] = useState({ requireLogin: false, hasPassword: false });
const [settings, setSettings] = useState<any>({ requireLogin: false, hasPassword: false });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using any for the state type bypasses TypeScript's type checking, reducing code safety and maintainability. It's better to define a specific interface for the settings object to ensure type safety, especially since new properties like requireAuthForModels and blockedProviders are being used in this component.

You can define an interface at the top of the file or in a shared types file:

interface SecuritySettings {
  requireLogin: boolean;
  hasPassword: boolean;
  requireAuthForModels?: boolean;
  blockedProviders?: string[];
}

Then, use this interface with Partial for the state, as the full object is fetched asynchronously.

Suggested change
const [settings, setSettings] = useState<any>({ requireLogin: false, hasPassword: false });
const [settings, setSettings] = useState<Partial<SecuritySettings>>({ requireLogin: false, hasPassword: false });

const [loading, setLoading] = useState(true);
const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" });
const [passStatus, setPassStatus] = useState({ type: "", message: "" });
Expand Down Expand Up @@ -37,6 +38,29 @@ export default function SecurityTab() {
}
};

const updateSetting = async (key: string, value: any) => {
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: value }),
});
if (res.ok) {
setSettings((prev) => ({ ...prev, [key]: value }));
}
} catch (err) {
console.error(`Failed to update ${key}:`, err);
}
};

const toggleBlockedProvider = (providerId: string) => {
const current: string[] = settings.blockedProviders || [];
const updated = current.includes(providerId)
? current.filter((p) => p !== providerId)
: [...current, providerId];
updateSetting("blockedProviders", updated);
};
Comment on lines +41 to +62

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateSetting()/toggleBlockedProvider() PATCH new keys (requireAuthForModels, blockedProviders) to /api/settings, but the server-side Zod updateSettingsSchema currently only allows a fixed set of fields and does not include these. As a result, these PATCH requests will be rejected (400) and the UI will silently fail to persist changes. Add the new fields to the settings update schema (and ideally surface an error message in the UI when PATCH fails).

Copilot uses AI. Check for mistakes.

const handlePasswordChange = async (e) => {
e.preventDefault();
if (passwords.new !== passwords.confirm) {
Expand Down Expand Up @@ -70,6 +94,8 @@ export default function SecurityTab() {
}
};

const blockedProviders: string[] = settings.blockedProviders || [];

return (
<div className="flex flex-col gap-6">
<Card>
Expand Down Expand Up @@ -146,6 +172,92 @@ export default function SecurityTab() {
)}
</div>
</Card>

{/* API Endpoint Protection */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
api
</span>
</div>
<h3 className="text-lg font-semibold">API Endpoint Protection</h3>
</div>
<div className="flex flex-col gap-4">
{/* Require auth for /models */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Require API key for /models</p>
<p className="text-sm text-text-muted">
When ON, the{" "}
<code className="text-xs bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded">
/v1/models
</code>{" "}
endpoint returns 404 for unauthenticated requests. Prevents model discovery by
unauthorized users.
</p>
</div>
<Toggle
checked={settings.requireAuthForModels === true}
onChange={() => updateSetting("requireAuthForModels", !settings.requireAuthForModels)}
disabled={loading}
/>
</div>

{/* Blocked Providers */}
<div className="pt-4 border-t border-border/50">
<div className="mb-3">
<p className="font-medium">Blocked Providers</p>
<p className="text-sm text-text-muted">
Hide specific providers from the{" "}
<code className="text-xs bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded">
/v1/models
</code>{" "}
response. Blocked providers will not appear in model listings.
</p>
</div>
<div className="flex flex-wrap gap-2">
{Object.values(AI_PROVIDERS).map((provider: any) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using any for the provider type in the map function bypasses TypeScript's type checking. Since AI_PROVIDERS is a well-defined constant, we can create a specific type for the provider object to improve type safety and developer experience (e.g., autocompletion).

Based on its usage and definition in src/shared/constants/providers.ts, you could define an interface like this:

interface AiProvider {
  id: string;
  name: string;
  icon: string;
  color?: string;
}

And use it in the map function.

Suggested change
{Object.values(AI_PROVIDERS).map((provider: any) => {
{Object.values(AI_PROVIDERS).map((provider: AiProvider) => {

const isBlocked = blockedProviders.includes(provider.id);
return (
<button
key={provider.id}
onClick={() => toggleBlockedProvider(provider.id)}
disabled={loading}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all border ${
isBlocked
? "bg-red-500/10 border-red-500/30 text-red-600 dark:text-red-400"
: "bg-black/[0.02] dark:bg-white/[0.02] border-transparent text-text-muted hover:bg-black/[0.05] dark:hover:bg-white/[0.05]"
}`}
title={isBlocked ? `Unblock ${provider.name}` : `Block ${provider.name}`}
>
<span
className="material-symbols-outlined text-[14px]"
style={{ color: isBlocked ? undefined : provider.color }}
>
{isBlocked ? "block" : provider.icon}
</span>
{provider.name}
{isBlocked && (
<span className="material-symbols-outlined text-[12px] text-red-500">
close
</span>
)}
</button>
);
})}
</div>
{blockedProviders.length > 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">warning</span>
{blockedProviders.length} provider{blockedProviders.length !== 1 ? "s" : ""} blocked
from /models
</p>
)}
</div>
</div>
</Card>

<SessionInfoCard />
<IPFilterSection />
</div>
Expand Down
26 changes: 24 additions & 2 deletions src/app/api/v1/models/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { getProviderConnections, getCombos, getAllCustomModels } from "@/lib/localDb";
import { getProviderConnections, getCombos, getAllCustomModels, getSettings } from "@/lib/localDb";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.ts";
import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts";
import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry.ts";
Expand Down Expand Up @@ -94,10 +95,28 @@ export async function OPTIONS() {
* GET /v1/models - OpenAI compatible models list
* Returns models from all active providers, combos, embeddings, and image models in OpenAI format
*/
export async function GET() {
export async function GET(request: Request) {
try {
// Issue #100: Optionally require API key for /models (security hardening)
// When enabled, unauthenticated requests get 404 to hide endpoint existence
let settings: Record<string, any> = {};
try {
settings = await getSettings();
} catch {}
if (settings.requireAuthForModels === true) {
const apiKey = extractApiKey(request);
if (!apiKey || !(await isValidApiKey(apiKey))) {
return new Response("Not Found", { status: 404 });
}
}
Comment on lines +103 to +111

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The authentication check for the /v1/models endpoint fails open if getSettings() fails (e.g., due to a database issue). This allows unauthenticated access to the model listing because the settings object remains empty, causing settings.requireAuthForModels === true to evaluate to false. Security-critical configurations should fail closed or use a safe default when they cannot be loaded, as demonstrated in the suggestion. Additionally, consider logging the error for debugging purposes.

Suggested change
try {
settings = await getSettings();
} catch {}
if (settings.requireAuthForModels === true) {
const apiKey = extractApiKey(request);
if (!apiKey || !(await isValidApiKey(apiKey))) {
return new Response("Not Found", { status: 404 });
}
}
try {
settings = await getSettings();
} catch {
// Default to secure behavior if settings cannot be loaded to prevent bypass
settings = { requireAuthForModels: true };
}
if (settings.requireAuthForModels === true) {
const apiKey = extractApiKey(request);
if (!apiKey || !(await isValidApiKey(apiKey))) {
return new Response("Not Found", { status: 404 });
}
}


const { aliasToProviderId, providerIdToAlias } = buildAliasMaps();

// Issue #96: Allow blocking specific providers from the models list
const blockedProviders: Set<string> = new Set(
Array.isArray(settings.blockedProviders) ? settings.blockedProviders : []
);
Comment on lines +100 to +118

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint reads settings.requireAuthForModels and settings.blockedProviders, but /api/settings PATCH validation currently rejects unknown keys (Zod updateSettingsSchema is an explicit allowlist). Without updating that schema to include these new settings, the dashboard cannot actually enable auth-gating or provider blocking, so this code path will never be exercised via the UI.

Copilot uses AI. Check for mistakes.

// Get active provider connections
let connections = [];
let totalConnectionCount = 0; // Track if DB has ANY connections (even disabled)
Expand Down Expand Up @@ -150,6 +169,9 @@ export async function GET() {
const providerId = aliasToProviderId[alias] || alias;
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId;

// Skip blocked providers (Issue #96)
if (blockedProviders.has(alias) || blockedProviders.has(canonicalProviderId)) continue;

// Only include models from providers with active connections
if (!activeAliases.has(alias) && !activeAliases.has(canonicalProviderId)) {
continue;
Expand Down
23 changes: 20 additions & 3 deletions src/lib/db/migrationRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,26 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import type Database from "better-sqlite3";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const MIGRATIONS_DIR = path.join(__dirname, "migrations");
/**
* Resolve the migrations directory path safely across platforms.
* On Windows with global npm installs, `import.meta.url` may not be a valid
* `file://` URL, causing `fileURLToPath` to throw `ERR_INVALID_FILE_URL_PATH`.
*/
function resolveMigrationsDir(): string {
try {
const metaUrl = import.meta.url;
if (metaUrl && metaUrl.startsWith("file://")) {
const __filename = fileURLToPath(metaUrl);
return path.join(path.dirname(__filename), "migrations");
}
} catch {
// fileURLToPath failed (e.g. Windows global install) — use fallback
}
// Fallback: resolve relative to cwd (works for both dev and global installs)
return path.join(process.cwd(), "src", "lib", "db", "migrations");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Using process.cwd() to resolve the migrations directory is insecure. The current working directory is an external input that can be controlled by the user or environment starting the application. If the application is run from a directory where an attacker has write access, they could place a malicious src/lib/db/migrations directory containing arbitrary SQL files. These files would then be executed by the migration runner with database privileges, potentially leading to Remote Code Execution (RCE) or full database compromise. Application-internal paths should be resolved relative to the application's installation directory or a trusted, absolute configuration path.

}

const MIGRATIONS_DIR = resolveMigrationsDir();

/**
* Ensure the schema_migrations tracking table exists.
Expand Down
Loading