Skip to content

fix(moshpit): put the pinned-TLS proxy on the path, and stop trusting CAs as leaves - #343

Merged
ralyodio merged 2 commits into
mainfrom
fix/moshpit-stock-tls
Aug 9, 2026
Merged

fix(moshpit): put the pinned-TLS proxy on the path, and stop trusting CAs as leaves#343
ralyodio merged 2 commits into
mainfrom
fix/moshpit-stock-tls

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Two commits. Together they are why curl https://chovy.hacker never verified.

1. dns enable never turned proxy mode on

Everything needed was already built and none of it was connected:

  • moshpit-proxy verifies an origin against the registry pin and re-signs with a root it generates locally.
  • addressAnswer knows how to point every live name at that proxy (proxied: true).
  • dns start --proxy can switch it on.
  • dns enable already installs the root the proxy signs with.

Nothing ever passed --proxy. So names resolved straight to their origin, a stock client got a certificate no CA had signed, and the trust store was populated for a proxy that was never on the path. dns enable now probes for the proxy and starts the bridge in proxy mode when it finds one.

The probe is a TLS handshake, not a connect. This is the part that matters. proxyReachable answers "is something listening", and on one common class of machine the two answers differ in the worst possible way: an origin runs nginx on 0.0.0.0:443, which covers loopback — so a connect succeeds, proxy mode goes on, and every live Moshpit name on the machine is pointed at a web server that has never heard of them. That is not a certificate problem, it is every name serving the wrong site at once.

So proxyServes() completes a handshake and checks who issued the certificate. The proxy mints a leaf per name from the root it generated here; nginx serves the origin's own self-signed certificate, issued by itself. Nothing is trusted in the process — the peer certificate is read rather than verified, and the only thing taken from it is the issuer name (verifying properly would need the root already installed, which has not happened yet at that point).

Refusing is the default in every uncertain case, because proxy mode with nothing behind it resolves every name and then refuses every connection — the sites look down while dig stays healthy. A bridge this run did not start keeps its own mode, so the probe is skipped rather than run and discarded; announcing a proxy and retracting it two lines later is worse than not looking. --no-proxy opts out.

2. dns trust would install a CA as a trust anchor

moshcode dns trust <name> installed whatever the socket served, provided the registry published a matching pin. The pin proves the registry vouches for that key; it says nothing about whether trusting it is bounded.

A certificate installed by this path becomes a trust anchor, and an anchor marked CA:TRUE may issue for any name. The code claimed the opposite — "its SAN limits it to this one name" — but a SAN describes what a certificate speaks for, not what a key trusted as an authority may sign. Same hole requireNameConstraints closes on the root path, arriving by the other door.

It went unnoticed because openssl's req -x509 defaults to CA:TRUE, so every origin created by setup-origin.sh serves exactly the shape that must be refused, and it is indistinguishable from a correct one until someone trusts it. All three names on the dev box are CA:TRUE today.

The refusal names a remedy that costs nothing — re-issuing from the same key leaves the published pin untouched — because a gate with no way forward is a gate people route around.

Pairs with profullstack/moshpit-proxy#18, which changes what is issued.

Tests

13 new (10 proxy mode, 3 trust). The proxy probe tests use real openssl chains and a real TLS server, since what separates the two cases is the issuer on a completed handshake and a stub would only prove the stub sets the field the code reads. Full suite 1073 pass / 0 fail.

🤖 Generated with Claude Code

`dns trust <name>` installed whatever the socket served, provided the registry
published a matching pin. The pin proves the registry vouches for that *key*; it
says nothing about whether trusting it is bounded.

A certificate installed here is installed as a trust anchor, and an anchor
marked CA:TRUE may issue for any name. The code claimed the opposite — "its SAN
limits it to this one name" — but a SAN describes what a certificate speaks for,
not what a key trusted as an authority may sign. This is the same hole
requireNameConstraints closes on the root path, arriving by the other door.

It went unnoticed because openssl's `req -x509` defaults to CA:TRUE, so every
origin created by setup-origin.sh serves exactly the shape that must be refused,
and it is indistinguishable from a correct one until someone trusts it.

The refusal names a remedy that costs nothing: re-issue as CA:FALSE reusing the
key, and the published pin does not move. A gate with no way forward is a gate
people route around.

An unreadable certificate is treated as a CA — the safe direction to fail in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

92 finding(s)

HIGH/CRITICAL: 2 | MEDIUM: 42 | LOW: 48

