Repository navigation
Conversation
srpatcha
left a comment
There was a problem hiding this comment.
Review — eos#163 "fix(pkg): a .eapp header names files, it does not name paths"
head: c3ba938 author: Kartikey1306 ci: pass — 29 green, 0 red; Create GitHub Release and assign skipped
Verdict: First look at 9569226..c3ba938 (one commit, four files — services/pkg/eos_pkg.c
+72/−5, tests/test_pkg_install_paths.c new +321, its tests/CMakeLists.txt registration, one
CHANGELOG line) as the body asks; the other eight files belong to #144, reviewed separately at
this exact base. The core fix is correct and the headline claim reproduces: I built the
tree, ran ctest (44/44), and the negative control fails at the exact file:line the body
names, printing installed 'payload' v1.0.0 and Path: test_install_paths_apps/../test_install_paths_escape.
eos_pkg_field_is_component() is a well-chosen whitelist and the placement argument — after the
signature so an unsigned package still reports as unsigned — is right.
But the validation guards the ingress and three consumers of the same fields are outside it.
The strongest is the one I proved by execution at this head: a package record loaded from the
database still reaches system(). I built a db whose install_path contains a double quote,
called eos_pkg_remove(), and touch /tmp/PWNED_163 ran. That is the same shell path this
PR's comment says it closed, entered from persisted state instead of from the header.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | High (P1) | services/pkg/eos_pkg.c:78-88 (eos_rmdir_recursive), reached from :816; also :1099 and :1106 (eos_pkg_stop); ingress eos_pkg_load_db at :415-468 |
A db record still reaches a shell. Proved by execution at this head. eos_pkg_load_db() validates magic, version, count ≤ EAPP_MAX_PACKAGES and truncation — and nothing about the strings inside each eapp_package_t. eos_pkg_remove() then hands pkg->install_path to rm -rf "%s" / rmdir /s /q "%s" through system(), and eos_pkg_stop() hands pkg->name to pkill -f "%s" / taskkill /IM "%s". Measured: a db record with install_path = apps/x" ; touch /tmp/PWNED_163 ; echo ", saved with eos_pkg_save_db(), reloaded by eos_pkg_init(), then eos_pkg_remove(&db, "legacy") → eos-pkg: removed 'payload' v0.0.0, exit 0, and /tmp/PWNED_163 exists. The precondition is a db that already contains such a record, which is exactly what the pre-fix code this PR is written against could write: install_path is built at :771 from an unvalidated package_id. So any device that ran eos-pkg install before this commit carries the record, and remove executes it afterwards. Rated High, not Critical: it is not an eBoot TCB break, a committed secret, silent data loss, or missing authorization on user data — the enumerated Critical cases in .ai/reviewer.md. It is arbitrary command execution as whoever runs eos-pkg, from data at rest, and .ai/security.md is explicit that a parser's severity is not rounded down because the path looks narrow. |
Two parts, and the second is the durable one. (a) Run the same check on load: eos_pkg_load_db() should reject or drop a record whose name/package_id is not eos_pkg_field_is_component(), and whose install_path is not under db->apps_dir. That closes the legacy record without a format change. (b) Stop interpolating into system() at all. eos#156 is open and adds exactly the mechanism for this — core/include/eos/shell_cmd.h and core/src/shell_cmd.c, "one checked builder" — but its file list does not include services/pkg/eos_pkg.c, so these three call sites are covered by no open PR. Routing them through shell_cmd once #156 lands, or replacing eos_rmdir_recursive with a real nftw/FindFirstFile walk, removes the class rather than the instance. |
| 2 | Medium (P2) | services/pkg/eos_pkg.c:846-876 |
update() validates a different read of the file than the one it uses. It fopens, reads its own hdr, fcloses, then calls eos_pkg_verify(eapp_path) — which opens the file again and validates its copy of the header. Everything after that uses update()'s own hdr: eos_pkg_find(db, hdr.package_id) at :867, the '%s' is not installed message at :869-870, eos_pkg_remove(db, hdr.package_id) at :881, and the final printf at :892. If the file changes between the two fopens, none of those are covered — and the comment the commit adds at :861-863 claims they are: "nothing below prints or compares a package_id the package was not entitled to carry." The window is real but narrow (an attacker needs write access to the .eapp path while update runs), and update() already re-opened the file twice before this PR, so the race is not new — the overstated comment is. |
One line, no race analysis needed: if (eos_pkg_check_header_names(&hdr) != 0) return -1; immediately after update()'s own fread/fclose at :857. Then update()'s copy is validated whatever happens to the file. Keep the verify() call where it is; it earns its place for the signature. |
| 3 | Low (P3) | services/pkg/eos_pkg.c:628-632 |
install() prints hdr.package_id with %s before validating it. The already-installed guard fires 68 lines above eos_pkg_check_header_names(). The comment at :692-694 says the check runs "before the first snprintf that would read them", which is true — the fprintf is not an snprintf. A package_id filling all 64 bytes with no terminator is one of the three defects the PR names, and %s on it walks past package_id[64] into ver_major, capabilities, arch_count, supported_archs[48] until it meets a zero byte. It stays inside the 303-byte eapp_header_t in practice, so this is a malformed print, not an out-of-bounds read of the object. Reachability is narrow, and gets narrower with this PR: eos_pkg_find() at :373-383 uses strncmp(..., EAPP_MAX_NAME), which is correctly bounded and does not over-read, so the fprintf only executes when an installed record matches all 64 bytes — which, once every install goes through the new check, it cannot. It remains reachable against a legacy db, i.e. under finding 1's precondition. |
Move eos_pkg_check_header_names(&hdr) up to immediately after the magic/version checks at :622-627, and keep nothing else. The author's reason for putting it after the signature — that an unsigned package must report as unsigned — is sound for verify(), where the message is the product. In install() nothing is printed about the package before the signature check except this line, so hoisting it costs nothing and covers the fprintf too. If the ordering must stay, use "%.*s", (int)sizeof(hdr.package_id), hdr.package_id. |
| 4 | Low (P3) | PR body, Verification table row 1 | "PASS; no warnings in the touched files (eos_eapp builds with -Wall -Wextra -Wpedantic)" is not what the build produces. services/pkg/eos_pkg.c emits 8 warnings at this head: -Wunused-result on system at :1106, four -Wformat-truncation at :703, :717, :743, :751, three -Wstringop-truncation at :769-771. The flags claim is accurate — services/pkg/CMakeLists.txt:17-21 does set -Wall -Wextra -Wpedantic for eos_eapp. The substance of the claim holds and I verified it: the identical eight warnings appear at the base 9569226 at lines :1039, :639, :653, :679, :687, :705-707, and the whole-tree count is 89 at the base and 89 at this head — the PR adds none. Recorded per brief item 5 for the wording, which says something stronger than what was measured. |
Say "no new warnings in the touched files; the 8 pre-existing ones in eos_pkg.c are unchanged", and note that -Wunused-result on system at :1106 is finding 1 wearing a compiler's hat. |
What this PR gets right, because it is most of the change and should not be lost in four
findings:
eos_pkg_field_is_component()is a whitelist, not a blacklist of dangerous characters,
which is the only version of this check that stays correct as consumers change. It handles all
four cases in one place: unterminated (n == field_sz), empty (n == 0),./.., and any
byte outside[A-Za-z0-9._-]— which covers/,\,",$, backtick, newline and tab
without naming any of them.- Placing the check after the signature in
verify()is the right call and the comment says
why: an unsigned package must be reported as unsigned, and the #120 refuse-unless-anchored gate
still fires first. I confirmed the order at:568-578—magic,version,binary_size,
seek,malloc, exact-sizefread, SHA-256, Ed25519, then names, then the first
printfof either field. - Not folding in the header-signing change is the right scope call, and #162 exists for it.
Signingcapabilitiesand the resource fields is a package-format change and belongs behind
§23.2's migration policy, not in a path-traversal fix. - The new test is a real functional test with a real negative control, not a mock.
Architecture conformance
Conforms. §21 places eos in Tier 1 — Foundation; services/pkg/ is inside the owning repo
and §21.1 is not engaged. §5.1's direction-of-dependency law holds: eos_pkg.c includes
<eos/crypto.h> and <ed25519.h>, both downward, and the diff adds no include, link line or
manifest entry — target_link_libraries(eos_eapp PUBLIC eos_crypto) is unchanged. §8's eBoot
TCB rules are not engaged; this is the application package manager, not boot.
What it serves is §10.1's component contract — Integrity: hashes, signatures and provenance —
and .ai/platform.md's rule that permissions and resource caps in a manifest are enforced, not
merely recorded. This PR is the narrower neighbour of that rule: a header field that names a
filesystem location must be constrained to what it claims to be. §11's artifact table lists
.eapp as the application package, and §14.1 asks for a documented threat model for package
distribution — which is precisely the gap the PR's own #162 names, since the signature covers
the binary and not the header that describes it.
No new proposal appended. The design gap this sits on — §11 having no provenance rule, so a
package's metadata is only as trustworthy as the document naming it — is already written up as
the 2026-09-03 proposal "§11 has no provenance rule, so a package's checksum is only as
trustworthy as the document that names its URL", and the signed-header question is tracked as
eos#162. Nothing at this head reveals a further gap in the master design; findings 1–3 are
code-level, not design-level.
Proposed changes
Before merge, both one-liners:
eos_pkg.c:857 eos_pkg_check_header_names(&hdr) on update()'s own header (finding 2)
eos_pkg.c:622 hoist the same call above the already-installed guard (finding 3)
Before merge or immediately after, not a one-liner:
eos_pkg_load_db() reject a record whose name/package_id is not a component
and whose install_path is not under apps_dir (finding 1a)
Separately, and the one that ends the class:
eos_rmdir_recursive() and eos_pkg_stop()'s two system() calls route through
eos#156's core/src/shell_cmd.c, or stop shelling out entirely (finding 1b)
Housekeeping:
PR body row 1 -> "no new warnings" (finding 4)
No fix PR opened. Finding 1 is High, which is inside the brief's autofix rule, and unlike
the other PRs in this run the defect does exist on origin/master — eos_rmdir_recursive()
and both eos_pkg_stop() system() calls are unchanged there — so fix-start.sh would have
something to patch. I am not opening one anyway, for two reasons the brief gives directly.
First, the fix is a refactor: routing three call sites through a command builder, or
replacing rm -rf with a directory walk, is not "an unchecked return, a wrong CMake option
name". Second, eos#156 is open and owns this mechanism — it adds core/src/shell_cmd.c for
exactly this purpose — and the brief says that where an existing PR covers the ground, say so in
the review and open nothing. What #156 does not cover is services/pkg/eos_pkg.c; its file
list is backends and services/linux, so someone has to extend it or follow it, and saying that
here is the useful contribution. Finding 1a (validate on load) is small enough to fix, but it
belongs in this PR, next to the check it extends, not in a competing branch.
Verification performed for this review
The eos checkout has a clean working tree (git status --short empty, before and after)
and was not modified — no worktree, checkout, stash or reset. The PR head was fetched
read-only into refs/autoreview/pr163. Everything below ran against git archive | tar -x into
temporary directories outside the repository — one at c3ba9386, one at the base 9569226 for
the controls, and a third throwaway for the injection harness. Nothing was committed or pushed.
| Check | Result |
|---|---|
cmake -B build/host -G Ninja -DCMAKE_BUILD_TYPE=Release -DEOS_BUILD_TESTS=ON -DEOS_PRODUCT=vbox_test |
PASS |
cmake --build build/host --parallel 4 |
PASS |
ctest --output-on-failure --no-tests=error --timeout 120 --parallel 4 |
PASS — 100%, 44/44, 0 failed. Matches the body's "44/44" exactly (43 at the base + the new suite) |
ctest -R test_pkg_install_paths --verbose |
PASS, 8 [PASS] — matches the body's "8/8". The suite executes; it is not registered-and-unbuilt |
pytest tests/unit -q (CPython 3.12, pyyaml) |
PASS — 43 passed in 0.34s. Matches the body |
Negative control — git show 9569226:services/pkg/eos_pkg.c restored, target rebuilt |
FAIL, as claimed, at the exact line: [FAIL] tests/test_pkg_install_paths.c:182: eos_pkg_install(&db, EAPP_PATH) != 0, preceded by eos-pkg: installed 'payload' v1.0.0 (x86_64) and Path: test_install_paths_apps/../test_install_paths_escape. Restored; green again |
Finding 1, executed at this head — db record with install_path = apps/x" ; touch /tmp/PWNED_163 ; echo ", saved via eos_pkg_save_db(), reloaded via eos_pkg_init(), then eos_pkg_remove() |
loaded count=1 install_path=[apps/x" ; touch /tmp/PWNED_163 ; echo "], eos-pkg: removed 'payload' v0.0.0, exit 0, and /tmp/PWNED_163 was created. Cleaned up afterwards |
eos_pkg_find() over-read check |
Not a defect — :380 is strncmp(..., EAPP_MAX_NAME), correctly bounded. Only the fprintf at :629 is unbounded (finding 3) |
eos_pkg_load_db() input validation |
magic, version, count ≤ EAPP_MAX_PACKAGES, per-record truncation — all present and correct. No validation of any string field |
grep -n "system(|popen(" services/pkg/eos_pkg.c |
four sites: :81, :85 (eos_rmdir_recursive), :1099, :1106 (eos_pkg_stop). eos_pkg_run at :1058 correctly uses execv with an argv array, not a shell |
| Warnings, base vs head | identical set, identical whole-tree count (89 → 89). Eight in eos_pkg.c on both sides, line numbers shifted by the insert. The PR adds none — finding 4 is the wording, not the build |
services/pkg/CMakeLists.txt:17-21 |
eos_eapp does build with -Wall -Wextra -Wpedantic on GNU/Clang and /W4 on MSVC, as the body says |
Open PRs touching services/pkg/eos_pkg.c |
only this one. eos#156's file list is backends, core/shell_cmd, services/linux and tests — it does not reach eos_pkg.c |
git diff --stat 9569226 c3ba9386 |
4 files, +399/−5 — the body's scope claim is accurate |
CI on this head (checks.txt) |
29 pass, 0 fail, incl. Build & Test (Linux x86_64), Build & Test (Windows MSVC), Host Tests (x86_64), Cross-compile ARM Cortex-M4, Cross-compile ARM64 kernel, QEMU Boot Test, QEMU ARM64 simulation (13 TCs), Static Analysis (cppcheck + clang-tidy), CodeQL, Analyze (C/C++) |
| PR metadata | mergeable: MERGEABLE, mergeStateStatus: BLOCKED, reviewDecision: REVIEW_REQUIRED |
Not checked
- Finding 2's race was not raced. The TOCTOU between
update()'s ownfreadand
eos_pkg_verify()'s re-open is read from:846-867, not demonstrated by swapping the file
mid-call. The one-line fix makes the race question moot, which is why I did not build a
harness for it. - Finding 3's over-read was not provoked. With the fix in place I could not construct a db
record whosepackage_idfills 64 bytes, because the new check prevents one being written —
which is the point. It is read from:628-632, and its reachability is stated as conditional
on a legacy db, not asserted. - Windows — NOT BUILT.
rmdir /s /q "%s"andtaskkill /IM "%s"in finding 1 are the MSVC
arms; only the POSIX arm was compiled and executed.cmd.exequoting differs fromsh
quoting, so whether the same record yields command execution there is Unknown — I did not
test it and am not asserting it. The_rmdir/<direct.h>cleanup the body describes in the
new test is likewise unexercised. TheBuild & Test (Windows MSVC)leg is green in CI. - Cross-compilation, QEMU, cppcheck/clang-tidy — NOT RUN locally. All green in CI, none
reproduced. Note that neither cppcheck nor clang-tidy flagged finding 1 on this head, and the
-Wunused-resultonsystemat:1106is present in the build and not treated as an error. - GCC version differs from CI. My host GCC is newer than CI's
gcc-12, so the warning set
is not guaranteed identical to the one the green legs saw. The base-vs-head comparison was
made with the same compiler on both sides, which is what finding 4 rests on. - No fuzzing of the header.
tests/fuzz/exists and is opt-in (-DEOS_ENABLE_FUZZING=ON,
Clang only); I did not enable it, and there is no fuzz target foreapp_header_t.
.ai/security.mdasks for fuzz coverage on externally reachable parsers, and the.eapp
header is one. Not raised as a finding because it is not this PR's to add, but it is the
obvious next thing after #162. hdr.binary_sizeis unbounded beforemalloc()at:540and theinstall()equivalent
— a 4 GiB header field forces a 4 GiB allocation attempt before the read fails. Pre-existing,
untouched by this diff, not raised as a finding; recorded so it is not mistaken for reviewed.- The #144 half of the 12-file diff is not reviewed here — reviewed separately at head
95692266, which is this PR's base. If #144 changes, the 44/44 figure no longer describes
what will merge. mergeStateStatus: BLOCKED—REVIEW_REQUIREDplus the #144 stack. Land #144 first.
Automated architecture review of c3ba93860ef2 — scheduled, model claude-opus-5, checked against the EmbeddedOS Master Design v2.0. Advisory only: this reviewer never approves, requests changes, or merges. Reply here to discuss or push back — a wrong finding is a bug worth reporting.
|
All four taken, in Finding 1 — a db record still reached a shell. Both parts. (a) Finding 2 — Finding 3 — Finding 4 — the warnings claim. Reworded: no new warnings. Clang here compiles
|
|
CodeQL's check-run (the "new alerts in code changed by this PR" one, not the Analyze job) flagged three high alerts on
|
srpatcha
left a comment
There was a problem hiding this comment.
The path-traversal fix is solid: allowlisted names, install_path rebuilt and compared, symlink-safe removal. Its tests fail on master and pass here. One blocking issue: the component-name check allows a leading -, and eos_pkg_stop() now runs {"pkill", "-f", pkg->name}. A package named -u0 therefore becomes pkill -f -u0, which signals every root process (confirmed in WSL: pgrep -f -u0 lists PID 1, 2, 6 …). Fix: refuse a leading - in eos_pkg_field_is_component(), or pass -- before the name.
|
Daily-loop code review (the loop does review; not a merge). The pkg fix is sound and well-tested. Same bundling concern as #165: this PR also carries the unrelated kernel-scheduler rework ( |
eos_pkg_install() wrote the binary to apps_dir/<package_id>/<name> with both fields taken raw from the package header. The signature covers the binary alone, so both are attacker-chosen even for a genuinely signed package: a package_id of "../escape" installed a 0755 binary outside apps_dir and returned 0; a 64-byte field with no terminator was printed and joined into paths with %s, reading past the end of the header struct; and because eos_pkg_remove() hands install_path to `rm -rf "..."` through system(), a quote in package_id reached a shell. Each field is now required to be a single path component: terminated, non-empty, [A-Za-z0-9._-] only, and neither "." nor "..". verify(), install() and update() all apply it. The check runs after the signature so an unsigned package is still reported as unsigned, and before the first snprintf that would read the fields. update() verifies before it looks the id up, so nothing prints or compares a package_id the package was not entitled to carry. tests/test_pkg_install_paths.c signs its packages with RFC 8032 section 7.1 Test 2, as test_pkg_trust_anchor.c does, so every refusal is about the name: the traversal case, both separators, "." and "..", thirteen shell metacharacter ids, unterminated and empty fields, the update path, and a control that installs the same payload under a plain name. Against the unfixed file the first test fails at its install() assertion and leaves test_install_paths_escape/payload, mode 0755, beside apps_dir. Signing the header itself is a package-format change and is filed separately; this closes the reachable path with the format as it is.
The header was the first ingress; the db is the second. A record an earlier version of this tool wrote from an unvalidated header carries whatever the header did, and eos_pkg_remove() handed its install_path to `rm -rf "%s"` through system() -- proved in review: a record with install_path = apps/x" ; touch /tmp/PWNED ; echo " reloaded by eos_pkg_init() and removed with exit 0 and the sentinel present. eos_pkg_stop() did the same with the name through `pkill -f "%s"`. eos_pkg_load_db() now drops, with a message, any record whose name or package_id is not a single path component or whose install_path is not the one install builds from apps_dir and package_id; the next save writes the db without it. Nothing in the file reaches a shell any more: eos_rmdir_recursive() is a directory walk in C on both platforms (symlinks unlinked, not followed) and remove() refuses a path other than the package's own install directory before walking it; stop() hands the name to pkill / taskkill as an argv element through posix_spawnp() / _spawnvp(). Two smaller things from the same review. update() validated verify()'s read of the header and used its own; it now checks the copy it uses, before anything prints or compares it, and still verifies. install() printed hdr.package_id in the already-installed message before the name check ran; the check is hoisted to right after the magic and version checks, where nothing else is said about the package before the signature, so the ordering argument that holds for verify() -- an unsigned package reports as unsigned -- costs nothing here. tests/test_pkg_install_paths.c: a db with a legacy record whose install_path carries a shell escape, one whose name does, and one that is well formed, saved and reloaded -- two dropped, one kept, remove and stop refuse the dropped ones, no sentinel; and a removal that takes the package tree down (a nested directory, a file named with backticks) while a sibling directory survives, plus a record whose install_path is the apps directory itself being refused at removal. Against c3ba938 the first fails at the record count. 10/10; ctest 44/44.
…ator load_db() compares a record's install_path with the string install builds, which joins with EOS_PATH_SEP -- a backslash on Windows. The new test wrote its well-formed record with a forward slash, so on the MSVC leg that record was dropped with the two hostile ones and the count assertion failed. The records now use the separator the code under test uses; the file operations keep forward slashes, which the Windows CRT accepts.
CodeQL, high, on this PR's own new code, three alerts. The POSIX
directory walk did lstat(child) and then unlink(child) or a recursion
on the same path -- a check followed by a use of a path that can be
swapped in between. The walk now acts relative to an open directory
descriptor and never through a path it inspected: unlinkat() first,
which removes a file or a symlink and never follows one; only when the
kernel answers EISDIR (Linux) or EPERM (BSD) is the entry opened with
openat(O_DIRECTORY | O_NOFOLLOW) and descended into, and it is removed
afterwards with unlinkat(AT_REMOVEDIR). Nothing is stat'ed. The test
plants a symlink from inside the package to a sibling directory; the
link goes and the sibling stays.
The two fixture files in tests/test_pkg_install_paths.c were created
with fopen("wb") -- 0666 masked by umask -- the class this repository
has fixed twice before (bba834a, 8dca787). Same fix: open(O_CREAT,
0600) and fdopen(), the way write_eapp_named() in the same file already
creates the package; fopen kept for the Windows branch.
…dedos-org#162) d1b52bb made the signature cover the header, binary and resources, so the RFC 8032 Test 2 vector these fixtures embedded (a v1 signature over the binary alone) no longer verifies and 2 of the 10 tests failed on 'signature verification failed' before reaching the name checks. The fixtures are now signed at test time over eos_pkg_envelope_digest() with the key test_pkg_trust_anchor.c uses, so each refusal is still about the name: against master's eos_pkg.c the suite fails at :230 (a signed '../escape' installs), with this branch it passes 10/10.
f58ee19 to
a369270
Compare
|
@Aswin-V done: this PR is now only the pkg fix and its tests. The scheduler and Rebased head a369270 touches four files: Two things had to change because of your v2 envelope (d1b52bb):
Measured at a369270 on macOS arm64 (
Could you take another look? |
…h --
eos_pkg_stop() hands pkg->name to pkill as an argv element, so a package
named "-u0" ran `pkill -f -u0`, which signals every process owned by
root (confirmed in WSL: `pgrep -f -u0` lists PID 1, 2, 6 ...).
eos_pkg_field_is_component() now refuses a field starting with '-', so
such a name never reaches install, update or load_db; pkill also gets
"--" before the name as defence in depth. A dash elsewhere is still a
name character ("com.example.my-app" installs).
test_install_refuses_a_leading_dash fails before this change (line 282)
and passes after; pkg tests 3/3.
|
Pushed 87ed567 to address my blocking review: |
Fixes #161
Stacked on #144; review
9569226..f58ee19for this change alone -- four commits;f58ee19clears the CodeQL check-run (three high alerts on the new code: the POSIX walk now acts on directory descriptors withunlinkat/openat(O_NOFOLLOW)instead oflstatthenunlink, and the two fixture files are created 0600) (the third, a one-line test fix for the MSVC leg: the db records now use the platform path separator).c3ba938is the fix;3e2b9a8answers the review's four findings (services/pkg/eos_pkg.c,tests/test_pkg_install_paths.c,CHANGELOG.md).c3ba938alone was four files:services/pkg/eos_pkg.c,tests/test_pkg_install_paths.c(new), its registration intests/CMakeLists.txt, one CHANGELOG line at the end of### Fixed. Merge #144 first. Independent of #156 and #158 (the registration sits besidetest_pkg_trust_anchor, not where #156 addstest_shell_cmd).The defect
eos_pkg_install()writes the binary toapps_dir/<package_id>/<name>with both fields taken raw from the package header, and the signature covers the binary alone (eos_pkg_check_signature(hdr.signature, binary_data, hdr.binary_size)), so both are attacker-chosen even for a genuinely signed package:package_id = "../escape": install returns 0, printsinstalled 'payload' v1.0.0, and leavesapps_dir/../escape/payloadat mode 0755. Reproduced on9569226by the new test's negative control (below).verify/install/updateprint and join it with%s, reading past the header struct.eos_pkg_remove()handsinstall_pathtorm -rf "..."/rmdir /s /q "..."throughsystem(), so a"inpackage_idreached a shell.The fix
eos_pkg_field_is_component(): a field is a single path component -- terminated, non-empty,[A-Za-z0-9._-]only, neither.nor...eos_pkg_check_header_names()applies it to both fields and is called fromverify(),install()andupdate():snprintfthat reads the fields;update()now callseos_pkg_verify()before looking the id up, so nothing prints or compares apackage_idthe package was not entitled to carry.Signing the header itself (
capabilities, version, names, resources) is a package-format change; filed as #162 rather than folded in here. This PR closes the reachable filesystem and shell paths with the format as it is.Verification
cmake -B build -DEOS_BUILD_TESTS=ON→cmake --buildeos_pkg.ccompiles clean at the base and at this head; the review counted 8 pre-existing GCC warnings in the file at the base (-Wformat-truncation,-Wstringop-truncation,-Wunused-resultonsystematstop()), and the tree-wide GCC count was 89 at both. The-Wunused-resultone is gone at3e2b9a8with thesystem()call it was on; the other seven are untouched pre-existingsnprintf/strncpysites.eos_eappbuilds with-Wall -Wextra -Wpedantic.ctest --test-dir buildtest_pkg_install_paths, now 10 tests)tests/test_pkg_install_pathspackage_id; separator inname(/and\);.and..in either field; 13 shell-metacharacter ids (x" ; echo pwned ; ",x$HOME,x`id`, newline, tab, ...); unterminated 64-byte fields throughverifyandinstall; empty fields; theupdatepath; and the control -- the same signed payload underorg.eos.test.paths/payload-1.0_xinstalls and lands where it shouldgit show 9569226:services/pkg/eos_pkg.c > services/pkg/eos_pkg.c, rebuild, reruntests/test_pkg_install_paths.c:182: eos_pkg_install(&db, EAPP_PATH) != 0after printinginstalled 'payload' v1.0.0, andtest_install_paths_escape/payload(-rwxr-xr-x, 1 byte) is left besideapps_dirpytest tests/unit -q(test_cmake_test_registration.pyamong them)The fixture is created and removed in the working directory, the way
test_pkg_trust_anchor.cdoes it; the POSIX branch creates the package 0600, and the Windows branch uses_rmdirfrom<direct.h>for cleanup.Review follow-up (
3e2b9a8)rm -rf "%s"/pkill -f "%s"throughsystem()eos_pkg_load_db()holds every record to the rules install enforces:nameandpackage_idmust be single path components, andinstall_pathmust be exactlyapps_dir/<package_id>; a record that fails is dropped with a message (the next save writes the db without it), soremoveandstopnever see it. (b) Nothing in the file reaches a shell any more, without waiting for #156:eos_rmdir_recursive()is a directory walk in C on both platforms (opendir/lstat/unlink/rmdir;FindFirstFileA/DeleteFileA/RemoveDirectoryA), symlinks unlinked rather than followed, andremove()refuses a path other than the package's own install directory before walking it;stop()hands the name topkill -f/taskkill /IMas an argv element throughposix_spawnp()/_spawnvp(). Tests: your legacy record (install_path = apps/x" ; touch sentinel ; echo "), one with a shell escape inname, and one well-formed, saved witheos_pkg_save_db()and reloaded -- two dropped, one kept,remove/stoprefuse the dropped ones, no sentinel; and a removal that takes down a nested directory and a file named with backticks while a sibling directory survives, plus a record whoseinstall_pathis the apps directory itself refused at removal. Againstc3ba938the first fails at the record count.update()validated verify()'s read and used its owneos_pkg_check_header_names(&hdr)right afterupdate()'s ownfread/fclose, before the lookup, the message,remove()and the finalprintf;verify()stays where it is for the signature. The overstated comment is replaced with what is now true.install()printedhdr.package_idbefore validating itmagic/versionchecks, as you suggest -- ininstall()nothing is said about the package before the signature except that line, so the verify-side ordering argument costs nothing here.-Wunused-resultonsystematstop()is gone with the call.CodeQL (check-run, not the Analyze job): three high alerts on
3c72b23's new code -- a TOCTOU in the walk (lstatthenunlink/recurse on the same path) and twofopen("wb")fixtures. Cleared inf58ee19: the walk consumes an open directory descriptor,unlinkat()first (removes a file or a symlink, never follows), descends only onEISDIR/EPERMthroughopenat(O_DIRECTORY|O_NOFOLLOW), removes withunlinkat(AT_REMOVEDIR); nothing is stat'ed. The test plants a symlink from inside the package to a sibling directory and asserts the link goes and the sibling stays. Fixtures:open(O_CREAT, 0600)+fdopen(), the class fixed inbba834aand8dca787.