Compare commits

...
Author SHA1 Message Date
GRAB Racem 334f6fdddc fix(llm): send a saved API key only to the endpoint it was saved for
POST /api/llm/test and POST /api/llm/models take provider and base_url from
the form but fell back to the stored profile key (and, for the test, the
global key) when the form had none. Any base_url typed into the form, or
supplied by a crafted request, received the saved key.

The saved key is now used only when the request's provider and base URL
normalize to the ones it was saved with (sameLLMEndpoint, built on
agent.ConfigFrom). Otherwise the key must be entered in the form, and the
error says why.
2026-11-03 23:25:47 +01:00
BaaRai 23cf14cf0a Merge pull request #137 from BaaRai/fix/auth-init-setup-token
fix(auth): require a setup token for /api/auth/init and bind 8787 to localhost
2026-11-03 22:22:30 +01:00
BaaRai 5bad722593 fix(auth): make the published bind address configurable
Publishing 127.0.0.1:8787 removed the only working access path for installs
reached through a host or WSL IP, without an obvious way back. Keep loopback as
the default and let ARTEX_BIND_ADDR override it (0.0.0.0 restores the old
behaviour; the setup token still protects the first-run claim).
2026-11-03 22:18:27 +01:00
GRAB Racem 2aab3e8210 fix(auth): require a setup token for /api/auth/init and bind 8787 to localhost
POST /api/auth/init accepted any caller while auth.password_hash was empty,
so whoever reached port 8787 first could set the admin password and take the
instance. When no password is set at startup, the server now generates a
random one-time setup token and writes it to the server log. authInit
compares it in constant time, rejects a missing or wrong token with 403, and
clears it once the password is stored.

