Repository navigation
feat(security): optional API key gate and configurable bind host #134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -482,6 +482,12 @@ const defaultModel = | |
| (modelProvider === "azure-anthropic" ? "claude-opus-4-5" : "databricks-claude-sonnet-4-5"); | ||
|
|
||
| const port = Number.parseInt(process.env.PORT ?? "8080", 10); | ||
| // Interface to bind. Default keeps today's behaviour (all interfaces); set 127.0.0.1 for a | ||
| // loopback-only sidecar (e.g. when an editor embeds Lynkr). Not hot-reloadable. | ||
| const bindHost = (process.env.LYNKR_BIND_HOST ?? "0.0.0.0").trim() || "0.0.0.0"; | ||
| // Optional API key required on /v1/*, /routing/*, /metrics* and /dashboard/api/* (see | ||
| // src/middleware/api-key-auth.js). Empty/unset = open, exactly as before. | ||
| const lynkrApiKey = (process.env.LYNKR_API_KEY ?? "").trim() || null; | ||
| const sessionDbPath = | ||
| process.env.SESSION_DB_PATH ?? path.join(process.cwd(), "data", "sessions.db"); | ||
| const workspaceRoot = path.resolve(process.env.WORKSPACE_ROOT ?? process.cwd()); | ||
|
|
@@ -666,6 +672,8 @@ const workerOffloadThresholdBytes = Number.parseInt(process.env.WORKER_OFFLOAD_T | |
| var config = { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| env: process.env.NODE_ENV ?? "production", | ||
| port: Number.isNaN(port) ? 8080 : port, | ||
| bindHost, | ||
| apiKey: lynkrApiKey, | ||
| databricks: { | ||
| baseUrl: rawBaseUrl, | ||
| apiKey, | ||
|
|
@@ -1227,6 +1235,7 @@ function reloadConfig() { | |
| // Update mutable config values (those that can safely change at runtime) | ||
| // API keys and endpoints | ||
| config.databricks.apiKey = process.env.DATABRICKS_API_KEY; | ||
| config.apiKey = (process.env.LYNKR_API_KEY ?? "").trim() || null; | ||
| config.azureAnthropic.apiKey = process.env.AZURE_ANTHROPIC_API_KEY ?? null; | ||
| config.ollama.model = process.env.OLLAMA_MODEL ?? "qwen2.5-coder:7b"; | ||
| config.openrouter.apiKey = process.env.OPENROUTER_API_KEY ?? null; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,77 @@ | ||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||
| * Optional API-key gate for Lynkr's own HTTP surface. | ||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||
| * When `LYNKR_API_KEY` is set (config.apiKey), every request under /v1/*, /routing/*, /metrics* | ||||||||||||||||||||||||||||||||||||||||||
| * and /dashboard/api/* must present that key as `x-api-key: <key>`, `api-key: <key>` or | ||||||||||||||||||||||||||||||||||||||||||
| * `Authorization: Bearer <key>`. Health probes stay open. When no key is configured the | ||||||||||||||||||||||||||||||||||||||||||
| * middleware is a no-op, so existing deployments behave exactly as before. | ||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||
| * Why all three header forms: Anthropic-style clients send x-api-key, Azure-style clients send | ||||||||||||||||||||||||||||||||||||||||||
| * api-key, OpenAI-style clients send a bearer. A client may legitimately carry an *upstream* | ||||||||||||||||||||||||||||||||||||||||||
| * OAuth bearer (subscription passthrough, see src/auth-mode.js) alongside the Lynkr key in | ||||||||||||||||||||||||||||||||||||||||||
| * x-api-key — any one matching credential is enough, the others are left untouched for the | ||||||||||||||||||||||||||||||||||||||||||
| * downstream auth-mode logic. | ||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||
| const crypto = require("node:crypto"); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const PROTECTED = [/^\/v1(\/|$)/, /^\/routing(\/|$)/, /^\/metrics(\/|$)/, /^\/dashboard\/api(\/|$)/]; | ||||||||||||||||||||||||||||||||||||||||||
| const EXEMPT = new Set(["/health", "/health/live", "/health/ready"]); | ||||||||||||||||||||||||||||||||||||||||||
| const OPENAI_SURFACE = [/^\/v1\/chat\/completions/, /^\/v1\/responses/, /^\/v1\/embeddings/]; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| /** Constant-time string comparison (length leak only). */ | ||||||||||||||||||||||||||||||||||||||||||
| function safeEqual(a, b) { | ||||||||||||||||||||||||||||||||||||||||||
| const ab = Buffer.from(String(a ?? ""), "utf8"); | ||||||||||||||||||||||||||||||||||||||||||
| const bb = Buffer.from(String(b ?? ""), "utf8"); | ||||||||||||||||||||||||||||||||||||||||||
| if (ab.length === 0 || ab.length !== bb.length) return false; | ||||||||||||||||||||||||||||||||||||||||||
| return crypto.timingSafeEqual(ab, bb); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+22
to
+27
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggestion:
Suggested change
Comment on lines
+22
to
+27
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| /** Every credential the request presents, in header order of preference. */ | ||||||||||||||||||||||||||||||||||||||||||
| function presentedKeys(headers = {}) { | ||||||||||||||||||||||||||||||||||||||||||
| const out = []; | ||||||||||||||||||||||||||||||||||||||||||
| const get = (name) => { | ||||||||||||||||||||||||||||||||||||||||||
| const v = headers[name] ?? headers[name.toLowerCase()]; | ||||||||||||||||||||||||||||||||||||||||||
| return Array.isArray(v) ? v[0] : v; | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
| const xApiKey = get("x-api-key"); | ||||||||||||||||||||||||||||||||||||||||||
| if (xApiKey) out.push(String(xApiKey).trim()); | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+30
to
+37
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||||||||||||||||||||||
| const apiKey = get("api-key"); | ||||||||||||||||||||||||||||||||||||||||||
| if (apiKey) out.push(String(apiKey).trim()); | ||||||||||||||||||||||||||||||||||||||||||
| const auth = get("authorization"); | ||||||||||||||||||||||||||||||||||||||||||
| if (auth && /^Bearer\s+/i.test(auth)) out.push(String(auth).replace(/^Bearer\s+/i, "").trim()); | ||||||||||||||||||||||||||||||||||||||||||
| return out; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+30
to
+43
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggestion:
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| function isProtected(path) { | ||||||||||||||||||||||||||||||||||||||||||
| const p = String(path || "/").split("?")[0]; | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+45
to
+46
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||||||||||||||||||||||||||
| if (EXEMPT.has(p)) return false; | ||||||||||||||||||||||||||||||||||||||||||
| return PROTECTED.some((re) => re.test(p)); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+45
to
+49
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggestion:
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| function isOpenAiSurface(path) { | ||||||||||||||||||||||||||||||||||||||||||
| const p = String(path || "/").split("?")[0]; | ||||||||||||||||||||||||||||||||||||||||||
| return OPENAI_SURFACE.some((re) => re.test(p)); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||
| * @param {string|null|(() => string|null)} keyOrGetter configured key, or a getter so hot-reloaded | ||||||||||||||||||||||||||||||||||||||||||
| * values (config.apiKey) are honoured without re-mounting. | ||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||
| function createApiKeyAuth(keyOrGetter) { | ||||||||||||||||||||||||||||||||||||||||||
| const getKey = typeof keyOrGetter === "function" ? keyOrGetter : () => keyOrGetter; | ||||||||||||||||||||||||||||||||||||||||||
| return function apiKeyAuth(req, res, next) { | ||||||||||||||||||||||||||||||||||||||||||
| const key = getKey(); | ||||||||||||||||||||||||||||||||||||||||||
| if (!key) return next(); | ||||||||||||||||||||||||||||||||||||||||||
| const path = req.path || req.url; | ||||||||||||||||||||||||||||||||||||||||||
| if (!isProtected(path)) return next(); | ||||||||||||||||||||||||||||||||||||||||||
| if (presentedKeys(req.headers).some((k) => safeEqual(k, key))) return next(); | ||||||||||||||||||||||||||||||||||||||||||
| res.status(401); | ||||||||||||||||||||||||||||||||||||||||||
| res.set("WWW-Authenticate", 'Bearer realm="lynkr"'); | ||||||||||||||||||||||||||||||||||||||||||
| if (isOpenAiSurface(path)) { | ||||||||||||||||||||||||||||||||||||||||||
| return res.json({ error: { message: "invalid or missing API key", type: "invalid_request_error", code: "invalid_api_key" } }); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| return res.json({ type: "error", error: { type: "authentication_error", message: "invalid or missing API key" } }); | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| module.exports = { createApiKeyAuth, isProtected, isOpenAiSurface, presentedKeys, safeEqual }; | ||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggestion:
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -29,6 +29,11 @@ function createApp() { | |||||
| const path = require('path'); | ||||||
| const fs = require('fs'); | ||||||
|
|
||||||
| // Optional API key (LYNKR_API_KEY) — mounted first so it also covers the dashboard JSON API and | ||||||
| // /metrics* registered below. Reads config.apiKey through a getter so a hot-reloaded .env applies. | ||||||
| const { createApiKeyAuth } = require('./middleware/api-key-auth'); | ||||||
| app.use(createApiKeyAuth(() => config.apiKey)); | ||||||
|
|
||||||
| // Dashboard — registered first so it is never shadowed by the main router | ||||||
| const DASHBOARD_HTML = path.resolve(__dirname, '../public/dashboard.html'); | ||||||
| app.get('/dashboard', (_req, res) => { | ||||||
|
|
@@ -233,12 +238,15 @@ async function start() { | |||||
| } | ||||||
| } | ||||||
|
|
||||||
| const server = app.listen(config.port, (err) => { | ||||||
| const bindHost = config.bindHost || "0.0.0.0"; | ||||||
| const server = app.listen(config.port, bindHost, (err) => { | ||||||
| // Express 5 invokes this callback on bind errors too (it wires the | ||||||
| // callback to the server 'error' event) — without the guard, a failed | ||||||
| // bind prints "listening" right before the EADDRINUSE failure. | ||||||
| if (err) return; | ||||||
| console.log(`Claude→Databricks proxy listening on http://localhost:${config.port}`); | ||||||
| const shown = bindHost === "0.0.0.0" || bindHost === "::" ? "localhost" : bindHost; | ||||||
| console.log(`Claude→Databricks proxy listening on http://${shown}:${config.port} (bound to ${bindHost}${config.apiKey ? ", API key required" : ""})`); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggestion:
Suggested change
|
||||||
| logger.info({ port: config.port, bindHost, apiKeyRequired: Boolean(config.apiKey) }, "HTTP server bound"); | ||||||
| }); | ||||||
|
|
||||||
| // Surface bind failures to the caller. Without this, EADDRINUSE from a | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
config.bindHostproperty is added to the config object but is not updated in thereloadConfig()function. The comment indicates it is 'Not hot-reloadable', which is acceptable, but this should be documented consistently. Sinceconfig.apiKeyis hot-reloadable, havingbindHostnot be hot-reloadable may cause confusion if someone expects both to update via the same mechanism.