Skip to content

fix(docker): read .env last, and restore RUN_AS_UID/GID from positional parameters - #2235

Merged
efiten merged 1 commit into
Kpa-clawbot:masterfrom
nullrouten0:sec/env-scope-after-source
Oct 16, 2026
Merged

efiten merged 1 commit into
Kpa-clawbot:masterfrom
nullrouten0:sec/env-scope-after-source

Conversation

@nullrouten0

Copy link
Copy Markdown
Contributor

Follow-up to #2125: the six non-blocking notes on its approval, with note 3 as the one that changes behaviour.

What changes

  • .env is now read last. The hand-over of the data directory moves before it, and the only code that runs after the file is sourced reads prepare_service_user's positional parameters: the account, the container's RUN_AS_UID/RUN_AS_GID (set or unset, and value) and the file's path are parked in $1..$6 before the ..
  • The comment gives the real reason the account was already safe: the . happens inside load_env_file, so a set -- root in .env lands in that function's $1, not in prepare_service_user's (note 1). It also says that a trusted .env is root's code and this does not defend against it (it can redefine functions or set PATH); what it cannot do by assigning variables is change the account, where the hand-over goes, or RUN_AS_UID/RUN_AS_GID as the services see them.
  • svc_log uses printf (note 5); the entrypoint check also pins that it does not assign CORESCOPE_SERVICE_USER (note 4); the test header is reflowed (note 6); TEST_SHELL picks the inner shell so the suite runs under dash, bash or busybox without editing.

Why

A sourced file shares the shell's variables: every global, the locals of load_env_file, and, by dynamic scoping, the locals of its caller too (checked under dash, bash 5.3 and busybox ash 1.37). So a trusted .env that assigned _uid_before or _uid_was skipped the restore and left RUN_AS_UID=0 in the services' environment, as note 3 says. The same class reached the hand-over: with SERVICE_DATA_DIR=/ in .env, the walk ran over /. Nothing but the entrypoint reads RUN_AS_UID/RUN_AS_GID, so the effect was a wrong value in the services' environment and a log line that said "ignoring" when it had not.

What I checked

  • docker/test-select-service-user.sh: 116 assertions, 0 failed, under dash and bash. The new cases fail on master for the stated reasons and nothing else does.
  • New cases: .env running set -- root (note 1; this is the case that catches sourcing the file inline, the names case alone does not); .env assigning the restore's names, _env_file, SERVICE_ENV_FILE and RUN_AS_*, with uid 1000 and gid 1001 so a swapped parameter shows; RUN_AS_GID alone; .env pointing the three SERVICE_* paths elsewhere; a logged path containing \012.
  • Mutations, each applied to a copy and confirmed to make the suite fail: hand-over moved back after .env; restore from load_env_file's locals (master); restore from prepare_service_user's locals; file sourced inline in prepare_service_user with the refusal log kept; restore dropped; only the uid compared; gid restored from the uid's parameter; set/unset flipped; the log reading SERVICE_ENV_FILE after the .; the entrypoint reassigning the account. svc_log back to echo fails under dash only, which is where note 5 applies.
  • The image, built from this branch (linux/amd64), with a root-owned, root-only-writable .env containing all of the above plus DISABLE_MOSQUITTO=true, DISABLE_CADDY=true and set -- root: (A) RUN_AS_UID unset: root, nothing re-owned, DISABLE_* honoured, RUN_AS_* not in the services' environment. (B) RUN_AS_UID=1000 RUN_AS_GID=1001 on A's data directory, root-owned meshcore.db inside: services run as 1000, their environment has RUN_AS_UID=1000 RUN_AS_GID=1001, meshcore.db and the directory are 1000:1001, .env stays root's, /etc/passwd stays root's. (C) .env owned by uid 1000: refused with the reason, services run as 1000. The same image with master's two scripts mounted over it, in B's shape: services' environment RUN_AS_UID=0 RUN_AS_GID=0, /etc/passwd owned by 1000, .env owned by 1000:1001.
  • sh test-all.sh passes; no JS or Go change.

Not checked

  • Windows: the suite stops at the symlink fixture, as before.
  • busybox as TEST_SHELL on my machine: Ubuntu's busybox prefers its own applets over PATH, so the stubs are bypassed and 28 cases fail, on master and on this branch alike. Your WSL Debian run (103 passed) and the image itself, which runs Alpine's ash with real binaries, are what cover ash.
  • Rootless Docker and userns-remap: unchanged, docs gap already noted.

Note 2: kept the names case as a second guard; the set -- root case is the one that pins the design.

🤖 Generated with Claude Code

…al parameters

Follow-up to Kpa-clawbot#2125, the six non-blocking notes on its approval.

A sourced file shares the shell's variables: every global, the locals of
load_env_file, which sources it, and, by dynamic scoping, the locals of
prepare_service_user too. So a trusted .env that assigned _uid_before or
_uid_was skipped the RUN_AS_UID/RUN_AS_GID restore and left RUN_AS_UID=0 in
the services' environment (note 3), and one that assigned SERVICE_DATA_DIR
pointed the hand-over elsewhere, since it ran after the file was read.

