Skip to content

feat(security): optional API key gate and configurable bind host - #134

Open
veerareddyvishal144 wants to merge 2 commits into
mainfrom
feat/api-key-auth-and-bind-host
Open

veerareddyvishal144 wants to merge 2 commits into
mainfrom
feat/api-key-auth-and-bind-host

Conversation

@veerareddyvishal144

Copy link
Copy Markdown
Contributor

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.

Env var Default Effect
LYNKR_BIND_HOST 0.0.0.0 Bind interface. 127.0.0.1 keeps Lynkr reachable only from the local machine (editor sidecars, laptops). Needs a restart.
LYNKR_API_KEY unset (open) Shared secret required on the API surface. Hot-reloads with .env.

API key gate

src/middleware/api-key-auth.js is mounted before the dashboard and router, so it covers /v1/*, /routing/*, /metrics* and /dashboard/api/*. /health, /health/live and /health/ready stay open for probes.

  • Accepts x-api-key, api-key or Authorization: Bearer — Anthropic-, Azure- and OpenAI-style clients each already send one of these.
  • Comparison is constant-time (crypto.timingSafeEqual); length is the only leak.
  • 401 bodies use the Anthropic error shape (authentication_error), or invalid_api_key on the OpenAI endpoints, so existing client error handling keeps working.
  • The configured key is read through a getter, so config.reloadConfig() picks up a new LYNKR_API_KEY without remounting the middleware.
  • Subscription passthrough is preserved: a client can send the Lynkr key in x-api-key and keep its upstream OAuth bearer in Authorization — any one matching credential passes and the others are left for src/auth-mode.js.

Why not just always require a key

Unset must stay open: existing deployments, Docker setups behind a firewall, and lynkr wrap flows depend on it. This is a hardening option, not a breaking change.

Tests

test/api-key-auth.test.js — 8 tests, wired into test:unit:

  • unset key leaves everything open
  • protected routes 401 without a key; health stays open
  • each header form unlocks; a wrong key does not
  • upstream OAuth bearer alongside a valid x-api-key passes
  • error body shapes (OpenAI vs Anthropic)
  • getter form honours hot-reloaded keys
  • config parses/trims LYNKR_BIND_HOST and LYNKR_API_KEY, empty → open
✔ 8 pass, 0 fail

npx eslint src/server.js src/config/index.js src/middleware/api-key-auth.js — clean.

Notes

  • Version bumped to 9.14.23 (auto-tag will tag it as-is rather than bump-patching).
  • Docs updated in .env.example and documentation/production.md.

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.
@veerareddyvishal144
veerareddyvishal144 force-pushed the feat/api-key-auth-and-bind-host branch from 70f14eb to c78aa80 Compare October 31, 2026 07:05
@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 6 issue(s) in this PR.

  • ✅ Successfully posted inline: 6 comment(s)

Comment thread src/config/index.js
@@ -652,6 +658,8 @@ const workerOffloadThresholdBytes = Number.parseInt(process.env.WORKER_OFFLOAD_T
var config = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · high
The config object is declared using var instead of const or let. According to code quality standards, var is strictly prohibited.

Comment on lines +30 to +37
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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · high
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.

Comment on lines +45 to +46
function isProtected(path) {
const p = String(path || "/").split("?")[0];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
Path normalization logic (splitting on '?' to remove query parameters) is duplicated in both isProtected and isOpenAiSurface functions. Consider extracting this to a shared helper function for consistency and easier maintenance.

};
}

module.exports = { createApiKeyAuth, isProtected, isOpenAiSurface, presentedKeys, safeEqual };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
The middleware exports internal functions (isProtected, isOpenAiSurface, presentedKeys, safeEqual) that are not needed by external consumers. Consider removing these exports and keeping them private to the module. Only createApiKeyAuth should be publicly exported.

Comment thread src/server.js
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" : ""})`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · low
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:

Suggested change
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})`);

Comment thread src/config/index.js
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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
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.

Comment on lines +22 to +27
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · medium
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:

Suggested change
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;
}

Comment on lines +22 to +27
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · medium
The safeEqual function uses crypto.timingSafeEqual which requires buffers of equal length. The length check is present but creates a timing side-channel. Consider using constant-time comparison for length as well.

Comment on lines +30 to +43
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · low
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:

Suggested change
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;

Comment on lines +45 to +49
function isProtected(path) {
const p = String(path || "/").split("?")[0];
if (EXEMPT.has(p)) return false;
return PROTECTED.some((re) => re.test(p));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
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:

Suggested change
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 };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
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:

Suggested change
module.exports = { createApiKeyAuth, isProtected, isOpenAiSurface, presentedKeys, safeEqual };
module.exports = { createApiKeyAuth };

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant