mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-14 04:00:54 +08:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce279d77d3 | ||
|
|
41e10456d7 | ||
|
|
43424404ee | ||
|
|
cb13a22673 | ||
|
|
8beb9c50d6 | ||
|
|
4be2fc5c9a | ||
|
|
d0d153b768 | ||
|
|
2419038884 | ||
|
|
e6e9b2f978 | ||
|
|
248d9d9673 |
@@ -3,6 +3,9 @@
|
||||
# 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
|
||||
# Reverse proxies (comma-separated CIDRs) whose X-Forwarded-For the login rate
|
||||
# limit trusts. Empty: the header is ignored. See "Reverse proxy deployment" in README.md.
|
||||
ARTEX_TRUSTED_PROXIES=
|
||||
POSTGRES_USER=artex
|
||||
POSTGRES_PASSWORD=change-me-please
|
||||
POSTGRES_DB=artex
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
# shellcheck is preinstalled on the ubuntu runners. -S warning: the scripts
|
||||
# currently have only info-level notes (intentional A && B || C chains in
|
||||
# update.sh, an unquoted glob in backup.sh's prune), so fail on warnings.
|
||||
- run: shellcheck -S warning install.sh update.sh start.sh scripts/backup.sh
|
||||
- run: shellcheck -S warning install.sh update.sh start.sh scripts/backup.sh reset-password.sh
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+15
-2
@@ -2,7 +2,14 @@
|
||||
|
||||
All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.5.1] - 2026-11-05
|
||||
|
||||
Login rate limiting per client IP with trusted-proxy support, a 12-character minimum for new passwords, a loopback-only default for the binary, and the JWT signing key in its own Docker volume.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The binary listens on `127.0.0.1:8787` by default**: the `-addr` default was `:8787`, every interface. Running `artex` or `./start.sh` without `-addr` now accepts connections from the local machine only; pass `-addr :8787` to listen on every interface. Docker is unchanged: the image starts the server with `-addr :8787` inside the container, and `docker-compose.yml` already publishes it on `127.0.0.1` by default (`ARTEX_BIND_ADDR`).
|
||||
- **New passwords need at least 12 characters** (was 8), at first-run setup (`POST /api/auth/init` and the `/setup` page) and in `POST /api/auth/change-password`. Login does not check length, so a password set under the old minimum keeps working until it is changed. `reset-password.sh` enforces the same minimum.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -13,6 +20,11 @@ All notable changes to this project are documented in this file. The format is b
|
||||
docker cp artex-artex-1:/app/jwt.key ./keys/jwt.key
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
- **Login rate limit**: `POST /api/auth/login` allows 10 attempts per minute per client IP. The next attempt gets 429 with a `Retry-After` header (seconds) and a JSON error, and the IP is blocked for 1 minute. Each attempt while blocked, and each later block, doubles the wait, up to 15 minutes. A successful login clears the IP's count. There is no per-account lockout, so failing logins cannot lock the admin out from other addresses. The state is in memory: a restart clears it, an IP idle for 15 minutes is dropped, and at most 10,000 IPs are tracked (the least recently seen is dropped first). The IP is the TCP peer address, and `X-Forwarded-For` is ignored, unless the peer is listed in the new `ARTEX_TRUSTED_PROXIES` (comma-separated CIDRs, empty by default, passed through by `docker-compose.yml`). For a listed peer the header is read from the right and the first address that is not a trusted proxy is the client; entries the client added further left are never used. Behind a reverse proxy, set it: otherwise every client has the proxy's address and shares one budget, and an attacker failing logins through the proxy blocks the real users, admin included, for up to 15 minutes. The README nginx example now sends `X-Forwarded-For`.
|
||||
- **`GET /api/health` hides the version from anonymous callers**: without a valid token it answers `{"ok":true}` only. With a token (the web UI sends one for the version in its top bar) the answer is unchanged: `ok`, `service` and `version`.
|
||||
|
||||
## [0.5.0] - 2026-11-04
|
||||
|
||||
Ollama support, a setup token for the first-run claim, secret scrubbing for child processes, a Docker image that builds from a fresh clone, Go 1.26.10 and Node 22, and race and shellcheck runs in CI. Entries marked "direct commit" landed on `main` without a pull request.
|
||||
@@ -978,7 +990,8 @@ First release of this fork. The engine underneath is still upstream's, but this
|
||||
- [@Autumn-27](https://github.com/Autumn-27)
|
||||
- [@neouks](https://github.com/neouks)
|
||||
|
||||
[Unreleased]: https://github.com/BaaRai/ARTEX/compare/v0.5.0...HEAD
|
||||
[Unreleased]: https://github.com/BaaRai/ARTEX/compare/v0.5.1...HEAD
|
||||
[0.5.1]: https://github.com/BaaRai/ARTEX/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://github.com/BaaRai/ARTEX/compare/v0.4.0...v0.5.0
|
||||
[0.4.0]: https://github.com/BaaRai/ARTEX/compare/v0.3.15...v0.4.0
|
||||
[0.3.10]: https://github.com/Autumn-27/ARTEX/compare/v0.3.9...v0.3.10
|
||||
|
||||
@@ -9,6 +9,8 @@ AI autonomous penetration testing system (Go backend + Next.js frontend)
|
||||
|
||||
</div>
|
||||
|
||||
<a href="https://trendshift.io/repositories/316805?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-316805" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/316805/daily?language=Go" alt="BaaRai%2FARTEX | Trendshift" width="250" height="55"/></a>
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
@@ -261,7 +263,7 @@ Optional: `ARTEX_LLM_PROVIDER` (anthropic | openai | openai-responses | ollama)
|
||||
|
||||
### Reverse proxy deployment (HTTPS / only exposing 443)
|
||||
|
||||
The frontend and API/SSE are served by the same backend port (default `:8787`), and the realtime activity stream uses the **same-origin** address by default, so **there is no need to configure `NEXT_PUBLIC_SSE_BASE`**: just expose 443 publicly and keep 8787 on the internal network.
|
||||
The frontend and API/SSE are served by the same backend port (8787; the binary listens on `127.0.0.1:8787` by default, the Docker container on `:8787` published on `127.0.0.1`), and the realtime activity stream uses the **same-origin** address by default, so **there is no need to configure `NEXT_PUBLIC_SSE_BASE`**: just expose 443 publicly and keep 8787 on the internal network.
|
||||
|
||||
SSE is a long-lived connection with continuous pushes, so the reverse proxy **must disable buffering**, otherwise the browser connects but never receives events (the activity stream spins forever). Nginx example:
|
||||
|
||||
@@ -275,6 +277,7 @@ server {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# SSE essentials: buffering off, long timeouts, HTTP/1.1
|
||||
proxy_buffering off;
|
||||
@@ -286,6 +289,13 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
**Login rate limit behind a proxy**: login allows 10 attempts per minute per client IP. Behind a proxy every request comes from the proxy's address, so without further setup all users share one budget and an attacker who fails 10 logins blocks everyone, admin included, for up to 15 minutes. To avoid that, keep the `X-Forwarded-For` line above and set `ARTEX_TRUSTED_PROXIES` (comma-separated CIDRs, empty by default) to the address ARTEX sees the proxy connect from:
|
||||
|
||||
- **nginx and ARTEX both on the host** (binary, `./start.sh`): the peer is `127.0.0.1`. Start ARTEX with `ARTEX_TRUSTED_PROXIES=127.0.0.1/32`.
|
||||
- **nginx on the host, ARTEX in Docker** (`docker-compose.yml`): the connection reaches the container from the Docker bridge gateway (a `172.x` address), not from `127.0.0.1`. Find it with `docker network inspect artex_default --format '{{(index .IPAM.Config 0).Gateway}}'` (the network is `<project directory>_default`; `docker network ls` lists it), put `ARTEX_TRUSTED_PROXIES=<gateway>/32` in `.env`, and run `docker compose up -d`.
|
||||
|
||||
For a peer in that list, ARTEX reads `X-Forwarded-For` from the right and takes the first address that is not a trusted proxy; from any other peer the header is ignored. Trust only addresses of proxies you run: a trusted peer can claim any client address.
|
||||
|
||||
> Only when SSE needs a different origin from the page (e.g. a dedicated subdomain) should you set `NEXT_PUBLIC_SSE_BASE` **at build time** (this variable is baked into the static bundle during `next build`; setting it at container runtime has no effect).
|
||||
|
||||
---
|
||||
|
||||
+11
-2
@@ -50,7 +50,7 @@ func main() {
|
||||
|
||||
func run() int {
|
||||
var (
|
||||
addr = flag.String("addr", ":8787", "HTTP listen address")
|
||||
addr = flag.String("addr", "127.0.0.1:8787", "HTTP listen address")
|
||||
dataDir = flag.String("data", filepath.Join(config.BaseDir(), "data"), "data directory for SQLite stores (default: data/ next to the executable)")
|
||||
proxy = flag.String("proxy", "127.0.0.1:8788", "traffic recording proxy address (empty to disable)")
|
||||
)
|
||||
@@ -108,7 +108,16 @@ func run() int {
|
||||
keyDir = abs
|
||||
}
|
||||
log.Printf("[config] JWT key directory: %s", keyDir)
|
||||
srv := server.New(ctx, mgr, skillDir, *dataDir, keyDir, server.WithSecretScrub())
|
||||
// Proxies whose X-Forwarded-For the login rate limit reads; empty trusts none.
|
||||
trusted, err := server.ParseTrustedProxies(os.Getenv("ARTEX_TRUSTED_PROXIES"))
|
||||
if err != nil {
|
||||
log.Printf("[config] ARTEX_TRUSTED_PROXIES: %v", err)
|
||||
return 1
|
||||
}
|
||||
if len(trusted) > 0 {
|
||||
log.Printf("[config] trusted proxies: %v", trusted)
|
||||
}
|
||||
srv := server.New(ctx, mgr, skillDir, *dataDir, keyDir, server.WithSecretScrub(), server.WithTrustedProxies(trusted))
|
||||
httpSrv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: srv.Handler(),
|
||||
|
||||
@@ -35,6 +35,7 @@ services:
|
||||
ARTEX_LLM_BASE_URL: ${ARTEX_LLM_BASE_URL:-}
|
||||
ARTEX_LLM_PROXY: ${ARTEX_LLM_PROXY:-}
|
||||
ARTEX_JWT_KEY_DIR: /app/keys
|
||||
ARTEX_TRUSTED_PROXIES: ${ARTEX_TRUSTED_PROXIES:-}
|
||||
ports:
|
||||
- "${ARTEX_BIND_ADDR:-127.0.0.1}:8787:8787"
|
||||
- "127.0.0.1:8788:8788"
|
||||
|
||||
+6
-2
@@ -14,7 +14,7 @@
|
||||
#
|
||||
# Usage examples:
|
||||
# ./reset-password.sh # local, auto-reads config.json/env, prompts for the new password
|
||||
# ./reset-password.sh -p 'NewPass!' # local, new password given directly
|
||||
# ./reset-password.sh -p 'New-Passphrase!' # local, new password given directly (at least 12 characters)
|
||||
# ./reset-password.sh --dsn postgres://u:p@h:5432/artex
|
||||
# ./reset-password.sh -H 127.0.0.1 -P 5433 -U autopentest -W pass -d artex
|
||||
# ./reset-password.sh -m docker # docker deployment (reads POSTGRES_* from .env)
|
||||
@@ -95,7 +95,7 @@ PY
|
||||
}
|
||||
|
||||
apply_config_fields() {
|
||||
local line key val
|
||||
local key val
|
||||
while IFS=$'\t' read -r key val; do
|
||||
[[ -z "$key" ]] && continue
|
||||
case "$key" in
|
||||
@@ -130,6 +130,10 @@ if [[ -z "$NEWPASS" ]]; then
|
||||
[[ "$NEWPASS" == "$NEWPASS2" ]] || die "the two entries do not match"
|
||||
fi
|
||||
[[ -n "$NEWPASS" ]] || die "password cannot be empty"
|
||||
# Same minimum as the server (minPasswordRunes in server/auth.go), counted in
|
||||
# characters like the server: UTF-8 bytes minus continuation bytes, whatever the locale.
|
||||
NEWPASS_CHARS=$(printf '%s' "$NEWPASS" | LC_ALL=C tr -d '\200-\277' | wc -c)
|
||||
(( NEWPASS_CHARS >= 12 )) || die "password must be at least 12 characters"
|
||||
|
||||
# Hand the password to psql via environment variable (read with \getenv, never in argv/ps)
|
||||
export ARTEX_RESET_NEWPASS="$NEWPASS"
|
||||
|
||||
+21
-2
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -29,7 +30,9 @@ const (
|
||||
// The lower bound matches the setup page's frontend validation — validation that
|
||||
// lives only in the frontend is as good as none: hitting the API directly bypasses
|
||||
// it. The upper bound is bcrypt's hard limit: past 72 bytes GenerateFromPassword returns ErrPasswordTooLong; rejecting early beats a cryptic "password encryption failed".
|
||||
minPasswordRunes = 8
|
||||
// Both apply to new passwords only (init, change-password); login never checks
|
||||
// length, so passwords set under the old 8-character minimum still log in.
|
||||
minPasswordRunes = 12
|
||||
maxPasswordBytes = 72
|
||||
)
|
||||
|
||||
@@ -372,6 +375,21 @@ func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
s.login(w, r, pg.GetSetting)
|
||||
}
|
||||
|
||||
// login is authLogin past the data-source check; getSetting is pg.GetSetting,
|
||||
// a parameter so tests can run it without PostgreSQL. Every attempt counts
|
||||
// against the caller's IP (s.loginLimit, keyed by clientIP) and a successful
|
||||
// one clears it. There is no per-account lockout and no password length check here.
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request, getSetting func(string) (string, bool, error)) {
|
||||
ip := clientIP(r, s.trustedProxies)
|
||||
if wait := s.loginLimit.allow(ip); wait > 0 {
|
||||
secs := int((wait + time.Second - 1) / time.Second)
|
||||
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||
writeErr(w, 429, fmt.Sprintf("Too many login attempts, try again in %d seconds", secs))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -384,7 +402,7 @@ func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 401, "Incorrect username or password")
|
||||
return
|
||||
}
|
||||
hash, ok, err := pg.GetSetting(authPassKey)
|
||||
hash, ok, err := getSetting(authPassKey)
|
||||
if err != nil {
|
||||
writeErr(w, 503, errDataSourceUnavailable)
|
||||
return
|
||||
@@ -397,6 +415,7 @@ func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 401, "Incorrect username or password")
|
||||
return
|
||||
}
|
||||
s.loginLimit.reset(ip)
|
||||
tok, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
writeErr(w, 500, "token generation failed")
|
||||
|
||||
+5
-4
@@ -302,10 +302,11 @@ func TestValidatePassword(t *testing.T) {
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", true},
|
||||
{"seven chars", "1234567", true},
|
||||
{"eight chars", "12345678", false},
|
||||
{"eight multibyte chars counted as runes not bytes", strings.Repeat("€", 8), false},
|
||||
{"three chars reach 9 bytes but only 3 runes", strings.Repeat("€", 3), true},
|
||||
{"eight chars, the old minimum", "12345678", true},
|
||||
{"eleven chars", "12345678901", true},
|
||||
{"twelve chars", "123456789012", false},
|
||||
{"twelve multibyte chars counted as runes not bytes", strings.Repeat("€", 12), false},
|
||||
{"four chars reach 12 bytes but only 4 runes", strings.Repeat("€", 4), true},
|
||||
{"72 bytes", strings.Repeat("a", 72), false},
|
||||
{"73 bytes exceeds the bcrypt cap", strings.Repeat("a", 73), true},
|
||||
} {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Login attempts are limited per client IP, never per account: a per-account
|
||||
// lockout would let anyone lock the admin out by failing on purpose.
|
||||
const (
|
||||
loginMaxAttempts = 10 // attempts allowed per IP per window
|
||||
loginWindow = time.Minute // the counting window
|
||||
loginBaseBlock = time.Minute // length of the first block
|
||||
loginMaxBlock = 15 * time.Minute // cap on the block; an entry idle this long is dropped
|
||||
loginSweepEvery = time.Minute // how often stale entries are swept
|
||||
loginMaxEntries = 10000 // hard cap on tracked IPs
|
||||
)
|
||||
|
||||
type loginEntry struct {
|
||||
attempts int
|
||||
windowStart time.Time
|
||||
blockedUntil time.Time
|
||||
block time.Duration // current block length; doubles on every new block or blocked attempt
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// loginLimiter counts login attempts per IP. Past loginMaxAttempts in one
|
||||
// loginWindow the IP is blocked for loginBaseBlock; every attempt while blocked,
|
||||
// and every later block, doubles the block up to loginMaxBlock. State is in
|
||||
// memory only and swept of idle entries, so a restart clears it.
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
entries map[string]*loginEntry
|
||||
lastSweep time.Time
|
||||
}
|
||||
|
||||
func newLoginLimiter(now func() time.Time) *loginLimiter {
|
||||
return &loginLimiter{now: now, entries: map[string]*loginEntry{}}
|
||||
}
|
||||
|
||||
// allow records one attempt from ip. It returns 0 when the attempt may go on,
|
||||
// otherwise how long ip must wait.
|
||||
func (l *loginLimiter) allow(ip string) time.Duration {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
l.sweep(now)
|
||||
e := l.entries[ip]
|
||||
if e == nil {
|
||||
if len(l.entries) >= loginMaxEntries {
|
||||
l.evictOldest()
|
||||
}
|
||||
e = &loginEntry{windowStart: now}
|
||||
l.entries[ip] = e
|
||||
}
|
||||
e.lastSeen = now
|
||||
if now.Before(e.blockedUntil) {
|
||||
e.block = min(2*e.block, loginMaxBlock)
|
||||
e.blockedUntil = now.Add(e.block)
|
||||
return e.block
|
||||
}
|
||||
if now.Sub(e.windowStart) >= loginWindow {
|
||||
e.attempts, e.windowStart = 0, now
|
||||
}
|
||||
if e.attempts >= loginMaxAttempts {
|
||||
if e.block == 0 {
|
||||
e.block = loginBaseBlock
|
||||
} else {
|
||||
e.block = min(2*e.block, loginMaxBlock)
|
||||
}
|
||||
e.blockedUntil = now.Add(e.block)
|
||||
e.attempts, e.windowStart = 0, now
|
||||
return e.block
|
||||
}
|
||||
e.attempts++
|
||||
return 0
|
||||
}
|
||||
|
||||
// reset forgets ip, after a successful login. A blocked IP never reaches the
|
||||
// password check, so this only runs outside a block.
|
||||
func (l *loginLimiter) reset(ip string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.entries, ip)
|
||||
}
|
||||
|
||||
// sweep drops entries idle for loginMaxBlock, at most once per loginSweepEvery.
|
||||
// Such an entry is never blocked: a block ends at most loginMaxBlock after the
|
||||
// attempt that set it.
|
||||
func (l *loginLimiter) sweep(now time.Time) {
|
||||
if now.Sub(l.lastSweep) < loginSweepEvery {
|
||||
return
|
||||
}
|
||||
l.lastSweep = now
|
||||
for ip, e := range l.entries {
|
||||
if now.Sub(e.lastSeen) >= loginMaxBlock {
|
||||
delete(l.entries, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evictOldest drops the least recently seen entry to stay under loginMaxEntries.
|
||||
func (l *loginLimiter) evictOldest() {
|
||||
var oldest string
|
||||
var at time.Time
|
||||
for ip, e := range l.entries {
|
||||
if oldest == "" || e.lastSeen.Before(at) {
|
||||
oldest, at = ip, e.lastSeen
|
||||
}
|
||||
}
|
||||
delete(l.entries, oldest)
|
||||
}
|
||||
|
||||
// peerIP is the host part of the TCP peer address.
|
||||
func peerIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// clientIP is the address login attempts are counted against. It is the TCP
|
||||
// peer unless the peer is in trusted: then X-Forwarded-For is read from the
|
||||
// right, past trusted hops, and the first untrusted hop is the client. Entries
|
||||
// left of that hop come from the client itself and are never used, so a client
|
||||
// cannot pick its own bucket. With trusted empty the header is ignored.
|
||||
func clientIP(r *http.Request, trusted []netip.Prefix) string {
|
||||
peer := peerIP(r)
|
||||
addr, err := netip.ParseAddr(peer)
|
||||
if err != nil || !isTrusted(addr, trusted) {
|
||||
return peer
|
||||
}
|
||||
hops := strings.Split(strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",")
|
||||
for i := len(hops) - 1; i >= 0; i-- {
|
||||
hop, err := netip.ParseAddr(strings.TrimSpace(hops[i]))
|
||||
if err != nil {
|
||||
break // keep the last address a trusted proxy reported
|
||||
}
|
||||
addr = hop
|
||||
if !isTrusted(addr, trusted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return addr.Unmap().WithZone("").String()
|
||||
}
|
||||
|
||||
func isTrusted(a netip.Addr, trusted []netip.Prefix) bool {
|
||||
a = a.Unmap().WithZone("")
|
||||
for _, p := range trusted {
|
||||
if p.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseTrustedProxies parses ARTEX_TRUSTED_PROXIES: comma-separated CIDRs (a
|
||||
// bare address means that one host). Empty means no trusted proxy.
|
||||
func ParseTrustedProxies(s string) ([]netip.Prefix, error) {
|
||||
var out []netip.Prefix
|
||||
for _, f := range strings.Split(s, ",") {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
if p, err := netip.ParsePrefix(f); err == nil {
|
||||
out = append(out, p.Masked())
|
||||
continue
|
||||
}
|
||||
a, err := netip.ParseAddr(f)
|
||||
if err != nil || a.Zone() != "" {
|
||||
return nil, fmt.Errorf("%q is not a CIDR or an IP address", f)
|
||||
}
|
||||
a = a.Unmap()
|
||||
out = append(out, netip.PrefixFrom(a, a.BitLen()))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WithTrustedProxies sets the proxies whose X-Forwarded-For the login rate
|
||||
// limit reads (see clientIP).
|
||||
func WithTrustedProxies(p []netip.Prefix) Option {
|
||||
return func(s *Server) { s.trustedProxies = p }
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type fakeClock struct{ t time.Time }
|
||||
|
||||
func (c *fakeClock) now() time.Time { return c.t }
|
||||
func (c *fakeClock) advance(d time.Duration) { c.t = c.t.Add(d) }
|
||||
|
||||
func newFakeClock() *fakeClock { return &fakeClock{t: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} }
|
||||
|
||||
// oldPassword is 8 characters: valid under the old minimum, too short for a new password now.
|
||||
const oldPassword = "abcd1234"
|
||||
|
||||
// loginServer returns a Server whose login reads a stored hash of oldPassword
|
||||
// from a stub instead of PostgreSQL.
|
||||
func loginServer(t *testing.T, clk *fakeClock) (*Server, func(remote, password string, hdr map[string]string) *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(oldPassword), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
getSetting := func(key string) (string, bool, error) {
|
||||
if key != authPassKey {
|
||||
return "", false, nil
|
||||
}
|
||||
return string(hash), true, nil
|
||||
}
|
||||
s := &Server{jwtKey: []byte("0123456789abcdef0123456789abcdef"), loginLimit: newLoginLimiter(clk.now)}
|
||||
post := func(remote, password string, hdr map[string]string) *httptest.ResponseRecorder {
|
||||
body := fmt.Sprintf(`{"username":"ARTEX","password":%q}`, password)
|
||||
r := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||
r.RemoteAddr = remote
|
||||
for k, v := range hdr {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.login(w, r, getSetting)
|
||||
return w
|
||||
}
|
||||
return s, post
|
||||
}
|
||||
|
||||
func failN(t *testing.T, post func(string, string, map[string]string) *httptest.ResponseRecorder, remote string, n int) {
|
||||
t.Helper()
|
||||
for i := 0; i < n; i++ {
|
||||
if w := post(remote, "wrong-password", nil); w.Code != 401 {
|
||||
t.Fatalf("attempt %d from %s: status=%d want 401; body=%s", i+1, remote, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitReturns429WithRetryAfter(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
_, post := loginServer(t, clk)
|
||||
const ip = "192.0.2.10:40000"
|
||||
failN(t, post, ip, loginMaxAttempts)
|
||||
|
||||
w := post(ip, oldPassword, nil) // even the right password is refused while blocked
|
||||
if w.Code != 429 {
|
||||
t.Fatalf("status=%d want 429; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Header().Get("Retry-After"); got != "60" {
|
||||
t.Fatalf("Retry-After=%q want 60", got)
|
||||
}
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body.Error == "" || body.Token != "" {
|
||||
t.Fatalf("want a JSON error body without a token, got %s (err=%v)", w.Body.String(), err)
|
||||
}
|
||||
|
||||
// Each attempt while blocked doubles the wait, capped at loginMaxBlock.
|
||||
for _, want := range []string{"120", "240", "480", "900", "900"} {
|
||||
if got := post(ip, "wrong-password", nil).Header().Get("Retry-After"); got != want {
|
||||
t.Fatalf("Retry-After=%q want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Once the block runs out the IP gets a fresh window.
|
||||
clk.advance(loginMaxBlock)
|
||||
if w := post(ip, oldPassword, nil); w.Code != 200 {
|
||||
t.Fatalf("after the block: status=%d want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginSuccessResetsCounter(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
_, post := loginServer(t, clk)
|
||||
const ip = "192.0.2.11:40000"
|
||||
failN(t, post, ip, loginMaxAttempts-1)
|
||||
if w := post(ip, oldPassword, nil); w.Code != 200 {
|
||||
t.Fatalf("status=%d want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// The counter starts over: a full budget of failures before the next block.
|
||||
failN(t, post, ip, loginMaxAttempts)
|
||||
if w := post(ip, "wrong-password", nil); w.Code != 429 {
|
||||
t.Fatalf("status=%d want 429", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitIsPerIP(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
_, post := loginServer(t, clk)
|
||||
failN(t, post, "192.0.2.12:40000", loginMaxAttempts)
|
||||
if w := post("192.0.2.12:40001", "wrong-password", nil); w.Code != 429 {
|
||||
t.Fatalf("same IP, other port: status=%d want 429", w.Code)
|
||||
}
|
||||
if w := post("198.51.100.7:40000", "wrong-password", nil); w.Code != 401 {
|
||||
t.Fatalf("other IP: status=%d want 401", w.Code)
|
||||
}
|
||||
if w := post("[2001:db8::1]:40000", oldPassword, nil); w.Code != 200 {
|
||||
t.Fatalf("other IP: status=%d want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitIgnoresForwardedFor(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
_, post := loginServer(t, clk)
|
||||
const blocked = "192.0.2.13"
|
||||
failN(t, post, blocked+":40000", loginMaxAttempts)
|
||||
|
||||
// A blocked peer cannot escape by claiming another address.
|
||||
for _, h := range []string{"X-Forwarded-For", "X-Real-IP"} {
|
||||
if w := post(blocked+":40000", oldPassword, map[string]string{h: "203.0.113.9"}); w.Code != 429 {
|
||||
t.Fatalf("%s from blocked peer: status=%d want 429", h, w.Code)
|
||||
}
|
||||
}
|
||||
// And another peer naming the blocked address is not blocked.
|
||||
if w := post("198.51.100.8:40000", oldPassword, map[string]string{"X-Forwarded-For": blocked}); w.Code != 200 {
|
||||
t.Fatalf("X-Forwarded-For naming the blocked IP: status=%d want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func mustTrusted(t *testing.T, s string) []netip.Prefix {
|
||||
t.Helper()
|
||||
p, err := ParseTrustedProxies(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestParseTrustedProxies(t *testing.T) {
|
||||
if p, err := ParseTrustedProxies(" "); err != nil || len(p) != 0 {
|
||||
t.Fatalf("empty: %v, %v; want no proxies", p, err)
|
||||
}
|
||||
p, err := ParseTrustedProxies(" 127.0.0.1/32, 10.1.2.3/8 ,::1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fmt.Sprint(p); got != "[127.0.0.1/32 10.0.0.0/8 ::1/128]" {
|
||||
t.Fatalf("got %s", got)
|
||||
}
|
||||
for _, bad := range []string{"proxy.local", "10.0.0.0/33", "127.0.0.1,nope"} {
|
||||
if _, err := ParseTrustedProxies(bad); err == nil {
|
||||
t.Fatalf("%q: want an error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP(t *testing.T) {
|
||||
trusted := mustTrusted(t, "127.0.0.1/32,10.0.0.0/8")
|
||||
for _, tc := range []struct {
|
||||
name, remote string
|
||||
xff []string
|
||||
trusted []netip.Prefix
|
||||
want string
|
||||
}{
|
||||
{"direct client, no proxies configured", "198.51.100.9:5000", nil, nil, "198.51.100.9"},
|
||||
{"direct client, proxies configured", "198.51.100.9:5000", nil, trusted, "198.51.100.9"},
|
||||
{"spoofed header, no proxies configured", "198.51.100.9:5000", []string{"203.0.113.1"}, nil, "198.51.100.9"},
|
||||
{"spoofed header from an untrusted peer", "198.51.100.9:5000", []string{"203.0.113.1"}, trusted, "198.51.100.9"},
|
||||
{"spoofed header claiming a trusted address", "198.51.100.9:5000", []string{"127.0.0.1"}, trusted, "198.51.100.9"},
|
||||
{"one trusted proxy", "127.0.0.1:5000", []string{"198.51.100.7"}, trusted, "198.51.100.7"},
|
||||
{"client-supplied entries left of the real hop", "127.0.0.1:5000", []string{"203.0.113.1, 10.0.0.9, 198.51.100.7"}, trusted, "198.51.100.7"},
|
||||
{"chain of trusted proxies", "127.0.0.1:5000", []string{"203.0.113.1, 198.51.100.7, 10.0.0.5"}, trusted, "198.51.100.7"},
|
||||
{"chain split over header lines", "127.0.0.1:5000", []string{"203.0.113.1", "198.51.100.7, 10.0.0.5"}, trusted, "198.51.100.7"},
|
||||
{"trusted proxy without the header", "127.0.0.1:5000", nil, trusted, "127.0.0.1"},
|
||||
{"unparsable hop stops the walk", "127.0.0.1:5000", []string{"198.51.100.7, garbage, 10.0.0.5"}, trusted, "10.0.0.5"},
|
||||
{"IPv6 client behind the proxy", "127.0.0.1:5000", []string{"2001:db8::7"}, trusted, "2001:db8::7"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.RemoteAddr = tc.remote
|
||||
for _, v := range tc.xff {
|
||||
r.Header.Add("X-Forwarded-For", v)
|
||||
}
|
||||
if got := clientIP(r, tc.trusted); got != tc.want {
|
||||
t.Fatalf("clientIP=%s want %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Behind a trusted reverse proxy every request arrives from the proxy's address;
|
||||
// the limit must still apply per real client.
|
||||
func TestLoginRateLimitBehindTrustedProxy(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
s, post := loginServer(t, clk)
|
||||
s.trustedProxies = mustTrusted(t, "127.0.0.1/32")
|
||||
const proxy = "127.0.0.1:50000"
|
||||
via := func(xff string) map[string]string { return map[string]string{"X-Forwarded-For": xff} }
|
||||
|
||||
for i := 0; i < loginMaxAttempts; i++ {
|
||||
if w := post(proxy, "wrong-password", via("198.51.100.20")); w.Code != 401 {
|
||||
t.Fatalf("attempt %d: status=%d want 401", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
if w := post(proxy, oldPassword, via("198.51.100.20")); w.Code != 429 {
|
||||
t.Fatalf("attacker: status=%d want 429", w.Code)
|
||||
}
|
||||
// What nginx's $proxy_add_x_forwarded_for sends when the attacker adds its own header.
|
||||
if w := post(proxy, oldPassword, via("203.0.113.50, 198.51.100.20")); w.Code != 429 {
|
||||
t.Fatalf("attacker with a forged entry: status=%d want 429", w.Code)
|
||||
}
|
||||
// Another user behind the same proxy is not affected.
|
||||
if w := post(proxy, oldPassword, via("198.51.100.21")); w.Code != 200 {
|
||||
t.Fatalf("other user via the proxy: status=%d want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The 12-character minimum applies to new passwords only. A hash stored under the
|
||||
// old 8-character minimum must still log in.
|
||||
func TestEightCharPasswordLogsInButIsRejectedAsNew(t *testing.T) {
|
||||
if n := len([]rune(oldPassword)); n != 8 {
|
||||
t.Fatalf("oldPassword has %d characters, want 8", n)
|
||||
}
|
||||
_, post := loginServer(t, newFakeClock())
|
||||
w := post("192.0.2.14:40000", oldPassword, nil)
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), `"token"`) {
|
||||
t.Fatalf("login with an existing 8-character password: status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// authInit and authChangePassword both refuse a new password through validatePassword.
|
||||
if msg := validatePassword(oldPassword); msg == "" {
|
||||
t.Fatal("validatePassword accepted an 8-character new password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterSweepsIdleEntries(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
l := newLoginLimiter(clk.now)
|
||||
l.allow("192.0.2.20")
|
||||
clk.advance(loginMaxBlock - time.Second)
|
||||
l.allow("192.0.2.21") // sweeps, but .20 is not idle long enough yet
|
||||
if len(l.entries) != 2 {
|
||||
t.Fatalf("entries=%d want 2 before either is idle for %v", len(l.entries), loginMaxBlock)
|
||||
}
|
||||
clk.advance(loginSweepEvery)
|
||||
l.allow("192.0.2.22") // next sweep drops .20
|
||||
if _, ok := l.entries["192.0.2.20"]; ok || len(l.entries) != 2 {
|
||||
t.Fatalf("idle entry not swept: %d entries", len(l.entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterCapsEntries(t *testing.T) {
|
||||
l := newLoginLimiter(newFakeClock().now)
|
||||
for i := 0; i <= loginMaxEntries; i++ {
|
||||
l.allow(fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255))
|
||||
}
|
||||
if len(l.entries) != loginMaxEntries {
|
||||
t.Fatalf("entries=%d want the cap %d", len(l.entries), loginMaxEntries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthHidesVersionWithoutToken(t *testing.T) {
|
||||
key := []byte("0123456789abcdef0123456789abcdef")
|
||||
s := &Server{jwtKey: key}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.health(w, httptest.NewRequest("GET", "/api/health", nil))
|
||||
if got := strings.TrimSpace(w.Body.String()); w.Code != 200 || got != `{"ok":true}` {
|
||||
t.Fatalf("no token: status=%d body=%s want {\"ok\":true}", w.Code, got)
|
||||
}
|
||||
|
||||
tok, err := signJWT(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest("GET", "/api/health", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+tok)
|
||||
w = httptest.NewRecorder()
|
||||
s.health(w, r)
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body["version"] != BuildVersion || body["service"] != "artex" {
|
||||
t.Fatalf("valid token: body=%s want service and version", w.Body.String())
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -9,6 +9,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -51,6 +52,9 @@ type Server struct {
|
||||
setupMu sync.Mutex
|
||||
setupToken string // one-time token POST /api/auth/init requires; empty when none is active
|
||||
|
||||
loginLimit *loginLimiter // per-IP attempt limit on POST /api/auth/login
|
||||
trustedProxies []netip.Prefix // ARTEX_TRUSTED_PROXIES; see clientIP
|
||||
|
||||
// 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.
|
||||
@@ -155,7 +159,8 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{},
|
||||
profChatAgents: map[int64]*agent.ChatAgent{},
|
||||
provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg),
|
||||
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1)}
|
||||
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1),
|
||||
loginLimit: newLoginLimiter(time.Now)}
|
||||
for _, o := range opts {
|
||||
o(s)
|
||||
}
|
||||
@@ -964,7 +969,14 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
// --- handlers ---
|
||||
|
||||
// health answers without a token (requireAuth exempts it), but only a caller
|
||||
// with a valid token gets the service name and version; the web UI's top bar
|
||||
// reads the version, and sends its token.
|
||||
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
if !verifyJWT(extractToken(r), s.jwtKey) {
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "service": "artex", "version": BuildVersion})
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ export default function SetupPage() {
|
||||
setError("The two passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError("Password must be at least 8 characters");
|
||||
if (password.length < 12) {
|
||||
setError("Password must be at least 12 characters");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -81,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? Enter the setup token from the server log, then 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 12 characters)"}
|
||||
</p>
|
||||
</div>
|
||||
{unavailable ? (
|
||||
@@ -111,7 +111,7 @@ export default function SetupPage() {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
placeholder="At least 12 characters"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user