Skip to content

Commit a76b22b

Browse files
CryptoJonesclaude
andcommitted
Scope the OpenAPI credential to the proxy operations; test a keyed preflight
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>
1 parent e65ea8a commit a76b22b

7 files changed

Lines changed: 52 additions & 17 deletions

File tree

‎SECURITY.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,10 @@ run on a different host so that PAIR sees its real address.
106106

107107
The gate compares keys in constant time, holds file keys in memory only as
108108
digests (a key supplied inline through the environment also remains in the
109-
process environment in clear, so prefer the file), never logs a presented key
110-
(a rejection logs a short digest fingerprint), and fails closed: a key file that other users can read (judged by Unix permission
109+
process environment in clear, so prefer the file where the proxy's own user or
110+
root can own it privately; on Kubernetes that is not possible without a
111+
group-readable mount, so use the variable there), never logs a presented key
112+
(a rejection logs a short digest fingerprint), and fails closed: a key file that its group or other users can read (judged by Unix permission
111113
bits; on Windows the check is skipped and the per-user data directory's ACL is
112114
the protection), that contains a malformed entry, or that cannot be read
113115
contributes no keys. That is judged per source: a rejected file does not

‎docs/troubleshooting.mdx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,11 @@ authenticated access with an API key; refer to
104104
The refusal then says what is missing:
105105

106106
- `403` `loopback-only`: the node has no usable key configured. Check the node's
107-
log; a key file that other users can read, or that contains a malformed entry,
108-
is ignored and the reason is logged.
107+
log; a key file that its group or other users can read, or that contains a
108+
malformed entry,
109+
is ignored and the reason is logged. A malformed `NVPAIR_PROXY_API_KEYS` or
110+
`NVPAIR_PROXY_ALLOWED_CIDRS` entry turns authenticated access off entirely
111+
until it is corrected.
109112
- `401` `unauthorized`: the request carried no key, or a key the node does not
110113
have. Send it as `Authorization: Bearer <key>` (or `X-Api-Key: <key>`). The
111114
node's log shows the first eight hex digits of the SHA-256 of the key it

‎fern/openapi.yml‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,18 @@ tags:
3434
description: OpenAI-compatible inference proxied to an eligible node.
3535
- name: Node info
3636
description: Read-only node telemetry polled from discovered PAIR nodes.
37-
# A credential is optional: loopback callers send none, and a caller on another
38-
# machine needs one only when the node's operator has enabled LAN access.
39-
security:
40-
- {}
41-
- bearerAuth: []
42-
- apiKeyAuth: []
4337
paths:
4438
/api/chat:
4539
post:
4640
tags:
4741
- Ollama-compatible
4842
operationId: ollamaChat
43+
# Optional: loopback callers send no credential; a caller on another
44+
# machine needs one only when the operator has enabled LAN access.
45+
security:
46+
- {}
47+
- bearerAuth: []
48+
- apiKeyAuth: []
4949
summary: Ollama-compatible chat
5050
description: >
5151
Send an Ollama-compatible chat request to the local PAIR proxy. PAIR
@@ -100,6 +100,12 @@ paths:
100100
tags:
101101
- OpenAI-compatible
102102
operationId: createChatCompletion
103+
# Optional: loopback callers send no credential; a caller on another
104+
# machine needs one only when the operator has enabled LAN access.
105+
security:
106+
- {}
107+
- bearerAuth: []
108+
- apiKeyAuth: []
103109
summary: OpenAI-compatible chat completion
104110
description: >
105111
Send an OpenAI-compatible chat completion request to the local PAIR

‎services/nvpair-proxy/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ sit on the engine's own port. The stored value predates the current default.
151151

152152
**Browser clients (CORS).** PAIR does not enable CORS or add default browser permissions. Configure origins through the engine; its built-in defaults and user configuration remain authoritative. Ordinary forwarded responses preserve the upstream status, body, and CORS headers, including missing headers. A denial is never replaced with a successful OPTIONS response or retried to find permission elsewhere. Proxy-generated errors carry their actual status without CORS permission headers, so browser JavaScript may see a generic CORS failure while curl and diagnostics show the real error.
153153

154-
A browser preflight (OPTIONS with Origin and Access-Control-Request-Method) queries every currently routable target, with concurrency eight, a ten-second query deadline, and no redirects. A single target's response is relayed. Multiple responding targets must all permit the requested origin, method, and headers; PAIR grants only their shared permissions. Credentials require unanimous explicit support. Synthesized preflights allow browsers to cache the result for 60 seconds; PAIR itself does not cache decisions. A policy denial returns 403. Unavailable targets are skipped; if none can answer, the proxy returns 502. Ordinary OPTIONS requests retain normal routing. Preflights do not create inference jobs or reserve scheduler capacity. Paired ingress forwards only to its local engine.
154+
A browser preflight (OPTIONS with Origin and Access-Control-Request-Method) from a loopback caller queries every currently routable target, with concurrency eight, a ten-second query deadline, and no redirects. A single target's response is relayed. Multiple responding targets must all permit the requested origin, method, and headers; PAIR grants only their shared permissions. Credentials require unanimous explicit support. Synthesized preflights allow browsers to cache the result for 60 seconds; PAIR itself does not cache decisions. A policy denial returns 403. Unavailable targets are skipped; if none can answer, the proxy returns 502. Ordinary OPTIONS requests retain normal routing. Preflights do not create inference jobs or reserve scheduler capacity. Paired ingress forwards only to its local engine. A preflight from another machine gets no exemption from the API-key gate: without a key it is refused with 401 before any target is queried, so a web page on another machine is not a supported client.
155155

