chore(security): hardening pass + Trae IDE provider

Bundle of small targeted improvements that landed in parallel with the
PR #2678 review pass.

Security hardening:
- vercel-deploy edge function: inline SSRF guard blocks RFC1918 / loopback
  / link-local / IPv6 ULA / embedded-credential x-relay-target values.
  Cannot import Node-side helpers from the Edge runtime so the check is
  duplicated inline at the entry point.
- webhooks/[id] GET: mask webhook.secret to first-10-chars + "..." so the
  detail endpoint no longer hands out the full signing secret.
- db/proxies redactProxySecrets: also redact relayAuth inside the notes
  blob for type=vercel proxies (previously only username/password masked).
- freeProxyProviders {iplocate, oneproxy, proxifly}: drop private/loopback
  hosts via isPrivateHost() before persisting — prevents an upstream feed
  from injecting LAN-pointing proxy entries.

9router supervisor:
- _lib.ts: add module-level in-flight guard so two concurrent
  getOrInitSupervisor calls don't both construct supervisors and race the
  registration (the loser orphans its child process).
- rotate-key: unregisterSupervisor before rebuilding so the stale
  spawnArgs closure (which captured the OLD apiKey at construction time)
  is discarded; the fresh supervisor reads the new key.

Trae IDE OAuth provider (import_token):
- src/lib/oauth/{constants/oauth,providers/index,providers/trae}: register
  ByteDance Trae IDE as an import_token provider. ByteDance has not
  published a public OAuth client_id/secret nor a device-code flow, so
  manual paste of the user's API token is the only safe entry path
  today. TODO comments mark the upgrade path if a public CLI ships.
- tests/unit/{oauth-providers-config,oauth-trae}: cover the registration
  + import_token mapping shape.

Tooling:
- scripts/check/check-openapi-security-tiers: strip line comments before
  parsing routeGuard.ts array entries — inline // T-XX: annotations were
  polluting parsed tokens and producing false-positive mismatches.
- package.json: add @types/bun devDep, mark workspace private.
This commit is contained in:
diegosouzapw 2026-05-26 12:48:39 -03:00
parent 0c94c397db
commit 0e56c5f54a
15 changed files with 296 additions and 41 deletions

View file

@ -232,7 +232,8 @@
"typescript-eslint": "^8.59.4",
"vitest": "^4.1.7",
"wait-on": "^9.0.10",
"wtfnode": "^0.10.1"
"wtfnode": "^0.10.1",
"@types/bun": "latest"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,mjs}": [
@ -259,5 +260,6 @@
"ip-address": "10.2.0",
"qs": "^6.15.2",
"uuid": "^14.0.0"
}
},
"private": true
}

View file

