fopen: `0c667188e0` regression — destination file truncated before temp-file rename, symlink target truncated without O_NOFOLLOW
I did this
Commit 0c667188e0 ("fopen: optimize", 2023-07-10) restructured Curl_fopen() so that curlx_fopen(filename, "w") runs unconditionally as the first operation, before any stat check or temp-file logic. This introduced two regressions on non-Windows builds.
Regression 1: existing file truncated if the write later fails
Before 0c667188e0, stat(filename) was called first. If the file was a regular file, the code went straight to creating a temp file — the original was never opened or truncated before the rename. After the commit, fopen(filename, "w") truncates the destination immediately. If anything fails afterward (directory not writable, temp-file creation fails, file-size limit hit), the original is already gone.
Reproducer:
WORK=$(mktemp -d)
cat > "$WORK/hsts.txt" <<'EOF'
# Your HSTS cache. https://curl.se/docs/hsts.html
# This file was generated by libcurl! Edit at your own risk.
example.com "29991231 23:59:59"
.example.org "29991231 23:59:59"
secure.example.net "29991231 23:59:59"
EOF
echo "BEFORE: $(wc -c < "$WORK/hsts.txt") bytes"
chmod 0555 "$WORK" # make rename impossible
./src/curl --max-time 2 -s -o /dev/null --hsts "$WORK/hsts.txt" "http://127.0.0.1:1/" || true
chmod 0755 "$WORK"
echo "AFTER: $(wc -c < "$WORK/hsts.txt") bytes"
rm -rf "$WORK"Output (current master):
BEFORE: 197 bytes
AFTER: 0 bytesSame pattern with ulimit -f 2 (RLIMIT_FSIZE) — a large HSTS cache is reduced to 0 bytes with no error returned to the caller.
Regression 2: symlink target truncated (no O_NOFOLLOW)
Before 0c667188e0, for a regular file reachable via a symlink, stat() was used first (no truncation). The subsequent rename(tempfile, filename) replaces the symlink path itself, not the target. After the commit, fopen(filename, "w") follows the symlink and truncates the target before the type check runs. The subsequent rename then replaces the symlink, but the damage is already done.
Reproducer:
WORK=$(mktemp -d)
printf 'DO NOT TRUNCATE\n' > "$WORK/other.txt"
ln -s "$WORK/other.txt" "$WORK/hsts.txt"
echo "BEFORE: $(cat "$WORK/other.txt")"
./src/curl --max-time 2 -s -o /dev/null --hsts "$WORK/hsts.txt" "http://127.0.0.1:1/" || true
echo "AFTER: $(cat "$WORK/other.txt")"
rm -rf "$WORK"Output:
BEFORE: DO NOT TRUNCATE
AFTER: (empty)Fix
Both issues share the same root: the destination path is opened (and truncated) before it's been validated. The correct structure is:
lstat(filename)— check type and capture mode bits without opening anything; treatENOENTas "new file"- Reject non-regular files before touching anything
- Create the temp file with
O_NOFOLLOW | O_CREAT | O_EXCL | 0600 - Inherit original mode bits (only if the file existed and is owned by the current user) via
fchmodon the temp fd after opening - Write, flush, then rename
This restores the pre-0c667188e0 invariant that the original file is never modified until the rename succeeds.
Relevant code: lib/curl_fopen.c:99 (curlx_fopen call), callers at lib/hsts.c:368, lib/altsvc.c:407, lib/cookie.c:1481.
I expected the following
no truncation, no symlink-followed-to-truncated either
curl/libcurl version
master
operating system
all maybe?
Source: curl/curl