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:
2026-10-04 15:34:30 -04:00
co-authored by Claude Opus 5.5
parent 9eeab7e5d2
commit 8eca16e175
7 changed files with 355 additions and 2 deletions
+11
View File
@@ -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"
+18
View File
@@ -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\""));
}
}
+72 -2
View File
@@ -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);
}
}
+9
View 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)
+1
View File
@@ -12,6 +12,7 @@ mod media_cache;
mod model;
mod net;
mod oggopus;
mod opus_packet;
mod profile;
mod rpc;
mod runtime;
+240
View File
@@ -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"
);
}
}
}
}
}
+4
View File
@@ -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,
),