Skip to content
Open
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
39 changes: 24 additions & 15 deletions camera_hub/src/notification_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,24 @@
//!
//! SPDX-License-Identifier: GPL-3.0-or-later

use secluso_client_lib::http_client::{HttpClient, NotificationTarget};
use secluso_client_lib::http_client::{validate_ios_relay_binding, HttpClient, NotificationTarget};
use std::fs;
use std::io;
use std::path::Path;

const TARGET_FILENAME: &str = "notification_target.json";

// Build the placeholder target we keep for iOS while no relay binding is available / after the current binding has been rejected.
fn ios_placeholder_target(platform: &str) -> NotificationTarget {
NotificationTarget {
platform: platform.to_string(),
ios_relay_binding: None,
unifiedpush_endpoint_url: None,
unifiedpush_pub_key: None,
unifiedpush_auth: None,
}
}

pub fn persist_notification_target(state_dir: &str, target: &NotificationTarget) -> io::Result<()> {
fs::create_dir_all(state_dir)?;
let path = Path::new(state_dir).join(TARGET_FILENAME);
Expand Down Expand Up @@ -55,13 +66,7 @@ pub fn refresh_notification_target(
});
if let Some(target) = cached {
if target.platform.eq_ignore_ascii_case("ios") {
let placeholder = NotificationTarget {
platform: target.platform.clone(),
ios_relay_binding: None,
unifiedpush_endpoint_url: None,
unifiedpush_pub_key: None,
unifiedpush_auth: None,
};
let placeholder = ios_placeholder_target(&target.platform);
if let Err(e) = persist_notification_target(state_dir, &placeholder) {
error!("Failed to persist iOS notification placeholder: {e}");
}
Expand Down Expand Up @@ -93,16 +98,20 @@ pub fn send_notification(
if let Some(target) = target {
if target.platform.eq_ignore_ascii_case("ios") {
if let Some(binding) = target.ios_relay_binding.as_ref() {
if let Err(e) = validate_ios_relay_binding(binding) {
let placeholder = ios_placeholder_target(&target.platform);
if let Err(clear_err) = persist_notification_target(state_dir, &placeholder) {
error!(
"Failed to persist iOS notification placeholder after relay validation failure: {clear_err}"
);
}
return Err(e);
}

let result = http_client.send_ios_notification(notification_msg, binding);
if let Err(e) = result.as_ref() {
if e.to_string().contains("Relay error: 403") {
let placeholder = NotificationTarget {
platform: target.platform.clone(),
ios_relay_binding: None,
unifiedpush_endpoint_url: None,
unifiedpush_pub_key: None,
unifiedpush_auth: None,
};
let placeholder = ios_placeholder_target(&target.platform);
if let Err(clear_err) = persist_notification_target(state_dir, &placeholder)
{
error!(
Expand Down
133 changes: 124 additions & 9 deletions client_lib/src/http_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use base64::engine::general_purpose::STANDARD as base64_engine;
use base64::{engine::general_purpose, Engine as _};
use reqwest::blocking::{Body, Client, RequestBuilder};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fs::File;
Expand Down Expand Up @@ -49,12 +50,86 @@ pub struct PairingStatus {
pub notification_target: Option<NotificationTarget>,
}

const TRUSTED_IOS_RELAY_HOSTS: &[&str] = &["relay.secluso.com", "testing-relay.secluso.com"];

//TODO: There's a lot of repitition between the functions here.

// Note: The server needs a unique name for each camera.
// The name needs to be available to both the camera and the app.
// We use the MLS group name for that purpose.

// Mirror the server-side relay checks before the hub sends any outbound iOS request.
// Ensures a malicious/stale notification target cannot turn the hub into a generic HTTPS client.
fn validate_ios_relay_base_url(raw_url: &str) -> io::Result<Url> {
let parsed = Url::parse(raw_url)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?;
if parsed.scheme() != "https" {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"iOS relay base URL must use https",
));
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"iOS relay base URL must not include credentials",
));
}
if parsed.query().is_some() || parsed.fragment().is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"iOS relay base URL must not include a query or fragment",
));
}
if parsed.path() != "/" {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"iOS relay base URL must not include a path prefix",
));
}

let host = parsed.host_str().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"iOS relay base URL is missing a host",
)
})?;
let port = parsed.port_or_known_default().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"iOS relay base URL is missing an https port",
)
})?;
if port != 443 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"iOS relay base URL must use the default https port",
));
}
if !TRUSTED_IOS_RELAY_HOSTS
.iter()
.any(|allowed| host.eq_ignore_ascii_case(allowed))
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("Refusing unexpected iOS relay host: {host}"),
));
}

Ok(parsed)
}

pub fn validate_ios_relay_binding(binding: &IosRelayBinding) -> io::Result<Url> {
let relay_base = binding.relay_base_url.trim();
if relay_base.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"iOS relay base URL is required",
));
}
validate_ios_relay_base_url(relay_base)
}

impl HttpClient {
pub fn authorized_headers(&self, request_builder: RequestBuilder) -> RequestBuilder {
let auth_value = format!("{}:{}", self.server_username, self.server_password);
Expand Down Expand Up @@ -150,14 +225,10 @@ impl HttpClient {
) -> io::Result<()> {
const IOS_RELAY_USER_AGENT: &str = "SeclusoCameraHub/1.0";

let relay_base = binding.relay_base_url.trim_end_matches('/');
if !relay_base.starts_with("https://") {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Relay base URL must use https",
));
}
let relay_url = format!("{relay_base}/hub/notify");
let relay_base = validate_ios_relay_binding(binding)?;
let relay_url = relay_base
.join("hub/notify")
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?;

let payload = json!({
"hub_token": binding.hub_token,
Expand All @@ -180,7 +251,7 @@ impl HttpClient {

// This does NOT need authorized_headers as it's a separate relay (public Secluso iOS relay)
let response = client
.post(&relay_url)
.post(relay_url)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("User-Agent", IOS_RELAY_USER_AGENT)
Expand Down Expand Up @@ -644,3 +715,47 @@ impl HttpClient {
Ok(response_vec)
}
}

#[cfg(test)]
mod tests {
use super::{validate_ios_relay_base_url, validate_ios_relay_binding, IosRelayBinding};

// Build an otherwise-valid relay binding and let each test vary only the relay base URL it wants to validate.
fn ios_binding(relay_base_url: &str) -> IosRelayBinding {
IosRelayBinding {
relay_base_url: relay_base_url.to_string(),
hub_token: "hub-token".to_string(),
app_install_id: "install-id".to_string(),
hub_id: "hub-id".to_string(),
device_token: "device-token".to_string(),
expires_at_epoch_ms: 1,
}
}

#[test]
// Tests that the camera hub accepts the public production relay.
fn accepts_trusted_ios_relay_host() {
validate_ios_relay_base_url("https://relay.secluso.com")
.expect("trusted relay host should be accepted");
}

#[test]
// Tests that server-side iOS relay checks reject unexpected relay hosts before the target can be persisted to the hub.
fn rejects_untrusted_ios_relay_host() {
let err = validate_ios_relay_base_url("https://evil.example")
.expect_err("unexpected relay host should be rejected");

assert!(err
.to_string()
.contains("Refusing unexpected iOS relay host"));
}

#[test]
// Tests that the binding-level check rejects incomplete relay bindings before send_notification hands them to the HTTP client.
fn rejects_empty_ios_relay_base_url() {
let err = validate_ios_relay_binding(&ios_binding(" "))
.expect_err("empty relay base URL should be rejected");

assert!(err.to_string().contains("iOS relay base URL is required"));
}
}
Loading
Loading