Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

78 changes: 55 additions & 23 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,9 @@ impl Db {
crate::log_error!("stream_find_by: rejected disallowed column '{column}'");
return DbLookup::Failed;
}
if !crate::keygen::is_valid_access_key(key) {
return DbLookup::Missing;
}
let conn = self.conn.lock();
map_optional(conn.query_row(
&format!(
Expand All @@ -472,6 +475,9 @@ impl Db {
/// belongs to a disabled/pending-delete stream, so the RTMP auth-failure
/// rate limiter only counts the former as a credential mismatch.
pub fn stream_find_by_publish_key_any(&self, key: &str) -> DbLookup<Stream> {
if !crate::keygen::is_valid_access_key(key) {
return DbLookup::Missing;
}
let conn = self.conn.lock();
map_optional(conn.query_row(
&format!(
Expand Down Expand Up @@ -655,6 +661,9 @@ impl Db {
}

pub fn viewer_find_by_play_key(&self, key: &str) -> DbLookup<StreamViewer> {
if !crate::keygen::is_valid_access_key(key) {
return DbLookup::Missing;
}
let conn = self.conn.lock();
map_optional(conn.query_row(
&format!(
Expand Down Expand Up @@ -1125,9 +1134,9 @@ mod tests {
id: id.to_string(),
name: format!("{id} name"),
app: "live".to_string(),
publish_key: pub_key.to_string(),
play_key: play_key.to_string(),
stats_key: stats_key.to_string(),
publish_key: crate::keygen::test_pad_access_key(pub_key),
play_key: crate::keygen::test_pad_access_key(play_key),
stats_key: crate::keygen::test_pad_access_key(stats_key),
enabled: true,
created_at: now_ts(),
}
Expand Down Expand Up @@ -1177,18 +1186,18 @@ mod tests {
assert_eq!(got.name, "stream1 name");

assert_eq!(
match db.stream_find_by_publish_key("pub_key_123") {
match db.stream_find_by_publish_key(&s.publish_key) {
DbLookup::Ok(s) => s.id,
_ => panic!("publish key lookup failed"),
},
"stream1"
);
assert!(matches!(
db.viewer_find_by_play_key("pl_key_456"),
db.viewer_find_by_play_key(&s.play_key),
DbLookup::Ok(_)
));
assert!(matches!(
db.stream_find_by_stats_key("st_key_789"),
db.stream_find_by_stats_key(&s.stats_key),
DbLookup::Ok(_)
));
assert!(matches!(
Expand All @@ -1202,13 +1211,8 @@ mod tests {
#[test]
fn publishers_players_and_stats() {
let db = Db::open(":memory:").unwrap();
db.stream_add(&sample_stream(
"stream1",
"pub_key_123",
"pl_key_456",
"st_key_789",
))
.unwrap();
let s = sample_stream("stream1", "pub_key_123", "pl_key_456", "st_key_789");
db.stream_add(&s).unwrap();

let mut p = Publisher {
id: "pub1".to_string(),
Expand All @@ -1231,7 +1235,7 @@ mod tests {
assert!(db.publisher_try_acquire(&p));
assert_eq!(db.publisher_list(Some("stream1")).len(), 1);

let DbLookup::Ok(viewer) = db.viewer_find_by_play_key("pl_key_456") else {
let DbLookup::Ok(viewer) = db.viewer_find_by_play_key(&s.play_key) else {
panic!("viewer not found");
};
let player = Player {
Expand Down Expand Up @@ -1380,7 +1384,10 @@ mod tests {
});

// Simulate on_close for pub1: find by publish_key -> stream_id -> list.
let DbLookup::Ok(found) = db.stream_find_by_publish_key("pub_key_1") else {
let DbLookup::Ok(stream1) = db.stream_get("stream1") else {
panic!("stream not found");
};
let DbLookup::Ok(found) = db.stream_find_by_publish_key(&stream1.publish_key) else {
panic!("publish key not found");
};
let mut pubs = db.publisher_list(Some(&found.id));
Expand All @@ -1401,14 +1408,9 @@ mod tests {
#[test]
fn player_try_acquire_enforces_per_key_connection_cap() {
let db = Db::open(":memory:").unwrap();
db.stream_add(&sample_stream(
"stream1",
"pub_key_123",
"pl_key_456",
"st_key_789",
))
.unwrap();
let DbLookup::Ok(viewer) = db.viewer_find_by_play_key("pl_key_456") else {
let s = sample_stream("stream1", "pub_key_123", "pl_key_456", "st_key_789");
db.stream_add(&s).unwrap();
let DbLookup::Ok(viewer) = db.viewer_find_by_play_key(&s.play_key) else {
panic!("viewer not found");
};

Expand All @@ -1434,4 +1436,34 @@ mod tests {
};
assert!(!db.player_try_acquire(&overflow));
}

#[test]
fn legacy_short_access_keys_are_rejected_at_lookup() {
let db = Db::open(":memory:").unwrap();
let s = Stream {
id: "legacy".to_string(),
name: "Legacy".to_string(),
app: "live".to_string(),
publish_key: "a".to_string(),
play_key: "b".to_string(),
stats_key: "c".to_string(),
enabled: true,
created_at: now_ts(),
};
assert!(db.stream_add(&s).is_ok());

assert!(matches!(
db.stream_find_by_publish_key("a"),
DbLookup::Missing
));
assert!(matches!(
db.stream_find_by_publish_key_any("a"),
DbLookup::Missing
));
assert!(matches!(db.viewer_find_by_play_key("b"), DbLookup::Missing));
assert!(matches!(
db.stream_find_by_stats_key("c"),
DbLookup::Missing
));
}
}
16 changes: 2 additions & 14 deletions src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,21 +206,9 @@ fn is_valid_stream_key_part(value: &str) -> bool {
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}

/// Minimum length for operator-supplied publish/play/stats keys. Shorter custom
/// keys are trivially brute-forced over the unrate-limited RTMP auth path.
const MIN_ACCESS_KEY_LEN: usize = 32;

/// Publish/play/stats keys: safe ASCII, no slashes, minimum entropy via length.
fn is_valid_access_key(value: &str) -> bool {
if value.len() < MIN_ACCESS_KEY_LEN || value.len() > 63 {
return false;
}
let mut chars = value.chars();
let Some(first) = chars.next() else {
return false;
};
first.is_ascii_alphanumeric()
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
crate::keygen::is_valid_access_key(value)
}

fn trim_optional_string(value: Option<String>) -> Option<String> {
Expand Down Expand Up @@ -249,7 +237,7 @@ fn resolve_or_generate_access_key(
}
}

const ACCESS_KEY_VALIDATION_MSG: &str = "Key must be 32-63 characters, start with a letter or number, and use only letters, numbers, dots, underscores, or hyphens";
const ACCESS_KEY_VALIDATION_MSG: &str = crate::keygen::ACCESS_KEY_VALIDATION_MSG;

fn access_keys_must_be_unique(keys: &[&str]) -> bool {
let mut seen = HashSet::with_capacity(keys.len());
Expand Down
38 changes: 38 additions & 0 deletions src/keygen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,37 @@ pub const PREFIX_STATS_KEY: &str = "sts_";
/// Prefix for configured viewer-slot row ids (panel-managed play access).
pub const PREFIX_VIEWER_ID: &str = "vi_";

/// Minimum length for publish/play/stats keys used at runtime. Shorter keys stored
/// in legacy databases are rejected on RTMP and public stats paths.
pub const MIN_ACCESS_KEY_LEN: usize = 32;

/// Publish/play/stats keys: safe ASCII, no slashes, minimum entropy via length.
pub fn is_valid_access_key(value: &str) -> bool {
if value.len() < MIN_ACCESS_KEY_LEN || value.len() > 63 {
return false;
}
let mut chars = value.chars();
let Some(first) = chars.next() else {
return false;
};
first.is_ascii_alphanumeric()
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}

pub const ACCESS_KEY_VALIDATION_MSG: &str = "Key must be 32-63 characters, start with a letter or number, and use only letters, numbers, dots, underscores, or hyphens";

#[cfg(test)]
pub fn test_pad_access_key(value: &str) -> String {
if is_valid_access_key(value) {
return value.to_string();
}
let mut padded = value.to_string();
while padded.len() < MIN_ACCESS_KEY_LEN {
padded.push('x');
}
padded.chars().take(63).collect()
}

fn keygen_with_entropy(prefix: &str, entropy_bytes: usize) -> Result<String, String> {
let mut rnd = vec![0u8; entropy_bytes];
SysRng
Expand Down Expand Up @@ -65,4 +96,11 @@ mod tests {
assert_eq!(token.len(), API_TOKEN_ENTROPY_BYTES * 2);
assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
}

#[test]
fn access_key_validation_enforces_minimum_length() {
assert!(is_valid_access_key("live.main_1_with_sufficient_length_ok"));
assert!(!is_valid_access_key("too_short"));
assert!(!is_valid_access_key("a"));
}
}
Loading