fix(conf): reject embedded NUL in rootfs and cgroup path validation - #95
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
There was a problem hiding this comment.
Code Review
This pull request adds configuration validation to reject embedded NUL bytes in the "rootfs" and cgroup "path" isolation options, and ensures "rootfs" is validated as an absolute path other than "/". However, a security vulnerability was identified where a rootfs path configured as // can bypass the validation and resolve to / at runtime, disabling isolation. It is recommended to strip trailing slashes during validation to prevent this bypass and to add a corresponding test case.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
|
@claude review |
|
Claude finished @andypost's task in 1m 25s —— View job Code Review
Reviewed against 🔴
|
|
Addressed in The Fix strips all trailing slashes before the length check, in two places:
Regression cases Drove a live Left cgroup-style |
2d09972 to
a80ddb3
Compare
|
Claude finished @andypost's task in 3m 8s —— View job Re-review of
|
| input | after strip | verdict |
|---|---|---|
"" |
len 0 | ✅ rejected (len <= 1) |
/ |
len 1 | ✅ rejected |
// |
len 1 | ✅ rejected |
/// |
len 1 | ✅ rejected |
app/rootfs |
— | ✅ rejected (start[0] != '/') |
/app\0/injected |
— | ✅ rejected (memchr) |
/srv/app |
len 8 | ✅ passes |
/srv/app/ |
len 8 | ✅ passes (legit trailing slash preserved) |
Two details I checked and they're right:
memchrscansrootfs.length(the original, un-stripped length) — correct; it must see a NUL anywhere in the string, not just before the stripped tail.- The loop guard
len > 1prevents stripping the leading/itself, so a bare/…///never underflows — it bottoms out atlen == 1and gets rejected.
Runtime — nxt_isolation_set_rootfs (src/nxt_isolation.c:505)
while (str.length > 1 && str.start[str.length - 1] == '/') {
str.length--;
}
if (nxt_slow_path(str.length <= 1 || str.start[0] != '/')) { ... }The if → while change is the real root-cause fix: the old single-strip left /// → // (still resolving to /). Now all trailing slashes are removed before the length <= 1 re-check, so // and /// both fail. Storing the stripped str.length also normalizes /srv/app/ → /srv/app, which is harmless-to-better. Good defense-in-depth even though the config gate should catch these first.
Tests (test/test_python_isolation.py:252)
check_invalid('//') and check_invalid('///') added alongside '', /, relative, and embedded-NUL — the previously-invisible gap is now regression-guarded.
Out-of-scope call
Agree with leaving /../ rejection out for rootfs — a chroot target doesn't build a NUL-terminated path buffer the way the cgroup path does, so the kernel resolves .. against the real FS at chroot/pivot_root time. NUL + collapse-to-/ are the relevant classes here.
Verdict
LGTM. ✅ The NUL rejection, the %*s → %.*s / snprintf fix, and now the ////// collapse-to-/ bypass are all correctly closed at both config and runtime, with regression coverage. Nothing blocking from my side. (I confirmed against the PR-head blobs; I trust your live unitd + clean-compile verification for the build itself.)
· branch fix/conf-path-validation
|
/gemini review |
|
Warning Gemini encountered an error creating the review. You can try again by commenting |
a80ddb3 to
7bd766c
Compare
The cgroup "path" and "rootfs" isolation options are validated as
length-tracked config strings but later reused as NUL-terminated C
strings during isolation setup (chroot/pivot_root, cgroup path build).
An embedded NUL could truncate the effective path, and a "rootfs" of
"//" or "///" collapsed to "/" at runtime — a silent no-op chroot that
disables rootfs isolation.
Validate both at config time (nxt_conf_vldt_cgroup_path, new
nxt_conf_vldt_rootfs_path): reject empty values and embedded NUL bytes,
strip all trailing slashes before the length check, and require rootfs
to be an absolute path other than "/" (previously runtime-only). Replace
the cgroup-path sprintf("%*s") — a width, not precision, specifier that
over-reads a non-terminated buffer — with snprintf("%.*s", ...) bounded
by the destination size.
For defense in depth, fix the runtime nxt_isolation_set_rootfs to strip
all trailing slashes (if -> while) and re-validate length after
stripping, since its single-strip was the root cause of the "//" bypass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7bd766c to
8f231a0
Compare
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Claude finished @andypost's task in 1m 15s —— View job Re-review — PR head (
|
| input | after trim | verdict |
|---|---|---|
"" / / / // / /// |
≤1 | ✅ rejected |
app/rootfs |
— | ✅ rejected (start[0] != '/') |
/app/rootfs\0/injected |
— | ✅ rejected (memchr) |
/srv/app , /srv/app/ |
8 | ✅ passes (legit trailing slash) |
runtime — nxt_isolation_set_rootfs (src/nxt_isolation.c:505)
The if→while strip before the length <= 1 re-check is the root-cause fix for the ///→//→/ collapse. Correct defense-in-depth.
tests (test/test_python_isolation.py:228)
cgroup_invalid gains the \0 case; rootfs_invalid covers '', /, //, ///, relative, and embedded-NUL — the previously-invisible bypass is regression-guarded.
🟡 Minor (non-blocking, unchanged from before)
/../(path-traversal) is intentionally not rejected forrootfs. Agreed out-of-scope: a chroot target doesn't build a NUL-terminated path buffer the way the cgroup path does; the kernel resolves..against the real FS atchroot/pivot_roottime. NUL + collapse-to-/are the relevant classes here.
Verdict
LGTM ✅ — the responses to the gemini thread are accurate (the // check runs on the trimmed length, and ////// cases are present in the test). NUL rejection, the %*s→%.*s/snprintf fix, and the ////// collapse bypass are all closed at both config and runtime with regression coverage. Nothing blocking. (I trust your live unitd + clean-compile verification for the build itself.)
· branch fix/conf-path-validation
There was a problem hiding this comment.
Code Review
This pull request introduces validation for the 'rootfs' and cgroup 'path' isolation options. Specifically, it rejects embedded NUL bytes in both options, ensures 'rootfs' is validated as an absolute path other than '/' at configuration time, and replaces an unsafe sprintf with snprintf in the cgroup path validation. Additionally, trailing slashes are now properly trimmed in a loop, and comprehensive unit tests have been added to verify these validation rules. I have no further feedback to provide as the changes are well-implemented and include appropriate test coverage.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Scan the tree (Cargo.lock, go.mod/sum, package-lock, requirements, Dockerfiles) with Trivy on a weekly schedule, on pushes/PRs touching dependency manifests, and on demand. Findings are uploaded as SARIF to the Security tab and rendered into a severity-sorted Markdown report (sort-trivy-results.py) appended to the job summary and artifact. Automatic runs are report-only and never fail the build; a blocking severity gate is opt-in via workflow_dispatch. security-events:write is scoped to the audit job, not the whole workflow. The report parser guards Trivy edge outputs: a CVSS source whose value is explicitly null, a null "Results" field, and a non-dict top-level report, all of which would otherwise raise. Reworked from the CI portion of andypost#26; the cgroup/rootfs config-validation fix from that draft ships separately in #95. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A "rootfs" like "/.", "/..", or "/foo/.." passes the absolute-path and
embedded-NUL checks but collapses to "/" at chroot/pivot_root time, where
chroot("/") is a no-op and silently defeats rootfs isolation. This is the
same collapse-to-root class as the "//" case fixed in #95; close it for both
"/" and any path that normalizes to it.
Add nxt_rootfs_resolves_to_root(): a lexical normalizer that collapses "."
and ".." components (".." clamped at root, matching kernel behaviour) and
returns TRUE when nothing remains. Wire it into nxt_conf_vldt_rootfs_path()
(authoritative gate) and mirror it into nxt_isolation_set_rootfs() for
defence-in-depth, consistent with how #95 kept the two gates in sync.
Tests: expand test_python_isolation_rootfs_invalid with the dot/dotdot
matrix (".", "..", mixed, trailing variants) and add
test_python_isolation_rootfs_dotdot_valid, a regression guard proving a
rootfs that contains ".." but resolves to a real directory is accepted and
still confines the app.
Follow-up to #95; based on its head so this PR's diff shows only the
normalizer addition.
Co-Authored-By: Claude <noreply@anthropic.com>
Several length-tracked nxt_str_t config values are later consumed as NUL-terminated C strings, so an embedded NUL (expressible in JSON via a unicode escape, which the parser accepts) survives validation and silently truncates the effective value at the sink -- privilege/target confusion, a wrong bind/log/exec path, or loading the wrong file/symbol. #95/#105 fixed isolation.cgroup.path and isolation.rootfs; this extends the same guard to the rest. conf validation: add nxt_conf_vldt_c_string (reject empty and embedded NUL, modeled on nxt_conf_vldt_rootfs_path) and wire it onto every option that reaches a C-string sink: - common/process: user (getpwnam), group (getgrnam), working_directory (chdir), stdout/stderr (open); - external: executable (execve); - per-language app targets that map to NXT_CONF_MAP_CSTRZ: python home, perl script, java webapp and unit_jars, wasm module and its *_handler symbol names, and wasm-component component. The option name for the diagnostic is passed via the member's .u.string. Only fields that actually map to a CSTRZ sink are wired -- php/ruby "script" and python "module" are consumed differently and are left alone; the "arguments" array elements are already NUL-guarded by nxt_conf_vldt_argument. sockaddr: reject embedded NUL in a pathname unix-domain socket address inside nxt_sockaddr_unix_parse -- the single choke point for both config validation and bind(2)/unlink(2). sun_path is used as a C string, so a NUL would bind/unlink a shorter path than validated. Abstract sockets (unix:@...) legitimately carry NULs and are exempt. Verified end-to-end: escaped-NUL and empty values on user/group/ working_directory/stdout/stderr/executable are rejected with a specific message; a unix listener path with an embedded NUL is rejected; valid values still validate. The per-language target validators use the same wiring as executable and are exercised by the language CI legs. Add test/test_configuration.py negatives (empty + embedded NUL) for the common/external C-string fields and pathname unix sockets, and a CHANGES entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Reworked salvage of the C-hardening half of the stale draft andypost#26 , rebased onto current
pre-1.35.6and extended.Two isolation config options —
isolation.cgroup.pathandisolation.rootfs— are validated as length-tracked strings but later reused as NUL-terminated C strings during isolation setup (cgroup path construction;chroot/pivot_root). An embedded NUL byte survives config validation and silently truncates the effective path downstream.Changes in
nxt_conf_vldt_cgroup_path()/ newnxt_conf_vldt_rootfs_path():memchr) for both options.rootfsas an absolute path other than/at config time — previously this was only enforced at runtime innxt_isolation_set_rootfs(), so a bad value was accepted by the control API and only failed on app start.sprintf(path, "/%*s/", ...)withsnprintf(path, sizeof(path), "/%.*s/", ...).%*sis a width, not a precision, specifier — it readscgpath.startto a NUL terminator a length-tracked string does not guarantee.%.*sbounds the copy to the given length, andsnprintfbounds the destination.What I deliberately left out of #26
sort-trivy-results.py— belongs in a separateci:PR, SHA-pinned, schedule-only.docs/security-audit-findings.md— a public list of suspected-unfixed vulnerabilities; kept out of the repo for disclosure hygiene. The one actionable item there (rootfs embedded-NUL, "SA-001") is fixed by this PR.Testing
unitdbuilt from this branch; drove the control API directly (PUT /config):rootfs: relative path,/, and/app\u0000/injected→ rejected withInvalid configuration+ detail;/srv/app→ passes validation.cgroup.path:scope\u0000pythonandscope/../python→ rejected;scope/python→ passes.\u0000escape decodes to a real 0x00 byte (nxt_utf8_encode), somemchrcatches it.test/test_python_isolation.py:test_python_isolation_cgroup_invalidgains a NUL case; newtest_python_isolation_rootfs_invalidcovers empty,/, relative, and embedded-NUL.nxt_conf_validation.oand fullunitdcompile clean.🤖 Generated with Claude Code