Repository navigation
fix(caddy): block the unauthenticated beacon validator namespace from public (VANA-BC-001) - #27
Merged
Merged
Conversation
… public
The public Caddy vhost forwarded all of /eth/* to the beacon REST gateway,
which includes Prysm's unauthenticated validator namespace. An unauthenticated
remote POST to /eth/v1/validator/prepare_beacon_proposer overwrites a
proposer's execution-layer fee recipient, and on a node that is both publicly
exposed and actively proposing this redirects the block's priority fees to an
attacker address. /eth/v1/validator/duties/proposer and the
/eth/v{2,3}/validator/blocks endpoints widen the surface. Ref VANA-BC-001.
Deny /eth/v1|v2|v3/validator (and everything under it) on the public path with
a 403, ahead of the public consensus handler. Move the trusted-IP handler ahead
of the public handlers so RPC_TRUSTED_IP_RANGES actually takes effect (it was
dead code, matched after the public wildcards) and the validator client keeps
full access over trusted ranges. Public reads are unchanged.
Left as documented follow-ups: narrow the public consensus handler to an
explicit read-only allowlist, and set CORS_ALLOWED_ORIGINS explicitly instead
of defaulting to *.
Claude-Session: https://claude.ai/code/session_01W58RjBw7v1TvptXHQoDjsA
inf-1oop
added a commit
that referenced
this pull request
Sep 14, 2026
## Problem
`docker-compose.yml` passes
`--beacon-rest-api-provider=${EXTERNAL_IP}:${RPC_PORT}`, which renders
as `0.0.0.0:4000`. That value is wrong three ways:
| | |
|---|---|
| **No scheme** | A REST endpoint is a URL. The gRPC provider on the
line above is a dial target and must *not* have one — they look alike
but are parsed by different code. |
| **Wrong port** | `4000` is the gRPC port. The HTTP API is on `3500`
(`--grpc-gateway-port`, now an alias for `--http-port`). |
| **Silently inert on v5** | Which is why nobody noticed. |
## Why it is breaking now, and not before
On Prysm **v5.1.0** the REST API is enabled only by the explicit
boolean, so the provider flag was dead config and the validator used
gRPC:
```go
// v5.1.0 config/features/config.go:296
if ctx.Bool(EnableBeaconRESTApi.Name) {
```
**v7.1.8** also enables it *implicitly* whenever the provider flag is
set:
```go
// v7.1.8 config/features/config.go:372
if ctx.Bool(EnableBeaconRESTApi.Name) || ctx.IsSet(validatorflags.BeaconRESTApiProviderFlag.Name) {
```
So the value stops being ignored and starts deciding how the validator
reaches the beacon.
**#26 moved `PRYSM_VERSION` from `v5.1.0` to `v7.1.8` and is already
merged**, so this is live on `main` today. Any operator who pulls `main`
and restarts their validator loses attestations.
## Reproduction
Against `gcr.io/prysmaticlabs/prysm/validator:v7.1.8` with the value
`main` currently produces, repeating every 12 seconds:
```
WARN flags: Enabled feature flag enable-beacon-rest-api=...
INFO rest: Initialized REST connection provider count=1 endpoints=[[invalid endpoint]]
ERROR beacon-api: failed to get health of node error=failed to create request for
endpoint [invalid endpoint]: parse "0.0.0.0:4000/eth/v1/node/health":
first path segment in URL cannot contain colon url=0.0.0.0:4000
WARN fallback: No responsive beacon node found tried=[[invalid endpoint]]
```
The same run on `v5.1.0` initialises no REST provider at all. Note the
gRPC provider *is* still constructed (`endpoints=[beacon:4000]`) — the
validator does not discard it, but the health check runs over REST,
fails to build a request, and the fallback layer concludes no beacon is
responsive. Net effect: zero duties.
## Fix
```diff
- --beacon-rest-api-provider=${EXTERNAL_IP}:${RPC_PORT:-4000}
+ --beacon-rest-api-provider=http://beacon:${GRPC_GATEWAY_PORT:-3500}
```
Verified on `v7.1.8` with isolated datadirs per container, so no
bolt-lock contention between runs:
| Config | REST endpoint | REST feature | parse errors in 40s |
|---|---|---|---|
| `0.0.0.0:4000` (current `main`) | `[invalid endpoint]` | on | **4** |
| `http://beacon:3500` (this PR) | `http://beacon:3500` | on | **0** |
| flag dropped entirely | `http://127.0.0.1:3500` (default, unused) |
off | 0 |
## Note on the approach
Dropping the flag was the other option: it restores v5.1.0's behaviour
exactly, since the feature flag then stays off and gRPC carries the
duties. Repointing it instead puts the validator on the standardised
Beacon API, which is where Prysm is heading and which works against any
consensus client — but it is a **transport change, not only a syntax
fix**, and deserves a validator-in-the-loop test before the fleet moves.
What is verified here is that the endpoint parses and initialises
correctly. What is **not** verified is that duties actually flow over
REST end to end — that needs a live beacon and real keys.
## Scope
`docker-compose.yml` only. The Caddyfile is untouched; public endpoint
exposure was handled separately in #27.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
maciejwitowski
added a commit
that referenced
this pull request
Sep 14, 2026
`npx semantic-release@25.0.9` pinned only the top package. semantic-release loads its plugins from its own node_modules tree, and every dependency below it (release-notes-generator ^14.1.0, conventional-changelog-writer ^8.0.0, the GitHub plugin's Octokit stack) resolved fresh on each run with no lockfile. Any new npm release in that tree reached a job that holds a contents:write token, unreviewed. Move the toolchain into .github/release/package.json with a committed package-lock.json: semantic-release 25.0.9, commit-analyzer 13.0.1, release-notes-generator 14.1.1, github 12.0.9, conventionalcommits preset 9.3.1. The job installs it with `npm ci --ignore-scripts` (no package in the tree has an install script) and runs that binary from the repo root. It lives under .github/ so the operator checkout gets no root package.json. Node is pinned to 24, the version this was verified on. Verified on Node 24 against main at 47db176, running the two workflow steps as written with the full .releaserc and a GitHub token, `--dry-run --no-ci`, no root node_modules: next version 2.3.2, notes render Bug Fixes with #26, #27 and #28. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dqz4KWjLzxNEJuea75Avb7
maciejwitowski
added a commit
that referenced
this pull request
Sep 24, 2026
## Problem The Release workflow fails at `generateNotes` on every push that should cut a release. No GitHub release has been published since v2.3.1 (2026-08-03), so #27, #26 and #28 are on `main` with no release. And v2.3.1 itself shipped with an empty body. | Run | Commit | Result | |---|---|---| | 2026-08-03 | ffd802d #21 | success, but v2.3.1 notes are the version header only | | 2026-08-12 → 08-19 | chore/ci commits | success (no release due, notes step never ran) | | 2026-09-04 | a4cb188 #27 | failure | | 2026-09-14 | a409872 #26 | failure | | 2026-09-14 | 47db176 #28 | failure | ``` Failed step "generateNotes" of plugin "@semantic-release/release-notes-generator" Error: Missing helper: "conventional-changelog-conventionalcommits requires conventional-changelog-writer@9 or newer ..." ``` ## Cause The workflow installed the release tooling unpinned, with no lockfile: - `npx semantic-release` resolves to 25.0.9. Its `@semantic-release/release-notes-generator` 14.1.1 (the latest) depends on `conventional-changelog-writer ^8`. - `npm install --no-save conventional-changelog-conventionalcommits` resolves to the latest preset. Preset **v10** targets writer 9: - 10.0.0–10.3.0 (from 2026-06-26): no error, but writer 8 renders only the version header. That is why the [v2.3.1 release](https://github.com/vana-com/vana/releases/tag/v2.3.1) has no notes, while v2.3.0 (January, preset 9.x) does. - 10.4.0 (2026-08-18): added an explicit guard, which turned the silent empty output into the hard failure above. Pinning only the top-level packages does not close this. semantic-release loads its plugins from its own `node_modules` tree, and every range below it (`release-notes-generator ^14.1.0`, `conventional-changelog-writer ^8.0.0`, the GitHub plugin's Octokit stack) resolves fresh on each run. The job holds a `contents: write` token, so any new npm release in that tree runs with it, unreviewed. ## Fix - New `.github/release/package.json` + committed `package-lock.json` for the toolchain: | Package | Version | |---|---| | `semantic-release` | 25.0.9 | | `@semantic-release/commit-analyzer` | 13.0.1 | | `@semantic-release/release-notes-generator` | 14.1.1 | | `@semantic-release/github` | 12.0.9 | | `conventional-changelog-conventionalcommits` | 9.3.1, the last major built for writer 8 | - The job installs it with `npm ci --ignore-scripts --prefix .github/release` (no package in the tree has an install script) and runs `.github/release/node_modules/.bin/semantic-release` from the repo root, so `.releaserc.yaml` is still the config. - It lives under `.github/` so the operator checkout gets no root `package.json`. `.gitignore` covers its `node_modules`. - Node pinned to `24`, the version this was verified on. - A comment in the workflow says when to bump the preset: once the notes generator ships writer 9. ## Verification Ran the two workflow steps as written, on Node 24 / npm 11, on a checkout of `main` at 47db176 with no root `node_modules`, the full `.releaserc.yaml` and a GitHub token, `--dry-run --no-ci`: - `npm ci` installs one deduped copy of each package; every plugin and the preset resolve from `.github/release/node_modules`. - GitHub `verifyConditions` passes, next version **2.3.2**, notes render `### Bug Fixes` with #26, #27, #28, same format as v2.3.0. Preset comparison (same command pair, earlier run): | Preset | Result | |---|---| | 9.3.1 | notes render Bug Fixes | | 10.3.0 | notes are the header only | | 10.4.0 | fails with the same "Missing helper" error as CI | Cosmetic: #26's commit body contains the word "closes" before "compatibility", so its line gets a bogus `closes #compatibility` link. Harmless; editing the release body after publish fixes it if wanted. ## Heads-up on merging Merging this triggers the workflow on `main`, which will **publish v2.3.2** as a public GitHub release containing #26, #27 and #28 (Prysm v7 config keys, the deposit contract switch config, the validator REST fix). The GitHub plugin also comments "included in version 2.3.2" on those PRs and labels them `released`, as it did on #21 for v2.3.1. Anyone watching this repo's releases gets notified, so merge when that announcement is fine to go out. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Dqz4KWjLzxNEJuea75Avb7 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
🎉 This PR is included in version 2.4.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The public Caddy vhost forwards all of
/eth/*to the beacon REST gateway. That prefix includes Prysm's validator namespace, which is unauthenticated by design (it is meant to be reached only by the validator client on a trusted network). As shipped it is reachable by anyone on the internet.An unauthenticated remote
POST /eth/v1/validator/prepare_beacon_proposerwrites into Prysm'sTrackedValidatorsCache, which is the source ofSuggestedFeeRecipientin the execution payload. The validator client signs the produced block without comparing the payload's fee recipient against its configured one. So on a node that is both publicly exposed and actively proposing, an attacker can set the fee recipient for a validator index and redirect that block's execution-layer priority fees to an address they control. The proposer schedule needed for timing is published by the same namespace (/eth/v1/validator/duties/proposer/{epoch}), and block production (/eth/v{2,3}/validator/blocks/{slot}) is exposed too.Reference: VANA-BC-001. Verified live against
rpc.vana.org(the write returns 200 and reaches the real Prysm handler; a dummy recipient was used, nothing moved) and reproduced locally against this exact config.Fix
/eth/v1|v2|v3/validatorand everything under it on the public path with a 403, ahead of the public consensus handler.@public_el/@public_clmatched first, which also meantRPC_TRUSTED_IP_RANGEShad no effect. Trusted ranges now keep full access to the execution and consensus layers, including the validator namespace the validator client needs.Testing
Reproduced the deployment locally (this Caddyfile in front of a stub beacon) and confirmed:
/eth/v{1,2,3}/validator/*path, includingprepare_beacon_proposer, returns 403/eth/v1/node/syncing,/eth/v1/beacon/genesisand other reads still reach the beacon (200)/still routes to gethcaddy validatepasses.Follow-ups (not in this PR)
CORS_ALLOWED_ORIGINSexplicitly and pass it to the caddy service indocker-compose.ymlinstead of defaulting to*.archive.vana.org:3500reportedly exposes the REST API directly on a public port; apply the same policy there.