SurrealML .surml header parser: uncaught panics on load (DoS)
Target: https://github.com/surrealdb/surrealml
Format: .surml (SurrealML's custom model container format)
Affected component: modules/core/src/storage/surml_file.rs (SurMlFile::from_file / from_bytes) and modules/core/src/storage/header/*.rs
Summary
A .surml file is [4-byte BE header length][header string][raw model bytes]. The header is a
//=>-delimited string. Several of the header's sub-field parsers use unchecked unwrap() /
unchecked indexing instead of the crate's own safe_eject! / safe_eject_option! error-propagation
macros used everywhere else in the same file. A single malformed field causes an uncaught Rust panic
while loading the file, with no way for the caller to catch it.
The only code path that reaches this parser is SurMlFile::from_file, which is called from the
extern "C" fn load_model in modules/c-wrapper/src/api/storage/load_model.rs โ the FFI entry point
used by the Python client (ctypes) and the TypeScript client, and by any host embedding this library
(e.g. a database loading a user-supplied .surml model). That function has no catch_unwind wrapper.
A Rust panic is not permitted to unwind across an extern "C" boundary, so the runtime aborts the
entire host process instead of returning an error โ there is no exception for the Python/TS caller
to catch.
Confirmed panic sites
All four were reproduced against the real, unmodified surrealml-core crate (compiled from the
public repo, no source changes) by calling SurMlFile::from_file directly.
| PoC file | Location | Trigger | Panic |
|---|---|---|---|
poc_1_input_dims_no_comma.surml |
modules/core/src/storage/header/input_dims.rs:36 |
input_dims field has no comma (e.g. "5") |
index out of bounds on dims[1] |
poc_2_input_dims_non_numeric.surml |
modules/core/src/storage/header/input_dims.rs:34 |
input_dims field is non-numeric (e.g. "x,y") |
unwrap() on ParseIntError |
poc_3_origin_no_arrow.surml |
modules/core/src/storage/header/origin.rs:114-115 |
origin field is non-empty with no => separator |
unwrap() on None |
poc_4_output_unknown_normaliser.surml |
modules/core/src/storage/header/output.rs:73 |
output field names a normaliser type that isn't one of the 4 known types | unwrap() on Err("Unknown normaliser type: ...") |
Each PoC file is 62-97 bytes: an empty/placeholder model payload plus a header string where every field is empty except the one field needed to trigger that specific panic.
Reproduction
// Cargo.toml: surrealml-core = { path = "<repo>/modules/core", default-features = false }
use surrealml_core::storage::surml_file::SurMlFile;
fn main() {
let path = std::env::args().nth(1).unwrap();
let result = std::panic::catch_unwind(|| SurMlFile::from_file(&path));
match result {
Ok(Ok(_)) => println!("loaded ok"),
Ok(Err(e)) => println!("clean error: {e}"),
Err(_) => println!("PROCESS PANIC"),
}
}
Running this against any of the four PoC files prints a panic originating at the exact file:line listed above, e.g.:
thread 'main' panicked at .../storage/header/output.rs:73:57:
called `Result::unwrap()` on an `Err` value: SurrealError { message: "Unknown normaliser type: totally_bogus_normaliser", ... }
Equivalently, from Python, SurMlFile.load("poc_1_input_dims_no_comma.surml", engine=...) aborts the
interpreter process outright (SIGABRT) rather than raising a catchable RuntimeError.
Impact
- Denial of Service. Any service that loads a
.surmlfile supplied by another party โ a multi-tenant database accepting model uploads, a marketplace of shared models, a client library loading a downloaded file โ can be crashed by a file well under 100 bytes. Because the crash is a process abort at the FFI boundary, it cannot be caught or recovered from by the embedding application; the whole process goes down, not just the load call. - This is a single root cause repeated across independent header sub-parsers (input dims, origin,
output/normaliser): missing input validation before
unwrap()/indexing, in code that otherwise consistently uses the crate's ownsafe_eject!/safe_eject_option!macros for exactly this purpose elsewhere in the same files.
Suggested fix
Replace the bare unwrap() calls and unchecked indexing in input_dims.rs, origin.rs, and
output.rs with the same safe_eject! / safe_eject_option! macros already used throughout
modules/core/src/storage/header/, so malformed fields produce a SurrealError instead of a panic.
For input_dims.rs specifically, validate dims.len() == 2 before indexing.