Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 80 additions & 9 deletions .github/scripts/iccdev-tiffdump-output-hardening-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ TOOLS="${ICCDEV_TOOLS_DIR:-$REPO_ROOT/Build/Tools}"
ICCDEV_TESTING="${ICCDEV_TESTING_DIR:-$REPO_ROOT/Testing}"
OUTDIR="${ICCDEV_TEST_OUTDIR:-/tmp/iccdev-tiffdump-output-hardening}"
TIFFDUMP="$TOOLS/IccTiffDump/iccTiffDump"
TOXML="$TOOLS/IccToXml/iccToXml"

mkdir -p "$OUTDIR"

Expand Down Expand Up @@ -75,7 +76,7 @@ newline.icc"
"$TIFFDUMP" "$SAMPLE_TIFF" "$dst" > "$log" 2>&1

[ -s "$dst" ] || return 1
grep -Fq 'Profile extracted to: '"$OUTDIR"'/export_with_\nnewline.icc' "$log"
grep -Fq 'Profile extracted byte-for-byte to: '"$OUTDIR"'/export_with_\nnewline.icc' "$log"
}

test_extra_arg_rejected() {
Expand Down Expand Up @@ -106,8 +107,9 @@ test_missing_embedded_profile_export_rejected() {
}

# ---------------------------------------------------------------------------
# #1380: palette photometric must be reported (not silently "Min Is White"), and
# a parsed embedded ICC that fails conformance must be rejected, not rewritten.
# #1380: palette photometric must be reported (not silently "Min Is White").
# Nonconformant embedded bytes must remain available as forensic artifacts even
# when parsing or validation returns a failure status.
# ---------------------------------------------------------------------------
PYTHON="$(command -v python3 || true)"

Expand Down Expand Up @@ -197,6 +199,34 @@ path.write_bytes(d)
PY
}

generate_nested_icc() {
# Build an ICC.2 profile with $2 nested embeddedV5ProfileTag entries. Each
# level is structurally small, so depth rather than payload size drives the
# parser path under test.
"$PYTHON" - "$1" "$2" <<'PY'
import pathlib, struct, sys
path = pathlib.Path(sys.argv[1]); depth = int(sys.argv[2])

def header(size):
h = bytearray(128)
struct.pack_into(">I", h, 0, size)
h[8:12] = b"\x05\0\0\0"
h[12:16] = b"mntr"
h[16:20] = b"RGB "
h[20:24] = b"XYZ "
h[36:40] = b"acsp"
return h

profile = header(132) + struct.pack(">I", 0)
for _ in range(depth):
tag = b"ICCp" + b"\0\0\0\0" + profile
size = 144 + len(tag)
profile = (header(size) + struct.pack(">I", 1) + b"ICC5" +
struct.pack(">II", 144, len(tag)) + tag)
path.write_bytes(profile)
PY
}