The setup page has a field for the token and api.initPassword sends it.
docker-compose publishes 8787 on 127.0.0.1 only, like 8788. README says where
to find the token.
2026-11-03 22:18:27 +01:00
BaaRai d45f0c7811 Merge pull request #136 from BaaRai/fix/shell-env-secrets
fix(security): remove startup secrets from the environment before child processes spawn
2026-11-03 22:17:26 +01:00
10 changed files with 302 additions and 16 deletions
+3
View File
@@ -1,5 +1,8 @@
# Docker deployment configuration (copy to .env)
# The image is built locally by `docker compose build`; there is no registry tag.
# Address the web UI is published on. 127.0.0.1 keeps it host-local; set 0.0.0.0
# to reach it from another machine or from Windows when ARTEX runs inside WSL.
ARTEX_BIND_ADDR=127.0.0.1
POSTGRES_USER=artex
POSTGRES_PASSWORD=change-me-please
POSTGRES_DB=artex
+3
View File
@@ -88,6 +88,9 @@ The script will: detect / auto-install Docker → then let you choose **① all-
- **② Local run**: choose a database (connect to an existing one / start one with Docker) → generates `config.json` → `go` builds the embedded single binary → starts.
Once installed, open **http://localhost:8787** (on first entry, `/setup` sets the admin password).
`/setup` asks for a one-time setup token: while no password is set, the backend prints it at startup in its log (`docker compose logs artex | grep "Setup token"`, or the terminal running `./start.sh`).
The web UI is published on `127.0.0.1` by default. To reach it from another machine, or from Windows when ARTEX runs inside WSL, set `ARTEX_BIND_ADDR=0.0.0.0` in `.env` (the setup token still protects the first-run claim), or keep loopback and use a reverse proxy or an SSH tunnel.
### Option 2: Docker Compose (manual)
+1 -1
View File
@@ -35,7 +35,7 @@ services:
ARTEX_LLM_BASE_URL: ${ARTEX_LLM_BASE_URL:-}
ARTEX_LLM_PROXY: ${ARTEX_LLM_PROXY:-}
ports:
- "8787:8787"
- "${ARTEX_BIND_ADDR:-127.0.0.1}:8787:8787"
- "127.0.0.1:8788:8788"
volumes:
# Bind the data directory (jwt.key, SQLite, etc.) to data/ in the project root,
+39 -1
View File
@@ -2,6 +2,8 @@ package server
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"log"
"math/big"
@@ -154,7 +156,34 @@ func (s *Server) authStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"initialized": hash != ""})
}
// initSetupToken generates the one-time setup token when no admin password is set
// and writes it to the server log. Without it, anyone who can reach the port
// before the operator could claim the instance through POST /api/auth/init.
func (s *Server) initSetupToken() {
if s.m == nil || s.m.pg == nil {
return
}
if hash, _, err := s.m.pg.GetSetting(authPassKey); err == nil && hash != "" {
return
}
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
log.Fatalf("[auth] generate setup token: %v", err)
}
s.setupToken = hex.EncodeToString(buf)
log.Printf("[auth] no admin password is set. Setup token for the /setup page: %s", s.setupToken)
}
// checkSetupToken compares got with the active setup token in constant time.
// It fails when no token is active.
func (s *Server) checkSetupToken(got string) bool {
s.setupMu.Lock()
defer s.setupMu.Unlock()
return s.setupToken != "" && subtle.ConstantTimeCompare([]byte(got), []byte(s.setupToken)) == 1
}
// POST /api/auth/init — sets the password for the first time; rejected if already set.
// Requires the setup token from the server log.
func (s *Server) authInit(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
@@ -170,12 +199,18 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
Password string `json:"password"`
Password string `json:"password"`
SetupToken string `json:"setup_token"`
}
if err := decode(r, &req); err != nil || req.Password == "" {
writeErr(w, 400, "Password cannot be empty")
return
}
// 403, not 401: the frontend treats any 401 as an expired session and redirects to /login.
if !s.checkSetupToken(req.SetupToken) {
writeErr(w, 403, "Invalid setup token. The server log prints it at startup while no password is set")
return
}
if msg := validatePassword(req.Password); msg != "" {
writeErr(w, 400, msg)
return
@@ -197,6 +232,9 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) {
writeErr(w, 403, "Password is already set")
return
}
s.setupMu.Lock()
s.setupToken = ""
s.setupMu.Unlock()
tok, err := signJWT(s.jwtKey)
if err != nil {
writeErr(w, 500, "token generation failed")
+56
View File
@@ -62,6 +62,62 @@ func TestAuthInitFailsClosedWhenDataSourceUnavailable(t *testing.T) {
}
}
// While no password is set, authInit must reject a missing or wrong setup token
// and accept the one generated at startup, once.
func TestAuthInitRequiresSetupToken(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable (%v)", err)
}
defer m.Close()
clearPassword := func() {
if _, err := m.pg.Exec(`DELETE FROM settings WHERE key=$1`, authPassKey); err != nil {
t.Fatal(err)
}
}
clearPassword()
defer clearPassword()
s := &Server{m: m, jwtKey: []byte("0123456789abcdef0123456789abcdef")}
s.initSetupToken()
setupToken := s.setupToken
if setupToken == "" {
t.Fatal("no setup token generated while no password is set")
}
post := func(body string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
s.authInit(w, httptest.NewRequest("POST", "/api/auth/init", strings.NewReader(body)))
return w
}
for name, body := range map[string]string{
"missing": `{"password":"correct horse battery"}`,
"empty": `{"password":"correct horse battery","setup_token":""}`,
"wrong": `{"password":"correct horse battery","setup_token":"0000"}`,
} {
if w := post(body); w.Code != 403 || strings.Contains(w.Body.String(), `"token"`) {
t.Fatalf("%s setup token: status=%d want 403 without a token; body=%s", name, w.Code, w.Body.String())
}
}
if hash, _, err := m.pg.GetSetting(authPassKey); err != nil || hash != "" {
t.Fatalf("a rejected request changed the password state: hash set=%v err=%v", hash != "", err)
}
ok := `{"password":"correct horse battery","setup_token":"` + setupToken + `"}`
if w := post(ok); w.Code != 200 || !strings.Contains(w.Body.String(), `"token"`) {
t.Fatalf("right setup token: status=%d want 200 with a token; body=%s", w.Code, w.Body.String())
}
if hash, _, err := m.pg.GetSetting(authPassKey); err != nil || hash == "" {
t.Fatalf("password not stored: err=%v", err)
}
// The token is spent: even with the password row removed, it is refused.
clearPassword()
if w := post(ok); w.Code != 403 {
t.Fatalf("reused setup token: status=%d want 403; body=%s", w.Code, w.Body.String())
}
}
func TestValidatePassword(t *testing.T) {
for _, tc := range []struct {
name, pw string
+127
View File
@@ -0,0 +1,127 @@
package server
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
)
func TestSameLLMEndpoint(t *testing.T) {
for _, tc := range []struct {
provA, baseA, provB, baseB string
want bool
}{
{"anthropic", "https://gw.example", "anthropic", "https://gw.example/", true},
{"anthropic", "https://gw.example/v1/messages", "anthropic", "https://gw.example", true},
{"", "", "anthropic", "", true}, // empty provider defaults to anthropic
{"openai", " https://gw.example/v1/chat/completions ", "openai", "https://gw.example/v1", true},
{"anthropic", "https://evil.example", "anthropic", "https://gw.example", false},
{"anthropic", "", "anthropic", "https://gw.example", false}, // canonical host != saved host
{"openai", "", "anthropic", "", false}, // different canonical hosts
{"openai", "https://gw.example/v1", "openai-responses", "https://gw.example/v1", false},
} {
if got := sameLLMEndpoint(tc.provA, tc.baseA, tc.provB, tc.baseB); got != tc.want {
t.Errorf("sameLLMEndpoint(%q,%q,%q,%q)=%v want %v", tc.provA, tc.baseA, tc.provB, tc.baseB, got, tc.want)
}
}
}
// keyRecorder is a fake Anthropic endpoint that records the x-api-key it receives.
func keyRecorder(t *testing.T) (*httptest.Server, *atomic.Int32, *atomic.Value) {
var hits atomic.Int32
var key atomic.Value
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
key.Store(r.Header.Get("x-api-key"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"m","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":"OK"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1},"data":[]}`))
}))
t.Cleanup(srv.Close)
return srv, &hits, &key
}
func postJSON(t *testing.T, h http.HandlerFunc, path string, body map[string]any) map[string]any {
t.Helper()
b, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h(rec, req)
var resp map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
return resp
}
// The global key must not follow a base_url typed into the test form.
func TestLLMTestGlobalKeyOnlyForGlobalEndpoint(t *testing.T) {
saved, savedHits, savedKey := keyRecorder(t)
other, otherHits, _ := keyRecorder(t)
s := &Server{}
s.llmCfg = agent.ConfigFrom("anthropic", "m", saved.URL, "global-secret", "")
resp := postJSON(t, s.testLLM, "/api/llm/test", map[string]any{
"provider": "anthropic", "model": "m", "base_url": other.URL, "api_key": "", "streaming": false,
})
if otherHits.Load() != 0 || resp["ok"] != false {
t.Fatalf("global key went to a form-supplied host: hits=%d resp=%v", otherHits.Load(), resp)
}
postJSON(t, s.testLLM, "/api/llm/test", map[string]any{
"provider": "anthropic", "model": "m", "base_url": saved.URL + "/", "api_key": "", "streaming": false,
})
if k, _ := savedKey.Load().(string); savedHits.Load() != 1 || k != "global-secret" {
t.Fatalf("global key not used for its own endpoint: hits=%d key=%q", savedHits.Load(), k)
}
}
// A stored profile's key goes only to that profile's base URL, for both the
// connection test and the model listing.
func TestStoredProfileKeyOnlyForStoredEndpoint(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable (%v)", err)
}
t.Cleanup(func() { m.Close() })
ctx, cancel := context.WithCancel(context.Background())
cancel()
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
s.cfgMu.Lock()
s.llmCfg = agent.Config{} // no global key to fall back on
s.cfgMu.Unlock()
saved, savedHits, savedKey := keyRecorder(t)
other, otherHits, _ := keyRecorder(t)
pid, err := m.pg.SaveProfile(&db.LLMProfile{
Name: "key-endpoint", Format: "anthropic", Model: "m", BaseURL: saved.URL, APIKey: "profile-secret",
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = m.pg.Exec("DELETE FROM llm_profiles WHERE id=$1", pid) })
for _, ep := range []struct {
path string
h http.HandlerFunc
}{{"/api/llm/test", s.testLLM}, {"/api/llm/models", s.pgListModels}} {
resp := postJSON(t, ep.h, ep.path, map[string]any{
"provider": "anthropic", "model": "m", "base_url": other.URL, "api_key": "", "profile_id": pid, "streaming": false,
})
if otherHits.Load() != 0 || resp["ok"] != false {
t.Fatalf("%s: stored key went to a form-supplied host: hits=%d resp=%v", ep.path, otherHits.Load(), resp)
}
before := savedHits.Load()
postJSON(t, ep.h, ep.path, map[string]any{
"provider": "anthropic", "model": "m", "base_url": saved.URL, "api_key": "", "profile_id": pid, "streaming": false,
})
if k, _ := savedKey.Load().(string); savedHits.Load() == before || k != "profile-secret" {
t.Fatalf("%s: stored key not used for its own endpoint: key=%q", ep.path, k)
}
}
}
+39 -6
View File
@@ -48,6 +48,9 @@ type Server struct {
scrubSecrets bool // drop ARTEX_PG_DSN and the API keys from the environment once read (binary only)
setupMu sync.Mutex
setupToken string // one-time token POST /api/auth/init requires; empty when none is active
// concMu serializes concurrency-cap decisions (admission + reconcile) so a
// scheduler tick and an HTTP settings change / task creation can't both count
// free slots off the same snapshot and over-promote past the limit.
@@ -153,6 +156,7 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
profChatAgents: map[int64]*agent.ChatAgent{},
provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg),
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1)}
s.initSetupToken()
s.initSideQuestions()
// Circuit-breaker thresholds/cooldowns are hot parameters on the failure path; push the global retry policy to the Registry once at startup,
// and again on every policy save (saveLLMRetryPolicy).
@@ -1462,19 +1466,38 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
if cfg.NeedsAPIKey() {
// API key resolution precedence: form input > the named profile's stored key > the global config's key.
// A stored profile's key is never sent back to the browser, so the form is empty when testing a stored profile and the key comes from the DB.
// A saved key is only sent to the endpoint it was saved for: base_url and
// provider come from the form, so without this check the test button sends
// the saved key to any host typed into the form.
withheld := false
if stored != nil && cfg.APIKey == "" {
// Trim like ConfigFrom does for form values: a key stored with
// surrounding whitespace runs fine in conversations (the runtime
// path re-trims) but would be sent raw here and fail auth.
cfg.APIKey = strings.TrimSpace(stored.APIKey)
if sameLLMEndpoint(req.Provider, req.BaseURL, stored.Format, stored.BaseURL) {
// Trim like ConfigFrom does for form values: a key stored with
// surrounding whitespace runs fine in conversations (the runtime
// path re-trims) but would be sent raw here and fail auth.
cfg.APIKey = strings.TrimSpace(stored.APIKey)
} else {
withheld = true
}
}
if cfg.APIKey == "" {
s.cfgMu.Lock()
cfg.APIKey = s.llmCfg.APIKey
global := s.llmCfg
s.cfgMu.Unlock()
if global.APIKey != "" {
if sameLLMEndpoint(req.Provider, req.BaseURL, global.Provider(), global.BaseURL) {
cfg.APIKey = global.APIKey
} else {
withheld = true
}
}
}
if cfg.APIKey == "" {
writeJSON(w, 200, map[string]any{"ok": false, "error": "no API key provided"})
msg := "no API key provided"
if withheld {
msg = "no API key provided: the saved key is only sent to the endpoint it was saved for; enter the key to test a different base URL or provider"
}
writeJSON(w, 200, map[string]any{"ok": false, "error": msg})
return
}
}
@@ -1492,6 +1515,16 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
})
}
// sameLLMEndpoint reports whether two provider/base URL pairs resolve to the same
// endpoint after ConfigFrom's normalization (trimmed, endpoint suffix stripped;
// an empty base URL means the provider's canonical host). A saved API key may only
// be sent when the request's endpoint matches the one it was saved for.
func sameLLMEndpoint(provA, baseA, provB, baseB string) bool {
a := agent.ConfigFrom(provA, "", baseA, "", "")
b := agent.ConfigFrom(provB, "", baseB, "", "")
return a.Format == b.Format && a.BaseURL == b.BaseURL
}
// truncateReply clips a connection-test reply for display. A model told to answer
// "OK" can still ramble (or think out loud); the UI only needs enough to show it
// really said something. Rune-based so multibyte text never splits mid-character.
+13 -3
View File
@@ -2053,17 +2053,27 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, err.Error())
return
}
// Resolve API key: form input > profile stored key.
// Resolve API key: form input > profile stored key. The stored key is only
// sent to the endpoint it was saved for, since base_url comes from the form.
apiKey := strings.TrimSpace(req.APIKey)
withheld := false
if apiKey == "" && req.ProfileID != nil {
if p, err := s.m.pg.ProfileByID(*req.ProfileID); err == nil && p != nil {
apiKey = p.APIKey
if sameLLMEndpoint(req.Provider, req.BaseURL, p.Format, p.BaseURL) {
apiKey = p.APIKey
} else {
withheld = p.APIKey != ""
}
}
}
provider := strings.TrimSpace(req.Provider)
// Ollama serves its model list keyless on the local server: no key gate.
if apiKey == "" && provider != "ollama" {
writeJSON(w, 200, map[string]any{"ok": false, "error": "no API Key provided"})
msg := "no API Key provided"
if withheld {
msg = "no API Key provided: the saved key is only sent to the endpoint it was saved for; enter the key to list models from a different base URL or provider"
}
writeJSON(w, 200, map[string]any{"ok": false, "error": msg})
return
}
+19 -4
View File
@@ -14,6 +14,7 @@ export default function SetupPage() {
const router = useRouter();
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [setupToken, setSetupToken] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(true);
@@ -47,7 +48,7 @@ export default function SetupPage() {
setLoading(true);
setError("");
try {
const { token } = await api.initPassword(password);
const { token } = await api.initPassword(password, setupToken.trim());
auth.setToken(token);
router.replace("/function/tasks");
} catch (err) {
@@ -80,7 +81,7 @@ export default function SetupPage() {
<p className="mx-auto max-w-xl text-muted-foreground">
{unavailable
? "The backend or database is temporarily unavailable. To avoid overwriting a password already set on this instance, setup is temporarily closed — restore the service and try again."
: "First time using ARTEX? Set a login password for the account (at least 8 characters)"}
: "First time using ARTEX? Enter the setup token from the server log, then set a login password for the account (at least 8 characters)"}
</p>
</div>
{unavailable ? (
@@ -92,6 +93,17 @@ export default function SetupPage() {
</div>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="space-y-1.5">
<Label htmlFor="setup-token">Setup token</Label>
<Input
id="setup-token"
value={setupToken}
onChange={(e) => setSetupToken(e.target.value)}
placeholder="Printed in the server log at startup"
autoFocus
autoComplete="off"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">New password</Label>
<Input
@@ -100,7 +112,6 @@ export default function SetupPage() {
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 8 characters"
autoFocus
autoComplete="new-password"
/>
</div>
@@ -116,7 +127,11 @@ export default function SetupPage() {
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading || !password || !confirm}>
<Button
type="submit"
className="w-full"
disabled={loading || !setupToken.trim() || !password || !confirm}
>
{loading ? "Saving..." : "Set password and sign in"}
</Button>
</form>
+2 -1
View File
@@ -240,7 +240,8 @@ export const api = {
// ---- auth ----
authStatus: () => get<{ initialized: boolean }>("/auth/status"),
login: (username: string, password: string) => post<{ token: string }>("/auth/login", { username, password }),
initPassword: (password: string) => post<{ token: string }>("/auth/init", { password }),
initPassword: (password: string, setupToken: string) =>
post<{ token: string }>("/auth/init", { password, setup_token: setupToken }),
changePassword: (oldPassword: string, newPassword: string) =>
post<{ ok: boolean }>("/auth/change-password", { old_password: oldPassword, new_password: newPassword }),