Skip to content

Restrict web3signer host allowlist to known DAppNode callers - #71

Merged
Marketen merged 4 commits into
marc/fix-mainnet-migrationfrom
marc/web3signer-host-allowlist
Oct 27, 2026
Merged

Marketen merged 4 commits into
marc/fix-mainnet-migrationfrom
marc/web3signer-host-allowlist

Conversation

@Marketen

@Marketen Marketen commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #70. That PR temporarily set --http-host-allowlist=* to match the legacy mainnet package. This PR replaces * with an explicit list.

Why

Web3signer checks the Host header of every request on :9000, including the keymanager API, which has no auth. The match is exact and case-insensitive, with the port stripped; the only wildcard is a bare *. That check is the only thing stopping a DNS-rebinding page, open in a browser on the DAppNode VPN or Wi‑Fi, from listing, importing or deleting keys. CORS doesn't help: after rebinding the page counts as same-origin.

The legacy mainnet package switched to * in 2.0.12 as a hotfix. Brain ≥0.1.20 calls signer.mainnet.dncore.dappnode, and the legacy 2.0.11 list didn't include it, so requests got 403.

Change

  • services/web3signer/entrypoint.sh builds the list from ${NETWORK} and ${WEB3SIGNER_DOMAIN}:
Host Caller
localhost, 127.0.0.1 web3signer defaults / local tooling
web3signer[-<net>].dappnode the package's default main-service alias
web3signer.web3signer[-<net>].dappnode validator clients via staker-scripts get_signer_api_url (e.g. prysm --validators-external-signer-url), Rocket Pool API, lido-csm, keys registered by brain ≤0.1.19, healthcheck
brain.web3signer[-<net>].dappnode kept from the current generic list
signer.<net>.dncore.dappnode brain ≥0.1.20 and the remote-key URL it registers on validator clients; lido-csm (lido-events)
web3signer.<net>.dncore.dappnode validator-tracker (missing from the current generic list)
signer/web3signer .<net>.staker.dappnode, .<net>.dappnode.private DAppManager stakers-module aliases
validator.<consensus client>.dappnode kept from the current generic list
web3signer, <domain>.private, web3signer.<domain>.private, container name the remaining names Docker and the DAppManager give the container (checked on a live node)
${EXTRA_HOST_ALLOWLIST} new env var (empty by default; set in the package config) for custom setups, e.g. a remote validator client. * restores the old behaviour.
  • This list is a superset of what gnosis, lukso and hoodi run with today, so those variants can't lose access.

How the list was built

  • Code: all 390 dappnode repos your token can see (90 private, 128 archived, 33 forks), shallow-cloned and grepped locally, plus a GitHub-wide search for third-party packages. The only third-party hit was the Erigon gnosis package, which uses web3signer.web3signer-gnosis.dappnode.
  • Published releases (what users actually run): latest on-chain release of 36 staking and related packages. The set is CL clients (all variants), web3signer (all variants), Lido CSM, Rocket Pool, SSV, Anchor, Obol, Obol Lido ejector, Stakewise, mev-boost and Optimism.
    • Pass 1 (every signer hostname in any file of any image layer): 35/36 done.
    • Pass 2 (shell/JS/config lines showing how each image builds its signer URL): 23 of 32 done, covering all CL clients, web3signer, Lido CSM and SSV. The other 9 (Rocket Pool, Anchor ×2, Obol ×2, Obol Lido ejector, Stakewise ×2, mev-boost) timed out on the IPFS gateway, but pass 1 already covered them. Every URL host pass 2 found is on the list.
    • What the images actually do:
      • prysm and lodestar entrypoints set --validators-external-signer-url / --externalSigner.url from staker-scripts get_signer_api_url, i.e. http://web3signer.${web3signer_domain}:9000 = web3signer.web3signer[-<net>].dappnode.
      • lighthouse and teku only use the remote keys the brain registers (signer.<net>.dncore.dappnode).
      • brain: signer.${network}.dncore.dappnode:9000.
      • lido-csm binary: signer.<net>.dncore.dappnode, and web3signer.web3signer[-hoodi].dappnode in the widget.
      • Rocket Pool API: web3signer.web3signer${w3sSuffix}.dappnode:9000.
      • validator-tracker: web3signer.%s.dncore.dappnode.
      • Optimism: no signer references.
    • Also checked: the published compose/manifest/setup-wizard of 264 registry packages. Every signer host is web3signer.web3signer[-<net>].dappnode.
    • No host outside this list was found.
    • Not verified:
      • op-node: image unavailable from both gateways. It's an L2 node, and its compose/manifest don't mention the signer.
      • Registry block ranges 22.0M–22.25M and 23.0M–23.75M failed to read, so packages registered in those windows weren't checked.

Testing