Severity Rule Location
HIGH manifest-typosquat apps/pwa/package.json:19
HIGH js-ssrf-outbound-request apps/pwa/public/sw.js:45
MEDIUM tls-verification-disabled apps/pwa/src/lib/moshpit-gateway.mjs:299
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:61
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:75
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:101
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:265
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:269
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:314
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:499
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:675
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:677
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:736
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:782
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:852
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:955
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1063
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1199
MEDIUM js-unescaped-html-sink apps/pwa/src/routes/moshpit.mjs:1419
MEDIUM js-dynamic-code-execution apps/pwa/test/apikey-mask.test.mjs:129
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:111
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:131
MEDIUM sql-template-interpolation apps/pwa/test/moshpit-terms.test.mjs:192
MEDIUM tls-verification-disabled src/dns.mjs:741
MEDIUM sql-template-interpolation src/dns.mjs:2549
MEDIUM sql-template-interpolation src/selfupdate.mjs:166
MEDIUM sql-template-interpolation src/selfupdate.mjs:170
MEDIUM sql-template-interpolation src/selfupdate.mjs:208
MEDIUM sql-template-interpolation src/selfupdate.mjs:209
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:93
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:310
MEDIUM insecure-temp-file test/plugins.test.mjs:152
MEDIUM insecure-temp-file test/pty.test.mjs:28
MEDIUM insecure-temp-file test/pty.test.mjs:31
MEDIUM insecure-temp-file test/pty.test.mjs:40
MEDIUM insecure-temp-file test/pty.test.mjs:42
MEDIUM insecure-temp-file test/pty.test.mjs:47
MEDIUM insecure-temp-file test/pty.test.mjs:48
MEDIUM insecure-temp-file test/pty.test.mjs:49
MEDIUM insecure-temp-file test/tabs.test.mjs:8
MEDIUM insecure-temp-file test/tabs.test.mjs:13
MEDIUM insecure-temp-file test/tabs.test.mjs:14
MEDIUM insecure-temp-file test/tabs.test.mjs:22
MEDIUM insecure-temp-file test/trust.test.mjs:240
LOW secret-generic-credential apps/pwa/test/apikey-bearer-scheme.test.mjs:30
LOW secret-generic-credential apps/pwa/test/apikey-mask.test.mjs:38
LOW secret-generic-credential apps/pwa/test/apikey-reveal.test.mjs:35
LOW secret-generic-credential apps/pwa/test/approvals-context.test.mjs:28
LOW secret-generic-credential apps/pwa/test/approvals-credits.test.mjs:28
LOW secret-generic-credential apps/pwa/test/approvals-notify.test.mjs:26

…and 42 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

… works

`addressAnswer` could already point every live name at the local pinned-TLS
proxy, `dns start --proxy` could already switch it on, and `dns enable` already
installed the root the proxy signs with. Nothing ever connected them. So names
resolved straight to their origin, a stock client got a certificate no CA had
signed, and the trust store was populated for a proxy that was never on the
path — which reads, correctly, as "this is still broken".

`dns enable` now probes for the proxy and starts the bridge in proxy mode when
it finds one.

The probe is a TLS handshake, not a connect. `proxyReachable` answers "is
something listening", and on one common class of machine the two answers differ
in the worst way: an origin runs nginx on 0.0.0.0:443, which covers loopback, so
a connect succeeds and proxy mode would point every live Moshpit name on the
machine at a web server that has never heard of them. That is not a certificate
problem, it is every name serving the wrong site at once.

So `proxyServes` completes a handshake and checks who issued the certificate.
The proxy mints a leaf per name from the root it generated here; nginx serves
the origin's own self-signed certificate, issued by itself. Nothing is trusted
in the process — the peer certificate is read, not verified, and only the issuer
name is taken from it.

Refusing is the default in every uncertain case. Proxy mode with nothing behind
it resolves every name and then refuses every connection, which looks like the
sites are down while `dig` stays healthy.

A bridge this run did not start keeps its own mode, so the probe is skipped
rather than run and then discarded — announcing a proxy and retracting it two
lines later is worse than not looking.

`--no-proxy` opts out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio ralyodio changed the title fix(trust): refuse to install a CA:TRUE certificate as a trust anchor fix(moshpit): put the pinned-TLS proxy on the path, and stop trusting CAs as leaves Aug 9, 2026
Comment thread src/dns.mjs
host: address,
port,
servername: name,
rejectUnauthorized: false,
@ralyodio
ralyodio merged commit 7f576a2 into main Aug 9, 2026
4 checks passed
@ralyodio
ralyodio deleted the fix/moshpit-stock-tls branch August 9, 2026 17:28
@ralyodio ralyodio mentioned this pull request Aug 9, 2026
ralyodio added a commit that referenced this pull request Aug 9, 2026
Bump to v0.33.0, releasing the herd (#342) — agent sessions that outlive
the terminal that started them — along with the PRD behind it (#341) and
the moshpit pinned-TLS proxy fix (#343), all of which have been sitting on
main unreleased.

Minor rather than patch: #342 adds six commands (herd, ps, attach, kill,
wait, restore) and six moshscript verbs, and changes none of the existing
ones. `moshcode start claude` with no -d behaves exactly as it did.

This release is what makes any of it reachable. install.sh serves the
latest release tarball rather than main, so until a release carries it
every installed machine answers `unknown command "ps"` — and the npm
channel only moves when publish.yml sees a published GitHub release.

No plugin bumps: stocks and crypto are untouched, and neither names a
command that moved.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants