From 88f3c7b87227714a3c6b6069e72e60d0020d2c05 Mon Sep 17 00:00:00 2001 From: wavex-connectors-qa Date: Wed, 3 Jun 2026 12:04:11 -0700 Subject: [PATCH] fix(connectors): gate unauthenticated /api/connectors/* routes + harden env write - Add gateBoard() to the four global /api/connectors/* routes (setup-status, setup, enable-managed, catalog) that previously ran with no auth while every sibling credential route is board-gated. setup persists COMPOSIO_API_KEY to .env and enable-managed flips managed billing, so these were unauthenticated state mutations. - Reject CR/LF in the setup apiKey body and in writeOrUpdateEnvFile values to prevent newline-injection of extra .env lines. - Write .env with mode 0o600 (was default umask) since it may hold a plaintext Composio key. - Document WAVEX_COMPOSIO_MANAGED and WAVEX_CORE_URL in .env.example and clarify that WAVEX_COMPOSIO_DISABLED=0 is inert until COMPOSIO_API_KEY is set. - De-drift stale op-omega/op-omega-server references in plugin doc comments to wavex-os-server (the live route path is already /wavex-os/...; comments only). Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 12 +++++++++ .../src/ui/ConnectorsSidebar.tsx | 4 +-- packages/paperclip-plugin-wavex/src/worker.ts | 8 +++--- .../wavex-os-server/src/routes/credentials.ts | 27 ++++++++++++++++--- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 5e9b36b..ff81bab 100644 --- a/.env.example +++ b/.env.example @@ -9,8 +9,20 @@ # Flipping WAVEX_COMPOSIO_DISABLED=0 switches the connectors directory # from the curated 16-toolkit fallback to Composio's live catalog # (~200+ toolkits with logos + categories). +# NOTE: WAVEX_COMPOSIO_DISABLED=0 (live) is INERT until COMPOSIO_API_KEY is +# filled — with a blank key, live mode returns a "key missing" error and the +# directory shows its empty state. The code's dev default (this var unset and +# NODE_ENV!=production) is disabled / curated-fallback. COMPOSIO_API_KEY= WAVEX_COMPOSIO_DISABLED=0 +# WaveX-managed mode: operator runs WaveX-as-a-service and provides the +# Composio key server-side; end users never see the BYO-key form and are +# billed via their subscription tier instead. POST /api/connectors/enable-managed +# sets this at runtime; set it here to start in managed mode. +# WAVEX_COMPOSIO_MANAGED=0 +# Origin the onboarding UI's Vite dev proxy forwards /api + /wavex-os to +# (the mock-core sidecar). Defaults to http://127.0.0.1:3101. +# WAVEX_CORE_URL=http://127.0.0.1:3101 # ── Anthropic (T2 tier calls in apikey mode) ──────────────────────── # Only needed when WAVEX_INFERENCE_MODE=apikey (default in production). diff --git a/packages/paperclip-plugin-wavex/src/ui/ConnectorsSidebar.tsx b/packages/paperclip-plugin-wavex/src/ui/ConnectorsSidebar.tsx index ff2e6a9..b2d9b9d 100644 --- a/packages/paperclip-plugin-wavex/src/ui/ConnectorsSidebar.tsx +++ b/packages/paperclip-plugin-wavex/src/ui/ConnectorsSidebar.tsx @@ -6,13 +6,13 @@ * filter/sort, grouped cards. Each card has a brand logo + name + * "#N popular" badge + add (+) button. * - Add triggers real OAuth via the existing - * POST /op-omega/onboarding/connectors/oauth/initiate route, + * POST /wavex-os/onboarding/connectors/oauth/initiate route, * opening the redirect URL in a new tab and polling the vault * until the slug shows vaulted_valid (then the card flips to * connected). * - Disconnect calls DELETE /api/connectors/:companyId/:slug. * - * Backend reuse: connect/list/callback exist in op-omega-server's + * Backend reuse: connect/list/callback exist in wavex-os-server's * connectors.ts; connected-status + disconnect routes I added to * credentials.ts. No new tables. */ diff --git a/packages/paperclip-plugin-wavex/src/worker.ts b/packages/paperclip-plugin-wavex/src/worker.ts index 5f09940..1e72f89 100644 --- a/packages/paperclip-plugin-wavex/src/worker.ts +++ b/packages/paperclip-plugin-wavex/src/worker.ts @@ -3,7 +3,7 @@ * * Registers data + action handlers consumed by the Mission Control UI * widgets and the Inception Status sidebar. The only outbound HTTP - * target is the wavex-os op-omega-server (default http://127.0.0.1:3101). + * target is the wavex-os-server (default http://127.0.0.1:3101). * * No writes through this plugin. State-changing actions still flow * through Paperclip's native commands or the wavex MC routes — the @@ -27,7 +27,7 @@ const PAPERCLIP_BASE = "http://127.0.0.1:3100"; * * The host's plugin-bridge HTTP client refuses any URL that resolves to a * private/loopback IP as an SSRF guard. Mission Control's data source is - * the wavex op-omega-server sibling on 127.0.0.1:3101 — strictly local — + * the wavex-os-server sibling on 127.0.0.1:3101 — strictly local — * so we use the worker process's native fetch, which is not subject to * that guard. This keeps the wavex plugin honest about its only outbound * target (the same machine the host is on) without weakening the @@ -1286,8 +1286,8 @@ const plugin = definePlugin({ ); // ------------------------------------------------------------------- - // inception-status — reads /api/companies//agents from op-omega - // server. Returns ready/total counts + manifest goal/signed_at. + // inception-status — reads /api/companies//agents from the + // wavex-os-server. Returns ready/total counts + manifest goal/signed_at. // ------------------------------------------------------------------- ctx.data.register("inception-status", async ({ companyId }) => { const cfg = (await ctx.config.get()) as PluginConfig | null; diff --git a/packages/wavex-os-server/src/routes/credentials.ts b/packages/wavex-os-server/src/routes/credentials.ts index 2abfa1d..d6ce8f2 100644 --- a/packages/wavex-os-server/src/routes/credentials.ts +++ b/packages/wavex-os-server/src/routes/credentials.ts @@ -233,6 +233,13 @@ async function writeOrUpdateEnvFile( updates: Record, ): Promise { const { readFile, writeFile } = await import("node:fs/promises"); + // Defense-in-depth: never let a value carry a newline/CR that would split + // into an extra `.env` line and inject an unrelated env var. + for (const [k, v] of Object.entries(updates)) { + if (/[\r\n]/.test(v)) { + throw new Error(`env value for ${k} contains illegal newline characters`); + } + } let existing = ""; try { existing = await readFile(envPath, "utf8"); @@ -256,7 +263,9 @@ async function writeOrUpdateEnvFile( } // Trim trailing blank lines, ensure single newline at end. while (next.length > 0 && next[next.length - 1]!.trim() === "") next.pop(); - await writeFile(envPath, `${next.join("\n")}\n`, "utf8"); + // Restrictive perms: the .env may hold a plaintext Composio key; avoid a + // world-readable (0644) file. + await writeFile(envPath, `${next.join("\n")}\n`, { encoding: "utf8", mode: 0o600 }); } export function registerCredentialRoutes(app: FastifyInstance): void { @@ -391,7 +400,8 @@ export function registerCredentialRoutes(app: FastifyInstance): void { // to /.env, mutates process.env in-memory, and resets the // composio-shim cached client. No process restart required. - app.get("/api/connectors/setup-status", async (_req, reply) => { + app.get("/api/connectors/setup-status", async (req, reply) => { + if (!gateBoard(req, reply)) return; const hasKey = Boolean((process.env.COMPOSIO_API_KEY ?? "").trim()); const disabled = (process.env.WAVEX_COMPOSIO_DISABLED ?? "").toLowerCase(); const isDisabled = disabled === "1" || disabled === "true" || disabled === "yes"; @@ -449,10 +459,17 @@ export function registerCredentialRoutes(app: FastifyInstance): void { app.post<{ Body: { apiKey?: string } }>( "/api/connectors/setup", async (req, reply) => { + if (!gateBoard(req, reply)) return; const apiKey = (req.body?.apiKey ?? "").trim(); if (!apiKey) { return reply.code(400).send({ ok: false, error: "apiKey is required." }); } + // Reject control characters: the value is later written verbatim into + // the repo .env as `KEY=value`; an embedded newline would inject + // arbitrary additional env lines (e.g. ANTHROPIC_API_KEY=...). + if (/[\r\n]/.test(apiKey)) { + return reply.code(400).send({ ok: false, error: "apiKey contains illegal characters." }); + } // 1. Mutate process.env so the next getClient() call picks up // the new key (composio-shim's getComposioApiKey() re-reads // process.env on every call, and we reset the cached client @@ -519,7 +536,8 @@ export function registerCredentialRoutes(app: FastifyInstance): void { // key-entry screen. If no server-side master key is configured, the // catalog renders its empty state ("activates with your subscription") // rather than the BYO-key form — same final UX, no secret in the browser. - app.post("/api/connectors/enable-managed", async (_req, reply) => { + app.post("/api/connectors/enable-managed", async (req, reply) => { + if (!gateBoard(req, reply)) return; process.env.WAVEX_COMPOSIO_MANAGED = "1"; process.env.WAVEX_COMPOSIO_DISABLED = "0"; resetComposioClient(); @@ -546,7 +564,8 @@ export function registerCredentialRoutes(app: FastifyInstance): void { * built from this. Cached in-process for 5 min — the catalog is * ~200+ entries and rarely changes. */ let _cachedCatalog: { ts: number; rows: Array<{ slug: string; name: string; logo?: string; description?: string; category?: string; noAuth?: boolean; authSchemes?: string[] }>; source: "composio" | "curated" } | null = null; - app.get("/api/connectors/catalog", async (_req, reply) => { + app.get("/api/connectors/catalog", async (req, reply) => { + if (!gateBoard(req, reply)) return; const now = Date.now(); if (_cachedCatalog && now - _cachedCatalog.ts < 5 * 60_000) { return reply.send({ ok: true, ...{ rows: _cachedCatalog.rows, source: _cachedCatalog.source, cached: true } });