Opt-in, API-key-authenticated LAN access to the compatibility endpoints - #38
CryptoJones wants to merge 4 commits into
Conversation
…k plaintext callers Both inference proxies refuse plaintext requests that do not arrive from loopback. This package is the credential gate they will share so a LAN caller can be admitted only when the operator opts in by configuring API keys (NVPAIR_PROXY_API_KEYS_FILE, default <appdir>/proxy-api-keys, or NVPAIR_PROXY_API_KEYS) and the caller presents one as Authorization: Bearer or X-Api-Key, optionally restricted by NVPAIR_PROXY_ALLOWED_CIDRS. Keys are held only as SHA-256 digests and compared in constant time across every configured digest with no early exit. Every failure fails closed: a key file readable by other users, a malformed entry, an unreadable file, or a malformed CIDR contributes no keys. The key file is re-read when its size, modification time, or mode changes, so keys can be rotated or revoked without a restart. A rejected key is logged only as an eight-hex-digit digest fingerprint. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxtwuoRxP75PdR6NAmMS3 Signed-off-by: Aaron K. Clark (CryptoJones) <cryptojones@owasp.org>
…io proxies when keys are configured With no key configured nothing changes: a non-loopback plaintext request is still refused with 403 loopback-only. Once the operator configures a key, handlePlain consults nvpair-shared/ingressauth for a non-loopback caller: a caller outside NVPAIR_PROXY_ALLOWED_CIDRS gets 403 source-not-allowed, a missing or unknown key gets 401 unauthorized with a Bearer challenge, and a caller presenting a configured key has the credential stripped and is routed through the same local router a loopback client uses. Loopback callers are never asked for a key, and an OPTIONS preflight is still answered ahead of the gate. Bump ollama-proxy to 0.27.0 and lmstudio-proxy to 0.17.0 (additive HTTP-visible behavior), and product/installer to 0.92.0 per VERSIONING. Closes NVIDIA#28. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxtwuoRxP75PdR6NAmMS3 Signed-off-by: Aaron K. Clark (CryptoJones) <cryptojones@owasp.org>
Loopback-only stays the documented default. SECURITY.md, the architecture trust tables, the getting-started guide (a new "Reaching PAIR from Another Machine" section with key generation, file location per OS, permissions, and client configuration), troubleshooting (the 401 unauthorized and 403 source-not-allowed cases), the overview, both proxy READMEs (environment variables and response codes), and the OpenAPI description (securitySchemes) now describe the opt-in gate, what it checks, what it does not add, and the exposure an operator accepts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxtwuoRxP75PdR6NAmMS3 Signed-off-by: Aaron K. Clark (CryptoJones) <cryptojones@owasp.org>
Read the key file through the opened handle and re-read it by content hash rather than a stat stamp: a stat-then-open check validated a file it did not necessarily read, and a stamp of size and modification time cannot see a same-length rewrite within the filesystem's timestamp granularity, which is exactly the rotation a compromised key needs. Re-check at most once per second so a node that never opted in does not pay an open() per LAN request. Refuse a key file another user owns, the way sshd treats authorized_keys, and fail closed when ownership cannot be determined. Judge each request from one Authorize call that also reports whether the gate is enabled, so enablement and the key set cannot change between the two questions. Apply the CIDR allowlist before answering a preflight, so a source the operator excluded gets nothing. Accept the key from either the Authorization or X-Api-Key header when both are present, since an SDK may send a placeholder Bearer beside the real key. Carry error="invalid_token" on a rejected key per RFC 6750, restrict keys to the b64token alphabet so they survive any conformant intermediary, and log a key rotation even when the key count is unchanged, as SECURITY.md promises. Documentation: a TLS-terminating or reverse proxy on the same host presents every client as loopback and must do its own authentication; a key reaches inference routed to peers and Ollama's model-management routes; inline environment keys remain in the process environment in clear; Windows relies on the data directory's ACL; any same-user process can enable the gate by creating the file. Add fuzz targets for the parsers and header extraction, a concurrent-rotation test, and the isLoopbackRemote table test to lmstudio-proxy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxtwuoRxP75PdR6NAmMS3 Signed-off-by: Aaron K. Clark (CryptoJones) <cryptojones@owasp.org>
|
Tried it on the OpenShift setup from #31/#32, as asked on #28. It does what the description says, and it is the better answer for a Kubernetes workload than the relay. Two findings for Kubernetes users, one of which is a fail-closed path that will catch people. Environment. Inline key (
Finding 1: the key-file path fails closed on Kubernetes, by design, and the reason is invisible to most operators. A Secret mounted as a file under a pod Finding 2, a good one: a bad key file does not poison inline keys. With the 0440 file still configured and the inline var added, the gate enabled and every row above held. So "contributes no keys" is per source, not per process; worth one sentence in the README since the description's "the LAN stays closed" reads as global. Source address on OVN-Kubernetes, for the Not captured: the startup For #31: once this merges, the manifests there can point the Service at port 1234 with the key from a Secret and drop the relay entirely, which also removes the trust-boundary paragraph I had to correct today. |
|
This is a great idea, thank you for bringing it up. We're having some discussions internally about the security implications and how we want to navigate that and will get back to you on this. |
Description
Closes #28.
PAIR's Ollama- and OpenAI-compatible proxies refuse every plaintext request that does not arrive from loopback (
403 loopback-only). That is the right default, but it leaves no supported way for an ordinary OpenAI-compatible client on a trusted LAN — an automation host, a container, a Kubernetes workload, a Windows workstation's SDK — to use a node without running PAIR itself. #28 asks for an explicit, documented opt-in; the relay described in its comments (#31) shows people are already working around the gate with something that does no authentication at all.This adds an opt-in, API-key-gated path for non-loopback plaintext callers, shared by both proxies:
403 loopback-only.TestHandlePlainRejectsNonLoopbackis untouched in both proxies.NVPAIR_PROXY_API_KEYS_FILE, defaultproxy-api-keysin the PAIR data directory) and/orNVPAIR_PROXY_API_KEYSinline. One key per line,#comments; a key must be ≥ 32 characters from the RFC 6750b64tokenalphabet (letters, digits,- . _ ~ + / =), generated randomly.Authorization: Bearer <key>(orX-Api-Key: <key>, the Anthropic SDK convention — relevant to [Feature]: Expose the Anthropic Messages API (POST /v1/messages) on the PAIR proxy #16/Add Anthropic Messages API Routing #27; either header may carry it) is routed through the same local router a loopback client uses. Missing key →401 unauthorizedwithWWW-Authenticate: Bearer realm="nvpair-proxy"; a presented but unknown key addserror="invalid_token"(RFC 6750 §3.1).NVPAIR_PROXY_ALLOWED_CIDRS; a caller outside it gets403 source-not-allowedbefore its key is even examined.Authorizationheader is forwarded untouched, as today.mesh.Refresh()already handles cluster membership. The file is a few short lines; the read is cheap.crypto/subtle.ConstantTimeCompareover every configured digest with no early exit; a rejection logs the caller's address and the first 8 hex digits of the presented key's digest, never the key. Forwarding headers (X-Forwarded-Foretc.) are never consulted. Enabling the gate logs aWARNat startup and on every key-set change.OPTIONSpreflight is still answered204ahead of the gate (browsers send noAuthorizationon a preflight); the existing test for that ordering is unchanged.This does not reopen the amplification concern that keeps the mTLS ingress terminal: an authenticated LAN caller takes the same one-hop path a loopback client takes today, and a peer hop still lands on the mTLS ingress, which never routes onward.
Scope
In:
nvpair-shared/ingressauth(new package, the single implementation both proxies use, following thenvpair-shared/corsprecedent); the gate call inhandlePlainof both proxies; tests at both layers; documentation.Out (deliberately): rate limiting / lockout (a ≥ 32-character random key makes online guessing infeasible; can be added later without changing the contract); TLS on the plaintext personality (terminate TLS in front if the network is not trusted — documented); Settings-tab / JSON-RPC wiring for the key (the key file is the operator surface for this PR — no JSON-RPC method or payload changes, so no broker relay, desktop bridge, or
service-contractschanges); per-key scopes.Validation
Go 1.26 on macOS 15 (arm64) and Ubuntu (x86_64). Desktop tree untouched, so the npm gates were not run.
gofmt -l/go vet ./...onshared,ollama-proxy,lmstudio-proxygo test ./...services/shared(incl. newingressauth, 24 tests, also under-race)go test ./...services/lmstudio-proxygo test ./...services/ollama-proxy-racego test -fuzzFuzzValidateKey/FuzzParseKeys/FuzzCredentialsFrom, 10 s eachservices/build.sh(13 binaries;ollama-proxy --version→ 0.27.0,lmstudio-proxy --version→ 0.17.0)make test-services(every Go module)services/tests²cd services/tests && go test ./...(cross-process, real binaries)node scripts/spdx-headers.mjs¹
TestAliasSelfTargetMatchesBoundLoopbackAddressNotPortAlonebinds127.0.0.2, which macOS does not alias by default; it fails identically on pristinemain(13b6811) and passes on Linux.²
TestWorkloadManager*/TestWorkload*inservices/testsfail identically on pristinemainon that host (server cert not pinnedagainst127.0.0.1:14320); the host runs a live PAIR service tree (cluster manager on :14321). They pass on the macOS host and do not exercise this change.Live LAN check,
ollama-proxybuilt from this branch on a spare port (11499) on one node, probed withcurlfrom another node on the LAN, key file0600holding oneopenssl rand -hex 32key:WARN authenticated LAN ingress ENABLED keys=1 …503 model inventory unavailable, since it knows no nodes; the gate is what is under test)401,Www-Authenticate: Bearer realm="nvpair-proxy",Access-Control-Allow-Origin: *, body{"code":"unauthorized",…}; logkey_fp=none401; logkey_fp=108f5470Authorization: Bearerkey_fp=7cf2fd42=sha256(key)[:8]X-Api-Keychmod 644key file403 loopback-only; logERROR … permissions 0644 allow other users to read it; chmod 600, thenINFO … disabledchmod 600againWARN … ENABLEDNVPAIR_PROXY_ALLOWED_CIDRS=10.99.0.0/24, LAN caller outside it, right key403 source-not-allowed; logkey_fp=none(key never examined)Independent review. The diff was reviewed by a panel of twelve independently trained models (Anthropic, OpenAI, Google, DeepSeek, Mistral, Z.AI, MiniMax, NVIDIA Nemotron, poolside), one at a time, each blind to the others. No lane found a bypass. Adopted into the final commit: the key file is opened and checked through the opened handle and re-read by content hash (closes a stat/open TOCTOU and a same-size, same-mtime rewrite that a stat stamp cannot see); a single
Authorizecall reports enablement and judges the request from one view (no window between "is the gate on" and "is this key good"); the documentation no longer suggests a same-host TLS-terminating proxy, which would present every external client as a loopback caller; RFC 6750error="invalid_token"on a rejected key; either presented header may carry the key; an sshd-style owner check on the key file; the key alphabet restricted tob64tokencharacters and bounded at 512 bytes (a presented credential longer than that is not hashed); the CIDR allowlist applied before the preflight; a 1 s re-check floor so a node that never opted in does notopen()the key file per LAN request; key rotation logged even when the key count is unchanged; successful LAN authentications logged at Info; fuzz targets and a concurrent-rotation test; and SECURITY.md statements about cluster-wide reach of a key, multi-user hosts, same-host reverse proxies, same-user processes, and Windows ACLs.Risk
SECURITY.md, the getting-started guide, and the startupWARN. A holder of a key can do everything a local application can, including routing to peers; documented.desktop/tree is untouched.127.0.0.1in a LAN user's browser reaches the proxy as a loopback caller today, before and after this change. fix: resolve 14 review issues across the service control plane #14's deny-by-default Origin allowlist is the fix path for that; this PR does not touch it.ingress.gofiles (CORS deny-by-default,writeIngressErrorsignature). This change is a self-contained block insidehandlePlainand uses the sameNVPAIR_PROXY_*env-var convention, so either order rebases cleanly.Versions
ollama-proxy0.26.2 → 0.27.0 andlmstudio-proxy0.16.2 → 0.17.0 (MINOR: new, additive, HTTP-visible behavior). Perservices/VERSIONING.md, a MINOR component bump impliesproduct/installer0.91.7 → 0.92.0; I applied that rule literally — please adjust if the product series is managed differently.AI disclosure
This change was written with AI assistance and reviewed by a panel of AI models, under the direction of the human author, who tested it on his own hardware and takes responsibility for it.
opus), Claude Sonnet (sonnet), Claude Haiku (haiku), Claude Fable 5.1 (fable) — via theclaudeCLI; the Opus lane also ran probes against the working treeopenai/gpt-oss-120bgemini-3.6-flash-medium(viaagy)deepseek/deepseek-v4-flashmistralai/mistral-large-2512z-ai/glm-5.3-flashminimax/minimax-m2.7nvidia/nemotron-3-super-120b-a12bpoolside/laguna-s-2.1No model found a bypass. Transcripts of every lane's answer are retained by the author and can be shared with maintainers on request.
Checklist
git commit -s), certifying the Developer Certificate of Origin.services/versions.json, and described user-visible changes above so they reach the release notes.Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/
🤖 Generated with Claude Code
https://claude.ai/code/session_01BwxtwuoRxP75PdR6NAmMS3