libopus: check every packet in Rust first, and say which libopus this is (#251)
Every Discord voice packet and soundboard sound is decoded by libopus, in C, and comes from other people. Two of the three measures #251 proposed: Bounding what reaches it. opus_packet::plausible checks RFC 6716's packet structure (R1-R7: frame sizes, the code 1-3 frame counts and length fields, padding) in Rust, and that the packet's audio fits the buffer it will be decoded into. Voice: a packet that fails is decoded as a lost one, which libopus conceals as it does any gap. Soundboard: failing packets are left out, and the file is capped at 1 MB - read a chunk at a time, not trusting Content-Length - where Discord's own limit is 512 KB; an oversized cached copy is refetched. Tested against everything libopus's encoder makes (three modes, 8-510 kb/s, 2.5-60 ms - all pass), malformed packets of each code (all refused), 20,000 random byte strings (no panic), and a real Ogg Opus file through the soundboard decoder (decodes whole). Knowing which libopus is in it. The bundled source, libopus_sys 0.3.3, is libopus 1.5.2 - checked file by file against upstream's tags - but has no version file, so a release (always bundled: CI and the Windows build have no system libopus) called itself "libopus unknown". .cargo/config.toml now names it, a test fails if libopus_sys changes without that being revisited, and the version RPC reports the decoder actually linked: libopus 1.5.2 in a release, the system's own where one was installed at build time. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
[env]
|
||||
# The version a bundled libopus reports (#251). libopus works its version out
|
||||
# from git, which a crate's copy of its source does not have, so the bundled
|
||||
# build - every release: CI and the Windows cross-build have no system
|
||||
# libopus - otherwise calls itself "unknown". A machine with libopus
|
||||
# installed links that instead, and it reports its own; this is not read then.
|
||||
#
|
||||
# libopus_sys 0.3.3 bundles 1.5.2: checked file by file against upstream's
|
||||
# tags. A test (audio.rs, bundled_libopus_is_the_one_named) fails when the
|
||||
# crate changes, so this cannot quietly go stale.
|
||||
OPUS_PACKAGE_VERSION = "1.5.2"
|
||||
@@ -1337,3 +1337,21 @@ mod tests {
|
||||
assert!(seconds > 1.5, "far less audio than elapsed time: {seconds:.2}s");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod libopus_version_tests {
|
||||
/// `.cargo/config.toml` names the libopus a bundled build carries
|
||||
/// (OPUS_PACKAGE_VERSION) for the libopus_sys it was checked against.
|
||||
/// A different libopus_sys may bundle a different libopus, and then the
|
||||
/// version every release reports would be wrong without anything saying so.
|
||||
#[test]
|
||||
fn bundled_libopus_is_the_one_named() {
|
||||
let lock = include_str!("../Cargo.lock");
|
||||
assert!(
|
||||
lock.contains("name = \"libopus_sys\"\nversion = \"0.3.3\""),
|
||||
"libopus_sys is no longer 0.3.3 - find which libopus it bundles and update OPUS_PACKAGE_VERSION in .cargo/config.toml"
|
||||
);
|
||||
let config = include_str!("../.cargo/config.toml");
|
||||
assert!(config.contains("OPUS_PACKAGE_VERSION = \"1.5.2\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,11 @@ fn cache_dir() -> std::path::PathBuf {
|
||||
.join("discord-sounds")
|
||||
}
|
||||
|
||||
/// The largest sound file accepted. Discord takes uploads up to 512 KB for
|
||||
/// at most 5.2 seconds; anything much bigger is not a soundboard sound, and
|
||||
/// what gets decoded here was uploaded by anyone in the server (#251).
|
||||
const LARGEST_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// The sound's file, kept: a sound id names one recording for good.
|
||||
async fn fetch(token: &str, id: &str) -> Result<Vec<u8>> {
|
||||
if !id.bytes().all(|b| b.is_ascii_digit()) {
|
||||
@@ -218,7 +223,9 @@ async fn fetch(token: &str, id: &str) -> Result<Vec<u8>> {
|
||||
}
|
||||
let path = cache_dir().join(id);
|
||||
if let Ok(bytes) = tokio::fs::read(&path).await {
|
||||
return Ok(bytes);
|
||||
if bytes.len() <= LARGEST_BYTES {
|
||||
return Ok(bytes);
|
||||
}
|
||||
}
|
||||
let resp = http_client_for(token)
|
||||
.get(format!("https://cdn.discordapp.com/soundboard-sounds/{id}"))
|
||||
@@ -228,7 +235,19 @@ async fn fetch(token: &str, id: &str) -> Result<Vec<u8>> {
|
||||
if !resp.status().is_success() {
|
||||
bail!("Discord has no file for that sound ({})", resp.status());
|
||||
}
|
||||
let bytes = resp.bytes().await.context("reading the sound")?.to_vec();
|
||||
if resp.content_length().is_some_and(|n| n as usize > LARGEST_BYTES) {
|
||||
bail!("that sound is larger than any soundboard sound can be");
|
||||
}
|
||||
// Read a piece at a time and stopped at the limit, rather than trusting
|
||||
// a Content-Length that need not be there or be true.
|
||||
let mut resp = resp;
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = resp.chunk().await.context("reading the sound")? {
|
||||
bytes.extend_from_slice(&chunk);
|
||||
if bytes.len() > LARGEST_BYTES {
|
||||
bail!("that sound is larger than any soundboard sound can be");
|
||||
}
|
||||
}
|
||||
tokio::fs::create_dir_all(cache_dir()).await.ok();
|
||||
let _ = tokio::fs::write(&path, &bytes).await;
|
||||
Ok(bytes)
|
||||
@@ -266,6 +285,11 @@ pub fn decode(bytes: &[u8]) -> Result<Vec<i16>> {
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
// Checked in Rust before libopus sees it (#251); a packet that
|
||||
// is not one, or is longer than the buffer, is left out.
|
||||
if crate::opus_packet::plausible(&packet.data, frame.len() / 2).is_none() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(n) = opus.decode(&packet.data, &mut frame, false) {
|
||||
out.extend_from_slice(&frame[..n * 2]);
|
||||
}
|
||||
@@ -351,3 +375,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod opus_guard_tests {
|
||||
/// A real Ogg Opus file - what a guild's own sounds are - still decodes in
|
||||
/// full with every packet checked before libopus sees it (#251).
|
||||
#[test]
|
||||
fn a_real_ogg_opus_sound_decodes_whole() {
|
||||
// Two seconds of a tone at 48 kHz mono, written as Ogg Opus the way
|
||||
// voice messages are.
|
||||
let samples: Vec<f32> = (0..96_000).map(|i| (i as f32 * 0.03).sin() * 0.5).collect();
|
||||
let file = crate::oggopus::encode(&samples).unwrap();
|
||||
let pcm = super::decode(&file).unwrap();
|
||||
let seconds = pcm.len() as f64 / 2.0 / 48_000.0;
|
||||
assert!((1.9..=2.1).contains(&seconds), "decoded {seconds:.2}s of a 2s sound");
|
||||
}
|
||||
|
||||
/// Packets that are not Opus are left out rather than handed over.
|
||||
#[test]
|
||||
fn a_damaged_ogg_opus_sound_does_not_reach_the_decoder_whole() {
|
||||
let samples: Vec<f32> = (0..48_000).map(|i| (i as f32 * 0.03).sin() * 0.5).collect();
|
||||
let mut file = crate::oggopus::encode(&samples).unwrap();
|
||||
// Corrupt the audio pages (after the two header pages) without
|
||||
// touching the Ogg framing: each packet's first byte becomes a code 3
|
||||
// header claiming 63 frames, which no valid packet can.
|
||||
let mut i = 0;
|
||||
let mut pages = 0;
|
||||
while i + 27 < file.len() {
|
||||
if &file[i..i + 4] != b"OggS" {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let segments = file[i + 26] as usize;
|
||||
let body = i + 27 + segments;
|
||||
pages += 1;
|
||||
if pages > 2 && body + 1 < file.len() {
|
||||
file[body] = (31 << 3) | 3;
|
||||
file[body + 1] = 63;
|
||||
}
|
||||
let len: usize = file[i + 27..i + 27 + segments].iter().map(|&b| b as usize).sum();
|
||||
i = body + len;
|
||||
}
|
||||
// Still a file symphonia can read, and the check keeps the bad
|
||||
// packets out; it must not panic or error the whole sound.
|
||||
let _ = super::decode(&file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1354,6 +1354,15 @@ impl Jitter {
|
||||
fn decode(&mut self, opus: &[u8]) -> Option<Vec<i16>> {
|
||||
let decoder = self.decoder.as_mut()?;
|
||||
let mut pcm = vec![0i16; FRAME_SAMPLES * 3];
|
||||
// What another person's client sent, checked in Rust before libopus
|
||||
// reads it (#251). Not a packet, or longer than this buffer: treated
|
||||
// as lost, which the decoder conceals as it does any gap. Empty is
|
||||
// our own request for exactly that.
|
||||
let opus = if opus.is_empty() || crate::opus_packet::plausible(opus, pcm.len() / 2).is_some() {
|
||||
opus
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
let per_channel = decoder.decode(opus, &mut pcm, false).ok()?;
|
||||
pcm.truncate(per_channel * 2);
|
||||
Some(pcm)
|
||||
|
||||
@@ -12,6 +12,7 @@ mod media_cache;
|
||||
mod model;
|
||||
mod net;
|
||||
mod oggopus;
|
||||
mod opus_packet;
|
||||
mod profile;
|
||||
mod rpc;
|
||||
mod runtime;
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Whether bytes are an Opus packet worth handing to libopus.
|
||||
//!
|
||||
//! libopus is C, and what it decodes here comes from other people: every
|
||||
//! voice packet of everybody in a Discord call, and soundboard sounds anyone
|
||||
//! in a server can upload (#251). It validates packets itself and is fuzzed
|
||||
//! continuously, so this is not a second decoder - it is the structural part
|
||||
//! of RFC 6716 (§3.2, requirements R1-R7) checked in Rust first, so that
|
||||
//! anything malformed, or longer than the buffer it would be decoded into, is
|
||||
//! refused before C code reads a byte of it.
|
||||
|
||||
/// The most one frame may hold (RFC 6716 R2).
|
||||
const MAX_FRAME_BYTES: usize = 1275;
|
||||
|
||||
/// The most audio one packet may carry: 120 ms at 48 kHz (R5).
|
||||
pub const MAX_PACKET_SAMPLES: usize = 5760;
|
||||
|
||||
/// Samples per channel at 48 kHz in one frame of the packet's configuration
|
||||
/// (the top five bits of its first byte; RFC 6716 Table 2).
|
||||
fn frame_samples(config: u8) -> usize {
|
||||
match config {
|
||||
// SILK-only: narrow, medium, wide band; 10, 20, 40, 60 ms.
|
||||
0..=11 => [480, 960, 1920, 2880][(config % 4) as usize],
|
||||
// Hybrid: super-wide and full band; 10, 20 ms.
|
||||
12..=15 => [480, 960][(config % 2) as usize],
|
||||
// CELT-only: 2.5, 5, 10, 20 ms.
|
||||
_ => [120, 240, 480, 960][(config % 4) as usize],
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame length as codes 2 and 3 write one: one byte below 252, otherwise
|
||||
/// two (RFC 6716 §3.2.1). Returns the length and how many bytes it took.
|
||||
fn frame_length(bytes: &[u8]) -> Option<(usize, usize)> {
|
||||
let first = *bytes.first()? as usize;
|
||||
if first < 252 {
|
||||
return Some((first, 1));
|
||||
}
|
||||
let second = *bytes.get(1)? as usize;
|
||||
Some((second * 4 + first, 2))
|
||||
}
|
||||
|
||||
/// The packet's audio, in samples per channel at 48 kHz, if it is a
|
||||
/// well-formed Opus packet no longer than `max_samples`; None otherwise.
|
||||
///
|
||||
/// An empty packet is not one: an empty slice is how a caller asks the
|
||||
/// decoder to conceal a lost frame, and that is the caller's own business.
|
||||
pub fn plausible(packet: &[u8], max_samples: usize) -> Option<usize> {
|
||||
let toc = *packet.first()?;
|
||||
let per_frame = frame_samples(toc >> 3);
|
||||
let rest = &packet[1..];
|
||||
let frames = match toc & 0b11 {
|
||||
// One frame.
|
||||
0 => {
|
||||
if rest.len() > MAX_FRAME_BYTES {
|
||||
return None;
|
||||
}
|
||||
1
|
||||
}
|
||||
// Two frames of equal size.
|
||||
1 => {
|
||||
if !rest.len().is_multiple_of(2) || rest.len() / 2 > MAX_FRAME_BYTES {
|
||||
return None;
|
||||
}
|
||||
2
|
||||
}
|
||||
// Two frames, the first's length given.
|
||||
2 => {
|
||||
let (first, used) = frame_length(rest)?;
|
||||
let remaining = rest.len().checked_sub(used)?;
|
||||
let second = remaining.checked_sub(first)?;
|
||||
if first > MAX_FRAME_BYTES || second > MAX_FRAME_BYTES {
|
||||
return None;
|
||||
}
|
||||
2
|
||||
}
|
||||
// Any number of frames, with an optional padding run.
|
||||
_ => {
|
||||
let header = *rest.first()?;
|
||||
let vbr = header & 0x80 != 0;
|
||||
let padded = header & 0x40 != 0;
|
||||
let count = (header & 0x3f) as usize;
|
||||
if count == 0 || count * per_frame > MAX_PACKET_SAMPLES {
|
||||
return None;
|
||||
}
|
||||
let mut at = 1;
|
||||
let mut padding = 0usize;
|
||||
if padded {
|
||||
loop {
|
||||
let b = *rest.get(at)? as usize;
|
||||
at += 1;
|
||||
if b == 255 {
|
||||
padding += 254;
|
||||
} else {
|
||||
padding += b;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if vbr {
|
||||
let mut lengths = 0usize;
|
||||
for _ in 0..count - 1 {
|
||||
let (len, used) = frame_length(rest.get(at..)?)?;
|
||||
if len > MAX_FRAME_BYTES {
|
||||
return None;
|
||||
}
|
||||
at += used;
|
||||
lengths += len;
|
||||
}
|
||||
let data = rest.len().checked_sub(at)?.checked_sub(padding)?;
|
||||
let last = data.checked_sub(lengths)?;
|
||||
if last > MAX_FRAME_BYTES {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
let data = rest.len().checked_sub(at)?.checked_sub(padding)?;
|
||||
if !data.is_multiple_of(count) || data / count > MAX_FRAME_BYTES {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
};
|
||||
let samples = frames * per_frame;
|
||||
(samples <= max_samples.min(MAX_PACKET_SAMPLES)).then_some(samples)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// A 20 ms CELT full-band frame, config 31: TOC 0b11111_s_cc.
|
||||
const CELT_20MS: u8 = 31 << 3;
|
||||
|
||||
#[test]
|
||||
fn ordinary_voice_packets_pass() {
|
||||
// Code 0: one 20 ms frame of 60 bytes, what Discord voice is.
|
||||
let mut p = vec![CELT_20MS];
|
||||
p.extend(std::iter::repeat_n(0u8, 60));
|
||||
assert_eq!(plausible(&p, 2880), Some(960));
|
||||
// Code 1: two equal frames, 40 ms.
|
||||
let mut p = vec![CELT_20MS | 1];
|
||||
p.extend(std::iter::repeat_n(0u8, 80));
|
||||
assert_eq!(plausible(&p, 2880), Some(1920));
|
||||
// Code 2: two frames, the first 10 bytes long.
|
||||
let mut p = vec![CELT_20MS | 2, 10];
|
||||
p.extend(std::iter::repeat_n(0u8, 30));
|
||||
assert_eq!(plausible(&p, 2880), Some(1920));
|
||||
// Code 3, CBR, three 2.5 ms frames of 20 bytes.
|
||||
let mut p = vec![(16 << 3) | 3, 3];
|
||||
p.extend(std::iter::repeat_n(0u8, 60));
|
||||
assert_eq!(plausible(&p, 2880), Some(360));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_packets_are_refused() {
|
||||
assert_eq!(plausible(&[], 5760), None, "empty is the caller's concealment, not a packet");
|
||||
// Code 0 with a frame over 1275 bytes.
|
||||
let mut p = vec![CELT_20MS];
|
||||
p.extend(std::iter::repeat_n(0u8, 1276));
|
||||
assert_eq!(plausible(&p, 5760), None);
|
||||
// Code 1 with an odd length.
|
||||
assert_eq!(plausible(&[CELT_20MS | 1, 0, 0, 0], 5760), None);
|
||||
// Code 2 whose first frame claims more than the packet holds.
|
||||
assert_eq!(plausible(&[CELT_20MS | 2, 200, 0, 0], 5760), None);
|
||||
// Code 2 with its two-byte length cut off.
|
||||
assert_eq!(plausible(&[CELT_20MS | 2, 253], 5760), None);
|
||||
// Code 3 with no frames.
|
||||
assert_eq!(plausible(&[CELT_20MS | 3, 0], 5760), None);
|
||||
// Code 3, CBR, data that does not divide among the frames.
|
||||
assert_eq!(plausible(&[CELT_20MS | 3, 2, 0, 0, 0], 5760), None);
|
||||
// Code 3 whose padding runs past the end.
|
||||
assert_eq!(plausible(&[CELT_20MS | 3, 0x40 | 1, 255], 5760), None);
|
||||
// Code 3, VBR, lengths summing past the data.
|
||||
assert_eq!(plausible(&[CELT_20MS | 3, 0x80 | 2, 50, 0, 0], 5760), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_long_is_refused() {
|
||||
// Code 3 with 7 frames of 20 ms: 140 ms, over Opus's own 120.
|
||||
let mut p = vec![CELT_20MS | 3, 7];
|
||||
p.extend(std::iter::repeat_n(0u8, 70));
|
||||
assert_eq!(plausible(&p, 5760), None);
|
||||
// Three 20 ms frames: legal Opus, but over a 60 ms voice buffer.
|
||||
let mut p = vec![CELT_20MS | 3, 3];
|
||||
p.extend(std::iter::repeat_n(0u8, 30));
|
||||
assert_eq!(plausible(&p, 5760), Some(2880));
|
||||
assert_eq!(plausible(&p, 2880), Some(2880));
|
||||
assert_eq!(plausible(&p, 1920), None);
|
||||
// A 60 ms SILK frame, config 3.
|
||||
assert_eq!(plausible(&[3 << 3, 0, 0], 2880), Some(2880));
|
||||
}
|
||||
|
||||
/// Whatever arrives, the check itself never panics.
|
||||
#[test]
|
||||
fn arbitrary_bytes_never_panic() {
|
||||
let mut seed = 0x2545_f491_4f6c_dd1du64;
|
||||
for _ in 0..20_000 {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
let len = (seed % 64) as usize;
|
||||
let bytes: Vec<u8> = (0..len).map(|i| (seed >> (i % 56)) as u8 ^ i as u8).collect();
|
||||
let _ = plausible(&bytes, 5760);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod real_packets {
|
||||
use super::plausible;
|
||||
|
||||
/// What libopus itself produces must never be refused: a false refusal is
|
||||
/// a gap in somebody's voice. Every frame size, voice and music modes,
|
||||
/// low and high bitrates, mono speech-like noise and stereo.
|
||||
#[test]
|
||||
fn everything_the_encoder_makes_passes() {
|
||||
let mut seed = 12345u32;
|
||||
for &app in &[opus2::Application::Voip, opus2::Application::Audio, opus2::Application::LowDelay] {
|
||||
for &bitrate in &[8_000, 32_000, 64_000, 128_000, 510_000] {
|
||||
// 2.5, 5, 10, 20, 40, 60 ms per channel at 48 kHz.
|
||||
for &frame in &[120usize, 240, 480, 960, 1920, 2880] {
|
||||
let mut enc = opus2::Encoder::new(48_000, opus2::Channels::Stereo, app).unwrap();
|
||||
enc.set_bitrate(opus2::Bitrate::Bits(bitrate)).unwrap();
|
||||
let pcm: Vec<i16> = (0..frame * 2)
|
||||
.map(|i| {
|
||||
seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||||
(((i as f32 * 0.05).sin() * 8000.0) as i32 + (seed >> 20) as i32 - 2048) as i16
|
||||
})
|
||||
.collect();
|
||||
let mut out = vec![0u8; 4000];
|
||||
let n = enc.encode(&pcm, &mut out).unwrap();
|
||||
assert_eq!(
|
||||
plausible(&out[..n], 2880),
|
||||
Some(frame),
|
||||
"{app:?} at {bitrate} b/s, {frame} samples: refused a packet libopus made"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,6 +296,10 @@ pub async fn dispatch(
|
||||
Some(serde_json::json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"commit": env!("NOBILIS_BUILD_COMMIT"),
|
||||
// The C decoder every Discord voice packet and soundboard
|
||||
// sound goes through (#251) - bundled in a release, the
|
||||
// system's own where one was installed when this was built.
|
||||
"opus": opus2::version(),
|
||||
})),
|
||||
None,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user