diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt
index aca5cb63c8..96f42a0f8c 100644
--- a/cmake/CMakeLists.txt
+++ b/cmake/CMakeLists.txt
@@ -96,6 +96,11 @@ if(BUILD_BONSAIVIEWER AND NOT BUILD_BONSAIVIEWER_WGPU)
endif()
option(BUILD_PACKAGE "" OFF)
+option(
+ BUILD_FUZZERS
+ "Build libFuzzer security-fuzzing harnesses. Requires a Clang toolchain configured with -fsanitize=fuzzer (typically also address,undefined) via CMAKE_CXX_FLAGS."
+ OFF
+)
option(
IFCOPENSHELL_DEPLOY_QT_RUNTIME
"Deploy Qt runtime dependencies for installed Qt applications."
@@ -749,6 +754,10 @@ if(BUILD_BONSAIVIEWER)
add_subdirectory(../src/bonsaiviewer bonsaiviewer)
endif()
+if(BUILD_FUZZERS)
+ add_subdirectory(../src/ifcfuzz ifcfuzz)
+endif()
+
# Cmake uninstall target
if(NOT TARGET uninstall)
configure_file(
diff --git a/src/ifcfuzz/CMakeLists.txt b/src/ifcfuzz/CMakeLists.txt
new file mode 100644
index 0000000000..cda383f915
--- /dev/null
+++ b/src/ifcfuzz/CMakeLists.txt
@@ -0,0 +1,37 @@
+################################################################################
+# #
+# This file is part of IfcOpenShell. #
+# #
+# IfcOpenShell is free software: you can redistribute it and/or modify #
+# it under the terms of the Lesser GNU General Public License as published by #
+# the Free Software Foundation, either version 3.0 of the License, or #
+# (at your option) any later version. #
+# #
+# IfcOpenShell is distributed in the hope that it will be useful, #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
+# Lesser GNU General Public License for more details. #
+# #
+# You should have received a copy of the Lesser GNU General Public License #
+# along with this program. If not, see . #
+# #
+################################################################################
+
+# libFuzzer harness(es) for IfcOpenShell. Only built when BUILD_FUZZERS is ON,
+# which is expected to be paired with a Clang toolchain configured with
+# -fsanitize=fuzzer (and typically also address,undefined) in
+# CMAKE_CXX_FLAGS - this target does not add sanitizer flags itself.
+
+include_directories("${CMAKE_SOURCE_DIR}/../src")
+
+add_executable(ifcparse_fuzzer ifcparse_fuzzer.cpp)
+target_include_directories(ifcparse_fuzzer PRIVATE "${CMAKE_SOURCE_DIR}/../src")
+target_link_libraries(ifcparse_fuzzer PRIVATE IfcParse)
+
+# -fsanitize=fuzzer supplies its own main() and libFuzzer's driver, so it
+# must stay scoped to this one executable rather than going in the global
+# CMAKE_CXX_FLAGS - every other target (including CMake's own compiler
+# checks) would otherwise fail to link. ASan/UBSan, by contrast, are applied
+# globally via CMAKE_CXX_FLAGS so that IfcParse itself is instrumented.
+target_compile_options(ifcparse_fuzzer PRIVATE -fsanitize=fuzzer)
+target_link_options(ifcparse_fuzzer PRIVATE -fsanitize=fuzzer)
diff --git a/src/ifcfuzz/README.md b/src/ifcfuzz/README.md
new file mode 100644
index 0000000000..df1a07d157
--- /dev/null
+++ b/src/ifcfuzz/README.md
@@ -0,0 +1,82 @@
+# ifcparse_fuzzer
+
+A libFuzzer harness for `IfcParse::IfcFile`. It parses fuzzer input entirely
+in-memory (no subprocess, no temp files), then walks every parsed instance
+and calls `toString()` on it to force full lazy attribute evaluation -
+IfcOpenShell only tokenizes/evaluates on demand, so just constructing
+`IfcFile` barely exercises the parser.
+
+Disabled by default (`BUILD_FUZZERS=OFF`); building it needs Clang, not GCC.
+
+## Build
+
+libFuzzer (`-fsanitize=fuzzer`) is only implemented by Clang, and it
+supplies its own `main()`, so it has to be built in its own directory,
+separate from any normal GCC build of IfcOpenShell - putting
+`-fsanitize=fuzzer` in the global flags would break every other target,
+including CMake's own compiler check. That's why `BUILD_FUZZERS` only adds
+`-fsanitize=fuzzer` to this one target (see `CMakeLists.txt`); ASan/UBSan
+are applied globally instead, so that `IfcParse` itself is instrumented.
+
+```bash
+mkdir -p build-fuzz && cd build-fuzz
+cmake ../cmake \
+ -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \
+ -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all -g -O1 -fno-omit-frame-pointer" \
+ -DCMAKE_C_FLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all -g -O1 -fno-omit-frame-pointer" \
+ -DBUILD_FUZZERS=ON -DMINIMAL_BUILD=ON \
+ -DBUILD_IFCGEOM=OFF -DBUILD_CONVERT=OFF -DWITH_OPENCASCADE=OFF \
+ -DBUILD_ONLY_COMMON_SCHEMAS=ON
+cmake --build . --target ifcparse_fuzzer -- -j$(nproc)
+```
+
+`-fno-sanitize-recover=all` matters: without it, most UBSan checks just log
+and continue rather than aborting, so a fuzzing session would run straight
+past real bugs without ever capturing them as a crash artifact.
+
+`-DBUILD_IFCGEOM=OFF -DBUILD_CONVERT=OFF -DWITH_OPENCASCADE=OFF
+-DMINIMAL_BUILD=ON` keep the build scoped to `IfcParse` (the code this
+harness actually exercises) so it doesn't also have to compile/instrument
+OpenCASCADE-dependent geometry code.
+
+## Run
+
+```bash
+mkdir -p corpus # or point at your own seed corpus of .ifc files
+./src/ifcfuzz/run.sh corpus/
+```
+
+`run.sh` just sets sane sanitizer defaults and execs the binary - any
+libFuzzer flag can be passed through, e.g. `-jobs=4 -workers=4` for
+parallel fuzzing, or `-runs=0 ` to run once against a specific input.
+
+No seed corpus or dictionary ships in this repo. Any small set of valid and
+invalid `.ifc` files works as a starting corpus; a dictionary of STEP/IFC
+tokens (`ISO-10303-21`, `HEADER`, common `IFCxxx` entity names, etc.) passed
+via `-dict=` measurably helps the mutator get past the header boilerplate.
+
+### Leak detection
+
+`run.sh` sets `ASAN_OPTIONS=detect_leaks=0` by default. A leak that used to
+fire on almost any malformed header (`IfcSpfLexer` allocated in
+`in_memory_file_storage::read_from_stream`, not freed if header parsing
+returned early or threw) has been fixed, but a second, narrower leak
+remains in entity attribute parsing when a syntactically valid header is
+followed by malformed entity data. libFuzzer treats a detected leak like a
+crash and halts the *entire* session on the first occurrence, so leak
+detection stays off by default until that one's fixed too. Run a separate,
+short, deliberate pass with `ASAN_OPTIONS=detect_leaks=1` instead if you're
+specifically hunting for leaks.
+
+## Minimizing and deduplicating crashes
+
+Not covered by `run.sh` - use libFuzzer's own flags directly:
+
+```bash
+./ifcparse_fuzzer -minimize_crash=1 -max_total_time=60 -exact_artifact_path=minimized crash-input
+```
+
+Sanitizer reports for two different bugs can look identical at a glance
+(same `SUMMARY` line) if the bug is a duplicated code pattern hit from
+multiple call sites - check the full symbolized stack trace, not just the
+summary, before assuming two crashes are the same bug.
diff --git a/src/ifcfuzz/ifcparse_fuzzer.cpp b/src/ifcfuzz/ifcparse_fuzzer.cpp
new file mode 100644
index 0000000000..82cca6a680
--- /dev/null
+++ b/src/ifcfuzz/ifcparse_fuzzer.cpp
@@ -0,0 +1,62 @@
+/********************************************************************************
+ * *
+ * This file is part of IfcOpenShell. *
+ * *
+ * IfcOpenShell is free software: you can redistribute it and/or modify *
+ * it under the terms of the Lesser GNU General Public License as published by *
+ * the Free Software Foundation, either version 3.0 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * IfcOpenShell is distributed in the hope that it will be useful, *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
+ * Lesser GNU General Public License for more details. *
+ * *
+ * You should have received a copy of the Lesser GNU General Public License *
+ * along with this program. If not, see . *
+ * *
+ ********************************************************************************/
+
+// libFuzzer entry point for IfcParse::IfcFile. Parses the input entirely
+// in-memory (no subprocess, no temp files) so a coverage-guided fuzzer can
+// reach the tokenizer and argument parser directly instead of only ever
+// observing IfcConvert's exit code.
+
+#include "ifcparse/IfcFile.h"
+
+#include
+#include
+#include
+#include
+
+extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
+ if (size == 0 || size > static_cast(std::numeric_limits::max())) {
+ return 0;
+ }
+
+ try {
+ IfcParse::IfcFile file(const_cast(static_cast(data)), static_cast(size));
+
+ if (file.good()) {
+ // IfcOpenShell parses lazily: merely constructing IfcFile only
+ // tokenizes the header and indexes instance byte offsets.
+ // toString() forces every attribute of every instance to be
+ // fully parsed, which is where most tokenizer/argument bugs
+ // would actually be reachable.
+ std::ostringstream discard;
+ for (const auto& entity : file) {
+ try {
+ entity.second->toString(discard);
+ } catch (const std::exception&) {
+ // Malformed attributes are expected on fuzzed input.
+ }
+ }
+ }
+ } catch (const std::exception&) {
+ // IfcException (and friends) is expected control flow for malformed
+ // input, not a bug. Only crashes caught by ASan/UBSan/libFuzzer
+ // itself - which bypass try/catch - are findings.
+ }
+
+ return 0;
+}
diff --git a/src/ifcfuzz/run.sh b/src/ifcfuzz/run.sh
new file mode 100755
index 0000000000..d66e2af769
--- /dev/null
+++ b/src/ifcfuzz/run.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# Minimal runner for ifcparse_fuzzer. See README.md for build instructions
+# and an explanation of the ASan/UBSan options set below.
+#
+# Usage: run.sh [libFuzzer args...]
+# FUZZER_BIN=./build-fuzz/ifcfuzz/ifcparse_fuzzer ./run.sh corpus/
+#
+# Env overrides:
+# FUZZER_BIN path to the built harness (default: ./build-fuzz/ifcfuzz/ifcparse_fuzzer)
+# SYMBOLIZER path to llvm-symbolizer, if not already on PATH
+
+set -euo pipefail
+
+BIN="${FUZZER_BIN:-./build-fuzz/ifcfuzz/ifcparse_fuzzer}"
+
+if [ ! -x "$BIN" ]; then
+ echo "error: fuzzer binary not found or not executable: $BIN" >&2
+ echo "build it first - see README.md in this directory" >&2
+ exit 1
+fi
+
+SYM_OPT=""
+if [ -n "${SYMBOLIZER:-}" ] && [ -x "$SYMBOLIZER" ]; then
+ SYM_OPT=":external_symbolizer_path=$SYMBOLIZER"
+fi
+
+# detect_leaks defaults OFF: entity attribute parsing can still leak when
+# malformed entity data follows a syntactically valid header (a narrower
+# case than the old header-parse leak, which has been fixed). libFuzzer
+# treats a leak like a crash and halts the whole session on the first one,
+# so leak detection needs to run as a separate, deliberate, short pass
+# instead of the default campaign mode.
+export ASAN_OPTIONS="${ASAN_OPTIONS:-abort_on_error=1:symbolize=1:detect_leaks=0$SYM_OPT}"
+export UBSAN_OPTIONS="${UBSAN_OPTIONS:-abort_on_error=1:print_stacktrace=1:symbolize=1$SYM_OPT}"
+
+exec "$BIN" "$@"