Same harness as #70: the real on-chain 2.0.17 images upgraded to this build, in a DAppNode-like compose with the DAppManager aliases. All checks pass, including:

  • the full Fix mainnet variant so legacy web3signer users can migrate to the generic package #70 suite: slashing DB, keys, brain-db, slashing protection, rollback and re-upgrade;
  • 14 legitimate Host values (including uppercase and :9000) → 200;
  • evil.example.com, a raw container IP, and other-network names → 403;
  • the brain container resolving signer.mainnet.dncore.dappnode, web3signer.web3signer.dappnode and web3signer.mainnet.dncore.dappnode and reaching /upcheck;
  • an attestation signed through the brain-registered URL (signer.mainnet.dncore.dappnode) → 200;
  • EXTRA_HOST_ALLOWLIST="my-remote-vc.example,10.0.0.5" → both allowed, evil.example.com still 403.

Release note to include

Mainnet previously accepted any Host. If you reach web3signer by a custom name or IP (for example a remote validator client), add it to EXTRA_HOST_ALLOWLIST in the package config. Without that, web3signer answers 403 Host not authorized.

Added since opening

  • The rule is now "every hostname Docker/the DAppManager assign to the container" (c025e96):
    • On a live DAppNode, today's published hoodi 0.1.5 rejected 9 of the container's own 14 names, including web3signer.hoodi.dncore.dappnode, the validator-tracker's URL.
    • validator-tracker's code goes through the brain on purpose to avoid this.
    • All of these are internal names, so allowing them costs no security.
  • Whitespace is stripped from EXTRA_HOST_ALLOWLIST (16a356a): a.example, b.example made web3signer fail to start (Unmatched argument at index 4: 'b.example').
  • Image scan: pass 2 now covers 25/32 staking images. SSV and Anchor only call the brain (brain.<domain>:3000), never web3signer directly.

Validation of every release order (#70, #71, #72)

I tested every state main can end up in:

State Contains
S1 #70
S2 #70 + #71
S3 #70 + #72
S4 all three

Local matrix (arm64), for mainnet, gnosis, lukso and hoodi:

  • Each variant starts from its latest on-chain release (2.0.17 / 1.0.15 / 0.1.5 / 0.1.3), seeded with a key and an EIP-3076 slashing history.
  • 6 upgrade chains per variant cover every merge order. Each ends with a rollback and a re-upgrade.
  • Extras per state: fresh install, flyway V11→V12 migration, and an injected failure during the Postgres upgrade.
  • At every hop I check: slashing DB identical, keyfiles and brain-db preserved, double/surround votes refused (412), new attestation signed and saved, flyway never restarted, allowlist accepts/rejects as expected.
  • 41 runs, 2,075 checks, 0 failures.

Real DAppNode (amd64, DAppManager 0.2.129, Docker 29.9, Compose v5.6), installed through the DAppManager installer:

Test Result
Live web3signer-hoodi 0.1.5 (20 remote keys in Lighthouse) → S4 ✅ 31/31: all 14 container DNS names accepted (0.1.5 rejected 9), keys/brain-db/slashing DB unchanged, Lighthouse still sees its 20 remote keys, 0 rejections
lukso 0.1.5 (published bytes) → S4 ✅ 17/17: Postgres 14→18 upgrade, data identical, slashing protection kept
lukso S4 → published 0.1.5 (downgrade) ✅ fails closed: old Postgres refuses the disabled cluster, web3signer never starts, nothing signs. DAppManager times out (15 min) and restores S4 by itself; 5/5 after
mainnet legacy 2.0.17 (on-chain registry install) → S1 ✅ migration: same Postgres 14 cluster (uid 999), slashing DB identical, legacy keystore loaded, double/surround refused, flyway 0 restarts
mainnet S1 → S4 ✅ 17/17: Postgres 14→18 of the cluster legacy 2.0.17 created, strict allowlist, all 13 container DNS names accepted

Release risk outside these PRs: DAppNode's IPFS gateways sometimes cut the trustless ?format=car download of large images partway (terminated). On the test node, 3 registry installs failed this way. I reproduced it once from another machine, and later attempts succeeded. It fails safe (nothing is installed), but expect some "download failed" reports right after publishing; the infra team should look at it.

🤖 Generated with Claude Code

Replace --http-host-allowlist=* with the hostnames DAppNode packages and
the DAppManager actually use to reach web3signer, so the unauthenticated
keymanager API stays protected against DNS rebinding from browsers on the
DAppNode VPN/Wi-Fi. Users can append hosts (or "*") through the new
EXTRA_HOST_ALLOWLIST env var.

The list is a superset of the one gnosis/lukso/hoodi already run with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Marketen and others added 3 commits October 26, 2026 16:26
Checked against a live DAppNode (DAppManager 0.2.129): besides the
stakers-module aliases the container also gets the bare service name,
<domain>.private, web3signer.<domain>.private and its container name.
No known caller uses them, but they are internal names, so allowing
them costs nothing and avoids 403s for tooling that does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web3signer flags are passed word-split, so "a.example, b.example"
made web3signer fail to start ("Unmatched argument at index 4:
'b.example'"). A typo in the package config must not stop the signer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Marketen
Marketen merged commit 5483bd5 into marc/fix-mainnet-migration Oct 27, 2026
1 check passed
Marketen added a commit that referenced this pull request Oct 27, 2026
Restrict web3signer host allowlist (bring #71 into main)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant