Skip to content

feat(dns,upgrade): ask for root per command, and refuse an escalated update - #274

Merged
ralyodio merged 1 commit into
mainfrom
feat/privileged-escalation
Aug 3, 2026
Merged

feat(dns,upgrade): ask for root per command, and refuse an escalated update#274
ralyodio merged 1 commit into
mainfrom
feat/privileged-escalation

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Items 2 and 3 of the trap that produced moshcoder/moshcode#272 and moshcoder/moshcoding#106.

Why

dns enable genuinely needs root — it writes /etc/resolver/<tld>, a systemd-resolved drop-in, or /etc/dnsmasq.d, and binds :53. So it told the operator to re-run the whole CLI:

dns enable edits system DNS and needs root.
  sudo moshcode dns enable

That advice is right for dns and dangerous everywhere else in this same CLI. moshcode update self-updates by re-running the installer (upgrade.mjs selfSpec), and every path the installer uses comes from $HOME. Escalated, that is /root.

So the habit dns teaches turns moshcode update into a silent reinstall of moshcode into root's home. link_system_bin then publishes /usr/local/bin/moshcode -> /root/.local/bin/moshcode (mode 0700), and the operator gets a moshcode that resolves on PATH for every user and executes for none:

$ moshcode install secrets
zsh: permission denied: moshcode

That is a real machine, and the path there was exactly dns enable → "use sudo" → sudo moshcode update.

What

dns escalates itself (src/escalate.mjs). It re-runs this CLI's own argv under sudo — or doas — with stdio: "inherit" so the password prompt works, raising only the privileged command instead of the whole CLI. The DNS state it writes lives in /etc and /var/lib/moshcode (manifestPath()), never the operator's home, so raising just this command loses nothing.

When escalation isn't possible it falls back to exactly the advice printed before:

  • no tty — in CI, sudo would fail or hang until the job times out
  • no sudo/doas on the box — an unprivileged container is a normal place to be
  • already escalated and still unprivileged — cannot stack password prompts

That fallback is also why the existing dns tests pass untouched: a test process has no tty, so it takes the old path.

moshcode update refuses an escalated run (src/upgrade.mjs). It names the user who would be locked out, shows the $HOME sudo substituted, and points at the fact that dns enable now asks for root by itself — closing the loop that created the habit. It returns a non-ok result so the caller's exit code is non-zero; a refusal must not read as "nothing to upgrade".

Both carve out the same exception as the installers: a bare root shell still works (no SUDO_USER — containers, CI images, root-only boxes), and MOSHCODE_ALLOW_ROOT overrides regardless.

Tests

13 new, covering the cases you cannot reach from a test process, all injected: escalator preference (sudodoas → none), MOSHCODE_ESCALATOR override that does not silently fall back when the named helper is missing, argv passed through intact with stdio: "inherit", the child's exit code surfacing (1 = operator cancelled at the prompt — a refusal, not a retryable error), no-tty, no-escalator, no double-escalation, and a failed spawn degrading to advice rather than a stack trace.

For the refusal: it runs nothing, names the user and $HOME, mentions dns enable, returns a non-ok result, and leaves bare-root / MOSHCODE_ALLOW_ROOT / ordinary-uid paths alone.

Suite: 1163 tests, 950 pass, 0 fail, 213 skipped — baseline on main is 1150 / 937 / 0 / 213, so this is exactly the 13 added and nothing disturbed. dns suite specifically: 200 pass, 0 fail.

Note

Based on #272, which is already merged. The companion moshcoder/moshcoding#106 fixes the installer that moshcoding.com/install.sh actually serves — #272 guarded this repo's install.sh, which turned out not to be the served script.

🤖 Generated with Claude Code

…update

Two halves of one trap.

`dns enable` genuinely needs root -- it writes /etc/resolver/<tld>, a
systemd-resolved drop-in or /etc/dnsmasq.d, and binds :53 -- and it told
the operator to re-run the whole CLI: `sudo moshcode dns enable`. That
advice is correct for dns and dangerous everywhere else, because
`moshcode update` self-updates by re-running the installer (selfSpec),
and every path the installer uses comes from $HOME. Escalated, that is
/root. So the habit dns teaches turns `moshcode update` into a silent
reinstall of moshcode into root's home, after which link_system_bin
publishes /usr/local/bin/moshcode -> /root/.local/bin/moshcode and the
operator gets:

    $ moshcode install secrets
    zsh: permission denied: moshcode

Both ends are fixed.

dns now escalates itself. src/escalate.mjs re-runs this CLI's own argv
under sudo (or doas) with stdio inherited, so the password prompt works
and only the privileged command is raised. The DNS state written lives
in /etc and /var/lib/moshcode, never the operator's home, so raising
just this command loses nothing. When escalation is not possible -- no
tty, no sudo on the box, or already escalated and still unprivileged --
it falls back to exactly the advice printed before, so nothing that
worked stops working. That fallback is also why the existing dns tests
still pass untouched: a test process has no tty.

`moshcode update` now refuses when escalated from a real user, names the
user who would be locked out, and points at the fact that dns asks for
root by itself now. It returns a non-ok result so the caller's exit code
is non-zero -- a refusal must not read as "nothing to upgrade".

Both carve out the same exception as the installers: a bare root shell
has no SUDO_USER and is a legitimate place to run either command, and
MOSHCODE_ALLOW_ROOT overrides regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio marked this pull request as ready for review August 3, 2026 16:53
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

87 finding(s)

HIGH/CRITICAL: 50 | MEDIUM: 37

Severity Rule Location
HIGH manifest-typosquat apps/pwa/package.json:19
HIGH js-ssrf-outbound-request apps/pwa/public/sw.js:45
HIGH secret-generic-credential apps/pwa/test/apikey-bearer-scheme.test.mjs:30
HIGH secret-generic-credential apps/pwa/test/apikey-mask.test.mjs:38
HIGH secret-generic-credential apps/pwa/test/apikey-reveal.test.mjs:35
HIGH secret-generic-credential apps/pwa/test/approvals-context.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/approvals-credits.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/approvals-notify.test.mjs:26
HIGH secret-generic-credential apps/pwa/test/approvals-resolve-race.test.mjs:20
HIGH secret-generic-credential apps/pwa/test/auth-form-email.test.mjs:29
HIGH secret-generic-credential apps/pwa/test/auth-form-email.test.mjs:33
HIGH secret-generic-credential apps/pwa/test/auth-page-error.test.mjs:36
HIGH secret-generic-credential apps/pwa/test/cli-device-token.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/cli-pages-balance.test.mjs:32
HIGH secret-generic-credential apps/pwa/test/cli-token.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/credits-pack.test.mjs:51
HIGH secret-generic-credential apps/pwa/test/credits-webhook-event-match.test.mjs:35
HIGH secret-generic-credential apps/pwa/test/credits-webhook.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/csrf-input-escaping.test.mjs:31
HIGH secret-generic-credential apps/pwa/test/csrf-input-escaping.test.mjs:101
HIGH secret-generic-credential apps/pwa/test/logout-csrf.test.mjs:29
HIGH secret-generic-credential apps/pwa/test/moshpit-api-key.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/moshpit-bulk-claim.test.mjs:23
HIGH secret-generic-credential apps/pwa/test/moshpit-claim-full-name.test.mjs:29
HIGH secret-generic-credential apps/pwa/test/moshpit-crawlable.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/moshpit-ending-page.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/moshpit-pins.test.mjs:22
HIGH secret-generic-credential apps/pwa/test/moshpit-pit-page.test.mjs:33
HIGH secret-generic-credential apps/pwa/test/moshpit-records-page.test.mjs:23
HIGH secret-generic-credential apps/pwa/test/moshpit-records.test.mjs:23
HIGH secret-generic-credential apps/pwa/test/moshpit-registry.test.mjs:20
HIGH secret-generic-credential apps/pwa/test/moshpit-related-endings.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/moshpit-sales.test.mjs:16
HIGH secret-generic-credential apps/pwa/test/moshpit-search.test.mjs:74
HIGH secret-generic-credential apps/pwa/test/moshpit-terms.test.mjs:19
HIGH secret-generic-credential apps/pwa/test/moshpit-tlds-pagination.test.mjs:28
HIGH secret-generic-credential apps/pwa/test/passkey-register-duplicate.test.mjs:38
HIGH secret-generic-credential apps/pwa/test/require-auth-next.test.mjs:31
HIGH secret-generic-credential apps/pwa/test/require-auth-next.test.mjs:35
HIGH secret-generic-credential apps/pwa/test/sessions-output-seq.test.mjs:30
HIGH secret-generic-credential apps/pwa/test/sessions-paste.test.mjs:24
HIGH secret-generic-credential apps/pwa/test/sessions-stream-replay.test.mjs:34
HIGH secret-generic-credential apps/pwa/test/sessions.test.mjs:24
HIGH secret-generic-credential apps/pwa/test/signature.test.mjs:6
HIGH secret-generic-credential test/auth.test.mjs:13
HIGH secret-generic-credential test/auth.test.mjs:63
HIGH secret-generic-credential test/console-cookie-malformed.test.mjs:15
HIGH secret-generic-credential test/console.test.mjs:12
HIGH secret-generic-credential test/mirror.test.mjs:37
HIGH secret-generic-credential test/mirror.test.mjs:77

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

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 58e7c73 into main Aug 3, 2026
4 checks passed
@ralyodio
ralyodio deleted the feat/privileged-escalation branch August 3, 2026 16:53
@ralyodio ralyodio mentioned this pull request Aug 3, 2026
ralyodio added a commit that referenced this pull request Aug 3, 2026
Three commits have been sitting unreleased on main, two of them the same
trap seen from both ends: an install that succeeds into the wrong home.

  #272  install.sh refuses `sudo curl … | sh` up front, before the
        download, instead of writing the payload into /root/.moshcode
        where the user who typed the command cannot read it.

  #274  `dns` escalates itself per command rather than telling the
        operator to `sudo moshcode …` — advice that was right for dns
        and dangerous for `update`, which self-updates through the
        installer and so reinstalls into root's home.

  #273  the /prd healthy-cwd test no longer launches a real engine; the
        suite went from 479s to ~70s.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Aug 3, 2026
