fix(arrow-cast): make b64_encode reject invalid UTF-8 from misbehaving Engine impls#10324
fix(arrow-cast): make b64_encode reject invalid UTF-8 from misbehaving Engine impls#10324bit2swaz wants to merge 4 commits into
b64_encode reject invalid UTF-8 from misbehaving Engine impls#10324Conversation
Rich-T-kid
left a comment
There was a problem hiding this comment.
Similar to what @Jefffrey mentioned here
It feels weird that we should defend in our downstream use here when we should be able to rely on the base64 engine producing correct base64 encoding (and thus valid utf8)
I don't think it's arrow-rs's responsibility to validate this. I think it's a good idea to give users the option to validate it themselves, without causing a performance regression for users who don't need that validation.
| fn test_b64_encode_rejects_invalid_utf8() { | ||
| let data: BinaryArray = vec![Some(b"hello".to_vec())].into_iter().collect(); | ||
| assert!(b64_encode(&EvilEngine, &data).is_err()); |
There was a problem hiding this comment.
I think adding similar test for the validate_utf8 = false is a good idea.
| GenericStringArray::new_unchecked(offsets, Buffer::from_vec(buffer), array.nulls().cloned()) | ||
| } | ||
| // `try_new` validates UTF-8 instead of trusting the (safe-trait) Engine. | ||
| GenericStringArray::try_new(offsets, Buffer::from_vec(buffer), array.nulls().cloned()) |
There was a problem hiding this comment.
I think it makes sense to have either an alternate function or passing in a boolean for utf8 validation.
I don't think its should be arrow-rs responsibility to validate the engine by default, especially when it comes with performance penalty.
maybe something similar to
pub fn b64_encode<E: Engine, O: OffsetSizeTrait>(
engine: &E,
array: &GenericBinaryArray<O>,
validate_utf8: boolean,
) and
switch validate_utf8 {
true => GenericStringArray::try_new(offsets, Buffer::from_vec(buffer), array.nulls().cloned())
false => GenericStringArray::new_unchecked(offsets, Buffer::from_vec(buffer), array.nulls().cloned())
}to avoid introducing an breaking API change it may make sense to make a separate function that handles the validation.
pub fn b64_encode_validate<E: Engine, O: OffsetSizeTrait>(
engine: &E,
array: &GenericBinaryArray<O>,
) |
i'll need to think on this a little; i dont think having a separate another solution could be to change the return type to be a binary array instead of a string array, since it then pushes the utf8 validation onto the user, though it seems also a clunky solution 🙁 |
|
the core thing id push back on: a to keep so there are really only two sound shapes:
on the perf worry: re @Jefffrey's "return a binary array" idea: its sound but just relocates the same happy to go either way tho. this PR is option 1. if youd rather keep it zero-cost then i'll switch it to the |
|
the only other ways i can think of is either a reflection based approach, e.g. use std::any::TypeId;
if TypeId::of::<E>() == TypeId::of::<base64::engine::GeneralPurpose>() {
// Safety: Base64 is valid UTF-8
unsafe {
GenericStringArray::new_unchecked(
offsets,
Buffer::from_vec(buffer),
array.nulls().cloned(),
)
}
} else {
GenericStringArray::try_new(offsets, Buffer::from_vec(buffer), array.nulls().cloned())
}or for something more flexible, a trait based approach like /// Implement this on an engine that guarantees it produces valid utf8
pub unsafe trait SafeBase64 {}
unsafe impl SafeBase64 for base64::engine::GeneralPurpose {}
/// Bas64 encode each element of `array` with the provided [`Engine`]
pub fn b64_encode<E: Engine + SafeBase64, O: OffsetSizeTrait>(frankly i dont know how common this base64 code is used downstream anyway (i cant find any usages in datafusion) so maybe we dont need to be overkill and can just force validation 🤔 edit: or we could remove it being generic over an engine and just make it take only |
|
agreed, id just force validation. this PR already is that so its ready if youre happy with it :) the thing with the other two is that
dropping the generic to |
Which issue does this PR close?
Fixes #10284
Rationale for this change
b64_encodebuilds aGenericStringArrayfrom the output of a caller-suppliedbase64::Enginevianew_uncheckedguarded by a// Safety: Base64 is valid UTF-8comment. That comment only holds for a correct engine.base64::Engineis a safe trait so a caller can implement it in 100% safe Rust to write non-UTF-8 bytes into the buffer. The resultingStringArraythen hands out an invalid&strwhich is UB reached from entirely safe codeA safe fn has to be sound for all safe inputs so this is a soundness hole. It is not a remotely exploitable vuln: the trigger is a caller-supplied
Engine, not untrusted dataWhat changes are included in this PR?
b64_encodenow returnsResult<GenericStringArray<O>, ArrowError>and builds the array through the checkedGenericStringArray::try_newconstructor instead ofunsafe { new_unchecked(...) }. A misbehaving engine now surfaces as anErrrather than UB. This matchesb64_decode, which already returnsResultunsafeblock is gonearrow-jsondoc example)EvilEngine(the issue's repro) that assertsb64_encodereturnsErrb64_decodeis untouched. It returnsGenericBinaryArray, which has no UTF-8 invariant, so this bug does not apply to itVerification
cargo test -p arrow-cast -p arrow-jsonpasses.EvilEnginerepro aborts with UB (char::from_u32_unchecked) against the currentnew_uncheckedcode, and returnsErrwith no UB after this fix.Are there any user-facing changes?
Yes, this is a breaking API change.
b64_encodenow returnsResult, so callers have to handle the error (with?or.unwrap()).There is an alternative: mark
b64_encodeasunsafe fnand document the engine precondition. That has zero runtime cost but pushesunsafeonto every honest caller. I went with theResultroute to keep the safe API safe, but I'm happy to switch to theunsafe fnshape if maintainers prefer it.Credit to the reporter (@Manishearth) for the Miri repro