Repository navigation
fix(os_services): OTA download and install run nothing through a shell - #165
Conversation
|
CodeQL's check-run flagged two high alerts on Both are Not touched: the two pre-existing |
|
One more from CodeQL on |
srpatcha
left a comment
There was a problem hiding this comment.
POSIX side looks right: the OTA shell injection is gone, and the tests fail on master and pass here (16/16). Blocking on Windows: _spawnvp joins argv with spaces and does not quote (os_services.c:130). The download path comes from %TEMP% (:187), which often contains a space (C:\\Users\\First Last\\...), so curl receives a split -o argument. The old code quoted it, so this is a regression on Windows. Quote each argv element for the Windows spawn, or build the command line with proper quoting. Not verified on a Windows machine; this is from _spawnvp's documented behaviour.
|
Daily-loop code review (the loop does review; not a merge). The OTA fix itself is sound. Replacing the two But this PR bundles two unrelated changes that should be split out:
Recommendation: keep this PR to the OTA fix (os_services.c + its tests + CHANGELOG), and file the kernel-scheduler and linux-security hardenings as separate PRs so each can be reviewed, tested, and reverted independently. As it stands the OTA fix can't land without also landing a scheduler rework. |
eos_ota_download() and eos_ota_install() built `wget -q -O "%s" "%s"` and `cp -f "%s" "%s"` with snprintf and handed them to system(), each behind its own denylist. Each denylist had holes: the URL's omitted the double quote and the ampersand, so a URL of `http://example.invalid/fw.bin" & touch probe & "` left the quoting, ran the command, and then reported a failed download; the target path's omitted the backtick, which is command substitution inside double quotes, so a target of `x`touch probe`` ran the command and reported EOS_OTA_COMPLETE. Both were demonstrated by execution in the review of Neither step needs a shell, so neither uses one now. The fetch runs wget (POSIX) or curl (Windows) with an argv through posix_spawnp() / _spawnvp(): nothing is parsed as shell syntax whatever the URL holds. The install is a file copy in C. What is left to check about the URL is what the downloader would do with it: it must be http:// or https:// and made of RFC 3986 URL characters, so it cannot be read as an option (`-O ...`) or a local file, and cannot put a control character into a log line; the sub-delims a signed URL carries (& ; = % and the rest) are allowed, because they were only ever dangerous to the shell that is no longer there. local_path, derived from TEMP on Windows and unvalidated before, is an argv element now as well. tests/test_os_services.c runs both probes as data: the download is refused before anything starts and the sentinel is never created; the install copies a file to a target whose name contains backticks, byte for byte, and creates no sentinel. Against the old file the first test fails at the sentinel check -- the injected touch ran.
CodeQL, high, on this PR's own new code: copy_file() opened the OTA target with fopen(dst, "wb"), which creates with 0666 masked by umask, so on a permissive umask the installed image was readable and writable by every user on the host. The test fixture at test_os_services.c had the same shape. Same class this repository has fixed twice before (bba834a, 8dca787); same fix: open(O_WRONLY|O_CREAT|O_TRUNC, 0600) and fdopen(), behind a helper, with fopen kept for the Windows branch where the mode bits do not apply. 0600 is a deliberate choice for the image, not a default: the updater writes it and the updater reads it back for verification, so no other uid needs it, and firmware is not something other users should read. A consumer that needs it wider is a policy decision of its own. The install test now asserts (st_mode & 077) == 0 on the target, and was run under umask 000 to show the mode does not depend on it.
…, not the path CodeQL, high, on the previous commit's own test: stat(target) followed by remove(target) is a check of a path and then a use of it. The mode comes from fstat() on the descriptor the test already has open for the read-back, and nothing stats the path before it is removed.
fc3cb2f to
fbd2738
Compare
|
@Aswin-V done: this PR is now only the OTA fix. The scheduler and Rebased head fbd2738 carries three commits and three files: Measured at fbd2738 on macOS arm64 (
Could you take another look when you have a moment? |
_spawnvp() joins argv with plain spaces and the child's C runtime splits the line again, so an OTA download path under a %TEMP% with a space (C:\Users\First Last\...) reached curl as two arguments -- a regression from the old quoted command line. eos_os_quote_win_arg() applies the inverse of the CRT's argument rules (wrap in quotes when needed, \" for a quote, double the backslashes before a quote or the closing quote) and run_argv() quotes every element on Windows before spawning. The quoting is plain C, so test_win_arg_quoting_round_trips checks it on every host against a reference CRT parser (14 round-trip cases incl. the %TEMP% path). The test harness now counts tests itself; it hardcoded tests_run = 16, so any added test made the binary exit 1. 17/17 pass; ctest 46/46 (test_net excluded: hangs in this WSL box, unrelated to this change). Not run on a Windows machine.
|
Pushed e4b1364 to address my blocking review: |
Fixes #164
Stacked on #144; review
9569226..fc3cb2ffor this change alone -- three commits;fc3cb2freads the installed image's mode off the open descriptor (fstat) rather thanstat-ing the path the test then removes, which CodeQL flagged as check-then-use;0b10524clears the CodeQL check-run (two high alerts: the installed image and a test fixture were created withfopen("wb")'s 0666; both areopen(O_CREAT, 0600)+fdopen()now).990d862alone was three files:services/os/src/os_services.c,tests/test_os_services.c, one CHANGELOG line at the top of### Fixed. Merge #144 first. This is #156's review finding 1 (Critical, pre-existing), done as the separate PR that review asked for; it does not depend on #156 because it does not needEosShellCmd-- there is no shell line left to build.The defect
eos_ota_download()andeos_ota_install()builtwget -q -O "%s" "%s"andcp -f "%s" "%s"withsnprintfand handed them tosystem(), each behind its own denylist with its own holes. The URL's omitted"and&: a URL ofhttp://example.invalid/fw.bin" & touch probe & "left the quoting, ran the command, then reported a failed download. The target path's omitted the backtick, which is command substitution inside double quotes: a target ofx`touch probe`ran the command and reportedEOS_OTA_COMPLETE. Both were demonstrated by execution in review; the first is reproduced here by the new test's negative control.The fix
Neither step needs a shell, so neither uses one:
wget(POSIX) orcurl(Windows) with an argv throughposix_spawnp()/_spawnvp(); nothing is parsed as shell syntax whatever the URL holds, andlocal_path(unvalidated before; fromTEMPon Windows) is an argv element too;fopen/fread/fwrite, errors checked,fcloseresult checked).The only check left on the URL is what the downloader would do with it:
url_is_fetchable()requireshttp://orhttps://(so-O…cannot be read as an option andfile://…is not a download) and RFC 3986 URL characters only (unreserved, gen-delims, sub-delims,%; no space, control, non-ASCII,",\, backtick,<>{}|^). The sub-delims a signed URL carries --&,;,=,%,'-- are allowed, because they were only ever dangerous to the shell that is no longer there. I chose the no-shell route over routing the two lines throughEosShellCmdbecauseeos_shell_cmd_arg()rightly refuses∧, and an OTA URL with a query string is a normal thing to fetch.Verification
cmake -B build -DEOS_BUILD_TESTS=ON→cmake --buildctest --test-dir buildtests/test_os_services-O…,file://,ftp://, a backtick, a space, a control character and the empty string are all refused withstate == EOS_OTA_FAILEDand the sentinel absent; a URL with?X-Sig=a%2Fb&Expires=1;v=2passes the shape check (the fetch then starts, which is observable throughlocal_pathbeing set); the install copies a payload to a target namedeos_ota_test_target`touch eos_ota_probe_install`byte for byte,EOS_OTA_COMPLETE, sentinel absent; a missing source, a missinglocal_path,NULLand""targets are refusedgit show 9569226:services/os/src/os_services.crestored, rebuilt, reruntest_ota_download_refuses_a_url_that_is_not_an_http_urlfails attests/test_os_services.c:214: !file_exists(OTA_SENTINEL_DL)-- the injectedtouchranpytest tests/unit -qNot done
eos_ota_verify()still compares hex strings withstrcmp; unchanged.wgeton POSIX) is as it was; a host withoutwgetfails the fetch as before, now withEOS_OTA_FAILEDfromposix_spawnprather than from a shell.eos_pkg.cpkill/rm -rflines fed from the package DB) is being handled on fix(pkg): a .eapp header names files, it does not name paths #163, which owns that file.CodeQL (check-run, not the Analyze job): two high alerts on
990d862--copy_file()created the OTA target withfopen(dst, "wb")(0666 masked by umask) and the install test's fixture did the same. Cleared in0b10524:open(O_WRONLY|O_CREAT|O_TRUNC, 0600)+fdopen()behind a helper,fopenkept for the Windows branch. 0600 is deliberate for the image: the updater writes it and reads it back for verification, so no other uid needs it, and firmware is not something other users should read; a consumer that needs it wider is a policy decision of its own. The install test asserts(st_mode & 077) == 0and was run underumask 000. Same class asbba834aand8dca787.