ralyodio added a commit that referenced this pull request Aug 3, 2026
The gate accepted a root that can vouch for anything, which is the one
thing it exists to refuse.

`requireNameConstraints` searched the whole `openssl x509 -text` output for
`DNS:.<tld>`. That string reads identically under `Permitted:` and under
`Excluded:`, so the check could not tell the two apart -- and RFC 5280
4.2.1.10 says constraints bind only the name *types* they mention. A root
carrying `nameConstraints=critical,excluded;DNS:.hacker` therefore permits
every DNS name on the internet, names the ending we asked about, is marked
critical, and passed:

    requireNameConstraints -> {"ok":true,"why":"constrained to Moshpit endings"}
    trustPlan.ok -> true | steps -> ["nss","system"]

It then went into the system store. So did a root permitting `.evil` while
excluding `.hacker`, and one permitting `.hacker` *and* `.com`.

Parse the extension instead: read Permitted and Excluded as the separate
lists they are, require a permitted DNS subtree to exist at all, require
every claimed ending inside it, and refuse anything else in it.

Also: the paths were root's, not the operator's.

Everything here keys off a home directory -- moshpit-proxy's root under
~/.moshpit, the NSS database the browser reads at ~/.pki/nssdb. But `dns
enable` needs root, and since #274 it escalates itself, so os.homedir()
is /root by the time this runs. It looked for the root in a directory
moshpit-proxy never wrote to and reported "no local root" on a machine
that had one. operatorHome() resolves SUDO_USER/DOAS_USER through the
shell (passwd, so macOS and unusual layouts work), and the NSS store is
chowned back afterwards -- left root-owned, the browser silently loses
the ability to update its own store. Same $HOME-under-sudo trap #272 and
#274 closed.

Also in this commit:

  - tests skip loudly instead of silently. Four used `if (!text) return`
    when openssl was unavailable, passing without asserting anything --
    a green guard that had tested nothing.
  - `--no-trust` is documented, in `dns` usage and in the CLI schema, so
    help renders it. It existed only as a string literal.
  - `verifyStockTls` is wired in rather than exported, tested and never
    called. It runs only when a root was installed and there is a probe
    name, and never fails the command.
  - merges main, so this sits on #274's self-escalation rather than
    predating it.