test_palette_photometric_report() {
[ -n "$PYTHON" ] || { echo " [SKIP] python3 unavailable"; return 0; }
local pal="$OUTDIR/palette.tif"
Expand All @@ -216,7 +246,7 @@ test_defaulted_packbits_gray_loads() {
grep -Eq 'Compression:[[:space:]]+PackBits' "$log"
}

test_noncompliant_embedded_icc_rejected() {
test_noncompliant_embedded_icc_preserved() {
[ -n "$PYTHON" ] || { echo " [SKIP] python3 unavailable"; return 0; }
local srgb="$ICCDEV_TESTING/sRGB_v4_ICC_preference.icc"
[ -f "$srgb" ] || { echo " [SKIP] missing $srgb"; return 0; }
Expand Down Expand Up @@ -244,10 +274,11 @@ PY
"$TIFFDUMP" "$tif" "$out" > "$export_log" 2>&1 || status=$?
[ "$status" -ge 1 ] && [ "$status" -le 127 ] || return 1
grep -Fq "violates the ICC specification" "$export_log" || return 1
[ ! -e "$out" ] # must NOT have written the non-conformant profile (#1380)
grep -Fq "Profile extracted byte-for-byte" "$export_log" || return 1
cmp -s "$bad" "$out"
}

test_malformed_embedded_icc_rejected() {
test_malformed_embedded_icc_preserved() {
[ -n "$PYTHON" ] || { echo " [SKIP] python3 unavailable"; return 0; }
local bad="$OUTDIR/malformed.icc"
local tif="$OUTDIR/rgb-malformed-icc.tif"
Expand All @@ -271,7 +302,45 @@ PY
"$TIFFDUMP" "$tif" "$out" > "$export_log" 2>&1 || status=$?
[ "$status" -ge 1 ] && [ "$status" -le 127 ] || return 1
grep -Fq "Unable to open embedded ICC profile" "$export_log" || return 1
[ ! -e "$out" ]
grep -Fq "Profile extracted byte-for-byte" "$export_log" || return 1
cmp -s "$bad" "$out"
}

test_nested_embedded_icc_is_bounded_and_preserved() {
[ -n "$PYTHON" ] || { echo " [SKIP] python3 unavailable"; return 0; }
local nested="$OUTDIR/nested-depth-512.icc"
local tif="$OUTDIR/nested-depth-512.tif"
local out="$OUTDIR/extracted-nested-depth-512.icc"
local log="$OUTDIR/tiffdump-nested-depth-512.log"
local status=0

generate_nested_icc "$nested" 512 || return 1
generate_tiff_with_icc "$tif" "$nested" || return 1
rm -f "$out"

timeout 10 "$TIFFDUMP" "$tif" "$out" > "$log" 2>&1 || status=$?
[ "$status" -ne 124 ] || { echo " iccTiffDump timed out"; return 1; }
[ "$status" -ge 1 ] && [ "$status" -le 127 ] || return 1
grep -Fq "Profile extracted byte-for-byte" "$log" || return 1
grep -Fq "Subprofile recursion halted" "$log" || return 1
cmp -s "$nested" "$out"
}

test_recursive_full_tag_read_is_bounded() {
[ -n "$PYTHON" ] || { echo " [SKIP] python3 unavailable"; return 0; }
[ -x "$TOXML" ] || { echo " [SKIP] missing executable: $TOXML"; return 0; }
local nested="$OUTDIR/readtags-nested-depth-512.icc"
local xml="$OUTDIR/readtags-nested-depth-512.xml"
local log="$OUTDIR/readtags-nested-depth-512.log"
local status=0

generate_nested_icc "$nested" 512 || return 1
rm -f "$xml"

timeout 10 "$TOXML" "$nested" "$xml" > "$log" 2>&1 || status=$?
[ "$status" -ne 124 ] || { echo " iccToXml timed out"; return 1; }
[ "$status" -ge 1 ] && [ "$status" -le 127 ] || return 1
grep -Fq "Unable to read" "$log"
}

echo "=== iccTiffDump output hardening regression ==="
Expand All @@ -282,8 +351,10 @@ run_ok "tiffdump-extra-arg-reject" test_extra_arg_rejected
run_ok "tiffdump-no-profile-export-reject" test_missing_embedded_profile_export_rejected
run_ok "tiffdump-palette-photometric-report" test_palette_photometric_report
run_ok "tiffdump-defaulted-packbits-gray-load" test_defaulted_packbits_gray_loads
run_ok "tiffdump-noncompliant-embedded-icc-reject" test_noncompliant_embedded_icc_rejected
run_ok "tiffdump-malformed-embedded-icc-reject" test_malformed_embedded_icc_rejected
run_ok "tiffdump-noncompliant-embedded-icc-preserve" test_noncompliant_embedded_icc_preserved
run_ok "tiffdump-malformed-embedded-icc-preserve" test_malformed_embedded_icc_preserved
run_ok "tiffdump-nested-embedded-icc-bounded-preserve" test_nested_embedded_icc_is_bounded_and_preserved
run_ok "embedded-profile-full-tag-read-bounded" test_recursive_full_tag_read_is_bounded

echo "iccTiffDump output hardening regression: $pass passed, $fail failed, $((pass + fail)) total"

Expand Down
4 changes: 2 additions & 2 deletions .github/scripts/iccdev-tool-coverage-baseline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -993,7 +993,7 @@ else
fi

if [ -f "$REPO_ROOT/.github/ci/test-data/spectral/spec_1" ]; then
run_expect_exit "tdump-06b" "Reject ICC export when TIFF has no profile" 255 \
run_expect_exit "tdump-06b" "Reject ICC export when TIFF has no profile" 1 \
"$TIFFDUMP" "$REPO_ROOT/.github/ci/test-data/spectral/spec_1" "$OUTDIR/tiff_no_profile.icc"
else
skip_test "tdump-06b" "Reject ICC export when TIFF has no profile" "no-profile TIFF fixture unavailable"
Expand Down Expand Up @@ -1059,7 +1059,7 @@ newline.icc"
"$tool" "$odd" "$dst" > "$log" 2>&1
[ -s "$dst" ]
grep -Fq "Filename: $outdir/name_with_\\nnewline.tif" "$log"
grep -Fq "Profile extracted to: $outdir/export_with_\\nnewline.icc" "$log"
grep -Fq "Profile extracted byte-for-byte to: $outdir/export_with_\\nnewline.icc" "$log"
' _ "$ICCDEV_TESTING/hybrid/Data/TShirtDesignKW.tif" "$TIFFDUMP" "$OUTDIR"
else
skip_test "tdump-08" "Escape controlled ICC description text" "TShirtDesignKW TIFF fixture unavailable"
Expand Down
16 changes: 15 additions & 1 deletion IccProfLib/IccProfile.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -976,7 +976,21 @@ bool CIccProfile::loadTags(CIccProfile *pProfile, IccLoadTagsMode mode)
*/
bool CIccProfile::ReadTags(CIccProfile* pProfile)
{
return loadTags(pProfile, icLoadTagsFull);
static thread_local unsigned int readTagsDepth = 0;
static const unsigned int maxReadTagsDepth = 8;

if (readTagsDepth >= maxReadTagsDepth)
return false;

class ReadTagsDepthGuard {
public:
ReadTagsDepthGuard(unsigned int &depth) : m_depth(depth) { m_depth++; }
~ReadTagsDepthGuard() { m_depth--; }
private:
unsigned int &m_depth;
} depthGuard(readTagsDepth);

return loadTags(pProfile, icLoadTagsFull);
}

/**
Expand Down
9 changes: 9 additions & 0 deletions Tools/CmdLine/IccTiffDump/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ Extract an embedded ICC profile:
iccTiffDump image.tif embedded.icc
```

Extraction copies the TIFF ICC profile field byte-for-byte before profile
parsing and validation. This preserves malformed profiles for diagnostics and
does not rewrite tag offsets, padding, or the profile ID. A later parse or
validation failure is still reported with a nonzero exit status, but does not
remove the extracted forensic artifact. Output is written to a sibling
temporary file and atomically renamed only after the complete write succeeds.
Existing regular files may be replaced atomically; device files, directories,
and symbolic links are rejected as extraction destinations.

The no-argument form is a help/syntax path and exits successfully. Other
malformed invocations fail: extra trailing arguments are rejected, missing input
files fail, and export requests fail when the TIFF has no embedded ICC profile.
Expand Down
Loading
Loading