Opt-in, API-key-authenticated LAN access to the compatibility endpoints - #38
CryptoJones wants to merge 6 commits into
Conversation
|
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. |
|
Heads up — This PR edits files in those directories, so it will need updating before it can Apologies for the churn, and thanks for the contribution. Happy to help work out |
…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> (cherry picked from commit ef26366)
develop merged the Ollama and LM Studio proxies into a single nvpair-proxy process (one facade per enabled engine), so this rebases the branch's four commits onto develop and re-applies the gate there: - handlePlain on the facade consults the gate; lanAuth lives on Proxy so every enabled engine's facade enforces one identical gate. - The default stays loopback-only; an enabled gate answers the CIDR allowlist first (even for a preflight), then 401 with the RFC 6750 challenge, and strips the key before routing. - ingress_auth_test.go is adapted to soleFacade/host.lanAuth. No version-file edits: versions are now declared via the PR's release-intent block and applied by CI. Signed-off-by: Aaron K. Clark <cryptojones@owasp.org>
a4ca630 to
751f98e
Compare
No apologies needed. Such is the life of a SWE. =) Please give me some time to implement and test. CJ |
develop replaced cors.WritePreflight with engine-backed preflights (cors.ServePreflight) when browser access began following the engine's CORS policy, so the port no longer compiled. A non-loopback preflight now skips only the credential check and continues into handleHTTP, which answers it from the engine exactly as for a loopback caller. The source allowlist still applies first, and the request that follows still receives the real 401/403. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSXRLJk5knQd4rNnn7XH1S Signed-off-by: Aaron K. Clark <cryptojones@owasp.org>
A Secret mounted under a pod fsGroup arrives root-owned and group-readable (0440), which the key-file check refused, so the gate stayed closed on OpenShift and most clusters (reported on NVIDIA#38). Group read is now accepted in that shape only: a root-owned file, readable and not writable by a group the proxy belongs to, with nothing granted to others. Requiring root ownership keeps a user from opening their own key to a shared login group such as macOS "staff". The permission and ownership rules move together into checkKeyFileAccess, per platform. The docs now recommend the inline variable from a Secret via valueFrom on Kubernetes, describe the accepted mount shape and the pod-CIDR allowlist, and state that a refused key file does not disable inline keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSXRLJk5knQd4rNnn7XH1S Signed-off-by: Aaron K. Clark <cryptojones@owasp.org>
An 11-model review of the port found two problems, several proved by probe tests against the branch. A keyless non-loopback preflight skipped the credential check and entered handleHTTP, which reads the whole body into memory before it recognizes a preflight (a 256 MiB probe was read in full), then fans the OPTIONS out to every cluster candidate over this node's mTLS identity and, with one candidate, relays the engine's raw reply for any path. A preflight is now judged like any other request: without a key it gets the 401. Browsers cannot send a key on a preflight, and the 401 carries no CORS, so a web page on another machine was never a working client; the docs now say so. The group-read exception for a root-owned key file in one of the proxy's groups could not tell a Kubernetes fsGroup from a shared group: root:staff, root:everyone and root:admin 0440 were all accepted on macOS, and the refusal message steered operators toward that shape. Group read is refused again. On Kubernetes the key goes in through NVPAIR_PROXY_API_KEYS from a Secret via valueFrom, as the docs say. Also: the proxy spec and README no longer say LAN plaintext is always refused, the OpenAPI document attaches its security schemes as optional, the docs state that a malformed inline key disables the gate, and a test comment no longer claims the 401 carries CORS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSXRLJk5knQd4rNnn7XH1S Signed-off-by: Aaron K. Clark <cryptojones@owasp.org>
…eflight From the verification round on e65ea8a, which found both fixes closed and no regression: - The optional credential moves from the document root onto the two proxy operations. /v1/node-info is served by nvpair-node-info, which has no key gate. - A preflight that carries a valid key is tested: routed like any authenticated request, with the key stripped. - The key-file refusal names group access and points at NVPAIR_PROXY_API_KEYS, and the package doc, SECURITY.md and the troubleshooting page say group-readable rather than only other-readable. SECURITY.md reconciles "prefer the file" with the Kubernetes guidance, and troubleshooting lists a malformed inline key or allowlist entry as a cause of 403 loopback-only. - The proxy README scopes its preflight description to loopback callers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSXRLJk5knQd4rNnn7XH1S Signed-off-by: Aaron K. Clark <cryptojones@owasp.org>
|
@DustinTrap thank you for the OpenShift run. It was exactly the evidence this needed. The branch is now ported onto What changed since you tested, and what to expect:
The rows I'd most like re-confirmed on your setup:
|
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 to the unified
nvpair-proxy, enforced identically by every engine facade it hosts (Ollama and LM Studio):403 loopback-only.TestHandlePlainRejectsNonLoopbackis untouched.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.fsGroupfrom a shared group such as macOSstaff; on Kubernetes the key goes in throughNVPAIR_PROXY_API_KEYSfrom a Secret viavalueFrom. The reason is logged once per distinct problem. A malformed inline key or CIDR disables the gate for the process — a mistyped allowlist must not silently leave the allowlist off.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 gets no exemption. A keyless preflight from another machine is refused401like any keyless request, before any body is read or any engine or peer is contacted. A browser cannot send a key on a preflight, so a web page on another machine is not a supported client; it never was, since before this PR every LAN caller was refused. Loopback preflights are 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, following thenvpair-shared/corsprecedent); the gate, held once onProxyand called fromhandlePlainof every facade; 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
Ported onto
developafter the two proxies were unified intonvpair-proxy(base6abb9ec). Go on macOS (arm64):gofmt -l/go vet ./...onshared/ingressauthandnvpair-proxy;GOOS=linuxandGOOS=windowsvet ofingressauthgo test ./...services/shared(incl.ingressauthand the owner/mode matrix inowner_unix_test.go)go test -race ./...services/nvpair-proxy(incl.ingress_auth_test.go)make check(SPDX, build-script verify, lint, typecheck, service contracts, desktop unit tests)npm run dead-code:checkmake test(desktop unit + every Go module)¹
nvpair-engine-managerTestUninstallTerminatesRunningInstancefails identically on pristinedevelop(6abb9ec, 5/5 runs) on this host; this PR does not touch that module.Kubernetes (from review, thanks @DustinTrap): verified on single-node OpenShift 4.22 with the key from a Secret via
valueFrom: every gate row held, including403 source-not-allowedfor a LAN host outside a pod-networkNVPAIR_PROXY_ALLOWED_CIDRS. The key-file path failed closed under a podfsGroup(root:<fsGroup> 0440). Accepting that shape was tried and withdrawn: the review below showed the same rule acceptsroot:staff,root:everyoneandroot:adminon macOS. The getting-started guide now directs Kubernetes tovalueFromand the pod CIDR for the allowlist, and says why a mounted Secret file is refused.Live LAN check (before the port, against the then-separate
ollama-proxy), built 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.Review of the port (2026-09-22). The ported branch (
f61aa4b) went to eleven independently trained models, one at a time, each blind to the others; four ran with tool access against a disposable worktree and wrote probe tests. They found two real problems, both since fixed ine65ea8a:handleHTTP, which reads the whole body before recognizing a preflight (a 256 MiB probe was read in full), fans theOPTIONSout to every cluster candidate over this node's mTLS identity, and with one candidate relays the engine's raw reply for any path. Proved independently by three lanes. Fixed by removing the exemption.A verification round on
e65ea8a(six lanes, four with tools) found both closed and no regression to loopback CORS, keyed requests, the out-of-CIDR403ordering, the mTLS path or the Windows build; its remaining findings (OpenAPI credential scoped to the proxy operations, a keyed-preflight test, wording) are ina76b22b. Not adopted, as a product decision for maintainers rather than a fix: answering LAN preflights locally from an operator-configured origin allowlist, which would restore browser access without reopening the first problem. Pre-existing ondevelopand left alone: Go's defaultOPTIONS *handler answers beforehandlePlain(no outbound request, body capped at 4 KB), and the OpenAPIErrorschema saysmessagewhere the ingress writeserror.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(proxy): cap request bodies and gate browser origins #108's deny-by-defaultNVPAIR_PROXY_ALLOWED_ORIGINSallowlist is the fix path for that; this PR does not touch it.services/nvpair-proxy/ingress.goandproxy.go(a 32 MiB request-body cap and an Origin allowlist ahead of forwarding). This PR's change is a self-contained block at the top ofhandlePlainfor non-loopback callers only, so a rebase in either order should be mechanical. The two compose: fix(proxy): cap request bodies and gate browser origins #108 bounds and origin-gates what an admitted caller can send, and this gate decides whether a non-loopback caller is admitted at all. Whichever lands second, I will rebase this one.Release intent
Changelog title
Opt-in authenticated LAN access to the compatibility endpoints
Changelog body
The Ollama- and OpenAI-compatible endpoints can now accept plaintext requests from other machines when the operator configures an API key (key file or
NVPAIR_PROXY_API_KEYS), optionally restricted byNVPAIR_PROXY_ALLOWED_CIDRS. With no key configured, behavior is unchanged: non-loopback plaintext is refused.Bumps
nvpair-proxyis MINOR: new, additive, HTTP-visible behavior.nvpair-shared/ingressauthis compiled only intonvpair-proxy.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 of the key check.
Review of the port (2026-09-22), same method, run by Claude (Anthropic, Opus 5.5) in Claude Code:
fable), Claude Opus (opus) — via theclaudeCLI, with toolscodexCLI, with tools (configuredgpt-5.6-sol; the lane self-reportedopenai/gpt-oss-120b)gemini-3.1-pro-high(viaagy, with tools)x-ai/grok-4.6nvidia/nemotron-3-ultra-550b-a55b,nvidia/nemotron-3-super-120b-a12bdeepseek/deepseek-v4-flashz-ai/glm-5.3-flashmoonshotai/kimi-k2.6nousresearch/hermes-4-405bamazon/nova-pro-v1Two configured lanes did not answer and are not counted: a local Qwen lane (host unreachable) and Mistral (model no longer served). 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.Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/
🤖 Generated with Claude Code
https://claude.ai/code/session_01BwxtwuoRxP75PdR6NAmMS3
https://claude.ai/code/session_01BSXRLJk5knQd4rNnn7XH1S