156156
Combined model lists forward the caller's origin and end-to-end headers, excluding Authorization and Cookie so credentials are not shared across engines. Multi-target preflights apply the same credential filtering. With an Origin header, every responding engine must return a valid list and permit sharing: one denial returns 403 and one invalid list returns 502, without partial inventory. An invalid-list error retains the combined CORS permissions when every responding engine allows the origin. Unavailable engines are skipped, with 502 returned when none can answer. Successful lists combine origin/credential permissions and Vary requirements. Requests without Origin retain partial aggregation when some inventories are unavailable. Engines without CORS support remain unavailable to cross-origin browser clients through PAIR.
157157

‎services/nvpair-proxy/ingress_auth_test.go‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ func TestHandlePlainXApiKeyAccepted(t *testing.T) {
109109

110110
// TestHandlePlainNonLoopbackWithoutKeyIs401: an enabled gate turns the LAN
111111
// refusal from 403 loopback-only into 401 with a challenge, and nothing is
112-
// forwarded. The refusal grants no CORS: a browser is not a supported LAN client.
112+
// forwarded.
113113
func TestHandlePlainNonLoopbackWithoutKeyIs401(t *testing.T) {
114114
f, seen := lanProxy(t)
115115
rec := httptest.NewRecorder()
@@ -288,3 +288,27 @@ func (c *countingReader) Read(b []byte) (int, error) {
288288
c.n.Add(int64(n))
289289
return n, err
290290
}
291+
292+
// TestHandlePlainKeyedPreflightIsRouted: a preflight that does carry a valid key
293+
// is routed like any authenticated request, with the key stripped.
294+
func TestHandlePlainKeyedPreflightIsRouted(t *testing.T) {
295+
f, seen := lanProxy(t)
296+
rec := httptest.NewRecorder()
297+
req := httptest.NewRequest(http.MethodOptions, "/v1/chat/completions", nil)
298+
req.RemoteAddr = lanRemote
299+
req.Header.Set("Origin", "http://app.test")
300+
req.Header.Set("Access-Control-Request-Method", "POST")
301+
req.Header.Set("Authorization", "Bearer "+lanKey)
302+
f.handlePlain(rec, req)
303+
304+
if rec.Code == http.StatusUnauthorized || rec.Code == http.StatusForbidden {
305+
t.Fatalf("keyed LAN preflight status = %d, want it routed", rec.Code)
306+
}
307+
h := seen.Load()
308+
if h == nil {
309+
t.Fatal("a keyed preflight never reached the engine")
310+
}
311+
if got := h.Get("Authorization"); got != "" {
312+
t.Errorf("engine saw Authorization %q, want it stripped", got)
313+
}
314+
}

‎services/shared/ingressauth/ingressauth.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
// Loopback callers are never asked for a key — the desktop application, the
1818
// terminal interface, and local tools are unaffected by enabling the gate.
1919
//
20-
// Every failure fails closed. A key file that cannot be read, is readable by
21-
// other users, or contains an entry that could never match over the wire
20+
// Every failure fails closed. A key file that cannot be read, is accessible to
21+
// its group or other users, or contains an entry that could never match over the wire
2222
// contributes no keys, the reason is logged, and the LAN stays closed. Keys are
2323
// held in memory only as SHA-256 digests and are compared in constant time; a
2424
// rejected credential is logged as a short digest fingerprint, never as itself.
@@ -264,8 +264,8 @@ func (g *Gate) enabledLocked() bool {
264264
// enabled/disabled state and the key set a request is judged against cannot
265265
// change between two calls. The allowlist is checked before the credential,
266266
// so a caller outside it learns nothing about whether its key is valid. A
267-
// preflight is judged like any other request. Authorize does not write to the response; the proxy does, in
268-
// its own error format.
267+
// preflight is judged like any other request. Authorize does not write to the
268+
// response; the proxy does, in its own error format.
269269
func (g *Gate) Authorize(r *http.Request) Decision {
270270
g.mu.Lock()
271271
g.refreshLocked()

‎services/shared/ingressauth/owner_unix.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func checkKeyFileAccessAs(info fs.FileInfo, euid int) error {
3535
return fmt.Errorf("owned by uid %d, not by the proxy's user (uid %d)", uid, euid)
3636
}
3737
if perm := info.Mode().Perm(); perm&0o077 != 0 {
38-
return fmt.Errorf("permissions %04o allow other users to read it; chmod 600", perm)
38+
return fmt.Errorf("permissions %04o grant access to its group or other users; chmod 600, or supply the key through NVPAIR_PROXY_API_KEYS", perm)
3939
}
4040
return nil
4141
}

0 commit comments

Comments
 (0)