OmniRoute/bin/cli/api.mjs
diegosouzapw 04d44f6262 fix(security): sanitize error messages, fix ReDoS patterns, harden OAuth callback
Error message sanitization (Hard Rule #12):
- claude-auth/export, codex-auth/export, gemini-cli-auth/export routes: replace
  raw err.message with sanitizeErrorMessage() from open-sse/utils/error.ts
- imageGeneration, musicGeneration, videoGeneration handlers: import
  sanitizeErrorMessage and replace all err.message in return values
- veoaifree-web executor: replace raw upstream response data in errResp() calls
  with static strings

OAuth callback page (callback/page.tsx):
- Remove useSearchParams/Suspense dependency that caused hydration failures in
  popup windows navigating back from Google OAuth (COOP header severs opener)
- Use window.location.search directly in useEffect with three send methods:
  postMessage, BroadcastChannel, localStorage
- Fix postMessage target from "*" to window.location.origin (semgrep finding)
- Move setCurrentUrl call to manual-only branch to avoid unnecessary renders

copilot-web executor:
- Move accessToken from WebSocket URL query string to Authorization header
  (avoids credential exposure in server logs)
- Add MAX_POOL_SIZE=100 cap to sessionPool with LRU eviction of oldest entry

CodeQL ReDoS fixes (js/polynomial-redos #233-240):
- Replace while(s.endsWith("/")) s=s.slice(0,-1) pattern (O(n²) allocations)
  with index-based loop (O(n) time, single final slice) in:
  bin/cli/api.mjs, all 6 cli-helper config generators, opencode-provider

Gemini OAuth:
- mapTokens: add idToken field to fix "missing id_token" export error
2026-05-18 17:42:09 -03:00

248 lines
8.4 KiB
JavaScript

import { setTimeout as sleep } from "node:timers/promises";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { resolveDataDir } from "./data-dir.mjs";
import { getCliToken, CLI_TOKEN_HEADER } from "./utils/cliToken.mjs";
export const RETRY_DEFAULTS = Object.freeze({
maxAttempts: 3,
baseMs: 500,
maxMs: 8000,
jitter: true,
retryableStatuses: [408, 425, 429, 502, 503, 504],
retryableErrorCodes: [
"ECONNRESET",
"ECONNREFUSED",
"ETIMEDOUT",
"ENOTFOUND",
"EAI_AGAIN",
"EPIPE",
],
});
const NON_RETRYABLE_ON_MUTATION = new Set([409, 422, 429]);
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
export function getBaseUrl(opts = {}) {
if (opts.baseUrl) return stripTrailingSlash(opts.baseUrl);
const envUrl = process.env.OMNIROUTE_BASE_URL;
if (envUrl) return stripTrailingSlash(envUrl);
try {
const configPath = join(resolveDataDir(), "config.json");
if (existsSync(configPath)) {
const cfg = JSON.parse(readFileSync(configPath, "utf8"));
const profile = cfg.activeProfile && cfg.profiles?.[cfg.activeProfile];
if (profile?.baseUrl) return stripTrailingSlash(profile.baseUrl);
if (cfg.baseUrl) return stripTrailingSlash(cfg.baseUrl);
}
} catch {
// Config read failures are not fatal — fall through to default.
}
const port = process.env.PORT || "20128";
return `http://localhost:${port}`;
}
function stripTrailingSlash(value) {
const s = String(value);
let end = s.length;
while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
return end === s.length ? s : s.slice(0, end);
}
function resolveUrl(path, opts) {
if (/^https?:\/\//i.test(path)) return path;
return `${getBaseUrl(opts)}${path.startsWith("/") ? path : `/${path}`}`;
}
async function buildHeaders(opts) {
const headers = new Headers(opts.headers || {});
if (!headers.has("accept")) headers.set("accept", "application/json");
if (opts.body && !headers.has("content-type") && typeof opts.body !== "string") {
headers.set("content-type", "application/json");
}
const apiKey = opts.apiKey ?? process.env.OMNIROUTE_API_KEY;
if (apiKey && !headers.has("authorization")) {
headers.set("authorization", `Bearer ${apiKey}`);
}
// Inject machine-id derived CLI token; env var override for testing.
const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken());
if (cliToken && !headers.has(CLI_TOKEN_HEADER)) {
headers.set(CLI_TOKEN_HEADER, cliToken);
}
if (opts.idempotencyKey && !headers.has("idempotency-key")) {
headers.set("idempotency-key", opts.idempotencyKey);
}
return headers;
}
function serializeBody(body, headers) {
if (body == null) return undefined;
if (typeof body === "string") return body;
if (body instanceof Buffer) return body;
if (body instanceof URLSearchParams) return body;
if (typeof body.pipe === "function") return body; // stream
if (headers.get("content-type")?.includes("application/json")) return JSON.stringify(body);
return JSON.stringify(body);
}
export function computeBackoff(attempt, retryAfterHeader, defaults = RETRY_DEFAULTS) {
if (retryAfterHeader != null) {
const secs = Number.parseFloat(String(retryAfterHeader));
if (Number.isFinite(secs) && secs >= 0) {
return Math.min(secs * 1000, defaults.maxMs);
}
}
const exp = Math.min(defaults.baseMs * 2 ** (attempt - 1), defaults.maxMs);
if (!defaults.jitter) return exp;
const jitter = exp * 0.25 * (Math.random() * 2 - 1);
return Math.max(0, exp + jitter);
}
export function shouldRetryStatus(status, method, opts = {}) {
if (opts.retry === false) return false;
const list = opts.retryableStatuses || RETRY_DEFAULTS.retryableStatuses;
if (!list.includes(status)) return false;
if (MUTATING_METHODS.has(method) && NON_RETRYABLE_ON_MUTATION.has(status)) {
return status === 429 ? Boolean(opts.retryMutationsOn429) : false;
}
return true;
}
export function shouldRetryError(err, opts = {}) {
if (opts.retry === false) return false;
const codes = opts.retryableErrorCodes || RETRY_DEFAULTS.retryableErrorCodes;
if (err?.code && codes.includes(err.code)) return true;
if (err?.name === "AbortError" || /timeout|abort/i.test(err?.message || "")) return true;
return false;
}
export function statusToExitCode(status) {
if (status >= 200 && status < 300) return 0;
if (status === 408) return 124;
if (status === 401 || status === 403) return 4;
if (status === 429) return 5;
if (status === 400 || status === 404 || status === 422) return 2;
if (status >= 500) return 1;
return 1;
}
export class ApiError extends Error {
constructor(message, { status, code, exitCode } = {}) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
this.exitCode = exitCode ?? (status != null ? statusToExitCode(status) : 1);
}
}
async function readResponseBody(res) {
const ct = res.headers.get("content-type") || "";
try {
if (ct.includes("application/json")) return await res.json();
return await res.text();
} catch {
return null;
}
}
function fetchOnce(url, init, timeoutMs) {
if (!timeoutMs) return fetch(url, init);
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), timeoutMs);
const merged = { ...init, signal: ac.signal };
return fetch(url, merged).finally(() => clearTimeout(t));
}
export async function apiFetch(path, opts = {}) {
const method = String(opts.method || "GET").toUpperCase();
const url = resolveUrl(path, opts);
const headers = await buildHeaders(opts);
const body = serializeBody(opts.body, headers);
const timeout =
opts.timeout ?? (Number.parseInt(process.env.OMNIROUTE_HTTP_TIMEOUT_MS || "", 10) || 30000);
const maxAttempts = opts.retry === false ? 1 : (opts.retryMax ?? RETRY_DEFAULTS.maxAttempts);
const verbose = opts.verbose ?? process.env.OMNIROUTE_VERBOSE === "1";
let lastErr;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const res = await fetchOnce(url, { method, headers, body }, timeout);
if (res.ok) return enrichResponse(res, opts);
if (attempt < maxAttempts && shouldRetryStatus(res.status, method, opts)) {
const delay = computeBackoff(attempt, res.headers.get("retry-after"));
if (verbose) {
process.stderr.write(
`[retry ${attempt}/${maxAttempts - 1}] ${method} ${url} → HTTP ${res.status}; wait ${Math.round(delay)}ms\n`
);
}
await sleep(delay);
continue;
}
return enrichResponse(res, opts);
} catch (err) {
lastErr = err;
if (attempt < maxAttempts && shouldRetryError(err, opts)) {
const delay = computeBackoff(attempt, null);
if (verbose) {
process.stderr.write(
`[retry ${attempt}/${maxAttempts - 1}] ${method} ${url}${err.code || err.message}; wait ${Math.round(delay)}ms\n`
);
}
await sleep(delay);
continue;
}
throw normalizeNetworkError(err);
}
}
throw normalizeNetworkError(lastErr);
}
function enrichResponse(res, opts) {
res.exitCode = statusToExitCode(res.status);
res.json = res.json.bind(res);
res.text = res.text.bind(res);
if (!res.ok && !opts.acceptNotOk) {
res.assertOk = async () => {
const payload = await readResponseBody(res);
const message = extractErrorMessage(payload, res.status);
throw new ApiError(message, { status: res.status });
};
} else {
res.assertOk = async () => res;
}
return res;
}
function extractErrorMessage(payload, status) {
if (payload && typeof payload === "object") {
if (typeof payload.error === "string") return payload.error;
if (payload.error?.message) return String(payload.error.message);
if (payload.message) return String(payload.message);
}
if (typeof payload === "string" && payload.length < 200) return payload;
return `HTTP ${status}`;
}
function normalizeNetworkError(err) {
if (err instanceof ApiError) return err;
const code = err?.code || (err?.name === "AbortError" ? "ETIMEDOUT" : undefined);
const exitCode = code === "ETIMEDOUT" ? 124 : 1;
return new ApiError(err?.message || "network error", { code, exitCode });
}
export async function isServerUp(opts = {}) {
try {
const res = await apiFetch("/api/health", {
...opts,
retry: false,
timeout: opts.timeout ?? 1500,
acceptNotOk: true,
});
return res.ok || res.status < 500;
} catch {
return false;
}
}