1348 tests, 1345 pass, 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Aug 3, 2026
* feat(dns): finish the job — install trust, not just routing

`dns enable` pointed names at the resolver and stopped, so a Moshpit
name resolved and then failed its TLS handshake. To the person who typed
the URL that is not "DNS works", it is "still broken" — which is exactly
how it was reported.

No certificate authority will ever close that gap. The Baseline
Requirements banned issuance for non-IANA names: it stopped in November
2015 and the survivors were revoked by October 2016, and a CA in a root
store that issued for `.rank` would be distrusted for doing it. The rule
is the penalty, so this is not a matter of trying harder.

moshpit-proxy already solved it — verify the origin key against the pin
the registry published, then re-sign with a root generated on this
machine, because restating the result is the only language a stock client
accepts. Nobody had wired it up. This is the wiring, not new crypto.

The one thing it must never do is install a root that could vouch for the
clearnet, so that is checked rather than assumed. A root without a
critical nameConstraints extension permitting our endings is REFUSED, not
warned about — there is no wording of a warning that makes an
unconstrained root in a system store acceptable. moshpit-proxy sets the
constraint today and tests it; this side cannot assume it always will,
because that regression would otherwise land automatically on every
machine that ran `dns enable`.

Reported honestly rather than summarised:

  - NSS and the system store are separate outcomes. NSS is user-level and
    always lands; curl's store needs root. Saying "installed" while curl
    still refuses is what sends someone back to conclude it is broken, so
    without root it does the half it can and names the half it cannot.
  - It never fails `enable`. DNS is switched and verified by the time
    this runs; failing the command over a trust store would roll back
    working resolution to fix a certificate.

`--no-trust` opts out.

This only helps machines it is run on. Nothing shippable makes a
stranger's curl trust a Moshpit name; TronBrowser is the answer for
people who have run nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(trust): make the name-constraints gate actually constrain

The gate accepted a root that can vouch for anything, which is the one
thing it exists to refuse.

`requireNameConstraints` searched the whole `openssl x509 -text` output for
`DNS:.<tld>`. That string reads identically under `Permitted:` and under
`Excluded:`, so the check could not tell the two apart -- and RFC 5280
4.2.1.10 says constraints bind only the name *types* they mention. A root
carrying `nameConstraints=critical,excluded;DNS:.hacker` therefore permits
every DNS name on the internet, names the ending we asked about, is marked
critical, and passed:

    requireNameConstraints -> {"ok":true,"why":"constrained to Moshpit endings"}
    trustPlan.ok -> true | steps -> ["nss","system"]

It then went into the system store. So did a root permitting `.evil` while
excluding `.hacker`, and one permitting `.hacker` *and* `.com`.

Parse the extension instead: read Permitted and Excluded as the separate
lists they are, require a permitted DNS subtree to exist at all, require
every claimed ending inside it, and refuse anything else in it.

Also: the paths were root's, not the operator's.

Everything here keys off a home directory -- moshpit-proxy's root under
~/.moshpit, the NSS database the browser reads at ~/.pki/nssdb. But `dns
enable` needs root, and since #274 it escalates itself, so os.homedir()
is /root by the time this runs. It looked for the root in a directory
moshpit-proxy never wrote to and reported "no local root" on a machine
that had one. operatorHome() resolves SUDO_USER/DOAS_USER through the
shell (passwd, so macOS and unusual layouts work), and the NSS store is
chowned back afterwards -- left root-owned, the browser silently loses
the ability to update its own store. Same $HOME-under-sudo trap #272 and
#274 closed.

Also in this commit:

  - tests skip loudly instead of silently. Four used `if (!text) return`
    when openssl was unavailable, passing without asserting anything --
    a green guard that had tested nothing.
  - `--no-trust` is documented, in `dns` usage and in the CLI schema, so
    help renders it. It existed only as a string literal.
  - `verifyStockTls` is wired in rather than exported, tested and never
    called. It runs only when a root was installed and there is a probe
    name, and never fails the command.
  - merges main, so this sits on #274's self-escalation rather than
    predating it.

1348 tests, 1345 pass, 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <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.

1 participant