Now .env is read last, after the hand-over, and the only code after it
reads prepare_service_user's positional parameters: the account, the
container's RUN_AS_UID/RUN_AS_GID (set or unset, and value) and the file's
path are parked in $1..$6 before the file is sourced. The comment states
the real reason the account was already safe (the `.` happens inside
load_env_file, whose own $1.. is what a `set -- root` would reach; note 1)
and that a trusted .env is root's code, which this does not defend against.

Also: svc_log uses printf, so dash's echo no longer cuts a logged path at
a backslash escape (note 5); the entrypoint check also pins that it does
not assign CORESCOPE_SERVICE_USER (note 4); the test header is reflowed
(note 6); and TEST_SHELL lets the suite run under dash, bash or busybox
without editing it.

Tests (notes 1 and 2): .env running `set -- root`; .env assigning the
restore's names, the SERVICE_* paths, and RUN_AS_GID alone; a logged path
with \012. 116 assertions under dash and bash. Each of these makes the
suite fail: the hand-over moved back after .env, the restore from locals
of either function, the file sourced inline in prepare_service_user, the
restore dropped, only the uid compared, the gid restored from the uid's
parameter, set/unset flipped, the log reading SERVICE_ENV_FILE, the
entrypoint reassigning the account, and svc_log back to echo (dash only).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@efiten efiten left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. Approving and merging.

What I checked at 195b6161

  • The fix. The account, both RUN_AS values (set or unset) and the env-file path are parked in prepare_service_user's positional parameters before .env is read, so a sourced .env can no longer reach them, and the hand-over (find/chown) runs before .env can change SERVICE_DATA_DIR or PATH.

  • Tests, under WSL Debian:

    scripts test file dash bash
    head head 116 passed 116 passed
    base base 103 passed 103 passed
    base head 110 passed, 6 failed 111 passed, 5 failed

    The red failures are the expected ones: restored RUN_AS_UID=0 RUN_AS_GID=0, the missing "ignoring" log line, no hand-over with SERVICE_DATA_DIR=/, and (dash only) the \012 log line split.

  • Precedence is unchanged for every variable other than RUN_AS_*, and docs/deployment.md:100-107 already describes the behaviour.

Not checked: busybox ash (no binary here) and the image-level runs A, B and C (no Docker here).

For the description, not blocking

  1. "The new cases fail on master" holds for three of the five. .env runs set -- root and RUN_AS_GID alone pass on master; they guard against a future regression.
  2. The load_env_file comment "Nothing runs here after the file" is not literal, since set +a follows. "Nothing reads a variable here after the file" would be exact.

@efiten
efiten merged commit cb0cd3c into Kpa-clawbot:master Oct 16, 2026
12 checks passed
efiten added a commit that referenced this pull request Oct 22, 2026
Release notes for v3.15.0: `docs/release-notes/v3.15.0.md` and the
`CHANGELOG.md` entry. The release workflow uses the notes file as the
GitHub release body.

## What is in it

- 77 commits since v3.14.0 (76 merged pull requests plus this one): 27
fix, 23 perf, 19 feat, 3 test, 2 ci, 2 docs, 1 chore. Nineteen feats, so
minor.
- "Read this before upgrading" first. It covers:
  - the liveness change (#2261, #2262);
- the `users.db` v6 migration and what it means for a rollback (#2174);
- the backfill at every ingestor start (#2133) and the 24 h
planner-statistics refresh (#2158);
- the caches that trade freshness for latency (#2165, #2222, #2147,
#2156);
- changes in hop names and region placement (#2188, #2190, #2234, #2245,
#2220);
  - the role thresholds on favorites (#2215);
  - the geo-filter save on a single-file mount (#2137);
  - the `/api/stats` polling in hidden tabs (#2162).
- Features, fixes, performance, then tests/CI/docs.
- 29 issues closed, taken from each pull request's
`closingIssuesReferences`.
- Contributors: pull request authors and issue reporters with counts.
The commit co-author trailers only name the pull request's own author or
tooling.

## How it was checked

- I read every pull request body. Every number in the notes comes from a
pull request body.
- I checked the config keys and defaults against `config.example.json`
on `2bb000f2`.
- I checked the UNASKED and opt-in claims for #2125, #2133, #2235 and
#2239 against the source.
- For the other pull requests I relied on their bodies and did not open
the diffs.

## Not covered

- The uncertain count is a single count on one staging instance on the
day of the change (1,682 repeaters and rooms). It is not a before/after
comparison.
- #2133 gives no measured cost for its backfill on a large database, and
the notes say so.

## After merge

Tag `v3.15.0` on the merge commit once its `:edge` image exists. Then
check in the registry that `v3.15.0`, `v3.15`, `v3` and `latest` share
one digest, and that the release has the notes as its body and both
`corescope-decrypt` binaries.

---------

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

2 participants