Skip to content

Commit 7970600

Browse files
authored
Fix decode error handling: PyList leak, unbounded preallocation, and swallowed errors in decode_dag_cbor_multi (#117)
1 parent eb9f064 commit 7970600

3 files changed

Lines changed: 87 additions & 35 deletions

File tree

pytests/test_dag_cbor.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,25 @@ def test_roundtrip_valid_cid_with_short_tag() -> None:
277277
encoded = libipld.encode_dag_cbor(decoded)
278278

279279
assert encoded == encoded_bytes
280+
281+
282+
def test_dag_cbor_decode_array_length_exceeds_data_error() -> None:
283+
# 9b0000000040000000 - array claiming 2**30 elements with no payload
284+
with pytest.raises(ValueError) as exc_info:
285+
libipld.decode_dag_cbor(bytes.fromhex('9b0000000040000000'))
286+
287+
assert 'Array length exceeds remaining data' in str(exc_info.value)
288+
289+
290+
def test_dag_cbor_decode_map_length_exceeds_data_error() -> None:
291+
# bb0000000040000000 - map claiming 2**30 entries with no payload
292+
with pytest.raises(ValueError) as exc_info:
293+
libipld.decode_dag_cbor(bytes.fromhex('bb0000000040000000'))
294+
295+
assert 'Map length exceeds remaining data' in str(exc_info.value)
296+
297+
298+
def test_dag_cbor_decode_error_mid_array() -> None:
299+
# 83010263616263 - [1, 2, "abc"] truncated after the second element's header
300+
with pytest.raises(ValueError):
301+
libipld.decode_dag_cbor(bytes.fromhex('8301026361'))

pytests/test_decode_dag_cbor_multi.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,21 @@ def test_decode_dag_cbor_multi(data) -> None:
3636
decoded = libipld.decode_dag_cbor_multi(encoded)
3737
assert len(decoded) == len(objects)
3838
assert decoded == objects
39+
40+
41+
def test_decode_dag_cbor_multi_corrupt_trailing_data_error() -> None:
42+
encoded = libipld.encode_dag_cbor({'abc': 1})
43+
44+
with pytest.raises(ValueError) as exc_info:
45+
# 9b0000000040000000 - array claiming 2**30 elements with no payload
46+
libipld.decode_dag_cbor_multi(encoded + bytes.fromhex('9b0000000040000000'))
47+
48+
assert 'Failed to decode DAG-CBOR' in str(exc_info.value)
49+
50+
51+
def test_decode_dag_cbor_multi_truncated_object_error() -> None:
52+
encoded = libipld.encode_dag_cbor({'abc': 1})
53+
54+
with pytest.raises(ValueError):
55+
# second object is truncated mid-string
56+
libipld.decode_dag_cbor_multi(encoded + bytes.fromhex('6361'))

src/dag_cbor/de.rs

Lines changed: 47 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,22 @@ where
6464
.into()
6565
}
6666
major::ARRAY => {
67-
let len: ffi::Py_ssize_t = types::Array::len(r)?
68-
.ok_or_else(|| anyhow!("Array must contain length"))?
69-
.try_into()?;
67+
let len = types::Array::len(r)?.ok_or_else(|| anyhow!("Array must contain length"))?;
68+
// Every element costs at least one byte; reject a claimed length
69+
// beyond the remaining input before allocating for it.
70+
if r.fill(len)?.as_ref().len() < len {
71+
return Err(anyhow!("Array length exceeds remaining data"));
72+
}
73+
let len: ffi::Py_ssize_t = len.try_into()?;
7074

7175
unsafe {
7276
let ptr = ffi::PyList_New(len);
77+
if ptr.is_null() {
78+
return Err(anyhow!(PyErr::fetch(py)));
79+
}
80+
// Owned before filling so an error mid-fill releases the list;
81+
// list dealloc tolerates the remaining NULL slots.
82+
let list: Bound<'_, PyList> = Bound::from_owned_ptr(py, ptr).cast_into_unchecked();
7383

7484
for i in 0..len {
7585
ffi::PyList_SET_ITEM(
@@ -79,12 +89,17 @@ where
7989
);
8090
}
8191

82-
let list: Bound<'_, PyList> = Bound::from_owned_ptr(py, ptr).cast_into_unchecked();
8392
list.into_pyobject(py)?.into()
8493
}
8594
}
8695
major::MAP => {
8796
let len = types::Map::len(r)?.ok_or_else(|| anyhow!("Map must contain length"))?;
97+
// Every entry costs at least two bytes (key + value); reject a
98+
// claimed length beyond the remaining input before presizing.
99+
let need = len.saturating_mul(2);
100+
if r.fill(need)?.as_ref().len() < need {
101+
return Err(anyhow!("Map length exceeds remaining data"));
102+
}
88103
// Length is known up front; presize to avoid rehashes as we fill.
89104
let dict = unsafe {
90105
let ptr = new_presized(len);
@@ -189,18 +204,28 @@ where
189204
})
190205
}
191206