@ -16,7 +16,10 @@ const ROUTE_GUARD_PATH = path.join(ROOT, "src", "server", "authz", "routeGuard.t
function parseStringArray(match) {
if (!match) return [];
// Strip line comments before splitting — array entries in routeGuard.ts often
// carry inline `// T-XX:` annotations that would otherwise pollute the parsed tokens.
return match[1]
.replace(/\/\/[^\n]*/g, "")
.split(",")
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
.filter(Boolean);

View file

@ -11,22 +11,41 @@ import { getOrCreateApiKey } from "@/lib/services/apiKey";
const TOOL = "9router";
const PORT = 20130;
// Module-level in-flight guard. Without this, two concurrent requests that
// both observe `getSupervisor() === null` and then await getOrCreateApiKey
// would each construct a supervisor and the second register overwrites the
// first — orphaning the first child process beyond lifecycle control.
let initInFlight: Promise<ServiceSupervisor> | null = null;
export async function getOrInitSupervisor(): Promise<ServiceSupervisor> {
const existing = getSupervisor(TOOL);
if (existing) return existing;
const apiKey = await getOrCreateApiKey(TOOL);
if (initInFlight) return initInFlight;
const sup = new ServiceSupervisor({
tool: TOOL,
port: PORT,
spawnArgs: () => resolveSpawnArgs(apiKey, PORT),
healthUrl: () => `http://127.0.0.1:${PORT}/api/health`,
healthIntervalMs: 2_000,
stopTimeoutMs: 15_000,
logsBufferBytes: 5_242_880,
initInFlight = (async () => {
// Double-check after entering the in-flight branch — a competing caller
// may have completed initialization while we awaited the lock check.
const racy = getSupervisor(TOOL);
if (racy) return racy;
const apiKey = await getOrCreateApiKey(TOOL);
const sup = new ServiceSupervisor({
tool: TOOL,
port: PORT,
spawnArgs: () => resolveSpawnArgs(apiKey, PORT),
healthUrl: () => `http://127.0.0.1:${PORT}/api/health`,
healthIntervalMs: 2_000,
stopTimeoutMs: 15_000,
logsBufferBytes: 5_242_880,
});
registerSupervisor(sup);
return sup;
})().finally(() => {
initInFlight = null;
});
registerSupervisor(sup);
return sup;
return initInFlight;
}

View file

@ -1,4 +1,4 @@
import { getSupervisor } from "@/lib/services/registry";
import { getSupervisor, unregisterSupervisor } from "@/lib/services/registry";
import { getOrInitSupervisor } from "../_lib";
import { generateServiceApiKey } from "@/lib/services/apiKey";
import { updateServiceField } from "@/lib/db/versionManager";
@ -17,7 +17,10 @@ export async function POST(): Promise<Response> {
let restarted = false;
if (wasRunning && sup) {
await sup.stop();
// Re-initialize supervisor so it picks up the new key from getOrCreateApiKey
// Unregister the existing supervisor so its stale spawnArgs closure (which
// captured the OLD apiKey at construction time) is discarded. getOrInitSupervisor
// will then build a fresh supervisor whose closure reads the new key.
unregisterSupervisor("9router");
const freshSup = await getOrInitSupervisor();
await freshSup.start();
restarted = true;

View file

@ -10,13 +10,56 @@ const POLL_INTERVAL_MS = 3000;
const POLL_MAX_ATTEMPTS = 40; // ~2 min
function buildRelayFunction(relayAuth: string): string {
// relayAuth is a random hex string generated server-side — no user input
// relayAuth is a random hex string generated server-side — no user input.
// The runtime SSRF guard is inlined into the edge function (cannot import
// Node-side helpers from the Edge runtime); it blocks RFC1918, loopback,
// link-local, IPv6 ULA, and embedded credentials on the x-relay-target host.
return `export const config = { runtime: "edge" };
function isPrivateHostname(h) {
if (!h) return true;
const host = h.trim().toLowerCase().replace(/^\\[|\\]$/g, "");
if (
host === "localhost" ||
host === "0.0.0.0" ||
host === "127.0.0.1" ||
host === "::1" ||
host.endsWith(".localhost") ||
host.endsWith(".local") ||
host.startsWith("::ffff:")
) return true;
const v4 = host.match(/^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/);
if (v4) {
const a = +v4[1], b = +v4[2];
if (a === 0 || a === 10 || a === 127) return true;
if (a === 169 && b === 254) return true;
if (a === 192 && b === 168) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 100 && b >= 64 && b <= 127) return true;
return false;
}
if (host.includes(":")) {
return host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80:");
}
return false;
}
export default async function handler(req) {
const auth = req.headers.get("x-relay-auth");
if (auth !== "${relayAuth}") return new Response("Unauthorized", { status: 401 });
const target = req.headers.get("x-relay-target");
if (!target) return new Response("missing x-relay-target", { status: 400 });
let targetUrl;
try { targetUrl = new URL(target); } catch { return new Response("invalid x-relay-target", { status: 400 }); }
if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") {
return new Response("forbidden x-relay-target protocol", { status: 403 });
}
if (targetUrl.username || targetUrl.password) {
return new Response("forbidden x-relay-target (embedded credentials)", { status: 403 });
}
if (isPrivateHostname(targetUrl.hostname)) {
return new Response("forbidden x-relay-target (private/loopback host)", { status: 403 });
}
const relayPath = req.headers.get("x-relay-path") || "/";
const headers = new Headers(req.headers);
["x-relay-target", "x-relay-path", "x-relay-auth", "host"].forEach(h => headers.delete(h));
@ -30,10 +73,7 @@ export default async function handler(req) {
}`;
}
async function pollDeployment(
deploymentApiUrl: string,
token: string
): Promise<"READY" | "ERROR"> {
async function pollDeployment(deploymentApiUrl: string, token: string): Promise<"READY" | "ERROR"> {
for (let i = 0; i < POLL_MAX_ATTEMPTS; i++) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
try {

View file

@ -39,7 +39,11 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string
if (!webhook) {
return NextResponse.json({ error: "Webhook not found" }, { status: 404 });
}
return NextResponse.json({ webhook });
const masked = {
...webhook,
secret: webhook.secret ? `${webhook.secret.slice(0, 10)}...` : null,
};
return NextResponse.json({ webhook: masked });
} catch (error: any) {
return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 });
}

View file

@ -127,8 +127,7 @@ function extractRelayAuth(notes: unknown): string | undefined {
function toRegistryProxyResolution(row: unknown, level: ProxyScope, levelId: string | null) {
const record = toRecord(row);
const relayAuth =
record.type === "vercel" ? extractRelayAuth(record.notes) : undefined;
const relayAuth = record.type === "vercel" ? extractRelayAuth(record.notes) : undefined;
return {
proxy: {
type: record.type,
@ -366,10 +365,22 @@ function coerceProxyPayload(value: unknown, fallbackName: string): ProxyPayload
}
export function redactProxySecrets(proxy: ProxyRegistryRecord): ProxyRegistryRecord {
let redactedNotes = proxy.notes;
if (proxy.type === "vercel" && proxy.notes) {
try {
const parsed = JSON.parse(proxy.notes);
if (parsed && typeof parsed === "object" && "relayAuth" in parsed) {
redactedNotes = JSON.stringify({ ...parsed, relayAuth: "***" });
}
} catch {
// Non-JSON notes pass through unchanged
}
}
return {
...proxy,
username: proxy.username ? "***" : "",
password: proxy.password ? "***" : "",
notes: redactedNotes,
};
}
@ -671,8 +682,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string
.get(connectionId);
if (accountAssignment) {
const record = toRecord(accountAssignment);
const relayAuth =
record.type === "vercel" ? extractRelayAuth(record.notes) : undefined;
const relayAuth = record.type === "vercel" ? extractRelayAuth(record.notes) : undefined;
return {
proxy: {
type: record.type,
@ -700,8 +710,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string
.get(connection.provider);
if (providerAssignment) {
const record = toRecord(providerAssignment);
const relayAuth =
record.type === "vercel" ? extractRelayAuth(record.notes) : undefined;
const relayAuth = record.type === "vercel" ? extractRelayAuth(record.notes) : undefined;
return {
proxy: {
type: record.type,
@ -725,8 +734,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string
.get();
if (globalAssignment) {
const record = toRecord(globalAssignment);
const relayAuth =
record.type === "vercel" ? extractRelayAuth(record.notes) : undefined;
const relayAuth = record.type === "vercel" ? extractRelayAuth(record.notes) : undefined;
return {
proxy: {
type: record.type,

View file

@ -1,7 +1,7 @@
import type { FreeProxyItem, FreeProxySyncResult, FreeProxyProvider } from "./types";
import { isPrivateHost } from "@/shared/network/outboundUrlGuard";
const BASE_URL =
"https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols";
const BASE_URL = "https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols";
const PROTOCOLS = ["http", "https", "socks4", "socks5"] as const;
// In-module cache to respect GitHub raw rate limits
@ -50,9 +50,7 @@ export class IplocateProvider implements FreeProxyProvider {
const res = await fetch(url, {
signal: AbortSignal.timeout(15000),
headers:
lastFetchAt > 0
? { "If-Modified-Since": new Date(lastFetchAt).toUTCString() }
: {},
lastFetchAt > 0 ? { "If-Modified-Since": new Date(lastFetchAt).toUTCString() } : {},
});
if (res.status === 304) continue;
@ -66,6 +64,10 @@ export class IplocateProvider implements FreeProxyProvider {
for (const p of data) {
if (!p.ip || !p.port) continue;
if (isPrivateHost(p.ip)) {
errors.push(`${proto}: skipped private/loopback host ${p.ip}`);
continue;
}
const item: FreeProxyItem = {
source: "iplocate",
host: p.ip,

View file

@ -1,4 +1,5 @@
import type { FreeProxyItem, FreeProxySyncResult, FreeProxyProvider } from "./types";
import { isPrivateHost } from "@/shared/network/outboundUrlGuard";
const DEFAULT_API_URL = "https://1proxy-api.aitradepulse.com/api/v1/proxies/advanced";
const DEFAULT_MAX = 500;
@ -81,6 +82,10 @@ export class OneproxyProvider implements FreeProxyProvider {
if (!Array.isArray(json.proxies) || json.proxies.length === 0) break;
for (const p of json.proxies) {
if (!p.ip || isPrivateHost(p.ip)) {
errors.push(`1proxy: skipped private/loopback host ${p.ip}`);
continue;
}
const item: FreeProxyItem = {
source: "1proxy",
host: p.ip,

View file

@ -1,4 +1,5 @@
import type { FreeProxyItem, FreeProxySyncResult, FreeProxyProvider } from "./types";
import { isPrivateHost } from "@/shared/network/outboundUrlGuard";
const DEFAULT_QUANTITY = 100;
const DEFAULT_ANONYMITY = "elite";
@ -38,22 +39,28 @@ export class ProxiflyProvider implements FreeProxyProvider {
try {
const proxiflyModule = await import("proxifly");
const proxifly = proxiflyModule.default ?? proxiflyModule;
const result = await (proxifly as { getProxy: (opts: unknown) => Promise<unknown> }).getProxy({
protocol: "http",
anonymity: anonymity as "elite" | "anonymous" | "transparent",
speed: "fast",
quantity,
});
const result = await (proxifly as { getProxy: (opts: unknown) => Promise<unknown> }).getProxy(
{
protocol: "http",
anonymity: anonymity as "elite" | "anonymous" | "transparent",
speed: "fast",
quantity,
}
);
const proxies: ProxiflyProxy[] = Array.isArray(result) ? result : [result as ProxiflyProxy];
for (const p of proxies) {
if (!p.ip || !p.port) continue;
if (isPrivateHost(p.ip)) {
errors.push(`Proxifly: skipped private/loopback host ${p.ip}`);
continue;
}
const item: FreeProxyItem = {
source: "proxifly",
host: p.ip,
port: Number(p.port),
type: ((p.protocol || "http").toLowerCase() as FreeProxyItem["type"]),
type: (p.protocol || "http").toLowerCase() as FreeProxyItem["type"],
countryCode: p.country?.slice(0, 2).toUpperCase() || null,
qualityScore: p.speed != null ? Math.min(100, Math.max(0, Math.round(p.speed))) : null,
latencyMs: null,

View file

@ -329,6 +329,29 @@ export const WINDSURF_CONFIG = {
extensionVersion: "3.14.0",
};
// Trae IDE OAuth Configuration (Import Token)
//
// Trae is an AI-native IDE by ByteDance. Authentication uses a personal API
// token that users can find inside the Trae IDE under:
// Settings → Account → API Token (or similar path)
//
// TODO(trae-auth): ByteDance has not published a public OAuth client_id/secret
// for the Trae IDE. Once a public CLI or device-code flow is documented at
// https://docs.trae.ai, update flowType to "device_code" and add the
// appropriate endpoints. Until then, import_token (manual paste) is the only
// confirmed safe default.
export const TRAE_CONFIG = {
// Inference API endpoint (used by the IDE extension)
apiEndpoint: "https://api.trae.ai",
// Chat completions path (mirrored from OpenAI-compatible providers)
chatEndpoint: "/v1/chat/completions",
// Trae website — users retrieve their token here after signing in
webUrl: "https://trae.ai",
// Token storage note for users — no automated extraction path is available
// because Trae does not expose a public SQLite / keychain location yet.
tokenNote: "Sign in to Trae IDE, then copy your API token from the account settings.",
};
// OAuth timeout (5 minutes)
export const OAUTH_TIMEOUT = 300000;
@ -351,4 +374,5 @@ export const PROVIDERS = {
CLINE: "cline",
WINDSURF: "windsurf",
DEVIN_CLI: "devin-cli",
TRAE: "trae",
};

View file

@ -24,6 +24,7 @@ import { cursor } from "./cursor";
import { kilocode } from "./kilocode";
import { cline } from "./cline";
import { windsurf } from "./windsurf";
import { trae } from "./trae";
export const PROVIDERS = {
claude,
@ -43,6 +44,7 @@ export const PROVIDERS = {
windsurf,
// devin-cli shares the same token format as windsurf (WINDSURF_API_KEY / devin auth login)
"devin-cli": windsurf,
trae,
};
export default PROVIDERS;

View file

@ -0,0 +1,32 @@
import { TRAE_CONFIG } from "../constants/oauth";
/**
* Trae IDE OAuth Provider (Import Token)
*
* Trae is an AI-native IDE by ByteDance. Authentication relies on a personal
* API token that the user copies from the Trae account settings page and pastes
* into the OmniRoute connection form.
*
* Why import_token and not device_code / authorization_code:
* ByteDance has not published a public OAuth client_id/secret for the Trae
* IDE, nor documented a device-code or browser-redirect flow for third-party
* integrations. The authHint in providers.ts (see IDE_PROVIDER_IDS) confirms
* that "paste your API token" is the supported onboarding path.
*
* TODO(trae-auth): if ByteDance publishes a public OAuth application for Trae,
* upgrade flowType to "device_code" or "authorization_code_pkce" and embed
* the client credentials via resolvePublicCred() (Hard Rule #11).
* Reference: https://docs.trae.ai (check for OAuth / CLI integration docs)
*/
export const trae = {
config: TRAE_CONFIG,
flowType: "import_token",
mapTokens: (tokens) => ({
accessToken: tokens.accessToken,
refreshToken: null,
expiresIn: tokens.expiresIn || 86400,
providerSpecificData: {
authMethod: "imported",
},
}),
};

View file

@ -36,6 +36,7 @@ const {
PROVIDERS: OAUTH_PROVIDER_IDS,
QODER_CONFIG,
QWEN_CONFIG,
TRAE_CONFIG,
WINDSURF_CONFIG,
} = oauthModule;
const { REGISTRY } = registryModule;
@ -60,6 +61,7 @@ const EXPECTED_PROVIDER_KEYS = [
"cline",
"windsurf",
"devin-cli",
"trae",
];
const EXPECTED_CONFIG_BY_PROVIDER = {
@ -79,6 +81,7 @@ const EXPECTED_CONFIG_BY_PROVIDER = {
cline: CLINE_CONFIG,
windsurf: WINDSURF_CONFIG,
"devin-cli": WINDSURF_CONFIG,
trae: TRAE_CONFIG,
};
const REQUIRED_FIELDS_BY_PROVIDER = {
@ -125,6 +128,7 @@ const REQUIRED_FIELDS_BY_PROVIDER = {
cline: ["appBaseUrl", "apiBaseUrl", "authorizeUrl", "tokenExchangeUrl", "refreshUrl"],
windsurf: ["authorizeUrl", "apiServerUrl", "exchangePath", "inferenceUrl"],
"devin-cli": ["authorizeUrl", "apiServerUrl", "exchangePath", "inferenceUrl"],
trae: ["apiEndpoint", "chatEndpoint", "webUrl"],
};
function getByPath(object, path) {
@ -325,6 +329,8 @@ test("device and import-token providers expose the flow-specific fields expected
assert.equal(PROVIDERS.cursor.flowType, "import_token");
assert.equal(CURSOR_CONFIG.dbKeys.accessToken, "cursorAuth/accessToken");
assert.equal(CURSOR_CONFIG.dbKeys.machineId, "storage.serviceMachineId");
assert.equal(PROVIDERS.trae.flowType, "import_token");
assert.equal(typeof TRAE_CONFIG.apiEndpoint, "string");
assert.ok(Array.isArray(KIRO_CONFIG.authMethods));
assert.ok(KIRO_CONFIG.authMethods.includes("builder-id"));
});

View file

@ -0,0 +1,98 @@
import test from "node:test";
import assert from "node:assert/strict";
const providersModule = await import("../../src/lib/oauth/providers/index.ts");
const oauthModule = await import("../../src/lib/oauth/constants/oauth.ts");
const oauthHandlers = await import("../../src/lib/oauth/providers.ts");
const PROVIDERS = providersModule.default;
const { TRAE_CONFIG, PROVIDERS: OAUTH_PROVIDER_IDS } = oauthModule;
const { getProvider } = oauthHandlers;
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
test("PROVIDERS map includes a 'trae' entry", () => {
assert.ok("trae" in PROVIDERS, "PROVIDERS must contain trae");
});
test("getProvider('trae') does not throw", () => {
assert.doesNotThrow(() => getProvider("trae"));
});
test("trae provider has the correct id via getProvider", () => {
// trae provider has no .config.id — confirm via OAUTH_PROVIDER_IDS constant
const traeKey = OAUTH_PROVIDER_IDS.TRAE;
assert.equal(traeKey, "trae");
});
// ---------------------------------------------------------------------------
// Shape
// ---------------------------------------------------------------------------
test("trae provider exposes the expected shape", () => {
const provider = PROVIDERS.trae;
assert.ok(provider, "trae provider must exist");
assert.equal(
provider.flowType,
"import_token",
"Trae uses import_token until ByteDance publishes a public OAuth client"
);
assert.ok(provider.config, "trae provider must have a config object");
assert.equal(typeof provider.mapTokens, "function", "trae provider must expose mapTokens");
});
test("TRAE_CONFIG has required API endpoint fields", () => {
assert.ok(typeof TRAE_CONFIG.apiEndpoint === "string" && TRAE_CONFIG.apiEndpoint.length > 0);
assert.ok(typeof TRAE_CONFIG.chatEndpoint === "string" && TRAE_CONFIG.chatEndpoint.length > 0);
assert.ok(typeof TRAE_CONFIG.webUrl === "string" && TRAE_CONFIG.webUrl.length > 0);
assert.ok(TRAE_CONFIG.apiEndpoint.startsWith("https://"), "apiEndpoint must use HTTPS");
assert.ok(TRAE_CONFIG.webUrl.startsWith("https://"), "webUrl must use HTTPS");
});
// ---------------------------------------------------------------------------
// mapTokens
// ---------------------------------------------------------------------------
test("trae mapTokens returns expected structure with valid input", () => {
const provider = PROVIDERS.trae;
const mapped = provider.mapTokens({
accessToken: "trae-test-token",
expiresIn: 7200,
});
assert.equal(mapped.accessToken, "trae-test-token");
assert.equal(mapped.refreshToken, null, "Trae import_token has no refresh token");
assert.equal(mapped.expiresIn, 7200);
assert.equal(mapped.providerSpecificData.authMethod, "imported");
});
test("trae mapTokens defaults expiresIn to 86400 when not provided", () => {
const provider = PROVIDERS.trae;
const mapped = provider.mapTokens({ accessToken: "trae-token-2" });
assert.equal(mapped.expiresIn, 86400);
});
test("trae mapTokens returns an object even when called with empty tokens", () => {
const provider = PROVIDERS.trae;
const mapped = provider.mapTokens({});
assert.ok(mapped && typeof mapped === "object");
assert.equal(mapped.refreshToken, null);
assert.equal(mapped.expiresIn, 86400);
});
// ---------------------------------------------------------------------------
// OAuth provider ID constant alignment
// ---------------------------------------------------------------------------
test("OAUTH_PROVIDER_IDS.TRAE matches the PROVIDERS key", () => {
const traeId = OAUTH_PROVIDER_IDS.TRAE;
assert.equal(traeId, "trae");
assert.ok(traeId in PROVIDERS, "PROVIDERS must include the key from OAUTH_PROVIDER_IDS.TRAE");
});