diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 33b613a..d8eef9a 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -10,11 +10,23 @@ jobs: verify: runs-on: container-builder container: - image: golang:1.24-bookworm + image: debian:trixie-slim + env: + GOPROXY: https://gitea.nehmer.net/api/packages/torben/go,https://proxy.golang.org,direct steps: + - name: Install CI Dependencies + run: | + apt-get update + apt-get install -y --no-install-recommends git nodejs curl unzip ca-certificates + - name: Checkout Code uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.x' + - name: Show Go Version run: go version diff --git a/Dockerfile b/Dockerfile index 371114a..00efb91 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,9 @@ -ARG GO_VERSION=1.24 +ARG GO_VERSION=1.26 +ARG GOPROXY_URL=https://gitea.nehmer.net/api/packages/torben/go,https://proxy.golang.org,direct FROM golang:${GO_VERSION}-bookworm AS build WORKDIR /src +ENV GOPROXY=${GOPROXY_URL} COPY go.mod ./ RUN go mod download diff --git a/README.md b/README.md index 6080655..a59be73 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ Kleiner Go-basierter Traefik-ForwardAuth-Microservice, der Bearer-Tokens gegen e ## Verhalten - Prueft `Authorization: Bearer `. -- Validiert Token gegen Tokens aus: - - `AUTH_PROXY_TOKENS_DIR` (jede Datei enthaelt genau einen Token), und/oder - - `AUTH_PROXY_TOKENS` (kommagetrennte Liste). +- Validiert Token gegen SHA-256-Hashes aus: + - `AUTH_PROXY_TOKEN_HASHES_DIR` (jede Datei enthaelt genau einen Token-Hash), und/oder + - `AUTH_PROXY_TOKEN_HASHES` (kommagetrennte Hash-Liste). - Antwortet mit: - `200` (leer) bei gueltigem Token, - `401` bei fehlendem/ungueltigem Header oder ungueltigem Token. @@ -17,6 +17,7 @@ Kleiner Go-basierter Traefik-ForwardAuth-Microservice, der Bearer-Tokens gegen e ## Sicherheitsaspekte - Token-Matching erfolgt auf Basis von SHA-256-Digests mit `crypto/subtle.ConstantTimeCompare`. +- Der Service speichert nur Hashes der erlaubten Tokens, keine Klartext-Tokens in der Konfiguration. - Token werden nie im Klartext geloggt; es wird nur ein kurzer Fingerprint (`sha256:...`) geloggt. - Start bricht fail-fast ab, wenn keine gueltigen Tokens geladen werden konnten. @@ -25,17 +26,46 @@ Kleiner Go-basierter Traefik-ForwardAuth-Microservice, der Bearer-Tokens gegen e - `AUTH_PROXY_LISTEN_ADDR` - Default: `:8080` - Beispiel: `:8080` -- `AUTH_PROXY_TOKENS_DIR` +- `AUTH_PROXY_TOKEN_HASHES_DIR` - Optional - - Pfad auf ein Verzeichnis, in dem jede Datei einen Token enthaelt (z. B. Kubernetes Secret Volume) -- `AUTH_PROXY_TOKENS` + - Pfad auf ein Verzeichnis, in dem jede Datei einen SHA-256-Token-Hash enthaelt (z. B. Kubernetes Secret Volume) +- `AUTH_PROXY_TOKEN_HASHES` - Optional - - Kommagetrennte Tokenliste, z. B. `token-a,token-b` + - Kommagetrennte Liste von SHA-256-Hashes (64 Hex-Zeichen) +- Hash-Format + - `abcdef...` (64 hex) oder `sha256:abcdef...` (64 hex) - `AUTH_PROXY_LOG_LEVEL` - Default: `info` - Werte wie `debug`, `info`, `warn`, `error` -Hinweis: Es muss mindestens eine Tokenquelle (`AUTH_PROXY_TOKENS_DIR` oder `AUTH_PROXY_TOKENS`) konfiguriert sein. +Hinweis: Es muss mindestens eine Hash-Quelle (`AUTH_PROXY_TOKEN_HASHES_DIR` oder `AUTH_PROXY_TOKEN_HASHES`) konfiguriert sein. + +## Go Package Registry (Gitea) + +Dieses Repo ist auf die Gitea Go Package Registry ausgerichtet. + +- Registry-Muster: `https:///api/packages/{owner}/go` +- In dieser Konfiguration: `https://gitea.nehmer.net/api/packages/torben/go` + +Beispiel lokal: + +```bash +go env -w GOPROXY="https://gitea.nehmer.net/api/packages/torben/go,https://proxy.golang.org,direct" +``` + +Annahme: Deine Angabe `https://gitea.example.com/api/packages/{owner}/go` ist ein Muster/Template. Implementiert wurde konkret `gitea.nehmer.net` mit Owner `torben`. + +## Healthcheck Security + +`/healthz` muss nicht world-readable sein. Fuer k3s Probes und Prometheus reicht interne Erreichbarkeit im Cluster. + +Empfehlung: + +- Service als `ClusterIP` belassen (bereits in Referenzmanifesten umgesetzt) +- Keine externe Ingress-Route auf `/healthz` publizieren +- Zugriff auf Pod/Service-Netzwerkebene einschränken (z. B. NetworkPolicy), falls eure CNI/Policies das bereits vorsehen + +Annahme: In eurem Setup ist der ForwardAuth-Service nur intern erreichbar und wird nicht direkt aus dem Internet exponiert. ## Lokal bauen und starten @@ -44,7 +74,21 @@ go test ./... -v go vet ./... go build ./cmd/authproxy -AUTH_PROXY_TOKENS="my-token-1,my-token-2" \ +AUTH_PROXY_TOKEN_HASHES="," \ +AUTH_PROXY_LISTEN_ADDR=":8080" \ +go run ./cmd/authproxy +``` + +Token-Hashes erzeugen: + +```bash +echo -n "my-token-1" | sha256sum | awk '{print $1}' +``` + +Startbeispiel mit Hashes: + +```bash +AUTH_PROXY_TOKEN_HASHES="," \ AUTH_PROXY_LISTEN_ADDR=":8080" \ go run ./cmd/authproxy ``` @@ -72,7 +116,7 @@ Das Projekt enthaelt ein Multi-Stage-Dockerfile mit statisch gelinktem Binary (` ```bash docker build -t gitea-mcp-auth-proxy:dev . docker run --rm -p 8080:8080 \ - -e AUTH_PROXY_TOKENS="my-token-1,my-token-2" \ + -e AUTH_PROXY_TOKEN_HASHES="," \ gitea-mcp-auth-proxy:dev ``` @@ -96,12 +140,15 @@ Referenzbeispiele fuer lokale Verifikation liegen unter: - `deploy/k3s/deployment.yaml` - `deploy/k3s/service.yaml` +- `deploy/k3s/networkpolicy.yaml` Diese Manifeste sind bewusst minimal und nicht als produktive FluxCD-Quelle gedacht. ## Annahmen -- Annahme: Go-Version ist `1.24` (aktuelle stabile Version zum Implementierungszeitpunkt muss ggf. angepasst werden). +- Annahme: Go-Version ist `1.26`. - Annahme: Release-Build pusht initial nur `linux/amd64`. - Annahme: Remote BuildKit ist im Runner-Netz unter `tcp://:1234` erreichbar. -- Annahme: Registry-Pfad ist `gitea.nehmer.net/torben/gitea-mcp-auth-proxy`. \ No newline at end of file +- Annahme: Registry-Pfad ist `gitea.nehmer.net/torben/gitea-mcp-auth-proxy`. +- Annahme: Traefik laeuft in `kube-system` mit Label `app.kubernetes.io/name=traefik`. +- Annahme: Prometheus laeuft in `monitoring` mit Label `app.kubernetes.io/name=prometheus`. \ No newline at end of file diff --git a/deploy/k3s/deployment.yaml b/deploy/k3s/deployment.yaml index 404cbee..e8b708f 100644 --- a/deploy/k3s/deployment.yaml +++ b/deploy/k3s/deployment.yaml @@ -4,6 +4,8 @@ metadata: name: gitea-mcp-auth-proxy labels: app: gitea-mcp-auth-proxy + annotations: + security.note/healthz: "Do not expose /healthz publicly; keep service internal and restrict ingress at network/ingress layer." spec: replicas: 1 selector: @@ -24,7 +26,7 @@ spec: env: - name: AUTH_PROXY_LISTEN_ADDR value: ":8080" - - name: AUTH_PROXY_TOKENS_DIR + - name: AUTH_PROXY_TOKEN_HASHES_DIR value: /var/run/secrets/auth-proxy - name: AUTH_PROXY_LOG_LEVEL value: info diff --git a/deploy/k3s/networkpolicy.yaml b/deploy/k3s/networkpolicy.yaml new file mode 100644 index 0000000..b5b5474 --- /dev/null +++ b/deploy/k3s/networkpolicy.yaml @@ -0,0 +1,31 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: gitea-mcp-auth-proxy-ingress + labels: + app: gitea-mcp-auth-proxy +spec: + podSelector: + matchLabels: + app: gitea-mcp-auth-proxy + policyTypes: + - Ingress + ingress: + - from: + # Assumption: Traefik Pods run in namespace kube-system and expose label app.kubernetes.io/name=traefik. + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + app.kubernetes.io/name: traefik + # Assumption: Prometheus Pods run in namespace monitoring and expose label app.kubernetes.io/name=prometheus. + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: monitoring + podSelector: + matchLabels: + app.kubernetes.io/name: prometheus + ports: + - protocol: TCP + port: 8080 diff --git a/go.mod b/go.mod index 93bd54c..f000777 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module gitea-mcp-forward-auth +module gitea.nehmer.net/torben/gitea-mcp-forward-auth -go 1.24 +go 1.26 diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f8dc907..f096ce5 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -3,6 +3,7 @@ package auth import ( "crypto/sha256" "crypto/subtle" + "encoding/hex" "errors" "fmt" "os" @@ -10,32 +11,36 @@ import ( "strings" ) -const EnvTokens = "AUTH_PROXY_TOKENS" +const EnvTokenHashes = "AUTH_PROXY_TOKEN_HASHES" var ( ErrMissingAuthHeader = errors.New("missing authorization header") ErrInvalidAuthHeader = errors.New("invalid authorization header format") - ErrEmptyTokenSet = errors.New("no tokens configured") - ErrTokenDirUnreadable = errors.New("unable to read token directory") + ErrEmptyTokenSet = errors.New("no token hashes configured") + ErrTokenDirUnreadable = errors.New("unable to read token hash directory") + ErrInvalidTokenHash = errors.New("invalid token hash format") ) type Validator struct { tokenDigests [][sha256.Size]byte } -func NewValidator(tokens []string) (*Validator, error) { - if len(tokens) == 0 { +func NewValidator(tokenHashes []string) (*Validator, error) { + if len(tokenHashes) == 0 { return nil, ErrEmptyTokenSet } - unique := make(map[[sha256.Size]byte]struct{}, len(tokens)) - digests := make([][sha256.Size]byte, 0, len(tokens)) - for _, token := range tokens { - t := strings.TrimSpace(token) - if t == "" { + unique := make(map[[sha256.Size]byte]struct{}, len(tokenHashes)) + digests := make([][sha256.Size]byte, 0, len(tokenHashes)) + for _, hashValue := range tokenHashes { + hashValue = normalizeHash(hashValue) + if hashValue == "" { continue } - digest := sha256.Sum256([]byte(t)) + digest, err := decodeHash(hashValue) + if err != nil { + return nil, err + } if _, exists := unique[digest]; exists { continue } @@ -86,21 +91,21 @@ func ParseBearerToken(authHeader string) (string, error) { return parts[1], nil } -func LoadTokens(tokensDir, tokensCSV string) ([]string, error) { - tokens := make([]string, 0) +func LoadTokenHashes(hashesDir, hashesCSV string) ([]string, error) { + hashes := make([]string, 0) - dirTokens, err := readTokensFromDir(tokensDir) + dirHashes, err := readHashesFromDir(hashesDir) if err != nil { return nil, err } - tokens = append(tokens, dirTokens...) - tokens = append(tokens, parseTokensCSV(tokensCSV)...) + hashes = append(hashes, dirHashes...) + hashes = append(hashes, parseHashesCSV(hashesCSV)...) - cleaned := make([]string, 0, len(tokens)) - for _, token := range tokens { - t := strings.TrimSpace(token) - if t != "" { - cleaned = append(cleaned, t) + cleaned := make([]string, 0, len(hashes)) + for _, hashValue := range hashes { + h := normalizeHash(hashValue) + if h != "" { + cleaned = append(cleaned, h) } } @@ -111,45 +116,67 @@ func LoadTokens(tokensDir, tokensCSV string) ([]string, error) { return cleaned, nil } -func readTokensFromDir(tokensDir string) ([]string, error) { - if strings.TrimSpace(tokensDir) == "" { +func readHashesFromDir(hashesDir string) ([]string, error) { + if strings.TrimSpace(hashesDir) == "" { return nil, nil } - entries, err := os.ReadDir(tokensDir) + entries, err := os.ReadDir(hashesDir) if err != nil { - return nil, fmt.Errorf("%w %q: %v", ErrTokenDirUnreadable, tokensDir, err) + return nil, fmt.Errorf("%w %q: %v", ErrTokenDirUnreadable, hashesDir, err) } - tokens := make([]string, 0, len(entries)) + hashes := make([]string, 0, len(entries)) for _, entry := range entries { if entry.IsDir() { continue } - path := filepath.Join(tokensDir, entry.Name()) + path := filepath.Join(hashesDir, entry.Name()) content, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("read token file %q: %w", path, err) + return nil, fmt.Errorf("read hash file %q: %w", path, err) } - tokens = append(tokens, strings.TrimSpace(string(content))) + hashes = append(hashes, strings.TrimSpace(string(content))) } - return tokens, nil + return hashes, nil } -func parseTokensCSV(tokensCSV string) []string { - if strings.TrimSpace(tokensCSV) == "" { +func parseHashesCSV(hashesCSV string) []string { + if strings.TrimSpace(hashesCSV) == "" { return nil } - parts := strings.Split(tokensCSV, ",") - tokens := make([]string, 0, len(parts)) + parts := strings.Split(hashesCSV, ",") + hashes := make([]string, 0, len(parts)) for _, part := range parts { - t := strings.TrimSpace(part) - if t != "" { - tokens = append(tokens, t) + h := normalizeHash(part) + if h != "" { + hashes = append(hashes, h) } } - return tokens + return hashes +} + +func normalizeHash(value string) string { + value = strings.TrimSpace(value) + if strings.HasPrefix(strings.ToLower(value), "sha256:") { + value = strings.TrimSpace(value[len("sha256:"):]) + } + return strings.ToLower(value) +} + +func decodeHash(value string) ([sha256.Size]byte, error) { + var digest [sha256.Size]byte + if len(value) != sha256.Size*2 { + return digest, fmt.Errorf("%w: expected %d hex chars, got %d", ErrInvalidTokenHash, sha256.Size*2, len(value)) + } + + decoded, err := hex.DecodeString(value) + if err != nil { + return digest, fmt.Errorf("%w: %v", ErrInvalidTokenHash, err) + } + copy(digest[:], decoded) + return digest, nil } func TokenFingerprint(token string) string { diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index c55d8b5..e1b1413 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -1,11 +1,20 @@ package auth -import "testing" +import ( + "crypto/sha256" + "fmt" + "testing" +) + +func hashHex(token string) string { + sum := sha256.Sum256([]byte(token)) + return fmt.Sprintf("%x", sum[:]) +} func TestValidator_IsValid(t *testing.T) { t.Parallel() - validator, err := NewValidator([]string{"token-one", "token-two", "token-three"}) + validator, err := NewValidator([]string{hashHex("token-one"), hashHex("token-two"), hashHex("token-three")}) if err != nil { t.Fatalf("NewValidator() error = %v", err) } @@ -45,6 +54,14 @@ func TestNewValidator_EmptyTokenSet(t *testing.T) { } } +func TestNewValidator_InvalidHash(t *testing.T) { + t.Parallel() + + if _, err := NewValidator([]string{"not-a-hash"}); err == nil { + t.Fatal("NewValidator(invalid hash) expected error, got nil") + } +} + func TestParseBearerToken(t *testing.T) { t.Parallel()