207+
// Wrap a decode failure; an error already set on the interpreter (e.g. the
208+
// RecursionError `restore`d above) wins, with the decode error as its cause.
209+
fn decode_error(py: Python, e: anyhow::Error) -> PyErr {
210+
let err = value_error("Failed to decode DAG-CBOR", e.to_string());
211+
if let Some(py_err) = PyErr::take(py) {
212+
py_err.set_cause(py, Option::from(err));
213+
py_err
214+
} else {
215+
err
216+
}
217+
}
218+
192219
#[pyfunction]
193220
pub fn decode_dag_cbor_multi<'py>(py: Python<'py>, data: &[u8]) -> PyResult<Bound<'py, PyList>> {
194221
let mut reader = SliceReader::new(data);
195222
let decoded_parts = PyList::empty(py);
196223
let max_depth = current_recursion_limit();
197224

198-
loop {
199-
let py_object = to_pyobject(py, &mut reader, 0, max_depth);
200-
if let Ok(py_object) = py_object {
201-
decoded_parts.append(py_object)?;
202-
} else {
203-
break;
225+
while !reader.fill(1)?.as_ref().is_empty() {
226+
match to_pyobject(py, &mut reader, 0, max_depth) {
227+
Ok(py_object) => decoded_parts.append(py_object)?,
228+
Err(e) => return Err(decode_error(py, e)),
204229
}
205230
}
206231

@@ -211,31 +236,18 @@ pub fn decode_dag_cbor_multi<'py>(py: Python<'py>, data: &[u8]) -> PyResult<Boun
211236
pub fn decode_dag_cbor(py: Python, data: &[u8]) -> PyResult<Py<PyAny>> {
212237
let mut reader = SliceReader::new(data);
213238
let max_depth = current_recursion_limit();
214-
let py_object = to_pyobject(py, &mut reader, 0, max_depth);
215-
if let Ok(py_object) = py_object {
216-
// check for any remaining data in the reader
217-
if reader.fill(1)?.as_ref().is_empty() {
218-
Ok(py_object)
219-
} else {
220-
Err(value_error(
221-
"Failed to decode DAG-CBOR",
222-
"Invalid DAG-CBOR: contains multiple objects (CBOR sequence)".to_string(),
223-
))
224-
}
225-
} else {
226-
let err = value_error(
227-
"Failed to decode DAG-CBOR",
228-
py_object.unwrap_err().to_string(),
229-
);
230-
231-
if let Some(py_err) = PyErr::take(py) {
232-
py_err.set_cause(py, Option::from(err));
233-
// in case something set global interpreter’s error,
234-
// for example C FFI function, we should return it
235-
// the real case: RecursionError (set by Py_EnterRecursiveCall)
236-
Err(py_err)
237-
} else {
238-
Err(err)
239+
match to_pyobject(py, &mut reader, 0, max_depth) {
240+
Ok(py_object) => {
241+
// check for any remaining data in the reader
242+
if reader.fill(1)?.as_ref().is_empty() {
243+
Ok(py_object)
244+
} else {
245+
Err(value_error(
246+
"Failed to decode DAG-CBOR",
247+
"Invalid DAG-CBOR: contains multiple objects (CBOR sequence)".to_string(),
248+
))
249+
}
239250
}
251+
Err(e) => Err(decode_error(py, e)),
240252
}
241253
}

0 commit comments

Comments
 (0)