mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix: harden security scan findings
This commit is contained in:
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -31,6 +31,7 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run audit:deps
|
||||
- run: npm run lint
|
||||
- run: npm run check:cycles
|
||||
- run: npm run check:route-validation:t06
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -99,8 +99,7 @@ app.__qa_backup/
|
||||
# npm publish still includes it via package.json "files" field
|
||||
/app/
|
||||
|
||||
# Electron (subproject dependency lock and build artifacts)
|
||||
electron/package-lock.json
|
||||
# Electron
|
||||
electron/dist-electron/
|
||||
electron/node_modules/
|
||||
icon.iconset/
|
||||
|
||||
@@ -10,7 +10,7 @@ with **MCP Server** (29 tools), **A2A v0.3 Protocol**, and **Electron desktop ap
|
||||
|
||||
## Stack
|
||||
|
||||
- **Runtime**: Next.js 16 (App Router), Node.js ≥18 <24, ES Modules (`"type": "module"`)
|
||||
- **Runtime**: Next.js 16 (App Router), Node.js `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <25`, ES Modules (`"type": "module"`)
|
||||
- **Language**: TypeScript 5.9 (`src/`) + JavaScript (`open-sse/`, `electron/`)
|
||||
- **Database**: better-sqlite3 (SQLite) — `DATA_DIR` configurable, default `~/.omniroute/`
|
||||
- **Streaming**: SSE via `open-sse` internal workspace package
|
||||
|
||||
@@ -8,7 +8,7 @@ Thank you for your interest in contributing! This guide covers everything you ne
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** >= 18 < 24 (recommended: 22 LTS)
|
||||
- **Node.js** `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <25` (recommended: 24 LTS)
|
||||
- **npm** 10+
|
||||
- **Git**
|
||||
|
||||
|
||||
20
README.md
20
README.md
@@ -1263,15 +1263,15 @@ See the [Proxy Guide](docs/PROXY_GUIDE.md) for setup instructions.
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
| Problem | Quick Fix |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| **"Language model did not provide messages"** | Provider quota exhausted → check quota tracker, use combo fallback |
|
||||
| **Rate limiting (429)** | Add fallback combo: `cc/claude → glm/glm-4.7 → if/kimi-k2-thinking` |
|
||||
| **OAuth token expired** | Auto-refreshed by OmniRoute. If stuck: delete + re-auth in Providers |
|
||||
| **`unsupported_country_region_territory`** | Configure proxy in Settings → Proxy (see [Proxy Guide](docs/PROXY_GUIDE.md)) |
|
||||
| **Docker SQLite locks** | Use `--stop-timeout 40` for clean WAL checkpoint on shutdown |
|
||||
| **Node.js 24+ errors** | Downgrade to Node.js 18-22 LTS — `better-sqlite3` is incompatible with Node 24+ |
|
||||
| **`system-info` for bug reports** | Run `npm run system-info` and attach `system-info.txt` to your issue |
|
||||
| Problem | Quick Fix |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| **"Language model did not provide messages"** | Provider quota exhausted → check quota tracker, use combo fallback |
|
||||
| **Rate limiting (429)** | Add fallback combo: `cc/claude → glm/glm-4.7 → if/kimi-k2-thinking` |
|
||||
| **OAuth token expired** | Auto-refreshed by OmniRoute. If stuck: delete + re-auth in Providers |
|
||||
| **`unsupported_country_region_territory`** | Configure proxy in Settings → Proxy (see [Proxy Guide](docs/PROXY_GUIDE.md)) |
|
||||
| **Docker SQLite locks** | Use `--stop-timeout 40` for clean WAL checkpoint on shutdown |
|
||||
| **Node.js runtime errors** | Use Node.js `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <25` (24 LTS recommended) |
|
||||
| **`system-info` for bug reports** | Run `npm run system-info` and attach `system-info.txt` to your issue |
|
||||
|
||||
📖 **Full troubleshooting guide:** [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md)
|
||||
|
||||
@@ -1280,7 +1280,7 @@ See the [Proxy Guide](docs/PROXY_GUIDE.md) for setup instructions.
|
||||
<details>
|
||||
<summary><b>Click to expand tech stack details</b></summary>
|
||||
|
||||
- **Runtime**: Node.js 18–22 LTS (⚠️ Node.js 24+ is **not supported** — `better-sqlite3` native binaries are incompatible)
|
||||
- **Runtime**: Node.js 20.20.2+, 22.22.2+, or 24.x LTS (24 LTS recommended)
|
||||
- **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (zero `any` in core modules since v2.0)
|
||||
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
|
||||
- **Database**: better-sqlite3 (SQLite) + LowDB (JSON legacy) — domain state, proxy logs, MCP audit, routing decisions, memory, skills
|
||||
|
||||
@@ -21,7 +21,7 @@ Use this checklist before tagging or publishing a new OmniRoute release.
|
||||
1. Review `docs/ARCHITECTURE.md` for storage/runtime drift.
|
||||
2. Review `docs/TROUBLESHOOTING.md` for env var and operational drift.
|
||||
3. Verify the release/runtime Node.js version still satisfies the supported secure floor:
|
||||
- `>=20.20.2 <21` or `>=22.22.2 <23`
|
||||
- `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <25`
|
||||
- `npm run check:node-runtime`
|
||||
4. Validate the npm publish artifact after building the standalone package:
|
||||
- `npm run build:cli`
|
||||
|
||||
@@ -281,21 +281,25 @@ function installUpdate() {
|
||||
function setupContentSecurityPolicy() {
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
const scriptSrc = isDev
|
||||
? "script-src 'self' 'unsafe-inline' 'unsafe-eval'"
|
||||
: "script-src 'self' 'unsafe-inline'";
|
||||
? "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:"
|
||||
: "script-src 'self' 'unsafe-inline' blob:";
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
`connect-src 'self' http://localhost:* ws://localhost:* https://*.omniroute.online https://*.omniroute.dev`,
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"frame-src 'none'",
|
||||
"child-src 'none'",
|
||||
"form-action 'self'",
|
||||
scriptSrc,
|
||||
"script-src-attr 'none'",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"media-src 'self'",
|
||||
"media-src 'self' data: blob:",
|
||||
`connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https://*.omniroute.online https://*.omniroute.dev`,
|
||||
"worker-src 'self' blob:",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"manifest-src 'self'",
|
||||
].join("; ");
|
||||
|
||||
callback({
|
||||
@@ -327,6 +331,7 @@ function createWindow() {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: true,
|
||||
webviewTag: false,
|
||||
},
|
||||
show: false,
|
||||
backgroundColor: "#0a0a0a",
|
||||
|
||||
4402
electron/package-lock.json
generated
Normal file
4402
electron/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,51 @@ import { fileURLToPath } from "node:url";
|
||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
const distDir = process.env.NEXT_DIST_DIR || ".next";
|
||||
const projectRoot = dirname(fileURLToPath(import.meta.url));
|
||||
const scriptSrc =
|
||||
process.env.NODE_ENV === "development"
|
||||
? "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:"
|
||||
: "script-src 'self' 'unsafe-inline' blob:";
|
||||
const contentSecurityPolicy = [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"form-action 'self'",
|
||||
scriptSrc,
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"media-src 'self' data: blob:",
|
||||
"connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https: wss:",
|
||||
"worker-src 'self' blob:",
|
||||
"manifest-src 'self'",
|
||||
].join("; ");
|
||||
const securityHeaders = [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: contentSecurityPolicy,
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "DENY",
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "strict-origin-when-cross-origin",
|
||||
},
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=()",
|
||||
},
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=63072000; includeSubDomains; preload",
|
||||
},
|
||||
];
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
@@ -183,6 +228,15 @@ const nextConfig = {
|
||||
return config;
|
||||
},
|
||||
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -125,7 +125,7 @@ export function translateNonStreamingResponse(
|
||||
`call_${Date.now()}_${toolCalls.length}`;
|
||||
let argsToEmit = itemObj.arguments;
|
||||
if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) {
|
||||
const cleaned = { ...argsToEmit };
|
||||
const cleaned: JsonRecord = { ...(argsToEmit as JsonRecord) };
|
||||
for (const [k, v] of Object.entries(cleaned)) {
|
||||
if (v === "" || (Array.isArray(v) && v.length === 0)) delete cleaned[k];
|
||||
}
|
||||
|
||||
@@ -17,6 +17,51 @@ interface LiteCompressionOptions {
|
||||
supportsVision?: boolean | null;
|
||||
}
|
||||
|
||||
function trimTrailingHorizontalWhitespace(line: string): string {
|
||||
let end = line.length;
|
||||
while (end > 0) {
|
||||
const code = line.charCodeAt(end - 1);
|
||||
if (code !== 32 && code !== 9) break;
|
||||
end--;
|
||||
}
|
||||
return end === line.length ? line : line.slice(0, end);
|
||||
}
|
||||
|
||||
function collapseNewlineRuns(content: string): string {
|
||||
let normalized = "";
|
||||
let newlineRun = 0;
|
||||
|
||||
for (const char of content) {
|
||||
if (char === "\n") {
|
||||
newlineRun++;
|
||||
if (newlineRun <= 2) {
|
||||
normalized += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
newlineRun = 0;
|
||||
normalized += char;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeMessageWhitespace(content: string): string {
|
||||
return collapseNewlineRuns(content).split("\n").map(trimTrailingHorizontalWhitespace).join("\n");
|
||||
}
|
||||
|
||||
function modelSupportsVision(model: string): boolean {
|
||||
const normalized = model.toLowerCase();
|
||||
return (
|
||||
normalized.includes("vision") ||
|
||||
normalized.includes("gpt-4") ||
|
||||
normalized.includes("4o") ||
|
||||
normalized.includes("claude-3") ||
|
||||
normalized.includes("gemini")
|
||||
);
|
||||
}
|
||||
|
||||
export function collapseWhitespace(body: ChatBody): {
|
||||
body: ChatBody;
|
||||
applied: boolean;
|
||||
@@ -25,7 +70,7 @@ export function collapseWhitespace(body: ChatBody): {
|
||||
let applied = false;
|
||||
const messages = body.messages.map((msg) => {
|
||||
if (typeof msg.content !== "string") return msg;
|
||||
const normalized = msg.content.replace(/\n{3,}/g, "\n\n").replace(/[ \t]+$/gm, "");
|
||||
const normalized = normalizeMessageWhitespace(msg.content);
|
||||
if (normalized !== msg.content) applied = true;
|
||||
return { ...msg, content: normalized };
|
||||
});
|
||||
@@ -101,7 +146,11 @@ export function replaceImageUrls(
|
||||
): { body: ChatBody; applied: boolean } {
|
||||
if (!body.messages) return { body, applied: false };
|
||||
const supportsVision =
|
||||
typeof options === "object" && options !== null ? options.supportsVision : undefined;
|
||||
typeof options === "object" && options !== null
|
||||
? options.supportsVision
|
||||
: typeof options === "string"
|
||||
? modelSupportsVision(options)
|
||||
: undefined;
|
||||
if (supportsVision !== false) return { body, applied: false };
|
||||
|
||||
let applied = false;
|
||||
|
||||
@@ -15,8 +15,8 @@ export function extractPreservedBlocks(text: string): { text: string; blocks: Pr
|
||||
return placeholder;
|
||||
};
|
||||
|
||||
// Extract fenced code blocks (```lang\n...\n```)
|
||||
result = result.replace(/```[a-z]*\n[\s\S]*?\n```/g, (match) => addBlock(match));
|
||||
// Extract fenced code blocks with a linear scan to avoid ReDoS-prone patterns.
|
||||
result = extractFencedCodeBlocks(result, addBlock);
|
||||
|
||||
// Extract inline code (`...`)
|
||||
result = result.replace(/`[^`\n]+`/g, (match) => addBlock(match));
|
||||
@@ -40,6 +40,38 @@ export function extractPreservedBlocks(text: string): { text: string; blocks: Pr
|
||||
return { text: result, blocks };
|
||||
}
|
||||
|
||||
function extractFencedCodeBlocks(text: string, addBlock: (content: string) => string): string {
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < text.length) {
|
||||
const start = text.indexOf("```", cursor);
|
||||
if (start === -1) {
|
||||
output += text.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
const openingLineEnd = text.indexOf("\n", start + 3);
|
||||
if (openingLineEnd === -1) {
|
||||
output += text.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
const closeStart = text.indexOf("\n```", openingLineEnd + 1);
|
||||
if (closeStart === -1) {
|
||||
output += text.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
const closeEnd = closeStart + 4;
|
||||
output += text.slice(cursor, start);
|
||||
output += addBlock(text.slice(start, closeEnd));
|
||||
cursor = closeEnd;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export function restorePreservedBlocks(text: string, blocks: PreservedBlock[]): string {
|
||||
let result = text;
|
||||
for (const block of blocks) {
|
||||
|
||||
@@ -3,9 +3,86 @@ import type { Summarizer, SummarizerOpts } from "./types.ts";
|
||||
const COMPRESSED_MARKER_RE = /^\[COMPRESSED:/;
|
||||
const INTENT_TRIGGERS =
|
||||
/^(?:request|fix|implement|add|remove|update|refactor|create|delete|change|build)\s*:/i;
|
||||
const FILE_PATH_RE =
|
||||
/[\w./-]+\.(?:ts|tsx|js|jsx|py|md|json|sql|css|html|yaml|yml|sh|rb|go|rs|java|c|cpp|h|hpp)/g;
|
||||
const ERROR_RE = /(?:Error|error|ERROR):\s*\S+|error\s+TS\d+|Exception:\s*\S+/g;
|
||||
const FILE_EXTENSIONS = new Set([
|
||||
"ts",
|
||||
"tsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"py",
|
||||
"md",
|
||||
"json",
|
||||
"sql",
|
||||
"css",
|
||||
"html",
|
||||
"yaml",
|
||||
"yml",
|
||||
"sh",
|
||||
"rb",
|
||||
"go",
|
||||
"rs",
|
||||
"java",
|
||||
"c",
|
||||
"cpp",
|
||||
"h",
|
||||
"hpp",
|
||||
]);
|
||||
|
||||
function stripTokenPunctuation(token: string): string {
|
||||
let start = 0;
|
||||
let end = token.length;
|
||||
const leading = new Set(["'", '"', "`", "(", "[", "{"]);
|
||||
const trailing = new Set(["'", '"', "`", ")", "]", "}", ",", ";", ":"]);
|
||||
|
||||
while (start < end && leading.has(token[start])) start++;
|
||||
while (end > start && trailing.has(token[end - 1])) end--;
|
||||
|
||||
return token.slice(start, end);
|
||||
}
|
||||
|
||||
function getKnownFilePathToken(token: string): string | null {
|
||||
const clean = stripTokenPunctuation(token);
|
||||
const lastDot = clean.lastIndexOf(".");
|
||||
if (lastDot <= 0 || lastDot === clean.length - 1) return null;
|
||||
|
||||
const extension = clean.slice(lastDot + 1).toLowerCase();
|
||||
if (!FILE_EXTENSIONS.has(extension)) return null;
|
||||
if (!clean.includes("/") && !clean.includes(".")) return null;
|
||||
|
||||
return clean;
|
||||
}
|
||||
|
||||
function extractFilePathTokens(text: string): string[] {
|
||||
const paths: string[] = [];
|
||||
for (const token of text.split(/\s+/)) {
|
||||
const filePath = getKnownFilePathToken(token);
|
||||
if (filePath) paths.push(filePath);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function extractErrorSnippets(text: string): string[] {
|
||||
const snippets: string[] = [];
|
||||
for (const segment of text.split(/[.\n]/)) {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (
|
||||
trimmed.includes("TypeError:") ||
|
||||
trimmed.includes("ReferenceError:") ||
|
||||
trimmed.includes("SyntaxError:") ||
|
||||
trimmed.includes("RangeError:") ||
|
||||
trimmed.includes("URIError:") ||
|
||||
trimmed.includes("EvalError:") ||
|
||||
trimmed.includes("Error:") ||
|
||||
trimmed.includes("Exception:") ||
|
||||
lower.includes("error ts")
|
||||
) {
|
||||
snippets.push(trimmed);
|
||||
}
|
||||
}
|
||||
return snippets;
|
||||
}
|
||||
|
||||
function extractIntents(messages: Array<{ role: string; content?: string | unknown[] }>): string[] {
|
||||
const intents: string[] = [];
|
||||
@@ -30,9 +107,9 @@ function extractFilePaths(
|
||||
for (const msg of messages) {
|
||||
const text = extractText(msg.content);
|
||||
if (!text) continue;
|
||||
const matches = text.match(FILE_PATH_RE);
|
||||
if (matches) {
|
||||
for (const m of matches) paths.add(m);
|
||||
const matches = extractFilePathTokens(text);
|
||||
for (const m of matches) {
|
||||
paths.add(m);
|
||||
}
|
||||
}
|
||||
return [...paths].slice(0, 20);
|
||||
@@ -43,10 +120,7 @@ function extractErrors(messages: Array<{ role: string; content?: string | unknow
|
||||
for (const msg of messages) {
|
||||
const text = extractText(msg.content);
|
||||
if (!text) continue;
|
||||
const matches = text.match(ERROR_RE);
|
||||
if (matches) {
|
||||
for (const m of matches) errors.push(m.slice(0, 150));
|
||||
}
|
||||
for (const match of extractErrorSnippets(text)) errors.push(match.slice(0, 150));
|
||||
}
|
||||
return errors.slice(0, 10);
|
||||
}
|
||||
@@ -64,14 +138,46 @@ function extractLastDecision(
|
||||
}
|
||||
|
||||
function trimCodeFences(text: string): string {
|
||||
const fenceRe = /```[a-z]*\n([\s\S]*?)\n```/g;
|
||||
return text.replace(fenceRe, (_match, code: string) => {
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < text.length) {
|
||||
const start = text.indexOf("```", cursor);
|
||||
if (start === -1) {
|
||||
output += text.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
const openingLineEnd = text.indexOf("\n", start + 3);
|
||||
if (openingLineEnd === -1) {
|
||||
output += text.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
const closeStart = text.indexOf("\n```", openingLineEnd + 1);
|
||||
if (closeStart === -1) {
|
||||
output += text.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
const closeEnd = closeStart + 4;
|
||||
const opening = text.slice(start, openingLineEnd);
|
||||
const code = text.slice(openingLineEnd + 1, closeStart);
|
||||
const lines = code.split("\n");
|
||||
if (lines.length <= 4) return _match;
|
||||
const head = lines.slice(0, 3).join("\n");
|
||||
const tail = lines[lines.length - 1];
|
||||
return "```" + head + "\n…\n" + tail + "\n```";
|
||||
});
|
||||
|
||||
output += text.slice(cursor, start);
|
||||
if (lines.length <= 4) {
|
||||
output += text.slice(start, closeEnd);
|
||||
} else {
|
||||
const head = lines.slice(0, 3).join("\n");
|
||||
const tail = lines[lines.length - 1];
|
||||
output += `${opening}\n${head}\n…\n${tail}\n\`\`\``;
|
||||
}
|
||||
|
||||
cursor = closeEnd;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function extractText(content?: string | unknown[]): string {
|
||||
|
||||
@@ -6,18 +6,64 @@ export interface CompressionResult {
|
||||
saved: number;
|
||||
}
|
||||
|
||||
const CODE_INDICATORS =
|
||||
/(?:^|\n)\s*(?:import\s|export\s|function\s|class\s|const\s|let\s|var\s|return\s|if\s*\(|for\s*\(|while\s*\()/;
|
||||
const GREP_LINE_RE = /^[\w./-]+:\d+:/m;
|
||||
const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g;
|
||||
const SHELL_PROMPT_RE = /\$\s/;
|
||||
const JSON_PREFIX_RE = /^\s*[{[]/;
|
||||
const ERROR_RE = /(?:Error|Exception|Traceback)[:\s]/i;
|
||||
|
||||
function isCodeLikeLine(rawLine: string): boolean {
|
||||
const line = rawLine.trimStart();
|
||||
return (
|
||||
line.startsWith("import ") ||
|
||||
line.startsWith("export ") ||
|
||||
line.startsWith("function ") ||
|
||||
line.startsWith("class ") ||
|
||||
line.startsWith("const ") ||
|
||||
line.startsWith("let ") ||
|
||||
line.startsWith("var ") ||
|
||||
line.startsWith("return ") ||
|
||||
line.startsWith("if(") ||
|
||||
line.startsWith("if (") ||
|
||||
line.startsWith("for(") ||
|
||||
line.startsWith("for (") ||
|
||||
line.startsWith("while(") ||
|
||||
line.startsWith("while (")
|
||||
);
|
||||
}
|
||||
|
||||
function parseGrepLinePath(line: string): string | null {
|
||||
const firstColon = line.indexOf(":");
|
||||
if (firstColon <= 0) return null;
|
||||
|
||||
const secondColon = line.indexOf(":", firstColon + 1);
|
||||
if (secondColon === -1) return null;
|
||||
|
||||
const lineNumber = line.slice(firstColon + 1, secondColon);
|
||||
if (!lineNumber || ![...lineNumber].every((char) => char >= "0" && char <= "9")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = line.slice(0, firstColon);
|
||||
if (!filePath || /\s/.test(filePath)) return null;
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function hasErrorLikeOutput(content: string): boolean {
|
||||
const lower = content.toLowerCase();
|
||||
return (
|
||||
lower.includes("error:") ||
|
||||
lower.includes("error ") ||
|
||||
lower.includes("[error]") ||
|
||||
lower.includes("exception:") ||
|
||||
lower.includes("exception ") ||
|
||||
lower.includes("[exception]") ||
|
||||
lower.includes("traceback")
|
||||
);
|
||||
}
|
||||
|
||||
function compressFileContent(content: string): string | null {
|
||||
const lines = content.split("\n");
|
||||
if (lines.length < 3) return null;
|
||||
if (!CODE_INDICATORS.test(content)) return null;
|
||||
if (!lines.some(isCodeLikeLine)) return null;
|
||||
const keep = 20;
|
||||
const tail = 5;
|
||||
if (lines.length <= keep + tail) return content;
|
||||
@@ -29,12 +75,12 @@ function compressFileContent(content: string): string | null {
|
||||
|
||||
function compressGrepSearch(content: string): string | null {
|
||||
const lines = content.split("\n");
|
||||
const grepLines = lines.filter((l) => GREP_LINE_RE.test(l));
|
||||
const grepLines = lines.filter((line) => parseGrepLinePath(line) !== null);
|
||||
if (grepLines.length === 0) return null;
|
||||
const paths = new Set<string>();
|
||||
for (const line of grepLines) {
|
||||
const match = line.match(/^([\w./-]+):\d+:/);
|
||||
if (match) paths.add(match[1]);
|
||||
const filePath = parseGrepLinePath(line);
|
||||
if (filePath) paths.add(filePath);
|
||||
}
|
||||
const top30 = grepLines.slice(0, 30);
|
||||
const remaining = grepLines.length - top30.length;
|
||||
@@ -99,7 +145,7 @@ function compressJson(content: string): string | null {
|
||||
}
|
||||
|
||||
function compressErrorMessage(content: string): string | null {
|
||||
if (!ERROR_RE.test(content)) return null;
|
||||
if (!hasErrorLikeOutput(content)) return null;
|
||||
const lines = content.split("\n");
|
||||
const errorLine = lines[0] || "";
|
||||
const stackLines = lines.slice(1);
|
||||
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -22,7 +22,6 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"bottleneck": "^2.19.5",
|
||||
"dompurify": "^3.4.0",
|
||||
"express": "^5.2.1",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
@@ -6732,9 +6731,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz",
|
||||
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
|
||||
"integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
|
||||
@@ -83,6 +83,8 @@
|
||||
"check:docs-sync": "node scripts/check-docs-sync.mjs",
|
||||
"check:node-runtime": "node --import tsx/esm scripts/check-supported-node-runtime.ts",
|
||||
"check:pack-artifact": "node --import tsx/esm scripts/validate-pack-artifact.ts",
|
||||
"audit:deps": "npm audit --audit-level=moderate && npm run audit:electron",
|
||||
"audit:electron": "npm --prefix electron audit --audit-level=moderate",
|
||||
"typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json",
|
||||
"typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json",
|
||||
"env:sync": "node scripts/sync-env.mjs",
|
||||
@@ -116,7 +118,6 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"bottleneck": "^2.19.5",
|
||||
"dompurify": "^3.4.0",
|
||||
"express": "^5.2.1",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
@@ -202,7 +203,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"lodash-es": "^4.18.1",
|
||||
"dompurify": "^3.4.0",
|
||||
"dompurify": "^3.4.2",
|
||||
"path-to-regexp": "^8.4.0",
|
||||
"postcss": "^8.5.10",
|
||||
"hono": "^4.12.14",
|
||||
|
||||
@@ -459,6 +459,15 @@ export default function MediaPageClient() {
|
||||
new Set(LOCAL_PROVIDERS) // Optimistic: show all until we know otherwise
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const audioUrl = result?.audioUrl;
|
||||
return () => {
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
}
|
||||
};
|
||||
}, [result?.audioUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch configured provider connections to determine which local providers are set up
|
||||
fetch("/api/providers")
|
||||
|
||||
@@ -99,7 +99,13 @@ export default function MemoryPage() {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `memory-export-${new Date().toISOString()}.json`;
|
||||
link.click();
|
||||
try {
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
} finally {
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
|
||||
@@ -233,6 +233,14 @@ export default function PlaygroundPage() {
|
||||
const isImageEndpoint = selectedEndpoint === "images";
|
||||
const supportsVision = isChatEndpoint && isVisionModel(selectedModel);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
}
|
||||
};
|
||||
}, [audioUrl]);
|
||||
|
||||
// Load connections for a given provider — filtered from allConnections
|
||||
const providerConnections = allConnections.filter((c) => {
|
||||
if (!selectedProvider) return false;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-2).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (await isAuthRequired()) {
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const REQUIRED_TABLES = ["provider_connections", "provider_nodes", "combos", "ap
|
||||
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-3).
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (await isAuthRequired()) {
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ function tryIdeAuth(): {
|
||||
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-4).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (await isAuthRequired()) {
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-5).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (await isAuthRequired()) {
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
* Exports a legacy 9router compatible JSON backup.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (await isAuthRequired()) {
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
@@ -41,8 +41,8 @@ export async function GET(request: Request) {
|
||||
// Metadata to identify export version
|
||||
_meta: {
|
||||
exportedAt: new Date().toISOString(),
|
||||
version: "omniroute-v3-legacy-export"
|
||||
}
|
||||
version: "omniroute-v3-legacy-export",
|
||||
},
|
||||
};
|
||||
|
||||
return new NextResponse(JSON.stringify(exportData, null, 2), {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { runJsonMigration, type LegacyJsonData } from "@/lib/db/jsonMigration";
|
||||
* 🔒 A pre-import backup is created automatically before any data is written.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (await isAuthRequired()) {
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
@@ -44,7 +44,9 @@ export async function POST(request: Request) {
|
||||
data = JSON.parse(rawText) as LegacyJsonData;
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON: the file could not be parsed. Please upload a valid .json backup." },
|
||||
{
|
||||
error: "Invalid JSON: the file could not be parsed. Please upload a valid .json backup.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ async function getModelCatalogAuthRejection(
|
||||
settings: Record<string, any>,
|
||||
headers: Record<string, string>
|
||||
): Promise<Response | null> {
|
||||
if (settings.requireAuthForModels !== true || !(await isAuthRequired())) return null;
|
||||
if (settings.requireAuthForModels !== true || !(await isAuthRequired(request))) return null;
|
||||
|
||||
const bearer = extractBearer(request.headers);
|
||||
if (bearer) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { isAuthRequired, isDashboardSessionAuthenticated } from "@/shared/utils/
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
|
||||
export async function requireManagementAuth(request: Request): Promise<Response | null> {
|
||||
if (!(await isAuthRequired())) {
|
||||
if (!(await isAuthRequired(request))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawn, execFile } from "child_process";
|
||||
import { createHash } from "crypto";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
@@ -9,8 +10,8 @@ import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const CLOUDFLARED_RELEASE_BASE =
|
||||
"https://github.com/cloudflare/cloudflared/releases/latest/download";
|
||||
const CLOUDFLARED_RELEASE_API_URL =
|
||||
"https://api.github.com/repos/cloudflare/cloudflared/releases/latest";
|
||||
const START_TIMEOUT_MS = 30000;
|
||||
const STOP_TIMEOUT_MS = 5000;
|
||||
const GENERIC_EXIT_ERROR_PREFIX = "cloudflared exited";
|
||||
@@ -33,7 +34,11 @@ type AssetSpec = {
|
||||
assetName: string;
|
||||
binaryName: string;
|
||||
archive: "none" | "tgz";
|
||||
};
|
||||
|
||||
type ResolvedAssetSpec = AssetSpec & {
|
||||
downloadUrl: string;
|
||||
expectedSha256: string;
|
||||
};
|
||||
|
||||
type CloudflaredRuntimeDirs = {
|
||||
@@ -401,7 +406,7 @@ export function getCloudflaredAssetSpec(
|
||||
platform = process.platform,
|
||||
arch = process.arch
|
||||
): AssetSpec | null {
|
||||
const matrix: Record<string, Record<string, Omit<AssetSpec, "downloadUrl">>> = {
|
||||
const matrix: Record<string, Record<string, AssetSpec>> = {
|
||||
linux: {
|
||||
x64: {
|
||||
assetName: "cloudflared-linux-amd64",
|
||||
@@ -443,9 +448,75 @@ export function getCloudflaredAssetSpec(
|
||||
const spec = matrix[platform]?.[arch];
|
||||
if (!spec) return null;
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
export function getSha256FromGitHubDigest(digest: string): string | null {
|
||||
const prefix = "sha256:";
|
||||
if (!digest.toLowerCase().startsWith(prefix)) return null;
|
||||
|
||||
const value = digest.slice(prefix.length);
|
||||
if (value.length !== 64) return null;
|
||||
for (const char of value) {
|
||||
const code = char.charCodeAt(0);
|
||||
const digit = code >= 48 && code <= 57;
|
||||
const lowerHex = code >= 97 && code <= 102;
|
||||
const upperHex = code >= 65 && code <= 70;
|
||||
if (!digit && !lowerHex && !upperHex) return null;
|
||||
}
|
||||
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
export function verifyCloudflaredDownloadDigest(
|
||||
buffer: Buffer,
|
||||
expectedSha256: string,
|
||||
assetName = "cloudflared"
|
||||
): void {
|
||||
const actualSha256 = createHash("sha256").update(buffer).digest("hex");
|
||||
if (actualSha256 !== expectedSha256.toLowerCase()) {
|
||||
throw new Error(
|
||||
`cloudflared download checksum mismatch for ${assetName}: expected ${expectedSha256}, got ${actualSha256}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCloudflaredDownloadSpec(spec: AssetSpec): Promise<ResolvedAssetSpec> {
|
||||
const response = await proxyFetch(CLOUDFLARED_RELEASE_API_URL, {
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
redirect: "follow",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to resolve cloudflared release metadata with status ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const release = (await response.json()) as { assets?: unknown };
|
||||
const assets = Array.isArray(release.assets) ? release.assets : [];
|
||||
const asset = assets
|
||||
.map((entry) =>
|
||||
entry && typeof entry === "object" ? (entry as Record<string, unknown>) : null
|
||||
)
|
||||
.find((entry) => entry?.name === spec.assetName);
|
||||
|
||||
if (!asset) {
|
||||
throw new Error(`cloudflared release asset not found: ${spec.assetName}`);
|
||||
}
|
||||
|
||||
const downloadUrl =
|
||||
typeof asset.browser_download_url === "string" ? asset.browser_download_url : "";
|
||||
const digest = typeof asset.digest === "string" ? asset.digest : "";
|
||||
const expectedSha256 = getSha256FromGitHubDigest(digest);
|
||||
|
||||
if (!downloadUrl || !expectedSha256) {
|
||||
throw new Error(`cloudflared release asset ${spec.assetName} is missing a sha256 digest`);
|
||||
}
|
||||
|
||||
return {
|
||||
...spec,
|
||||
downloadUrl: `${CLOUDFLARED_RELEASE_BASE}/${spec.assetName}`,
|
||||
downloadUrl,
|
||||
expectedSha256,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -488,13 +559,19 @@ async function extractArchive(archivePath: string, destinationDir: string) {
|
||||
await execFileAsync("tar", ["-xzf", archivePath, "-C", destinationDir], { timeout: 15000 });
|
||||
}
|
||||
|
||||
async function downloadToFile(url: string, destinationPath: string) {
|
||||
async function downloadToFile(
|
||||
url: string,
|
||||
destinationPath: string,
|
||||
expectedSha256: string,
|
||||
assetName: string
|
||||
) {
|
||||
const response = await proxyFetch(url, { redirect: "follow" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
verifyCloudflaredDownloadDigest(buffer, expectedSha256, assetName);
|
||||
await fs.writeFile(destinationPath, buffer);
|
||||
}
|
||||
|
||||
@@ -525,7 +602,13 @@ async function installManagedBinary() {
|
||||
});
|
||||
|
||||
try {
|
||||
await downloadToFile(spec.downloadUrl, tempDownloadPath);
|
||||
const downloadSpec = await resolveCloudflaredDownloadSpec(spec);
|
||||
await downloadToFile(
|
||||
downloadSpec.downloadUrl,
|
||||
tempDownloadPath,
|
||||
downloadSpec.expectedSha256,
|
||||
downloadSpec.assetName
|
||||
);
|
||||
|
||||
if (spec.archive === "tgz") {
|
||||
await extractArchive(tempDownloadPath, path.dirname(managedBinaryPath));
|
||||
|
||||
@@ -18,7 +18,7 @@ function isInternalModelSyncRequest(ctx: PolicyContext): boolean {
|
||||
export const managementPolicy: RoutePolicy = {
|
||||
routeClass: "MANAGEMENT",
|
||||
async evaluate(ctx: PolicyContext): Promise<AuthOutcome> {
|
||||
if (!(await isAuthRequired())) {
|
||||
if (!(await isAuthRequired(ctx.request))) {
|
||||
return allow({ kind: "anonymous", id: "anonymous", label: "auth-disabled" });
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,16 @@ type RequestLike = {
|
||||
};
|
||||
headers?: Headers;
|
||||
method?: string;
|
||||
nextUrl?: { pathname?: string | null } | null;
|
||||
nextUrl?: { hostname?: string | null; pathname?: string | null } | null;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
const LOOPBACK_HOSTNAMES = new Set(["localhost", "::1"]);
|
||||
|
||||
function hasConfiguredPassword(settings: Record<string, unknown>): boolean {
|
||||
return typeof settings.password === "string" && settings.password.length > 0;
|
||||
}
|
||||
|
||||
function getRequestPathname(request: RequestLike | Request | null | undefined): string | null {
|
||||
const nextPathname =
|
||||
request &&
|
||||
@@ -60,6 +66,56 @@ function getRequestMethod(request: RequestLike | Request | null | undefined): st
|
||||
return "GET";
|
||||
}
|
||||
|
||||
function getRequestHostname(request: RequestLike | Request | null | undefined): string | null {
|
||||
const nextHostname =
|
||||
request &&
|
||||
typeof request === "object" &&
|
||||
"nextUrl" in request &&
|
||||
request.nextUrl &&
|
||||
typeof request.nextUrl.hostname === "string"
|
||||
? request.nextUrl.hostname
|
||||
: null;
|
||||
|
||||
if (nextHostname) return nextHostname;
|
||||
|
||||
const rawUrl =
|
||||
request && typeof request === "object" && "url" in request && typeof request.url === "string"
|
||||
? request.url
|
||||
: "";
|
||||
|
||||
if (rawUrl) {
|
||||
try {
|
||||
return new URL(rawUrl, "http://localhost").hostname;
|
||||
} catch {
|
||||
// Fall through to Host header parsing.
|
||||
}
|
||||
}
|
||||
|
||||
const requestHeaders =
|
||||
request && typeof request === "object" && "headers" in request ? request.headers : undefined;
|
||||
const host = requestHeaders?.get("host") || requestHeaders?.get("Host") || null;
|
||||
if (!host) return null;
|
||||
|
||||
try {
|
||||
return new URL(`http://${host}`).hostname;
|
||||
} catch {
|
||||
return host.split(":")[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isLoopbackRequest(request: RequestLike | Request | null | undefined): boolean {
|
||||
const hostname = getRequestHostname(request);
|
||||
if (!hostname) return false;
|
||||
|
||||
const normalized = hostname
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\[(.*)\]$/, "$1");
|
||||
if (LOOPBACK_HOSTNAMES.has(normalized)) return true;
|
||||
if (/^127(?:\.\d{1,3}){3}$/.test(normalized)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function getCookieValueFromHeader(headers: Headers | undefined, name: string): string | null {
|
||||
const cookieHeader = headers?.get("cookie") || headers?.get("Cookie");
|
||||
if (!cookieHeader) return null;
|
||||
@@ -178,7 +234,7 @@ export async function verifyAuth(request: any): Promise<string | null> {
|
||||
*/
|
||||
export async function isAuthenticated(request: Request): Promise<boolean> {
|
||||
// If settings say login/auth is disabled, treat all requests as authenticated
|
||||
if (!(await isAuthRequired())) {
|
||||
if (!(await isAuthRequired(request))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -202,22 +258,28 @@ export function isPublicRoute(pathname: string, method = "GET"): boolean {
|
||||
|
||||
/**
|
||||
* Check if authentication is required based on settings.
|
||||
* If requireLogin is false AND no password is set, auth is skipped.
|
||||
* If requireLogin is explicitly false, auth is skipped. Fresh installs without
|
||||
* a password keep their unauthenticated bootstrap path only on loopback
|
||||
* requests; exposed network requests must configure INITIAL_PASSWORD or log in.
|
||||
*/
|
||||
export async function isAuthRequired(): Promise<boolean> {
|
||||
export async function isAuthRequired(
|
||||
request?: RequestLike | Request | null | undefined
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (settings.requireLogin === false) return false;
|
||||
// Allow access with no password set — there's nothing to authenticate against.
|
||||
// This covers two cases:
|
||||
// 1. Fresh installs (setupComplete=false) — first-run, no password yet
|
||||
// 2. setupComplete=true but password was skipped during onboarding (#256)
|
||||
// The user needs unauthenticated access to /dashboard/settings to set a password.
|
||||
// Note: this is safe because Bearer API key auth is still checked in verifyAuth().
|
||||
// The security concern from #151 (password row lost after being set) is handled by the
|
||||
// hasPassword flag — if a password WAS set and then somehow lost, the user can use the
|
||||
// reset-password CLI tool (bin/reset-password.mjs).
|
||||
if (!settings.password && !process.env.INITIAL_PASSWORD) return false;
|
||||
|
||||
if (!hasConfiguredPassword(settings) && !process.env.INITIAL_PASSWORD) {
|
||||
if (!request) return false;
|
||||
|
||||
const pathname = getRequestPathname(request);
|
||||
if (pathname && isPublicApiRoute(pathname, getRequestMethod(request))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return settings.setupComplete === true || !isLoopbackRequest(request);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
// On error, require auth (secure by default)
|
||||
|
||||
@@ -180,6 +180,25 @@ test("isAuthRequired is disabled while no password exists", async () => {
|
||||
assert.equal(result, false);
|
||||
});
|
||||
|
||||
test("isAuthRequired keeps fresh bootstrap open only on loopback", async () => {
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
|
||||
assert.equal(await apiAuth.isAuthRequired(new Request("http://localhost/api/providers")), false);
|
||||
assert.equal(await apiAuth.isAuthRequired(new Request("http://127.0.0.1/api/providers")), false);
|
||||
assert.equal(
|
||||
await apiAuth.isAuthRequired(new Request("https://example.com/api/providers")),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("isAuthenticated rejects remote management bootstrap without a configured password", async () => {
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
|
||||
const request = new Request("https://example.com/api/providers");
|
||||
|
||||
assert.equal(await apiAuth.isAuthenticated(request), false);
|
||||
});
|
||||
|
||||
test("isAuthRequired stays enabled when a password exists", async () => {
|
||||
await localDb.updateSettings({ requireLogin: true, password: "hashed-password" });
|
||||
|
||||
|
||||
@@ -56,6 +56,25 @@ function ctx(headers: Headers, method = "GET", path = "/api/keys") {
|
||||
};
|
||||
}
|
||||
|
||||
function remoteCtx(headers: Headers, method = "GET", path = "/api/keys") {
|
||||
return {
|
||||
request: {
|
||||
method,
|
||||
headers,
|
||||
url: `https://dashboard.example${path}`,
|
||||
nextUrl: { hostname: "dashboard.example", pathname: path },
|
||||
},
|
||||
classification: {
|
||||
routeClass: "MANAGEMENT" as const,
|
||||
reason: path.startsWith("/dashboard")
|
||||
? ("dashboard_prefix" as const)
|
||||
: ("management_api" as const),
|
||||
normalizedPath: path,
|
||||
},
|
||||
requestId: "req_remote_test",
|
||||
};
|
||||
}
|
||||
|
||||
test("managementPolicy: allows when auth not required (no password set)", async () => {
|
||||
await settingsDb.updateSettings({ requireLogin: true, password: null });
|
||||
const policy = await loadPolicy();
|
||||
@@ -67,6 +86,19 @@ test("managementPolicy: allows when auth not required (no password set)", async
|
||||
}
|
||||
});
|
||||
|
||||
test("managementPolicy: rejects remote fresh bootstrap without a password", async () => {
|
||||
await settingsDb.updateSettings({ requireLogin: true, password: null });
|
||||
const policy = await loadPolicy();
|
||||
|
||||
const out = await policy.evaluate(remoteCtx(new Headers()));
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 401);
|
||||
assert.equal(out.code, "AUTH_001");
|
||||
}
|
||||
});
|
||||
|
||||
test("managementPolicy: rejects 401 when auth required and no credentials", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
getDefaultCloudflaredCertEnv,
|
||||
getCloudflaredStartArgs,
|
||||
getCloudflaredAssetSpec,
|
||||
getSha256FromGitHubDigest,
|
||||
verifyCloudflaredDownloadDigest,
|
||||
} from "../../src/lib/cloudflaredTunnel.ts";
|
||||
|
||||
test("extractTryCloudflareUrl parses trycloudflare URL from log output", () => {
|
||||
@@ -61,8 +63,6 @@ test("getCloudflaredAssetSpec resolves linux amd64 binary", () => {
|
||||
assetName: "cloudflared-linux-amd64",
|
||||
binaryName: "cloudflared",
|
||||
archive: "none",
|
||||
downloadUrl:
|
||||
"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,8 +73,6 @@ test("getCloudflaredAssetSpec resolves darwin arm64 archive", () => {
|
||||
assetName: "cloudflared-darwin-arm64.tgz",
|
||||
binaryName: "cloudflared",
|
||||
archive: "tgz",
|
||||
downloadUrl:
|
||||
"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +80,25 @@ test("getCloudflaredAssetSpec returns null for unsupported platforms", () => {
|
||||
assert.equal(getCloudflaredAssetSpec("freebsd", "x64"), null);
|
||||
});
|
||||
|
||||
test("getSha256FromGitHubDigest accepts only GitHub sha256 digests", () => {
|
||||
const digest = "sha256:" + "a".repeat(64);
|
||||
|
||||
assert.equal(getSha256FromGitHubDigest(digest), "a".repeat(64));
|
||||
assert.equal(getSha256FromGitHubDigest("sha512:" + "a".repeat(64)), null);
|
||||
assert.equal(getSha256FromGitHubDigest("sha256:not-a-sha"), null);
|
||||
});
|
||||
|
||||
test("verifyCloudflaredDownloadDigest rejects checksum mismatches", () => {
|
||||
const buffer = Buffer.from("cloudflared-test-binary");
|
||||
const expected = "d23f921ab91d965bb151ad66dcfc7abe20acf79cd0325cf6b7f9919ed0251c9e";
|
||||
|
||||
verifyCloudflaredDownloadDigest(buffer, expected, "cloudflared-test");
|
||||
assert.throws(
|
||||
() => verifyCloudflaredDownloadDigest(buffer, "a".repeat(64), "cloudflared-test"),
|
||||
/checksum mismatch/
|
||||
);
|
||||
});
|
||||
|
||||
test("buildCloudflaredChildEnv keeps runtime essentials, isolates runtime dirs, and drops secrets", () => {
|
||||
const env = buildCloudflaredChildEnv(
|
||||
{
|
||||
|
||||
@@ -99,7 +99,7 @@ describe("caveman engine", () => {
|
||||
preservePatterns: [],
|
||||
});
|
||||
const text = result.body.messages[0].content as string;
|
||||
assert.ok(text.includes(url), `URL should be preserved`);
|
||||
assert.equal(text.split(/\s+/).includes(url), true, `URL should be preserved`);
|
||||
});
|
||||
|
||||
it("should handle empty messages array", () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("preservation", () => {
|
||||
const text = `Check ${url} please`;
|
||||
const { text: extracted, blocks } = extractPreservedBlocks(text);
|
||||
const restored = restorePreservedBlocks(extracted, blocks);
|
||||
assert.ok(restored.includes(url), "URL should be preserved");
|
||||
assert.equal(restored, text, "URL should be preserved");
|
||||
});
|
||||
|
||||
it("should preserve file paths", () => {
|
||||
@@ -51,8 +51,7 @@ describe("preservation", () => {
|
||||
const text = "See https://docs.example.com for:\n```\nfetch('https://api.example.com')\n```";
|
||||
const { text: extracted, blocks } = extractPreservedBlocks(text);
|
||||
const restored = restorePreservedBlocks(extracted, blocks);
|
||||
assert.ok(restored.includes("https://docs.example.com"));
|
||||
assert.ok(restored.includes("fetch('https://api.example.com')"));
|
||||
assert.equal(restored, text);
|
||||
});
|
||||
|
||||
it("should handle empty text", () => {
|
||||
|
||||
@@ -95,7 +95,7 @@ describe("pruneByScore", () => {
|
||||
it("should always keep force-preserved tokens (URLs)", () => {
|
||||
const text = "check https://example.com for details";
|
||||
const result = pruneByScore(text, 0.3);
|
||||
assert(result.includes("https://example.com"));
|
||||
assert.notEqual(result.split(/\s+/).indexOf("https://example.com"), -1);
|
||||
});
|
||||
|
||||
it("should always keep force-preserved tokens (numbers)", () => {
|
||||
|
||||
@@ -246,20 +246,33 @@ describe("Content Security Policy", () => {
|
||||
"default-src",
|
||||
"connect-src",
|
||||
"script-src",
|
||||
"script-src-attr",
|
||||
"style-src",
|
||||
"font-src",
|
||||
"img-src",
|
||||
"media-src",
|
||||
"object-src",
|
||||
"frame-src",
|
||||
"child-src",
|
||||
];
|
||||
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"connect-src 'self' http://localhost:* ws://localhost:*",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"frame-src 'none'",
|
||||
"child-src 'none'",
|
||||
"form-action 'self'",
|
||||
"script-src 'self' 'unsafe-inline' blob:",
|
||||
"script-src-attr 'none'",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"media-src 'self'",
|
||||
"media-src 'self' data: blob:",
|
||||
"connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*",
|
||||
"worker-src 'self' blob:",
|
||||
"manifest-src 'self'",
|
||||
].join("; ");
|
||||
|
||||
for (const directive of directives) {
|
||||
@@ -268,9 +281,10 @@ describe("Content Security Policy", () => {
|
||||
});
|
||||
|
||||
it("should not allow unsafe script sources from external domains", () => {
|
||||
const scriptSrc = "script-src 'self' 'unsafe-inline' 'unsafe-eval'";
|
||||
assert.ok(!scriptSrc.includes("http://"), "Should not allow external http scripts");
|
||||
assert.ok(!scriptSrc.includes("*"), "Should not wildcard script sources");
|
||||
const scriptSrc = "script-src 'self' 'unsafe-inline' blob:";
|
||||
assert.equal(scriptSrc.indexOf("http://"), -1, "Should not allow external http scripts");
|
||||
assert.equal(scriptSrc.indexOf("*"), -1, "Should not wildcard script sources");
|
||||
assert.equal(scriptSrc.indexOf("'unsafe-eval'"), -1, "Production CSP should not allow eval");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ test("nanobanana b64 mode can convert result URL bytes to b64_json", async () =>
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).includes("https://cdn.example.com/final.jpg")) {
|
||||
if (new URL(String(url)).href === "https://cdn.example.com/final.jpg") {
|
||||
return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
|
||||
@@ -35,11 +35,21 @@ test("next config exposes standalone build settings and canonical rewrites", asy
|
||||
const { default: nextConfig } = await loadNextConfig("distdir");
|
||||
|
||||
const rewrites = await nextConfig.rewrites();
|
||||
const headers = await nextConfig.headers();
|
||||
const securityHeaders = Object.fromEntries(
|
||||
headers[0].headers.map(({ key, value }) => [key, value])
|
||||
);
|
||||
|
||||
assert.equal(nextConfig.distDir, ".next-task607");
|
||||
assert.equal(nextConfig.output, "standalone");
|
||||
assert.equal(nextConfig.images.unoptimized, true);
|
||||
assert.deepEqual(nextConfig.transpilePackages, ["@omniroute/open-sse", "@lobehub/icons"]);
|
||||
assert.equal(headers[0].source, "/:path*");
|
||||
assert.match(securityHeaders["Content-Security-Policy"], /default-src 'self'/);
|
||||
assert.match(securityHeaders["Content-Security-Policy"], /frame-ancestors 'none'/);
|
||||
assert.equal(securityHeaders["X-Frame-Options"], "DENY");
|
||||
assert.equal(securityHeaders["X-Content-Type-Options"], "nosniff");
|
||||
assert.match(securityHeaders["Strict-Transport-Security"], /includeSubDomains/);
|
||||
assert.deepEqual(rewrites.slice(0, 4), [
|
||||
{
|
||||
source: "/chat/completions",
|
||||
|
||||
Reference in New Issue
Block a user