#21610·curl

fopen: `0c667188e0` regression — destination file truncated before temp-file rename, symlink target truncated without O_NOFOLLOW

Author: MegaManSecCreated May 14, 2026Updated Jun 26, 2026

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:

bash
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 bytes

Same 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:

bash
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:

  1. lstat(filename) — check type and capture mode bits without opening anything; treat ENOENT as "new file"
  2. Reject non-regular files before touching anything
  3. Create the temp file with O_NOFOLLOW | O_CREAT | O_EXCL | 0600
  4. Inherit original mode bits (only if the file existed and is owned by the current user) via fchmod on the temp fd after opening
  5. 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?