Repository navigation
fix(security): do not follow container-planted links in logs, build output or cache - #269
Conversation
…utput or cache The executor reads files that a runtime or build container can write: execution logs in /mnt/logs, build logs in /tmp/logging, the build output and the build cache. Every read followed symlinks, and the execution log id from the x-open-runtimes-log-id header was used unchecked. A function could point its log file at /proc/self/environ and get the executor's environment, including OPR_EXECUTOR_SECRET, back as its logs. RegularFile only reads an entry that lstat says is a regular file and that fstat confirms is the same inode once opened. Build output and cache uploads are re-checked afterwards, and the log id must be alphanumeric. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
🟢 Tier S · Ready to mergeAdds a regular-file-only reader and copier for files writable by runtime or build containers, then uses them for execution logs, build logs, build outputs, and cache artifacts. Adds unit and end-to-end coverage for planted symlink cases.
📂 Walkthrough · 7
🔇 Filtered out · 1Findings Hansi considered but did not post.
Reviewed |
Checking the path and re-checking it after the upload still left a window: the build container is running, so it could swap a symlink in for the upload's own open and put the original back before the re-check. RegularFile::copy now copies from the handle that passed the checks into /tmp/<runtime>, which no container mounts, and the upload reads that copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@hansi-codes review |
The findings that requested changes are resolved. See the summary comment for what is still open.
Existence checks only learn whether an entry is there, so they stay plain file_exists. A build output that is missing or not a regular file fails the same way it always failed when missing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The build-output and cache copies are removed after upload, on failure too, so a runtime kept after its build doesn't hold a second copy of its artifact. E2E: a build that leaves a symlink as its output gets no upload, and a function that swaps its log files for symlinks gets none of the executor's environment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@hansi-codes review |
A build swaps its cache artifact for a link to /proc/self/environ, and the next build with the same key prints what was restored. Against main it prints the executor's environment; on this branch nothing is stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@hansi-codes review |
|
Published executor 1.3.1 from merge commit Appwrite adoption is in appwrite/appwrite#15310, assigned to @loks0n. Compose validation and diff checks pass. Its CI/review and merge are the remaining consumer steps. |
Security: the executor follows symlinks a runtime or build plants
The executor reads several files from directories that a runtime or build container mounts read-write. Every one of those reads followed symlinks:
createExecution)/mnt/logs/<id>_logs.log,_errors.logLogs::get,getLogsstream)/tmp/logging/logs.txt/mnt/code/code.tar.gz(or.tar,.sqfs,.erofs)/cache/stores.sqfsThe execution log id also comes straight from the runtime's
x-open-runtimes-log-idresponse header, unchecked, so../in it walked anywhere the executor can read.A function that replaces its log file with
ln -sf /proc/self/environgets the executor's environment back as its logs, includingOPR_EXECUTOR_SECRET. With that secret, code running in a function can call the executor API itself. On a self-hosted install, the executor drives Docker, so that is very likely root on the host. Any install where people who aren't fully trusted can deploy functions or sites is exposed.This is the same class of bug as one exploited against Appwrite Cloud's executor on 1 November, which has been fixed there.
Fix
RegularFilereads only an entry thatlstatsays is a regular file, and only if opening it non-blocking ('rbn') lands on the same device and inode. So a symlink, FIFO, device or directory reads as empty, and so does a file swapped for a link between the check and the open. A FIFO can't hang the executor.RegularFile::readand are then unlinked. Unlinking a symlink only removes the link, never its target. The log id must be alphanumeric. The runtimes generate hex ids, so nothing legitimate changes.Logs::getand thegetLogsstream readlogs.txtthroughRegularFile.RegularFile::copycopies the output from the handle that passed the checks into/tmp/<runtime>/, which no container mounts, and the upload reads that copy, which is removed afterwards. The path is never reopened by name, so a swap after the check can't change what gets uploaded.stores.sqfsis skipped. Otherwise it's uploaded from a private copy, the same way as the build output.Tests
RegularFileTestcovers:copycopies a regular file, refuses a symlink, and won't write over an entry already at its destination.testBuildLogsDoNotFollowASymlinkTheBuildPlanteddrivesLogs::get, the function the build path calls.Logs.phprestored, that test fails and returnsOPR_EXECUTOR_SECRET=leakedas build log content. Withlstatswapped forstatinRegularFile, the symlink read and copy tests fail.testExecutionLogSymlinkIsNotFollowed: a function swaps its log files for links to/proc/self/environ, and the test asserts noOPR_EXECUTOR_SECRETcomes back.testBuildOutputSymlinkIsNotUploaded: a build leaves a symlink ascode.tar.gz, and the test asserts nothing is uploaded.testBuildCacheSymlinkIsNotStored: a build swaps its cache artifact for a symlink, and a second build with the same key prints what was restored.main, the build test gets a 201, the log test returns the executor's environment (test secret included) as logs, and the cache test restores and prints it.composer test:unit,format:check,analyze,refactor:checkthe three new E2E tests andtestBuildCachepass locally.After merge
Release, then bump the executor in appwrite/appwrite so self-hosted installs get it.
🤖 Generated with Claude Code