diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a4dc536 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.github +config.yaml +coverage.out +vault-proxy diff --git a/.github/workflows/github-release.yaml b/.github/workflows/github-release.yaml index 7ff865b..5f6075e 100644 --- a/.github/workflows/github-release.yaml +++ b/.github/workflows/github-release.yaml @@ -1,15 +1,18 @@ name: Create release + on: pull_request_target: branches: - main types: - closed + jobs: release: if: github.event.pull_request.merged == true && !contains(github.event.pull_request.title, 'skip-release') - uses: libops/.github/.github/workflows/bump-release.yaml@d7f0ba06b4c8a1f8559892ea65a0b1af4d024dd3 # main + uses: libops/.github/.github/workflows/bump-release.yaml@578137212ead4ab4059e95df17fa30e9b7ac4aed + with: + workflow_file: goreleaser.yaml permissions: contents: write actions: write - secrets: inherit diff --git a/.github/workflows/goreleaser.yaml b/.github/workflows/goreleaser.yaml index 07467a2..f3c8a70 100644 --- a/.github/workflows/goreleaser.yaml +++ b/.github/workflows/goreleaser.yaml @@ -1,4 +1,5 @@ name: goreleaser + on: workflow_dispatch: push: @@ -6,27 +7,54 @@ on: - "*" permissions: - contents: write + contents: read jobs: + test: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version-file: go.mod + - run: | + go mod download + go mod verify + go mod tidy -diff + go test -race ./... + goreleaser: + needs: [test] runs-on: ubuntu-24.04 + permissions: + contents: write steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: - go-version: ">=1.25.6" - - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7 + go-version-file: go.mod + - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: distribution: goreleaser - version: latest + version: v2.17.0 args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish-images: + if: github.ref_type == 'tag' + needs: [test, goreleaser] + permissions: + contents: read + id-token: write + packages: write + uses: ./.github/workflows/lint-test-build-push.yaml + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + GCLOUD_OIDC_POOL: ${{ secrets.GCLOUD_OIDC_POOL }} + GSA: ${{ secrets.GSA }} diff --git a/.github/workflows/lint-test-build-push.yaml b/.github/workflows/lint-test-build-push.yaml index af5cb14..510eae1 100644 --- a/.github/workflows/lint-test-build-push.yaml +++ b/.github/workflows/lint-test-build-push.yaml @@ -1,45 +1,136 @@ -name: lint-test-build-push +name: build-push + on: + workflow_call: + secrets: + CODECOV_TOKEN: + required: false + GCLOUD_OIDC_POOL: + required: false + GSA: + required: false + pull_request: push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + jobs: - test: - permissions: - contents: read + lint-test: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - go-version: ">=1.25.0" + fetch-depth: 0 + persist-credentials: false - - name: golangci-lint - uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: - version: latest + go-version-file: go.mod - - name: Install dependencies - run: go get . + - name: Download and verify modules + run: | + go mod download + go mod verify + go mod tidy -diff + + - name: Lint + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 + with: + version: v2.12.2 + + - name: Test + run: go test -race -coverprofile=coverage.out -covermode=atomic ./... - name: Build - run: go build -v + run: go build -trimpath ./... + + - name: Validate release configuration + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 + with: + distribution: goreleaser + version: v2.17.0 + args: check - - name: Test with the Go CLI - run: go test -v -race -coverprofile=coverage.out -covermode=atomic . + - name: Exercise snapshot release + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 + with: + distribution: goreleaser + version: v2.17.0 + args: release --snapshot --clean - - name: upload coverage to codecov - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6 + - name: Upload coverage + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6 with: files: ./coverage.out fail_ci_if_error: false env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - build-push: - needs: [test] - uses: libops/.github/.github/workflows/build-push.yaml@d7f0ba06b4c8a1f8559892ea65a0b1af4d024dd3 # main + image-check: + if: github.event_name == 'pull_request' + strategy: + matrix: + runner: + - ubuntu-24.04 + - ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 + + - name: Resolve native platform + id: platform + run: | + set -euo pipefail + case "$RUNNER_ARCH" in + X64) platform=amd64 ;; + ARM64) platform=arm64 ;; + *) echo "Unsupported runner architecture: $RUNNER_ARCH" >&2; exit 1 ;; + esac + echo "name=$platform" >> "$GITHUB_OUTPUT" + + - name: Build native image without credentials + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 + with: + context: . + load: true + platforms: linux/${{ steps.platform.outputs.name }} + provenance: false + push: false + tags: vault-proxy:ci-${{ steps.platform.outputs.name }} + + - name: Scan native image + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: vault-proxy:ci-${{ steps.platform.outputs.name }} + format: table + exit-code: "1" + ignore-unfixed: true + severity: HIGH,CRITICAL + vuln-type: os,library + + publish: + if: github.event_name != 'pull_request' + needs: [lint-test] + uses: libops/.github/.github/workflows/build-push.yaml@578137212ead4ab4059e95df17fa30e9b7ac4aed # guarded-signed-image-publisher with: - docker-registry: "libops" + ref: ${{ github.sha }} + expected-main-sha: ${{ github.ref == 'refs/heads/main' && github.sha || '' }} + additional-gar-registry: us-docker.pkg.dev/libops-images/public + scan: true + sign: true + certificate-identity: https://github.com/libops/.github/.github/workflows/build-push.yaml@578137212ead4ab4059e95df17fa30e9b7ac4aed permissions: contents: read packages: write - secrets: inherit + id-token: write + secrets: + GCLOUD_OIDC_POOL: ${{ secrets.GCLOUD_OIDC_POOL }} + GSA: ${{ secrets.GSA }} diff --git a/.goreleaser.yml b/.goreleaser.yml index 474063b..d015a3a 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,6 +1,5 @@ -before: - hooks: - - go mod tidy +version: 2 + builds: - binary: vault-proxy env: @@ -11,7 +10,8 @@ builds: - darwin archives: - - format: tar.gz + - formats: + - tar.gz # this name template makes the OS and Arch compatible with the results of uname. name_template: >- {{ .ProjectName }}_ @@ -23,11 +23,12 @@ archives: # use zip for windows archives format_overrides: - goos: windows - format: zip + formats: + - zip checksum: name_template: "checksums.txt" snapshot: - name_template: "{{ incpatch .Version }}-next" + version_template: "{{ incpatch .Version }}-next" changelog: sort: asc filters: diff --git a/Dockerfile b/Dockerfile index 3c99da0..f55a3db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,69 +1,26 @@ -FROM golang:1.25-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder +FROM ghcr.io/libops/go:1.26.5@sha256:ea764e85e42a243217c621891123b3fda9374674c29d59785414fc6b15815b3d AS builder -SHELL ["/bin/ash", "-o", "pipefail", "-ex", "-c"] - -WORKDIR /app - -ARG \ - # renovate: datasource=repology depName=alpine_3_22/ca-certificates - CA_CERTIFICATES_VERSION=20250911-r0 \ - # renovate: datasource=repology depName=alpine_3_22/dpkg - DPKG_VERSION=1.22.15-r0 \ - # renovate: datasource=repology depName=alpine_3_22/gnupg - GNUPG_VERSION=2.4.9-r0 \ - # renovate: datasource=github-releases depName=gosu packageName=tianon/gosu - GOSU_VERSION=1.19 - -RUN apk add --no-cache --virtual .gosu-deps \ - ca-certificates=="${CA_CERTIFICATES_VERSION}" \ - dpkg=="${DPKG_VERSION}" \ - gnupg=="${GNUPG_VERSION}" && \ - dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')" && \ - wget -q -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch" && \ - wget -q -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc" && \ - GNUPGHOME="$(mktemp -d)" && \ - export GNUPGHOME && \ - gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4 && \ - gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu && \ - gpgconf --kill all && \ - rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc && \ - apk del --no-network .gosu-deps && \ - chmod +x /usr/local/bin/gosu && \ - echo "gosu install complete." +WORKDIR /src COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ - go mod download + go mod download && \ + go mod verify COPY main.go ./ +ARG TARGETOS=linux +ARG TARGETARCH RUN --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/vault-proxy . - -FROM alpine:3.22@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412 - -SHELL ["/bin/ash", "-o", "pipefail", "-c"] + CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" \ + go build -buildvcs=false -trimpath -ldflags="-s -w" -o /out/vault-proxy . -ARG \ - # renovate: datasource=repology depName=alpine_3_22/curl - CURL_VERSION=8.14.1-r2 \ - # renovate: datasource=repology depName=alpine_3_22/jq - JQ_VERSION=1.8.1-r0 +FROM scratch +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=builder /out/vault-proxy /vault-proxy -RUN apk add --no-cache \ - curl=="${CURL_VERSION}" \ - jq=="${JQ_VERSION}" - -RUN adduser -S -G nobody -u 8080 vault-proxy - -WORKDIR /app - -COPY --from=builder /app/vault-proxy /app/vault-proxy -COPY --from=builder /usr/local/bin/gosu /usr/local/bin/gosu - +USER 65532:65532 EXPOSE 8080 -ENTRYPOINT ["gosu", "vault-proxy", "/app/vault-proxy", "-config", "/app/config.yaml"] - -HEALTHCHECK CMD curl -sf http://localhost:8080/v1/sys/health | jq .sealed | grep -q false +ENTRYPOINT ["/vault-proxy"] diff --git a/README.md b/README.md index 2aadcc1..a368525 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,88 @@ -# Lightweight Go Proxy for Vault - -This is a lightweight reverse proxy designed to sit in front of a HashiCorp Vault instance. It adds an additional authorization layer for admin access while allowing specific OIDC-related routes to be public. - -## Security Logic - -This proxy implements the following logic for every incoming request: - -1. **Public Routes**: The request path is checked against a list of public route prefixes (defined in `config.yaml`). If it matches, the request is forwarded directly to Vault without any checks. - -2. **Protected Routes**: If the path is not public, the proxy requires the request to have a header named `X-Admin-Token`. - -3. **Token Validation**: The value of this header MUST be a valid Google OAuth 2.0 access token. The proxy validates the token by calling Google's tokeninfo endpoint to verify: - - The token is valid and not expired - - The email claim is present and verified - -4. **Admin Check**: After the token is proven valid, the proxy checks the `email` and `email_verified` claims. If `email_verified` is true and the email is in the admin list, access is granted. - -5. **Access Denied**: If the header is missing, the token is invalid, or the user is not in the admin list, the proxy returns a `401 Unauthorized` or `403 Forbidden` error. - -6. **Forwarding**: If access is granted, the `X-Admin-Token` header is removed from the request, and the original request (including any original `Authorization` header containing a Vault token) is forwarded to Vault. - -This allows an admin to make an authenticated Vault API request while proving their identity to the proxy simultaneously. +# Vault proxy + +Vault proxy is the public Cloud Run sidecar for the LibOps Vault service. Vault +continues to authorize every Vault token and policy; the proxy adds a Google +administrator check to routes that are not explicitly required by customer +authentication or secret access flows. + +## Request policy + +- `GET /healthz` checks only that the proxy process can serve requests. +- A configured public route is forwarded without a Google administrator token. +- Every other route requires a valid Google access token in `X-Admin-Token` + whose verified email is in `admin_emails`. +- `X-Admin-Token` is always removed before forwarding. Vault credentials such + as `X-Vault-Token` and `Authorization` are preserved. +- Authentication failures return a generic `401`; logs record the denied path + without the Google credential or token-validation details. + +Version 2 replaces unsafe implicit prefixes with explicit path patterns. A +literal path matches exactly, `*` matches one complete segment, and a final +`/**` matches a subtree. For example, +`/v1/auth/userpass/login/**` permits login requests but does not expose +`/v1/auth/userpass/users/**`. Legacy routes ending in `/` are rejected so an +upgrade cannot silently retain the broader policy. ## Configuration -Configuration is managed via either a YAML file or the `VAULT_PROXY_YAML` environment variable. - -### Option 1: YAML File - -Create a `config.yaml` file: +Set `VAULT_PROXY_YAML` or pass `-config /path/to/config.yaml`. Environment +configuration takes precedence. Unknown YAML fields, multiple YAML documents, +invalid ports, non-HTTP(S) upstreams, malformed emails, and invalid route +patterns fail startup. ```yaml -vault_addr: "http://127.0.0.1:8200" +vault_addr: http://127.0.0.1:8200 port: 8080 admin_emails: - - admin@mycorp.com - - ops@mycorp.com + - admin@example.com public_routes: - - /.well-known/ - - /v1/identity/oidc/ - - /v1/auth/oidc/ - - /v1/auth/userpass/ + - /.well-known/** + - /v1/identity/oidc/provider/*/.well-known/** + - /v1/identity/oidc/provider/*/authorize + - /v1/identity/oidc/provider/*/token + - /v1/identity/oidc/provider/*/userinfo + - /ui/vault/identity/oidc/provider/*/authorize + - /v1/auth/oidc/oidc/auth_url + - /v1/auth/oidc/oidc/callback + - /ui/vault/auth/*/oidc/callback + - /v1/auth/userpass/login/** - /v1/sys/health ``` -### Option 2: Environment Variable +The example patterns permit OIDC discovery/exchange and user login while +leaving role, provider, user, policy, and system management protected. Add +secret-engine subtrees only when downstream Vault policies are intended to be +the authorization boundary for those paths. -Set the `VAULT_PROXY_YAML` environment variable with the YAML configuration as a string: +The provider paths follow Vault's +[OIDC provider API](https://developer.hashicorp.com/vault/api-docs/secret/identity/oidc-provider), +and the auth-method callback paths follow the +[JWT/OIDC auth API](https://developer.hashicorp.com/vault/api-docs/auth/jwt). -```bash -export VAULT_PROXY_YAML=' -vault_addr: "http://127.0.0.1:8200" -port: 8080 -admin_emails: - - admin@mycorp.com - - ops@mycorp.com -public_routes: - - /.well-known/ - - /v1/identity/oidc/ - - /v1/auth/oidc/ - - /v1/auth/userpass/ - - /v1/sys/health -' -``` +An administrator can supply a Google access token while independently passing +a Vault token: -**Note**: If `VAULT_PROXY_YAML` is set, it takes precedence over the `-config` flag. - -### Configuration Fields - -| Field | Required | Description | -|-------|----------|-------------| -| `vault_addr` | Yes | The full URL of the upstream Vault server (e.g., `http://127.0.0.1:8200`) | -| `admin_emails` | Yes | List of Google email addresses that are allowed admin access | -| `public_routes` | No | List of URL prefixes to allow through without any checks | -| `port` | No | The port for this proxy to listen on. Defaults to `8080` | - -### Example Public Routes +```sh +curl \ + -H "X-Admin-Token: $(gcloud auth print-access-token)" \ + -H "X-Vault-Token: ${VAULT_TOKEN}" \ + https://vault.example.com/v1/sys/policies/acl +``` -To support customer authentication (OIDC and username/password) and the OIDC Identity Broker flow, these paths should be public: +Service-account access tokens must include the +`https://www.googleapis.com/auth/userinfo.email` scope so Google's tokeninfo +response contains the verified service-account email. Tokens without that +identity claim fail closed. -- `/.well-known/`: For OIDC discovery (e.g., `/.well-known/openid-configuration`) -- `/v1/identity/oidc/`: For Vault's OIDC provider endpoints (like `/authorize` and `/token`) -- `/v1/auth/oidc/`: For OIDC authentication endpoints (login, callback) -- `/v1/auth/userpass/`: For username/password authentication endpoints -- `/v1/sys/health`: For Vault health checks (monitoring, load balancers) +## Images and releases -## How to Get an Admin Token +Pull requests build, test, lint, build both native image architectures, and +scan them without registry credentials. Protected `main` and release tags use +the LibOps shared publisher to publish and keylessly sign the same manifest in: -An admin can get their Google access token (the value for `X-Admin-Token`) using the `gcloud` CLI: +- `ghcr.io/libops/vault-proxy` +- `us-docker.pkg.dev/libops-images/public/vault-proxy` -```bash -export ADMIN_TOKEN=$(gcloud auth print-access-token) -curl -H "X-Vault-Token: " \ - -H "X-Admin-Token: $ADMIN_TOKEN" \ - http://localhost:8080/v1/sys/health -``` +Cloud Run deployments must use the GAR image pinned by digest. Other consumers +should use GHCR and may also pin the signed digest. Repository releases use PR +title markers (`[major]`, `[minor]`, or the default patch increment). diff --git a/config.example.yaml b/config.example.yaml index e0c6215..8f92de7 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -4,10 +4,14 @@ admin_emails: - me@example.com - gsa@gcp-project.iam.gserviceaccount.com public_routes: - - /.well-known/ - - /v1/identity/oidc/ - - /v1/auth/oidc/ - - /v1/auth/userpass/ - # this should always be set, as the docker healthcheck relies on it - # the healthcheck checks both the proxy is working and vault is unsealed + - /.well-known/** + - /v1/identity/oidc/provider/*/.well-known/** + - /v1/identity/oidc/provider/*/authorize + - /v1/identity/oidc/provider/*/token + - /v1/identity/oidc/provider/*/userinfo + - /ui/vault/identity/oidc/provider/*/authorize + - /v1/auth/oidc/oidc/auth_url + - /v1/auth/oidc/oidc/callback + - /ui/vault/auth/*/oidc/callback + - /v1/auth/userpass/login/** - /v1/sys/health diff --git a/docker-compose.yaml b/docker-compose.yaml index ee4866a..49bd02a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,11 +3,15 @@ networks: services: proxy: - image: libops/vault-proxy:main + image: ghcr.io/libops/vault-proxy:local build: . networks: default: ports: - 8080:8080 volumes: - - ./config.yaml:/app/config.yaml:r + - ./config.yaml:/config.yaml:ro + command: + - -config + - /config.yaml + read_only: true diff --git a/go.mod b/go.mod index 05d6e27..6400b1c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module vault-proxy -go 1.25.3 +go 1.26.5 require gopkg.in/yaml.v3 v3.0.1 diff --git a/main.go b/main.go index bcdadc3..dcc3d84 100644 --- a/main.go +++ b/main.go @@ -1,21 +1,37 @@ package main import ( + "bytes" "context" "encoding/json" + "errors" "flag" "fmt" + "io" "log/slog" "net/http" "net/http/httputil" + "net/mail" "net/url" "os" + "os/signal" + "path" + "strconv" "strings" + "syscall" + "time" "gopkg.in/yaml.v3" ) -// YAMLConfig represents the structure of the YAML configuration file. +const ( + defaultListenPort = 8080 + // #nosec G101 -- this is Google's public token-inspection endpoint, not a credential. + defaultTokenInfoURL = "https://oauth2.googleapis.com/tokeninfo" + maxTokenInfoBody = 1 << 20 +) + +// YAMLConfig represents the supported configuration file fields. type YAMLConfig struct { VaultAddr string `yaml:"vault_addr"` Port int `yaml:"port"` @@ -23,233 +39,333 @@ type YAMLConfig struct { PublicRoutes []string `yaml:"public_routes"` } -// Config holds all configuration for the proxy, loaded from YAML. +// Config is the validated runtime configuration. type Config struct { VaultTargetURL *url.URL PublicRoutes []string - AdminEmails map[string]bool // Use a map for fast O(1) lookups - ListenPort string + AdminEmails map[string]struct{} + ListenPort int +} + +// TokenInfo is the subset of Google's tokeninfo response used for access +// control. Google rejects expired or invalid access tokens before returning it. +type TokenInfo struct { + Email string `json:"email"` + EmailVerified string `json:"email_verified"` +} + +type tokenValidator interface { + Validate(context.Context, string, map[string]struct{}) error +} + +type googleTokenValidator struct { + client *http.Client + endpoint string } -// loadYAMLConfig loads configuration from a YAML file or VAULT_PROXY_YAML env var. func loadYAMLConfig(configPath string) (*YAMLConfig, error) { var data []byte var err error - // Check for VAULT_PROXY_YAML environment variable first if yamlEnv := os.Getenv("VAULT_PROXY_YAML"); yamlEnv != "" { data = []byte(yamlEnv) } else if configPath != "" { + // #nosec G304 -- the operator explicitly selects the configuration path; + // the process runs non-root and the container filesystem is read-only. data, err = os.ReadFile(configPath) if err != nil { - return nil, fmt.Errorf("failed to read config file: %w", err) + return nil, fmt.Errorf("read config file: %w", err) } } else { - return nil, fmt.Errorf("either VAULT_PROXY_YAML environment variable or -config flag must be set") + return nil, errors.New("either VAULT_PROXY_YAML or -config must be set") } - var yamlConfig YAMLConfig - if err := yaml.Unmarshal(data, &yamlConfig); err != nil { - return nil, fmt.Errorf("failed to parse YAML config: %w", err) + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + var config YAMLConfig + if err := decoder.Decode(&config); err != nil { + return nil, fmt.Errorf("parse YAML config: %w", err) } - - return &yamlConfig, nil + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return nil, errors.New("parse YAML config: multiple documents are not allowed") + } + return nil, fmt.Errorf("parse YAML config: %w", err) + } + return &config, nil } -// loadConfig loads configuration from YAML file or VAULT_PROXY_YAML env var. func loadConfig(configPath string) (*Config, error) { yamlConfig, err := loadYAMLConfig(configPath) if err != nil { - return nil, fmt.Errorf("failed to load YAML config: %w", err) + return nil, err } - // Validate required fields - if yamlConfig.VaultAddr == "" { - return nil, fmt.Errorf("vault_addr must be set in config file") - } - targetURL, err := url.Parse(yamlConfig.VaultAddr) + targetURL, err := validateVaultURL(yamlConfig.VaultAddr) if err != nil { - return nil, fmt.Errorf("invalid vault_addr: %w", err) + return nil, err } - if len(yamlConfig.AdminEmails) == 0 { - return nil, fmt.Errorf("admin_emails must contain at least one email") + adminEmails := make(map[string]struct{}, len(yamlConfig.AdminEmails)) + for _, configuredEmail := range yamlConfig.AdminEmails { + email := normalizeEmail(configuredEmail) + parsed, parseErr := mail.ParseAddress(email) + if email == "" || parseErr != nil || normalizeEmail(parsed.Address) != email { + return nil, fmt.Errorf("invalid admin email %q", configuredEmail) + } + adminEmails[email] = struct{}{} + } + if len(adminEmails) == 0 { + return nil, errors.New("admin_emails must contain at least one valid email") } - // Convert admin emails list to map for O(1) lookups - adminEmails := make(map[string]bool) - for _, email := range yamlConfig.AdminEmails { - email = normalizeEmail(email) - if email != "" { - adminEmails[email] = true + for _, route := range yamlConfig.PublicRoutes { + if err := validatePublicRoutePattern(route); err != nil { + return nil, err } } port := yamlConfig.Port if port == 0 { - port = 8080 + port = defaultListenPort + } + if port < 1 || port > 65535 { + return nil, fmt.Errorf("port must be between 1 and 65535, got %d", port) } return &Config{ VaultTargetURL: targetURL, - PublicRoutes: yamlConfig.PublicRoutes, + PublicRoutes: append([]string(nil), yamlConfig.PublicRoutes...), AdminEmails: adminEmails, - ListenPort: fmt.Sprintf("%d", port), + ListenPort: port, }, nil } -// isPublicRoute checks if a given request path matches any of the public route prefixes. -func isPublicRoute(path string, publicRoutes []string) bool { - for _, route := range publicRoutes { - if strings.HasPrefix(path, route) { - return true - } +func validateVaultURL(rawURL string) (*url.URL, error) { + if strings.TrimSpace(rawURL) != rawURL || rawURL == "" { + return nil, errors.New("vault_addr must be a non-empty absolute URL") } - return false + targetURL, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("invalid vault_addr: %w", err) + } + if (targetURL.Scheme != "http" && targetURL.Scheme != "https") || targetURL.Host == "" { + return nil, errors.New("vault_addr must use http or https and include a host") + } + if targetURL.User != nil || targetURL.RawQuery != "" || targetURL.Fragment != "" { + return nil, errors.New("vault_addr must not contain credentials, a query, or a fragment") + } + if targetURL.Path != "" && targetURL.Path != "/" { + return nil, errors.New("vault_addr must not contain a path") + } + return targetURL, nil } -// TokenInfo represents the response from Google's tokeninfo endpoint. -type TokenInfo struct { - Email string `json:"email"` - EmailVerified string `json:"email_verified"` - ExpiresIn string `json:"expires_in"` - Scope string `json:"scope"` +// Public route patterns are deliberately narrower than the legacy prefix +// rules. A literal path is exact, '*' matches one complete path segment, and a +// final '/**' matches the named subtree. Other wildcard forms are rejected. +func validatePublicRoutePattern(pattern string) error { + if pattern == "" || !strings.HasPrefix(pattern, "/") || pattern != path.Clean(pattern) { + return fmt.Errorf("public route %q must be a canonical absolute path", pattern) + } + segments := strings.Split(strings.TrimPrefix(pattern, "/"), "/") + for i, segment := range segments { + if segment == "**" { + if i != len(segments)-1 || i == 0 { + return fmt.Errorf("public route %q may use ** only as its final segment", pattern) + } + continue + } + if segment == "*" { + continue + } + if segment == "" || strings.ContainsAny(segment, "*?[]\\") { + return fmt.Errorf("public route %q contains an invalid segment", pattern) + } + } + return nil } -// validateAdminToken validates a Google OAuth 2.0 access token. -// It calls Google's tokeninfo endpoint to verify the token and extract email. -func validateAdminToken(ctx context.Context, tokenString string, adminEmails map[string]bool) (bool, error) { - // Call Google's tokeninfo endpoint to validate the access token - tokenInfoURL := "https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=" + url.QueryEscape(tokenString) +func isPublicRoute(requestPath string, publicRoutes []string) bool { + if requestPath == "" || requestPath != path.Clean(requestPath) { + return false + } + for _, pattern := range publicRoutes { + if strings.HasSuffix(pattern, "/**") { + base := strings.TrimSuffix(pattern, "/**") + if requestPath == base || strings.HasPrefix(requestPath, base+"/") { + return true + } + continue + } + matched, err := path.Match(pattern, requestPath) + if err == nil && matched { + return true + } + } + return false +} - req, err := http.NewRequestWithContext(ctx, "GET", tokenInfoURL, nil) +func (validator googleTokenValidator) Validate(ctx context.Context, token string, adminEmails map[string]struct{}) error { + if token == "" { + return errors.New("admin token missing") + } + endpoint, err := url.Parse(validator.endpoint) if err != nil { - return false, fmt.Errorf("failed to create tokeninfo request: %w", err) + return errors.New("token validator is misconfigured") } + query := endpoint.Query() + query.Set("access_token", token) + endpoint.RawQuery = query.Encode() - resp, err := http.DefaultClient.Do(req) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + // The parsed URL contains the access token as a query parameter. Do not + // wrap errors that can reproduce that URL in their text. + return errors.New("create tokeninfo request failed") + } + req.Header.Set("Accept", "application/json") + resp, err := validator.client.Do(req) if err != nil { - return false, fmt.Errorf("failed to call tokeninfo endpoint: %w", err) + // http.Client errors commonly include the complete request URL. Returning + // the transport error would make the Google credential loggable by the + // caller because tokeninfo receives it in the query string. + return errors.New("tokeninfo request failed") } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return false, fmt.Errorf("tokeninfo returned status %d", resp.StatusCode) + return fmt.Errorf("tokeninfo rejected token with status %d", resp.StatusCode) } var tokenInfo TokenInfo - if err := json.NewDecoder(resp.Body).Decode(&tokenInfo); err != nil { - return false, fmt.Errorf("failed to parse tokeninfo response: %w", err) - } - if err := validateAdminIdentity(tokenInfo, adminEmails); err != nil { - return false, err + decoder := json.NewDecoder(io.LimitReader(resp.Body, maxTokenInfoBody)) + if err := decoder.Decode(&tokenInfo); err != nil { + return fmt.Errorf("parse tokeninfo response: %w", err) } - - return true, nil + return validateAdminIdentity(tokenInfo, adminEmails) } -func validateAdminIdentity(tokenInfo TokenInfo, adminEmails map[string]bool) error { +func validateAdminIdentity(tokenInfo TokenInfo, adminEmails map[string]struct{}) error { email := normalizeEmail(tokenInfo.Email) if email == "" { - return fmt.Errorf("token email missing") + return errors.New("token email missing") } if _, isAdmin := adminEmails[email]; !isAdmin { - return fmt.Errorf("non-admin hitting protected route") - } - if tokenInfo.EmailVerified == "true" { - return nil + return errors.New("token identity is not an administrator") } - if isGoogleServiceAccountEmail(email) { - return nil + verified, err := strconv.ParseBool(tokenInfo.EmailVerified) + if err != nil || !verified { + return errors.New("token email is not verified") } - return fmt.Errorf("email not verified") + return nil } func normalizeEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) } -func isGoogleServiceAccountEmail(email string) bool { - return strings.HasSuffix(normalizeEmail(email), ".gserviceaccount.com") -} - -// createProxyHandler creates the main HTTP handler for the proxy. func createProxyHandler(config *Config) http.Handler { - // Create the reverse proxy that will forward to Vault - proxy := httputil.NewSingleHostReverseProxy(config.VaultTargetURL) + validator := googleTokenValidator{ + client: &http.Client{Timeout: 10 * time.Second}, + endpoint: defaultTokenInfoURL, + } + return createProxyHandlerWithValidator(config, validator) +} - proxy.Director = func(req *http.Request) { - req.URL.Scheme = config.VaultTargetURL.Scheme - req.URL.Host = config.VaultTargetURL.Host - req.Host = config.VaultTargetURL.Host +func createProxyHandlerWithValidator(config *Config, validator tokenValidator) http.Handler { + proxy := &httputil.ReverseProxy{} + proxy.Rewrite = func(request *httputil.ProxyRequest) { + // Rewrite mode removes the standard client-provided Forwarded and + // X-Forwarded-* values before this function runs. Remove extensions as + // well and do not reconstruct them: Vault must not make authorization or + // audit decisions using an attacker-supplied client IP, host, or scheme. + for header := range request.Out.Header { + normalized := strings.ToLower(header) + if normalized == "forwarded" || normalized == "x-real-ip" || strings.HasPrefix(normalized, "x-forwarded-") { + request.Out.Header.Del(header) + } + } + request.SetURL(config.VaultTargetURL) + request.Out.Host = request.Out.URL.Host + } + proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) { + slog.Error("vault upstream request failed", "error", err) + http.Error(w, "Vault upstream unavailable", http.StatusBadGateway) } - // Return the main handler function return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if isPublicRoute(r.URL.Path, config.PublicRoutes) { - slog.Info(r.Method, "path", r.URL.Path) - proxy.ServeHTTP(w, r) + if r.URL.Path == "/healthz" { + w.WriteHeader(http.StatusNoContent) return } + // Capture and remove the proxy credential before every forwarding path, + // including public routes. Vault must never receive this Google token. token := r.Header.Get("X-Admin-Token") - if token == "" { - slog.Warn("access denied: missing header", "path", r.URL.Path, "status", 401) - http.Error(w, "Access Denied: Missing X-Admin-Token header", http.StatusUnauthorized) + r.Header.Del("X-Admin-Token") + + if isPublicRoute(r.URL.Path, config.PublicRoutes) { + slog.Info("forwarding public Vault route", "method", r.Method, "path", r.URL.Path) + proxy.ServeHTTP(w, r) return } - - isValid, err := validateAdminToken(r.Context(), token, config.AdminEmails) - if err != nil { - slog.Warn("access denied: token validation error", "path", r.URL.Path, "error", err, "status", 401) - http.Error(w, fmt.Sprintf("Token Validation Error: %v", err), http.StatusUnauthorized) + if token == "" { + slog.Warn("protected Vault route denied", "path", r.URL.Path, "reason", "missing admin token") + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - - if !isValid { - slog.Warn("access denied: user not admin", "path", r.URL.Path, "status", 403) - http.Error(w, "Access Denied: User is not an admin", http.StatusForbidden) + if err := validator.Validate(r.Context(), token, config.AdminEmails); err != nil { + // Validator errors are intentionally not logged. A transport error may + // contain a token-bearing request URL, and authentication details are not + // needed to record the denied path. + slog.Warn("protected Vault route denied", "path", r.URL.Path, "reason", "token validation failed") + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - - r.Header.Del("X-Admin-Token") - - // Forward the request to Vault proxy.ServeHTTP(w, r) }) } func main() { - // Parse command-line flags - configPath := flag.String("config", "", "Path to YAML configuration file (optional if VAULT_PROXY_YAML is set)") + configPath := flag.String("config", "", "path to YAML configuration (optional when VAULT_PROXY_YAML is set)") flag.Parse() - // Load configuration from YAML file or VAULT_PROXY_YAML env var config, err := loadConfig(*configPath) if err != nil { slog.Error("failed to load configuration", "error", err) os.Exit(1) } - slog.Info("starting vault proxy", - "port", config.ListenPort, - "vault_addr", config.VaultTargetURL.String(), - "admin_emails", mapsKeys(config.AdminEmails), - "public_routes", config.PublicRoutes) - - // Create the handler and start the server - handler := createProxyHandler(config) - if err := http.ListenAndServe(":"+config.ListenPort, handler); err != nil { - slog.Error("failed to start server", "error", err) - os.Exit(1) + server := &http.Server{ + Addr: fmt.Sprintf(":%d", config.ListenPort), + Handler: createProxyHandler(config), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 5 * time.Minute, + IdleTimeout: 90 * time.Second, } -} - -// Helper function to get keys from a map for logging -func mapsKeys(m map[string]bool) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + serverErrors := make(chan error, 1) + go func() { + slog.Info("starting vault proxy", "port", config.ListenPort, "vault_addr", config.VaultTargetURL.Redacted(), "admin_count", len(config.AdminEmails), "public_routes", config.PublicRoutes) + serverErrors <- server.ListenAndServe() + }() + + select { + case err := <-serverErrors: + if !errors.Is(err, http.ErrServerClosed) { + slog.Error("vault proxy stopped", "error", err) + os.Exit(1) + } + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + slog.Error("vault proxy shutdown failed", "error", err) + os.Exit(1) + } } - return keys } diff --git a/main_test.go b/main_test.go index c9c614b..ab17f8c 100644 --- a/main_test.go +++ b/main_test.go @@ -1,424 +1,418 @@ package main import ( + "bytes" + "context" "encoding/json" + "errors" + "fmt" + "log/slog" "net/http" "net/http/httptest" "net/url" "os" + "path/filepath" + "strings" "testing" ) +type tokenValidatorFunc func(context.Context, string, map[string]struct{}) error + +func (fn tokenValidatorFunc) Validate(ctx context.Context, token string, admins map[string]struct{}) error { + return fn(ctx, token, admins) +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + func TestIsPublicRoute(t *testing.T) { - publicRoutes := []string{ - "/.well-known/", - "/v1/identity/oidc/", - "/v1/auth/oidc/", - "/v1/auth/userpass/", + routes := []string{ + "/.well-known/**", + "/v1/identity/oidc/provider/*/authorize", + "/v1/auth/userpass/login/**", + "/v1/sys/health", } - tests := []struct { - name string - path string - expected bool + path string + want bool }{ - {"well-known exact", "/.well-known/", true}, - {"well-known subpath", "/.well-known/openid-configuration", true}, - {"identity oidc", "/v1/identity/oidc/authorize", true}, - {"auth oidc", "/v1/auth/oidc/login", true}, - {"userpass login", "/v1/auth/userpass/login/alice", true}, - {"protected route", "/v1/sys/health", false}, - {"protected secrets", "/v1/secret/data/myapp", false}, - {"root path", "/", false}, - {"similar but not matching", "/v1/auth/token/create", false}, + {"/.well-known/openid-configuration", true}, + {"/v1/identity/oidc/provider/default/authorize", true}, + {"/v1/identity/oidc/provider/default/config", false}, + {"/v1/auth/userpass/login", true}, + {"/v1/auth/userpass/login/alice", true}, + {"/v1/auth/userpass/users/alice", false}, + {"/v1/sys/health", true}, + {"/v1/sys/health/extra", false}, + {"/v1/sys/healthcheck", false}, + {"/v1/auth/userpass/login/../users/alice", false}, } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isPublicRoute(tt.path, publicRoutes) - if result != tt.expected { - t.Errorf("isPublicRoute(%q) = %v, expected %v", tt.path, result, tt.expected) + for _, test := range tests { + t.Run(test.path, func(t *testing.T) { + if got := isPublicRoute(test.path, routes); got != test.want { + t.Fatalf("isPublicRoute(%q) = %v, want %v", test.path, got, test.want) } }) } } +func TestValidatePublicRoutePattern(t *testing.T) { + for _, pattern := range []string{ + "/v1/sys/health", + "/v1/auth/userpass/login/**", + "/v1/identity/oidc/provider/*/authorize", + } { + if err := validatePublicRoutePattern(pattern); err != nil { + t.Errorf("valid pattern %q rejected: %v", pattern, err) + } + } + for _, pattern := range []string{ + "v1/sys/health", + "/v1/auth/oidc/", + "/v1/**/config", + "/v1/auth*", + "/v1//health", + "/v1/../sys/health", + } { + if err := validatePublicRoutePattern(pattern); err == nil { + t.Errorf("invalid pattern %q accepted", pattern) + } + } +} + func TestLoadConfig(t *testing.T) { tests := []struct { - name string - configYAML string - expectError bool - errorMsg string + name string + yaml string + wantPort int + wantErr string + wantAdmins int }{ { - name: "valid config", - configYAML: `vault_addr: "http://127.0.0.1:8200" -port: 8080 + name: "valid and normalized", + yaml: `vault_addr: http://127.0.0.1:8200 admin_emails: + - Admin@Example.com - admin@example.com - - ops@example.com public_routes: - - /.well-known/ - - /v1/auth/oidc/ -`, - expectError: false, - }, - { - name: "missing vault_addr", - configYAML: `port: 8080 -admin_emails: - - admin@example.com + - /v1/sys/health `, - expectError: true, - errorMsg: "vault_addr must be set", + wantPort: 8080, + wantAdmins: 1, }, { - name: "missing admin_emails", - configYAML: `vault_addr: "http://127.0.0.1:8200" -port: 8080 -`, - expectError: true, - errorMsg: "admin_emails must contain at least one email", - }, - { - name: "invalid vault_addr URL", - configYAML: `vault_addr: "://invalid-url" -port: 8080 -admin_emails: - - admin@example.com + name: "explicit port", + yaml: `vault_addr: https://vault.example.com +port: 8443 +admin_emails: [admin@example.com] `, - expectError: true, - errorMsg: "invalid vault_addr", - }, - { - name: "port defaults to 8080", - configYAML: `vault_addr: "http://127.0.0.1:8200" -admin_emails: - - admin@example.com -`, - expectError: false, + wantPort: 8443, + wantAdmins: 1, }, + {name: "unknown field", yaml: "vault_addr: http://vault:8200\nadmin_emails: [admin@example.com]\nsurprise: true\n", wantErr: "field surprise not found"}, + {name: "relative URL", yaml: "vault_addr: vault:8200\nadmin_emails: [admin@example.com]\n", wantErr: "must use http or https"}, + {name: "URL credentials", yaml: "vault_addr: https://user:pass@vault.example.com\nadmin_emails: [admin@example.com]\n", wantErr: "must not contain credentials"}, + {name: "URL path", yaml: "vault_addr: https://vault.example.com/v1\nadmin_emails: [admin@example.com]\n", wantErr: "must not contain a path"}, + {name: "empty admins", yaml: "vault_addr: http://vault:8200\nadmin_emails: [' ']\n", wantErr: "invalid admin email"}, + {name: "invalid email", yaml: "vault_addr: http://vault:8200\nadmin_emails: [not-an-email]\n", wantErr: "invalid admin email"}, + {name: "invalid low port", yaml: "vault_addr: http://vault:8200\nport: -1\nadmin_emails: [admin@example.com]\n", wantErr: "between 1 and 65535"}, + {name: "invalid high port", yaml: "vault_addr: http://vault:8200\nport: 65536\nadmin_emails: [admin@example.com]\n", wantErr: "between 1 and 65535"}, + {name: "legacy route prefix", yaml: "vault_addr: http://vault:8200\nadmin_emails: [admin@example.com]\npublic_routes: [/v1/auth/userpass/]\n", wantErr: "canonical absolute path"}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a temporary config file - tmpfile, err := os.CreateTemp("", "config-*.yaml") - if err != nil { + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv("VAULT_PROXY_YAML", "") + configPath := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(configPath, []byte(test.yaml), 0o600); err != nil { t.Fatal(err) } - defer os.Remove(tmpfile.Name()) - - if _, err := tmpfile.Write([]byte(tt.configYAML)); err != nil { - t.Fatal(err) + config, err := loadConfig(configPath) + if test.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("loadConfig() error = %v, want substring %q", err, test.wantErr) + } + return } - if err := tmpfile.Close(); err != nil { + if err != nil { t.Fatal(err) } - - config, err := loadConfig(tmpfile.Name()) - - if tt.expectError { - if err == nil { - t.Errorf("expected error containing %q, got nil", tt.errorMsg) - } else if tt.errorMsg != "" && !contains(err.Error(), tt.errorMsg) { - t.Errorf("expected error containing %q, got %q", tt.errorMsg, err.Error()) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if config == nil { - t.Error("expected config to be non-nil") - } - if config != nil && config.ListenPort == "" { - t.Error("expected ListenPort to have a default value") - } + if config.ListenPort != test.wantPort { + t.Errorf("ListenPort = %d, want %d", config.ListenPort, test.wantPort) + } + if len(config.AdminEmails) != test.wantAdmins { + t.Errorf("admin count = %d, want %d", len(config.AdminEmails), test.wantAdmins) } }) } } -func TestCreateProxyHandler_PublicRoutes(t *testing.T) { - // Create a mock Vault server - vaultServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("vault response")) +func TestLoadConfigEnvironmentTakesPrecedence(t *testing.T) { + t.Setenv("VAULT_PROXY_YAML", "vault_addr: http://env-vault:8200\nadmin_emails: [admin@example.com]\n") + config, err := loadConfig(filepath.Join(t.TempDir(), "missing.yaml")) + if err != nil { + t.Fatal(err) + } + if config.VaultTargetURL.Host != "env-vault:8200" { + t.Fatalf("target = %q, want env-vault:8200", config.VaultTargetURL.Host) + } +} + +func TestGoogleTokenValidator(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("access_token") != "valid-token" { + http.Error(w, "rejected", http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(TokenInfo{Email: "ADMIN@example.com", EmailVerified: "true"}) })) - defer vaultServer.Close() + defer server.Close() - vaultURL, _ := url.Parse(vaultServer.URL) - config := &Config{ - VaultTargetURL: vaultURL, - PublicRoutes: []string{ - "/.well-known/", - "/v1/auth/oidc/", - }, - AdminEmails: map[string]bool{ - "admin@example.com": true, - }, - ListenPort: "8080", + validator := googleTokenValidator{client: server.Client(), endpoint: server.URL} + admins := map[string]struct{}{"admin@example.com": {}} + if err := validator.Validate(context.Background(), "valid-token", admins); err != nil { + t.Fatalf("valid token rejected: %v", err) + } + if err := validator.Validate(context.Background(), "invalid-token", admins); err == nil { + t.Fatal("invalid token accepted") + } +} + +func TestGoogleTokenValidatorTransportErrorRedactsToken(t *testing.T) { + const token = "secret-google-access-token" + validator := googleTokenValidator{ + client: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("request to %s failed", request.URL.String()) + })}, + endpoint: "https://oauth2.googleapis.com/tokeninfo", } - handler := createProxyHandler(config) + err := validator.Validate(context.Background(), token, map[string]struct{}{"admin@example.com": {}}) + if err == nil { + t.Fatal("transport failure accepted") + } + if strings.Contains(err.Error(), token) || strings.Contains(err.Error(), "access_token=") { + t.Fatalf("validator error exposed token-bearing URL: %q", err) + } +} +func TestValidateAdminIdentity(t *testing.T) { + admins := map[string]struct{}{ + "admin@example.com": {}, + "api-production@libops-api.iam.gserviceaccount.com": {}, + } tests := []struct { - name string - path string - expectedStatus int + name string + info TokenInfo + ok bool }{ - {"public well-known", "/.well-known/openid-configuration", http.StatusOK}, - {"public auth oidc", "/v1/auth/oidc/login", http.StatusOK}, - {"protected without token", "/v1/sys/health", http.StatusUnauthorized}, + {"verified user", TokenInfo{Email: "ADMIN@example.com", EmailVerified: "true"}, true}, + {"unverified user", TokenInfo{Email: "admin@example.com", EmailVerified: "false"}, false}, + {"unverified service account", TokenInfo{Email: "api-production@libops-api.iam.gserviceaccount.com", EmailVerified: "false"}, false}, + {"unknown user", TokenInfo{Email: "other@example.com", EmailVerified: "true"}, false}, + {"missing email", TokenInfo{EmailVerified: "true"}, false}, } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest("GET", tt.path, nil) - rr := httptest.NewRecorder() - - handler.ServeHTTP(rr, req) - - if rr.Code != tt.expectedStatus { - t.Errorf("handler returned wrong status code: got %v want %v", - rr.Code, tt.expectedStatus) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateAdminIdentity(test.info, admins) + if (err == nil) != test.ok { + t.Fatalf("validateAdminIdentity() error = %v, ok want %v", err, test.ok) } }) } } -func TestCreateProxyHandler_MissingToken(t *testing.T) { - vaultURL, _ := url.Parse("http://localhost:8200") - config := &Config{ - VaultTargetURL: vaultURL, - PublicRoutes: []string{}, - AdminEmails: map[string]bool{ - "admin@example.com": true, - }, - ListenPort: "8080", - } +func TestProxyHandlerPublicRouteStripsAdminCredential(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Admin-Token"); got != "" { + t.Errorf("upstream received X-Admin-Token %q", got) + } + if got := r.Header.Get("X-Vault-Token"); got != "vault-token" { + t.Errorf("upstream X-Vault-Token = %q", got) + } + if got := r.Header.Get("Authorization"); got != "Bearer vault-credential" { + t.Errorf("upstream Authorization = %q", got) + } + w.WriteHeader(http.StatusNoContent) + })) + defer upstream.Close() - handler := createProxyHandler(config) - req := httptest.NewRequest("GET", "/v1/sys/health", nil) - rr := httptest.NewRecorder() + handler := createProxyHandlerWithValidator(testConfig(t, upstream.URL, []string{"/v1/sys/health"}), tokenValidatorFunc(func(context.Context, string, map[string]struct{}) error { + t.Fatal("validator called for public route") + return nil + })) + req := httptest.NewRequest(http.MethodGet, "/v1/sys/health", nil) + req.Header.Set("X-Admin-Token", "google-token") + req.Header.Set("X-Vault-Token", "vault-token") + req.Header.Set("Authorization", "Bearer vault-credential") + response := httptest.NewRecorder() + handler.ServeHTTP(response, req) + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent) + } +} - handler.ServeHTTP(rr, req) +func TestProxyHandlerProtectsManagementRoutes(t *testing.T) { + upstreamCalls := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + upstreamCalls++ + })) + defer upstream.Close() - if rr.Code != http.StatusUnauthorized { - t.Errorf("expected status 401, got %d", rr.Code) + handler := createProxyHandlerWithValidator(testConfig(t, upstream.URL, []string{"/v1/auth/userpass/login/**"}), tokenValidatorFunc(func(context.Context, string, map[string]struct{}) error { + return nil + })) + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/v1/auth/userpass/users/alice", nil)) + if response.Code != http.StatusUnauthorized || strings.TrimSpace(response.Body.String()) != "Unauthorized" { + t.Fatalf("response = %d %q, want generic 401", response.Code, response.Body.String()) } - - expectedBody := "Access Denied: Missing X-Admin-Token header" - if !contains(rr.Body.String(), expectedBody) { - t.Errorf("expected body to contain %q, got %q", expectedBody, rr.Body.String()) + if upstreamCalls != 0 { + t.Fatalf("upstream called %d times", upstreamCalls) } } -func TestCreateProxyHandler_TokenRemoved(t *testing.T) { - // Create a mock Vault server that checks headers - vaultServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Verify X-Admin-Token was removed +func TestProxyHandlerProtectedRoute(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Admin-Token") != "" { - t.Error("X-Admin-Token header should have been removed") + t.Error("admin token leaked upstream") } - // Verify Authorization header is preserved - if r.Header.Get("Authorization") != "Bearer vault-token" { - t.Errorf("Authorization header not preserved: got %q", r.Header.Get("Authorization")) - } - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusAccepted) })) - defer vaultServer.Close() + defer upstream.Close() - // Create a mock tokeninfo server - tokeninfoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - response := TokenInfo{ - Email: "admin@example.com", - EmailVerified: "true", - ExpiresIn: "3600", + validator := tokenValidatorFunc(func(_ context.Context, token string, admins map[string]struct{}) error { + if token != "allowed" { + return errors.New("denied detail that must stay internal") } - _ = json.NewEncoder(w).Encode(response) - })) - defer tokeninfoServer.Close() - - // We can't easily override the tokeninfo URL in validateAdminToken, - // so this test is limited. In production, you'd want to make the URL configurable. - // For now, we'll skip the full integration test and just verify the header removal logic - t.Skip("Integration test requires configurable tokeninfo endpoint") -} - -func TestValidateAdminToken_Integration(t *testing.T) { - // This test would require either: - // 1. A real Google access token (not suitable for automated tests) - // 2. Mocking the HTTP client (requires refactoring validateAdminToken) - // 3. A test server that mimics Google's tokeninfo endpoint - - // Create a mock tokeninfo server - tokeninfoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check if the request is to the tokeninfo endpoint - if !contains(r.URL.String(), "access_token=") { - http.Error(w, "missing access_token", http.StatusBadRequest) - return + if _, ok := admins["admin@example.com"]; !ok { + t.Error("admin allowlist missing") } + return nil + }) + handler := createProxyHandlerWithValidator(testConfig(t, upstream.URL, nil), validator) - token := r.URL.Query().Get("access_token") - switch token { - case "valid-token": - response := TokenInfo{ - Email: "admin@example.com", - EmailVerified: "true", - ExpiresIn: "3600", - } - _ = json.NewEncoder(w).Encode(response) - case "invalid-email-token": - response := TokenInfo{ - Email: "notadmin@example.com", - EmailVerified: "true", - ExpiresIn: "3600", - } - _ = json.NewEncoder(w).Encode(response) - case "unverified-token": - response := TokenInfo{ - Email: "admin@example.com", - EmailVerified: "false", - ExpiresIn: "3600", - } - _ = json.NewEncoder(w).Encode(response) - default: - http.Error(w, "invalid token", http.StatusUnauthorized) + for _, test := range []struct { + token string + want int + }{ + {"", http.StatusUnauthorized}, + {"denied", http.StatusUnauthorized}, + {"allowed", http.StatusAccepted}, + } { + response := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/sys/config/state", nil) + if test.token != "" { + req.Header.Set("X-Admin-Token", test.token) + } + handler.ServeHTTP(response, req) + if response.Code != test.want { + t.Errorf("token %q: status = %d, want %d", test.token, response.Code, test.want) } + if test.want == http.StatusUnauthorized && strings.Contains(response.Body.String(), "denied detail") { + t.Error("internal validation detail leaked to caller") + } + } +} + +func TestProxyHandlerRedactsValidatorErrorsFromLogs(t *testing.T) { + const sensitiveDetail = "secret-google-access-token" + upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("denied request reached Vault") })) - defer tokeninfoServer.Close() + defer upstream.Close() - // Note: This test would require refactoring validateAdminToken to accept - // a custom HTTP client or tokeninfo URL. Skipping for now. - t.Skip("Requires refactoring validateAdminToken to be testable") -} + var logs bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) + defer slog.SetDefault(previousLogger) -func TestValidateAdminIdentity(t *testing.T) { - adminEmails := map[string]bool{ - "admin@example.com": true, - "api-production@libops-api.iam.gserviceaccount.com": true, - } + handler := createProxyHandlerWithValidator(testConfig(t, upstream.URL, nil), tokenValidatorFunc(func(context.Context, string, map[string]struct{}) error { + return errors.New(sensitiveDetail) + })) + request := httptest.NewRequest(http.MethodGet, "/v1/sys/config/state", nil) + request.Header.Set("X-Admin-Token", sensitiveDetail) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) - tests := []struct { - name string - tokenInfo TokenInfo - expectError bool - errorMsg string - }{ - { - name: "verified admin user", - tokenInfo: TokenInfo{ - Email: "admin@example.com", - EmailVerified: "true", - }, - expectError: false, - }, - { - name: "unverified admin user", - tokenInfo: TokenInfo{ - Email: "admin@example.com", - EmailVerified: "false", - }, - expectError: true, - errorMsg: "email not verified", - }, - { - name: "unverified admin service account", - tokenInfo: TokenInfo{ - Email: "api-production@libops-api.iam.gserviceaccount.com", - EmailVerified: "false", - }, - expectError: false, - }, - { - name: "unverified non-admin service account", - tokenInfo: TokenInfo{ - Email: "other@libops-api.iam.gserviceaccount.com", - EmailVerified: "false", - }, - expectError: true, - errorMsg: "non-admin hitting protected route", - }, - { - name: "missing email", - tokenInfo: TokenInfo{ - EmailVerified: "true", - }, - expectError: true, - errorMsg: "token email missing", - }, + if response.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", response.Code, http.StatusUnauthorized) } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateAdminIdentity(tt.tokenInfo, adminEmails) - if tt.expectError { - if err == nil { - t.Fatalf("expected error containing %q, got nil", tt.errorMsg) - } - if tt.errorMsg != "" && !contains(err.Error(), tt.errorMsg) { - t.Fatalf("expected error containing %q, got %q", tt.errorMsg, err.Error()) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) + if strings.Contains(logs.String(), sensitiveDetail) { + t.Fatalf("logs exposed validator credential: %q", logs.String()) } } -func TestMapsKeys(t *testing.T) { - tests := []struct { - name string - input map[string]bool - expected int - }{ - {"empty map", map[string]bool{}, 0}, - {"single key", map[string]bool{"admin@example.com": true}, 1}, - {"multiple keys", map[string]bool{ - "admin@example.com": true, - "ops@example.com": true, - "dev@example.com": true, - }, 3}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := mapsKeys(tt.input) - if len(result) != tt.expected { - t.Errorf("mapsKeys returned %d keys, expected %d", len(result), tt.expected) +func TestProxyHandlerRemovesSpoofedForwardingHeaders(t *testing.T) { + var upstream *httptest.Server + upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + for _, header := range []string{ + "Forwarded", + "X-Forwarded-For", + "X-Forwarded-Host", + "X-Forwarded-Port", + "X-Forwarded-Proto", + "X-Real-IP", + } { + if values := request.Header.Values(header); len(values) != 0 { + t.Errorf("upstream received spoofable %s header %q", header, values) } + } + if request.Host != strings.TrimPrefix(upstream.URL, "http://") { + t.Errorf("upstream Host = %q, want target host", request.Host) + } + w.WriteHeader(http.StatusNoContent) + })) + defer upstream.Close() - // Verify all keys are present - for key := range tt.input { - found := false - for _, k := range result { - if k == key { - found = true - break - } - } - if !found { - t.Errorf("key %q not found in result", key) - } - } - }) + handler := createProxyHandlerWithValidator(testConfig(t, upstream.URL, []string{"/v1/sys/health"}), tokenValidatorFunc(func(context.Context, string, map[string]struct{}) error { + t.Fatal("validator called for public route") + return nil + })) + request := httptest.NewRequest(http.MethodGet, "/v1/sys/health", nil) + request.Header.Set("Forwarded", "for=198.51.100.10;host=attacker.example;proto=https") + request.Header.Set("X-Forwarded-For", "198.51.100.10") + request.Header.Set("X-Forwarded-Host", "attacker.example") + request.Header.Set("X-Forwarded-Port", "443") + request.Header.Set("X-Forwarded-Proto", "https") + request.Header.Set("X-Real-IP", "198.51.100.10") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent) } } -// Helper function -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(substr) == 0 || - (len(s) > 0 && len(substr) > 0 && stringContains(s, substr))) +func TestProxyHealthEndpointDoesNotReachVault(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("healthz reached Vault") + })) + defer upstream.Close() + handler := createProxyHandlerWithValidator(testConfig(t, upstream.URL, nil), tokenValidatorFunc(func(context.Context, string, map[string]struct{}) error { + t.Fatal("healthz invoked token validator") + return nil + })) + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent) + } } -func stringContains(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } +func testConfig(t *testing.T, upstream string, routes []string) *Config { + t.Helper() + target, err := url.Parse(upstream) + if err != nil { + t.Fatal(err) + } + return &Config{ + VaultTargetURL: target, + PublicRoutes: routes, + AdminEmails: map[string]struct{}{"admin@example.com": {}}, + ListenPort: 8080, } - return false } diff --git a/publication_contract_test.go b/publication_contract_test.go new file mode 100644 index 0000000..1913a32 --- /dev/null +++ b/publication_contract_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +const sharedWorkflowSHA = "578137212ead4ab4059e95df17fa30e9b7ac4aed" + +func readPublicationFile(t *testing.T, path string) string { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(content) +} + +func TestPublicationUsesReviewedSharedWorkflows(t *testing.T) { + publisher := readPublicationFile(t, ".github/workflows/lint-test-build-push.yaml") + for _, required := range []string{ + "libops/.github/.github/workflows/build-push.yaml@" + sharedWorkflowSHA, + "additional-gar-registry: us-docker.pkg.dev/libops-images/public", + "expected-main-sha:", + "scan: true", + "sign: true", + "certificate-identity: https://github.com/libops/.github/.github/workflows/build-push.yaml@" + sharedWorkflowSHA, + } { + if !strings.Contains(publisher, required) { + t.Errorf("publisher workflow must contain %q", required) + } + } + + release := readPublicationFile(t, ".github/workflows/github-release.yaml") + if !strings.Contains(release, "libops/.github/.github/workflows/bump-release.yaml@"+sharedWorkflowSHA) { + t.Fatal("release workflow must use the reviewed shared release workflow") + } + for _, forbidden := range []string{"build-push.yaml@main", "bump-release.yaml@main", "secrets: inherit"} { + if strings.Contains(publisher, forbidden) || strings.Contains(release, forbidden) { + t.Errorf("publication workflows must not contain %q", forbidden) + } + } + + config := readPublicationFile(t, ".goreleaser.yml") + for _, required := range []string{"version: 2", "version_template:"} { + if !strings.Contains(config, required) { + t.Errorf("GoReleaser config must contain %q", required) + } + } +}