gateway: another computer is let in by the gateway key it sends wherever its client puts it, beside an empty, placeholder or stale token (悠悠哥 on Discord: magpie shared on a NAS, a client's model list from http://192.168.0.200:3425 failed, Anthropic Messages format, while from magpie on 127.0.0.1 it came) — loopback takes any token, so a request from this computer is answered whatever key it carries, but one from another computer is let in only by an enabled gateway key, and the key was read from one place: callerKey took Authorization whenever the header was there, and looked at x-api-key, x-goog-api-key or ?key= only when it wasn't. A client that sends the key in x-api-key with "Authorization: Bearer " beside it (no token set), "Bearer undefined", or a stale token there (ANTHROPIC_AUTH_TOKEN left at magpie) was refused from the network — "no API key was sent" or "not an enabled magpie gateway key" — while the same request on 127.0.0.1 listed the models; "bearer <key>" in lower case was read as the key "bearer <key>". Now callerKeys reads every place a key comes (Authorization's bearer token, any case, then x-api-key, x-goog-api-key, ?key=), passing over an empty one, and identifyCaller lets the request in by whichever is an enabled gateway key, the refusal still naming the first; a key-shaped value in any of them (managedKey) also has a request from this computer counted by its key. Nothing is let in that wasn't carrying an enabled key. Nothing else differed for the network: the gateway checks no Host header (Host 192.168.0.200:3425 is answered), lists the same catalog to a keyed LAN caller as to loopback (no per-key model filter), and answers /v1/models, /models and /v1/models?limit= alike, in a shape Anthropic and OpenAI clients both read. TestLANModelsWithKeyBesideAnotherToken (from 192.168.0.9 to Host 192.168.0.200:3425: the key in x-api-key beside an empty, bare, "undefined" or "magpie" bearer, lower-case bearer, x-goog-api-key beside a stale bearer, over /v1/models, /models and ?limit=1000, each listed and counted by the key; a wrong key or none still 401; loopback counted by the key beside a token) fails without the change (401 "no API key was sent" for the key beside "Bearer "). go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.
This commit is contained in:
1 parent
1a50db1a8a
commit
1c047f3685
2 files changed
+117
-12
No files matched your search
+51
-12
@@ -8,6 +8,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -182,7 +183,7 @@ func lanGuard(next http.Handler) http.Handler {
|
||||
http.Error(w, "magpie isn't shared on the local network", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if (remote && shared) || (!remote && access.Managed(callerKey(r))) {
|
||||
if (remote && shared) || (!remote && managedKey(r)) {
|
||||
var ok bool
|
||||
r, ok = identifyCaller(w, r)
|
||||
if !ok {
|
||||
@@ -199,7 +200,7 @@ func lanGuard(next http.Handler) http.Handler {
|
||||
// callerGuard also covers embedded handlers used by the web app and tests.
|
||||
func callerGuard(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if access.Caller(r.Context()).KeyID == "" && access.Managed(callerKey(r)) && (local(r) || settings.Load().LAN) {
|
||||
if access.Caller(r.Context()).KeyID == "" && managedKey(r) && (local(r) || settings.Load().LAN) {
|
||||
var ok bool
|
||||
r, ok = identifyCaller(w, r)
|
||||
if !ok {
|
||||
@@ -211,10 +212,15 @@ func callerGuard(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
func identifyCaller(w http.ResponseWriter, r *http.Request) (*http.Request, bool) {
|
||||
key := callerKey(r)
|
||||
who, ok := access.Authenticate(key)
|
||||
var who access.Identity
|
||||
ok := false
|
||||
for _, k := range callerKeys(r) {
|
||||
if who, ok = access.Authenticate(k); ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok && !local(r) {
|
||||
msg := refusedKey(key)
|
||||
msg := refusedKey(callerKey(r))
|
||||
log.Printf("refused %s %s from %s: %s", r.Method, r.URL.Path, r.RemoteAddr, msg)
|
||||
writeError(w, provider.Chat, http.StatusUnauthorized, msg)
|
||||
return r, false
|
||||
@@ -264,17 +270,50 @@ func sharedWith(r *http.Request) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// callerKey is the API key a request carries, however its client sends one.
|
||||
// callerKey is the API key a request carries, however its client sends one:
|
||||
// the first of callerKeys, "" for none.
|
||||
func callerKey(r *http.Request) string {
|
||||
if a := r.Header.Get("Authorization"); a != "" {
|
||||
return strings.TrimSpace(strings.TrimPrefix(a, "Bearer "))
|
||||
if ks := callerKeys(r); len(ks) > 0 {
|
||||
return ks[0]
|
||||
}
|
||||
for _, h := range []string{"x-api-key", "x-goog-api-key"} {
|
||||
if v := r.Header.Get(h); v != "" {
|
||||
return v
|
||||
return ""
|
||||
}
|
||||
|
||||
// callerKeys are the API keys a request carries, in the order they are
|
||||
// read: Authorization's bearer token, x-api-key, x-goog-api-key, ?key=. A
|
||||
// header that carries none ("Authorization: Bearer ", sent beside an
|
||||
// x-api-key by a client with no token set) is passed over, and a client
|
||||
// that sends two different values — a stale or placeholder token in
|
||||
// Authorization (ANTHROPIC_AUTH_TOKEN left at magpie), the key in x-api-key
|
||||
// — is let in by whichever is an enabled gateway key. Loopback takes any
|
||||
// token, so this mattered only from another computer, which was refused
|
||||
// what this one was answered (悠悠哥 on Discord: a client's model list from
|
||||
// magpie shared on a NAS failed, from magpie on 127.0.0.1 it came).
|
||||
func callerKeys(r *http.Request) []string {
|
||||
var out []string
|
||||
add := func(v string) {
|
||||
if v = strings.TrimSpace(v); v != "" && !slices.Contains(out, v) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return r.URL.Query().Get("key")
|
||||
a := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if len(a) >= 7 && strings.EqualFold(a[:7], "Bearer ") {
|
||||
a = a[7:]
|
||||
} else if strings.EqualFold(a, "Bearer") {
|
||||
a = ""
|
||||
}
|
||||
add(a)
|
||||
for _, h := range []string{"x-api-key", "x-goog-api-key"} {
|
||||
add(r.Header.Get(h))
|
||||
}
|
||||
add(r.URL.Query().Get("key"))
|
||||
return out
|
||||
}
|
||||
|
||||
// managedKey: the request carries a named gateway key's form (sk-magpie-…)
|
||||
// in any of the places a key is read.
|
||||
func managedKey(r *http.Request) bool {
|
||||
return slices.ContainsFunc(callerKeys(r), access.Managed)
|
||||
}
|
||||
|
||||
// refusedKey says why a caller's key was turned away: none came, or the
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yetone/magpie/internal/access"
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
"github.com/yetone/magpie/internal/settings"
|
||||
)
|
||||
|
||||
@@ -175,3 +177,67 @@ func TestInContainer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Another computer lists the models with the key it was given whatever
|
||||
// else its client sends beside it: an empty or placeholder Authorization
|
||||
// with the key in x-api-key, a stale token there, "bearer" in lower case
|
||||
// (悠悠哥 on Discord: a client's model list from magpie shared on a NAS
|
||||
// failed, while from magpie on 127.0.0.1, which takes any token, it came).
|
||||
// The request comes as it would from the network: another address, the
|
||||
// NAS's address as its Host.
|
||||
func TestLANModelsWithKeyBesideAnotherToken(t *testing.T) {
|
||||
setup(t, provider.Chat, &fake{t: t})
|
||||
t.Setenv("MAGPIE_ADDR", "")
|
||||
keys, secrets := newCaller(t, "PC")
|
||||
key := secrets[0]
|
||||
var who string
|
||||
h := lanGuard(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
who = access.Caller(r.Context()).KeyID
|
||||
New().Handler().ServeHTTP(w, r)
|
||||
}))
|
||||
list := func(from, path string, hdr ...string) (int, string) {
|
||||
who = ""
|
||||
r := httptest.NewRequest("GET", "http://192.168.0.200:3425"+path, nil)
|
||||
r.RemoteAddr = from
|
||||
for i := 0; i+1 < len(hdr); i += 2 {
|
||||
r.Header.Set(hdr[i], hdr[i+1])
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w.Code, w.Body.String()
|
||||
}
|
||||
for _, hdr := range [][]string{
|
||||
{"x-api-key", key, "anthropic-version", "2023-06-01"},
|
||||
{"x-api-key", key, "Authorization", "Bearer " + key, "anthropic-version", "2023-06-01"},
|
||||
{"x-api-key", key, "Authorization", "Bearer "},
|
||||
{"x-api-key", key, "Authorization", "Bearer"},
|
||||
{"x-api-key", key, "Authorization", "Bearer undefined"},
|
||||
{"x-api-key", key, "Authorization", "Bearer magpie"},
|
||||
{"Authorization", "bearer " + key},
|
||||
{"Authorization", "Bearer magpie", "x-goog-api-key", key},
|
||||
} {
|
||||
for _, path := range []string{"/v1/models", "/models", "/v1/models?limit=1000"} {
|
||||
c, b := list("192.168.0.9:50123", path, hdr...)
|
||||
if c != 200 || !strings.Contains(b, `"id":"fake/m1"`) {
|
||||
t.Fatalf("%s %q: %d %s", path, hdr, c, b)
|
||||
}
|
||||
if who != keys[0].ID {
|
||||
t.Fatalf("%s %q: counted as %q, not the key", path, hdr, who)
|
||||
}
|
||||
}
|
||||
}
|
||||
// a key the gateway doesn't have is refused wherever it is sent
|
||||
for _, hdr := range [][]string{
|
||||
{"x-api-key", "sk-magpie-wrong", "Authorization", "Bearer magpie"},
|
||||
{"Authorization", "Bearer "},
|
||||
{"x-api-key", key + "x"},
|
||||
} {
|
||||
if c, _ := list("192.168.0.9:50123", "/v1/models", hdr...); c != http.StatusUnauthorized {
|
||||
t.Fatalf("%q got %d", hdr, c)
|
||||
}
|
||||
}
|
||||
// this computer is counted by the key beside a token too
|
||||
if c, _ := list("127.0.0.1:50123", "/v1/models", "Authorization", "Bearer anything", "x-api-key", key); c != 200 || who != keys[0].ID {
|
||||
t.Fatalf("loopback: %d, counted as %q", c, who)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user