Repository navigation
feat(security): optional API key gate and configurable bind host - #134
veerareddyvishal144 wants to merge 2 commits into
Conversation
Lynkr listened on all interfaces and answered every request unauthenticated, which is fine behind a firewall but wrong for laptops and editor sidecars. - LYNKR_API_KEY gates /v1/*, /routing/*, /metrics* and /dashboard/api/* behind x-api-key, api-key or Authorization: Bearer (constant-time compare). Health probes stay open; unset keeps the previous open behaviour. The key is read through a getter so a hot-reloaded .env applies without remounting. - LYNKR_BIND_HOST defaults to 0.0.0.0 and accepts e.g. 127.0.0.1 for a loopback-only sidecar. - 401s use the Anthropic error shape, or invalid_api_key on OpenAI endpoints. - Upstream OAuth bearers survive: a client may send the Lynkr key in x-api-key and keep its subscription bearer for auth-mode passthrough. Docs in .env.example and documentation/production.md; 8 tests in test/api-key-auth.test.js wired into test:unit.
70f14eb to
c78aa80
Compare
|
🔍 OpenCodeReview found 6 issue(s) in this PR.
|
| @@ -652,6 +658,8 @@ const workerOffloadThresholdBytes = Number.parseInt(process.env.WORKER_OFFLOAD_T | |||
| var config = { | |||
| 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()); |
There was a problem hiding this comment.
The presentedKeys function extracts API keys from HTTP headers without validating their length before comparison. If a malicious client sends an extremely large header value (e.g., MBs of data), the Buffer.from() allocation in safeEqual could cause memory exhaustion or performance degradation (DoS vector). Consider adding a reasonable length limit (e.g., 1KB-2KB) for API key values before processing.
| function isProtected(path) { | ||
| const p = String(path || "/").split("?")[0]; |
| }; | ||
| } | ||
|
|
||
| module.exports = { createApiKeyAuth, isProtected, isOpenAiSurface, presentedKeys, safeEqual }; |
There was a problem hiding this comment.
| 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" : ""})`); |
There was a problem hiding this comment.
The server startup logs expose whether an API key is required. If logs are accessible to unauthorized parties, this could leak security configuration information. Consider using structured logging without sensitive flags, or masking the apiKeyRequired field in production.
Suggestion:
| console.log(`Claude→Databricks proxy listening on http://${shown}:${config.port} (bound to ${bindHost}${config.apiKey ? ", API key required" : ""})`); | |
| console.log(`Claude→Databricks proxy listening on http://${shown}:${config.port} (bound to ${bindHost})`); |
| 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"; |
There was a problem hiding this comment.
The config.bindHost property is added to the config object but is not updated in the reloadConfig() function. The comment indicates it is 'Not hot-reloadable', which is acceptable, but this should be documented consistently. Since config.apiKey is hot-reloadable, having bindHost not be hot-reloadable may cause confusion if someone expects both to update via the same mechanism.
| 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); | ||
| } |
There was a problem hiding this comment.
The early length check creates a timing side-channel. An attacker can measure response time to determine when lengths match. Consider using a constant-time length check or masking timing differences (e.g., add constant delay when lengths differ).
Suggestion:
| 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); | |
| } | |
| function safeEqual(a, b) { | |
| const ab = Buffer.from(String(a ?? ""), "utf8"); | |
| const bb = Buffer.from(String(b ?? ""), "utf8"); | |
| const match = crypto.timingSafeEqual(ab, bb); | |
| const lengthsMatch = ab.length === bb.length; | |
| return match && lengthsMatch; | |
| } |
| 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); | ||
| } |
| 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()); | ||
| 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; | ||
| } |
There was a problem hiding this comment.
Presented keys are trimmed but not validated for minimum length or format. If config.apiKey is accidentally set to a short/empty string, security is weakened. Consider adding a validation check when the key is set.
Suggestion:
| 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()); | |
| 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; | |
| } | |
| // In config/index.js, validate apiKey on load: | |
| const lynkrApiKey = (process.env.LYNKR_API_KEY ?? "").trim(); | |
| if (lynkrApiKey && lynkrApiKey.length < 16) { | |
| throw new Error("LYNKR_API_KEY must be at least 16 characters"); | |
| } | |
| const apiKey = lynkrApiKey || null; |
| function isProtected(path) { | ||
| const p = String(path || "/").split("?")[0]; | ||
| if (EXEMPT.has(p)) return false; | ||
| return PROTECTED.some((re) => re.test(p)); | ||
| } |
There was a problem hiding this comment.
Paths are split by ? to remove query strings, but if req.url contains a full URL (unlikely in Express but possible with certain proxy setups), this could fail. Add a defensive check or use req.path consistently.
Suggestion:
| function isProtected(path) { | |
| const p = String(path || "/").split("?")[0]; | |
| if (EXEMPT.has(p)) return false; | |
| return PROTECTED.some((re) => re.test(p)); | |
| } | |
| function isProtected(path) { | |
| const p = String(path || "/").replace(/^https?:\/\/[^\/]+/, "").split("?")[0]; | |
| if (EXEMPT.has(p)) return false; | |
| return PROTECTED.some((re) => re.test(p)); | |
| } |
| }; | ||
| } | ||
|
|
||
| module.exports = { createApiKeyAuth, isProtected, isOpenAiSurface, presentedKeys, safeEqual }; |
There was a problem hiding this comment.
Internal utility functions (isProtected, isOpenAiSurface, presentedKeys, safeEqual) are exported but should remain private to avoid external dependencies on implementation details. Consider keeping exports minimal: only createApiKeyAuth.
Suggestion:
| module.exports = { createApiKeyAuth, isProtected, isOpenAiSurface, presentedKeys, safeEqual }; | |
| module.exports = { createApiKeyAuth }; |
What
Two opt-in settings that close Lynkr's "open on all interfaces, no auth" default. Both leave current behaviour untouched unless you set them.
LYNKR_BIND_HOST0.0.0.0127.0.0.1keeps Lynkr reachable only from the local machine (editor sidecars, laptops). Needs a restart.LYNKR_API_KEY.env.API key gate
src/middleware/api-key-auth.jsis mounted before the dashboard and router, so it covers/v1/*,/routing/*,/metrics*and/dashboard/api/*./health,/health/liveand/health/readystay open for probes.x-api-key,api-keyorAuthorization: Bearer— Anthropic-, Azure- and OpenAI-style clients each already send one of these.crypto.timingSafeEqual); length is the only leak.authentication_error), orinvalid_api_keyon the OpenAI endpoints, so existing client error handling keeps working.config.reloadConfig()picks up a newLYNKR_API_KEYwithout remounting the middleware.x-api-keyand keep its upstream OAuth bearer inAuthorization— any one matching credential passes and the others are left forsrc/auth-mode.js.Why not just always require a key
Unset must stay open: existing deployments, Docker setups behind a firewall, and
lynkr wrapflows depend on it. This is a hardening option, not a breaking change.Tests
test/api-key-auth.test.js— 8 tests, wired intotest:unit:x-api-keypassesconfigparses/trimsLYNKR_BIND_HOSTandLYNKR_API_KEY, empty → opennpx eslint src/server.js src/config/index.js src/middleware/api-key-auth.js— clean.Notes
.env.exampleanddocumentation/production.md.