From 66701c2e735bdcdab6c5d664608932a0ef153473 Mon Sep 17 00:00:00 2001 From: ok-nick Date: Fri, 21 Nov 2025 17:39:31 -0500 Subject: [PATCH 1/9] feat: foundation for restricted http resolvers --- sdk/src/builder.rs | 51 ++++++- sdk/src/http/mod.rs | 9 ++ sdk/src/http/restricted.rs | 292 +++++++++++++++++++++++++++++++++++++ sdk/src/ingredient.rs | 50 ++++++- sdk/src/reader.rs | 77 ++++++++-- sdk/src/settings/mod.rs | 28 +++- 6 files changed, 484 insertions(+), 23 deletions(-) create mode 100644 sdk/src/http/restricted.rs diff --git a/sdk/src/builder.rs b/sdk/src/builder.rs index c2b0b1718..db3e63425 100644 --- a/sdk/src/builder.rs +++ b/sdk/src/builder.rs @@ -37,7 +37,10 @@ use crate::{ }, claim::Claim, error::{Error, Result}, - http::{AsyncGenericResolver, SyncGenericResolver}, + http::{ + restricted::{AsyncRestrictedResolver, SyncRestrictedResolver}, + AsyncGenericResolver, SyncGenericResolver, + }, jumbf_io, resource_store::{ResourceRef, ResourceResolver, ResourceStore}, settings::{self, Settings}, @@ -617,6 +620,22 @@ impl Builder { R: Read + Seek + Send, { let settings = crate::settings::get_settings().unwrap_or_default(); + let allowed_network_hosts = settings + .core + .allowed_network_hosts + .as_deref() + .unwrap_or_default(); + let http_resolver = if _sync { + SyncRestrictedResolver::with_allowed_hosts( + SyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ) + } else { + AsyncRestrictedResolver::with_allowed_hosts( + AsyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ) + }; let ingredient: Ingredient = Ingredient::from_json(&ingredient_json.into())?; @@ -632,10 +651,10 @@ impl Builder { } let ingredient = if _sync { - ingredient.with_stream(format, stream, &SyncGenericResolver::new(), &settings)? + ingredient.with_stream(format, stream, &http_resolver, &settings)? } else { ingredient - .with_stream_async(format, stream, &AsyncGenericResolver::new(), &settings) + .with_stream_async(format, stream, &http_resolver, &settings) .await? }; @@ -862,6 +881,15 @@ impl Builder { // so we will read the store directly here //crate::Reader::from_stream("application/c2pa", stream).and_then(|r| r.into_builder()) let settings = crate::settings::get_settings().unwrap_or_default(); + let allowed_network_hosts = settings + .core + .allowed_network_hosts + .as_deref() + .unwrap_or_default(); + let http_resolver = SyncRestrictedResolver::with_allowed_hosts( + SyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ); let mut validation_log = crate::status_tracker::StatusTracker::default(); stream.rewind()?; // Ensure stream is at the start @@ -871,7 +899,7 @@ impl Builder { &mut stream, false, &mut validation_log, - &SyncGenericResolver::new(), + &http_resolver, &settings, )?; let reader = Reader::from_store(store, &mut validation_log, &settings)?; @@ -1576,10 +1604,21 @@ impl Builder { W: Write + Read + Seek + Send, { let settings = crate::settings::get_settings().unwrap_or_default(); + let allowed_network_hosts = settings + .core + .allowed_network_hosts + .as_deref() + .unwrap_or_default(); let http_resolver = if _sync { - SyncGenericResolver::new() + SyncRestrictedResolver::with_allowed_hosts( + SyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ) } else { - AsyncGenericResolver::new() + AsyncRestrictedResolver::with_allowed_hosts( + AsyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ) }; let format = format_to_mime(format); diff --git a/sdk/src/http/mod.rs b/sdk/src/http/mod.rs index 22fedf69a..a2bb92ad1 100644 --- a/sdk/src/http/mod.rs +++ b/sdk/src/http/mod.rs @@ -19,6 +19,7 @@ use http::{Request, Response}; use crate::Result; mod reqwest; +pub mod restricted; mod ureq; mod wasi; @@ -150,6 +151,14 @@ pub enum HttpResolverError { #[error("the async http resolver is not implemented")] AsyncHttpResolverNotImplemented, + /// The remote URI is blocked by the allowed list. + /// + /// The allowed list is normally set in a [`SyncRestrictedResolver`]. + /// + /// [`SyncRestrictedResolver`]: restricted::SyncRestrictedResolver + #[error("remote URI \"{uri}\" is not permitted by the allowed list")] + UriDisallowed { uri: String }, + /// An error occured from the underlying HTTP resolver. #[error("an error occurred from the underlying http resolver")] Other(Box), diff --git a/sdk/src/http/restricted.rs b/sdk/src/http/restricted.rs new file mode 100644 index 000000000..1371518da --- /dev/null +++ b/sdk/src/http/restricted.rs @@ -0,0 +1,292 @@ +// Copyright 2025 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, +// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +// or the MIT license (http://opensource.org/licenses/MIT), +// at your option. + +// Unless required by applicable law or agreed to in writing, +// this software is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +// implied. See the LICENSE-MIT and LICENSE-APACHE files for the +// specific language governing permissions and limitations under +// each license. + +use std::io::Read; + +use async_trait::async_trait; +use http::{Request, Response, Uri}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::{ + http::{ + AsyncGenericResolver, AsyncHttpResolver, HttpResolverError, SyncGenericResolver, + SyncHttpResolver, + }, + Result, +}; + +#[derive(Debug)] +pub struct SyncRestrictedResolver { + inner: T, + allowed_hosts: Vec, +} + +impl SyncRestrictedResolver { + pub fn new(inner: T) -> Self { + Self { + inner, + allowed_hosts: Vec::new(), + } + } + + pub fn with_allowed_hosts(inner: T, allowed_hosts: Vec) -> Self { + Self { + inner, + allowed_hosts, + } + } + + pub fn allowed_hosts(&self) -> &[HostPattern] { + &self.allowed_hosts + } +} + +impl Default for SyncRestrictedResolver { + fn default() -> Self { + Self { + inner: SyncGenericResolver::new(), + allowed_hosts: Vec::new(), + } + } +} + +impl SyncHttpResolver for SyncRestrictedResolver { + fn http_resolve( + &self, + request: Request>, + ) -> Result>, HttpResolverError> { + match is_uri_allowed(self.allowed_hosts(), request.uri()) { + true => self.inner.http_resolve(request), + false => Err(HttpResolverError::UriDisallowed { + uri: request.uri().to_string(), + }), + } + } +} + +#[derive(Debug)] +pub struct AsyncRestrictedResolver { + inner: T, + allowed_hosts: Vec, +} + +impl AsyncRestrictedResolver { + pub fn new(inner: T) -> Self { + Self { + inner, + allowed_hosts: Vec::new(), + } + } + + pub fn with_allowed_hosts(inner: T, allowed_hosts: Vec) -> Self { + Self { + inner, + allowed_hosts, + } + } + + pub fn allowed_hosts(&self) -> &[HostPattern] { + &self.allowed_hosts + } +} + +impl Default for AsyncRestrictedResolver { + fn default() -> Self { + Self { + inner: AsyncGenericResolver::new(), + allowed_hosts: Vec::new(), + } + } +} + +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +impl AsyncHttpResolver for AsyncRestrictedResolver { + async fn http_resolve_async( + &self, + request: Request>, + ) -> Result>, HttpResolverError> { + match is_uri_allowed(self.allowed_hosts(), request.uri()) { + true => self.inner.http_resolve_async(request).await, + false => Err(HttpResolverError::UriDisallowed { + uri: request.uri().to_string(), + }), + } + } +} + +#[cfg_attr(feature = "json_schema", derive(schemars::JsonSchema))] +#[schemars(with = "String")] +#[derive(Debug, Clone, PartialEq)] +pub struct HostPattern { + uri: Uri, +} + +impl HostPattern { + // TODO: validate it doesn't have more than a scheme and a host and that it has at least 1 + pub fn new(uri: Uri) -> Self { + Self { uri } + } + + pub fn matches(&self, uri: &Uri) -> bool { + if let Some(allowed_host_pattern) = self.uri.host() { + if let Some(host) = uri.host() { + // If there's a wildcard, do an suffix match, otherwise do an exact match. + let host_allowed = if let Some(suffix) = allowed_host_pattern.strip_prefix("*.") { + let host = host.to_ascii_lowercase(); + let suffix = suffix.to_ascii_lowercase(); + + if host.len() <= suffix.len() || !host.ends_with(&suffix) { + false + } else { + // Make sure there is a component in place of the wildcard. + host.as_bytes()[host.len() - suffix.len() - 1] == b'.' + } + } else { + allowed_host_pattern.eq_ignore_ascii_case(host) + }; + + if host_allowed { + if let Some(allowed_scheme) = self.uri.scheme() { + if let Some(scheme) = uri.scheme() { + return scheme == allowed_scheme; + } + } else { + return true; + } + } + } + } else if let Some(allowed_scheme) = self.uri.scheme() { + if let Some(scheme) = uri.scheme() { + return scheme == allowed_scheme; + } + } + + false + } +} + +impl Serialize for HostPattern { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.uri.to_string()) + } +} + +impl<'de> Deserialize<'de> for HostPattern { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let string = String::deserialize(deserializer)?; + let uri = string.parse::().map_err(serde::de::Error::custom)?; + Ok(HostPattern::new(uri)) + } +} + +fn is_uri_allowed(patterns: &[HostPattern], uri: &Uri) -> bool { + if patterns.is_empty() { + return true; + } + + for pattern in patterns { + if pattern.matches(uri) { + return true; + } + } + + false +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn basic_wildcard_pattern() { + let pattern = HostPattern::new(Uri::from_static("*.contentauthenticity.org")); + + let uri = Uri::from_static("test.contentauthenticity.org"); + assert!(pattern.matches(&uri)); + + let uri = Uri::from_static("contentauthenticity.org"); + assert!(!pattern.matches(&uri)); + + let uri = Uri::from_static("fakecontentauthenticity.org"); + assert!(!pattern.matches(&uri)); + } + + #[test] + fn wildcard_pattern_with_scheme() { + let pattern = HostPattern::new(Uri::from_static("https://*.contentauthenticity.org")); + + let uri = Uri::from_static("test.contentauthenticity.org"); + assert!(!pattern.matches(&uri)); + + let uri = Uri::from_static("contentauthenticity.org"); + assert!(!pattern.matches(&uri)); + + let uri = Uri::from_static("fakecontentauthenticity.org"); + assert!(!pattern.matches(&uri)); + + let uri = Uri::from_static("https://test.contentauthenticity.org"); + assert!(pattern.matches(&uri)); + + let uri = Uri::from_static("https://contentauthenticity.org"); + assert!(!pattern.matches(&uri)); + + let uri = Uri::from_static("https://fakecontentauthenticity.org"); + assert!(!pattern.matches(&uri)); + + let uri = Uri::from_static("http://test.contentauthenticity.org"); + assert!(!pattern.matches(&uri)); + } + + #[test] + fn pattern_case_insensitive() { + let pattern = HostPattern::new(Uri::from_static("*.contentAuthenticity.org")); + + let uri = Uri::from_static("tEst.conTentauthenticity.orG"); + assert!(pattern.matches(&uri)); + } + + #[test] + fn pattern_exact() { + let pattern = HostPattern::new(Uri::from_static("contentauthenticity.org")); + + let uri = Uri::from_static("contentauthenticity.org"); + assert!(pattern.matches(&uri)); + + let uri = Uri::from_static("https://contentauthenticity.org"); + assert!(pattern.matches(&uri)); + + let uri = Uri::from_static("http://contentauthenticity.org"); + assert!(pattern.matches(&uri)); + } + + #[test] + fn pattern_exact_with_schema() { + let pattern = HostPattern::new(Uri::from_static("https://contentauthenticity.org")); + + let uri = Uri::from_static("https://contentauthenticity.org"); + assert!(pattern.matches(&uri)); + + let uri = Uri::from_static("http://contentauthenticity.org"); + assert!(!pattern.matches(&uri)); + + let uri = Uri::from_static("contentauthenticity.org"); + assert!(!pattern.matches(&uri)); + } +} diff --git a/sdk/src/ingredient.rs b/sdk/src/ingredient.rs index 6f1aa71aa..8b4e29403 100644 --- a/sdk/src/ingredient.rs +++ b/sdk/src/ingredient.rs @@ -35,7 +35,10 @@ use crate::{ crypto::base64, error::{Error, Result}, hashed_uri::HashedUri, - http::{AsyncGenericResolver, AsyncHttpResolver, SyncGenericResolver, SyncHttpResolver}, + http::{ + restricted::{AsyncRestrictedResolver, SyncRestrictedResolver}, + AsyncGenericResolver, AsyncHttpResolver, SyncGenericResolver, SyncHttpResolver, + }, jumbf::{ self, labels::{assertion_label_from_uri, manifest_label_from_uri}, @@ -733,7 +736,15 @@ impl Ingredient { options: &dyn IngredientOptions, ) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); - let http_resolver = SyncGenericResolver::new(); + let allowed_network_hosts = settings + .core + .allowed_network_hosts + .as_deref() + .unwrap_or_default(); + let http_resolver = SyncRestrictedResolver::with_allowed_hosts( + SyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ); Self::from_file_impl(path.as_ref(), options, &http_resolver, &settings) } @@ -842,9 +853,19 @@ impl Ingredient { /// Thumbnail will be set only if one can be retrieved from a previous valid manifest. pub fn from_stream(format: &str, stream: &mut dyn CAIRead) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); + let allowed_network_hosts = settings + .core + .allowed_network_hosts + .as_deref() + .unwrap_or_default(); + let http_resolver = SyncRestrictedResolver::with_allowed_hosts( + SyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ); + let ingredient = Self::from_stream_info(stream, format, "untitled"); stream.rewind()?; - ingredient.add_stream_internal(format, stream, &SyncGenericResolver::new(), &settings) + ingredient.add_stream_internal(format, stream, &http_resolver, &settings) } /// Create an Ingredient from JSON. @@ -1023,7 +1044,16 @@ impl Ingredient { /// Thumbnail will be set only if one can be retrieved from a previous valid manifest. pub async fn from_stream_async(format: &str, stream: &mut dyn CAIRead) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); - let http_resolver = AsyncGenericResolver::new(); + let allowed_network_hosts = settings + .core + .allowed_network_hosts + .as_deref() + .unwrap_or_default(); + let http_resolver = AsyncRestrictedResolver::with_allowed_hosts( + AsyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ); + Self::from_stream_async_with_settings(format, stream, &http_resolver, &settings).await } @@ -1042,7 +1072,7 @@ impl Ingredient { let (result, manifest_bytes) = match Store::load_jumbf_from_stream_async( format, stream, - &AsyncGenericResolver::new(), + http_resolver, settings, ) .await @@ -1489,7 +1519,15 @@ impl Ingredient { stream: &mut dyn CAIRead, ) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); - let http_resolver = AsyncGenericResolver::new(); + let allowed_network_hosts = settings + .core + .allowed_network_hosts + .as_deref() + .unwrap_or_default(); + let http_resolver = AsyncRestrictedResolver::with_allowed_hosts( + AsyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ); let mut ingredient = Self::from_stream_info(stream, format, "untitled"); let mut validation_log = StatusTracker::default(); diff --git a/sdk/src/reader.rs b/sdk/src/reader.rs index 0eff6e704..7dc7315cb 100644 --- a/sdk/src/reader.rs +++ b/sdk/src/reader.rs @@ -35,7 +35,10 @@ use crate::{ claim::Claim, dynamic_assertion::PartialClaim, error::{Error, Result}, - http::{AsyncGenericResolver, SyncGenericResolver}, + http::{ + restricted::{AsyncRestrictedResolver, SyncRestrictedResolver}, + AsyncGenericResolver, SyncGenericResolver, + }, jumbf::labels::{manifest_label_from_uri, to_absolute_uri, to_relative_uri}, jumbf_io, log_item, manifest::StoreOptions, @@ -140,11 +143,24 @@ impl Reader { #[cfg(not(target_arch = "wasm32"))] pub fn from_stream(format: &str, mut stream: impl Read + Seek + Send) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); + + let allowed_network_hosts = settings + .core + .allowed_network_hosts + .as_deref() + .unwrap_or_default(); let http_resolver = if _sync { - SyncGenericResolver::new() + SyncRestrictedResolver::with_allowed_hosts( + SyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ) } else { - AsyncGenericResolver::new() + AsyncRestrictedResolver::with_allowed_hosts( + AsyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ) }; + // TODO: passing verify is redundant with settings let verify = settings.verify.verify_after_reading; @@ -182,10 +198,21 @@ impl Reader { #[cfg(target_arch = "wasm32")] pub fn from_stream(format: &str, mut stream: impl Read + Seek) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); + let allowed_network_hosts = settings + .core + .allowed_network_hosts + .as_deref() + .unwrap_or_default(); let http_resolver = if _sync { - SyncGenericResolver::new() + SyncRestrictedResolver::with_allowed_hosts( + SyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ) } else { - AsyncGenericResolver::new() + AsyncRestrictedResolver::with_allowed_hosts( + AsyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ) }; // TODO: passing verify is redundant with settings let verify = settings.verify.verify_after_reading; @@ -309,10 +336,21 @@ impl Reader { stream: impl Read + Seek + Send, ) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); + let allowed_network_hosts = settings + .core + .allowed_network_hosts + .as_deref() + .unwrap_or_default(); let http_resolver = if _sync { - SyncGenericResolver::new() + SyncRestrictedResolver::with_allowed_hosts( + SyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ) } else { - AsyncGenericResolver::new() + AsyncRestrictedResolver::with_allowed_hosts( + AsyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ) }; let mut validation_log = StatusTracker::default(); @@ -363,10 +401,21 @@ impl Reader { mut fragment: impl Read + Seek + Send, ) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); + let allowed_network_hosts = settings + .core + .allowed_network_hosts + .as_deref() + .unwrap_or_default(); let http_resolver = if _sync { - SyncGenericResolver::new() + SyncRestrictedResolver::with_allowed_hosts( + SyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ) } else { - AsyncGenericResolver::new() + AsyncRestrictedResolver::with_allowed_hosts( + AsyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ) }; let mut validation_log = StatusTracker::default(); @@ -404,7 +453,15 @@ impl Reader { fragments: &Vec, ) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); - let http_resolver = SyncGenericResolver::new(); + let allowed_network_hosts = settings + .core + .allowed_network_hosts + .as_deref() + .unwrap_or_default(); + let http_resolver = SyncRestrictedResolver::with_allowed_hosts( + SyncGenericResolver::new(), + allowed_network_hosts.to_vec(), + ); let verify = settings.verify.verify_after_reading; let mut validation_log = StatusTracker::default(); diff --git a/sdk/src/settings/mod.rs b/sdk/src/settings/mod.rs index f271a062c..155189071 100644 --- a/sdk/src/settings/mod.rs +++ b/sdk/src/settings/mod.rs @@ -27,7 +27,10 @@ use config::{Config, FileFormat}; use serde_derive::{Deserialize, Serialize}; use signer::SignerSettings; -use crate::{crypto::base64, settings::builder::BuilderSettings, Error, Result, Signer}; +use crate::{ + crypto::base64, http::restricted::HostPattern, settings::builder::BuilderSettings, Error, + Result, Signer, +}; const VERSION: u32 = 1; @@ -219,6 +222,28 @@ pub struct Core { /// [`IdentityAssertion`]: crate::identity::IdentityAssertion /// [`Reader`]: crate::Reader pub decode_identity_assertions: bool, + /// List of host patterns that are allowed for outbound network requests. + /// + /// Each pattern may include: + /// - A scheme (e.g. `https://` or `http://`) + /// - A hostname, which may have a single leading wildcard (e.g. `*.contentauthenticity.org`) + /// + /// Matching is case-insensitive. A wildcard pattern such as `*.contentauthenticity.org` matches + /// `sub.contentauthenticity.org`, but does not match `contentauthenticity.org` or `fakecontentauthenticity.org`. + /// If a scheme is present in the pattern, only URIs using the same scheme are considered a match. If the scheme + /// is omitted, any scheme is allowed as long as the host matches. + /// + /// The behavior is as follows: + /// - `None` (default) no filtering enabled. + /// - `Some(vec)` where `vec` is empty, all outbound traffic is blocked. + /// - `Some(vec)` with at least one pattern, filtering enabled for only those patterns. + /// + /// These settings are consumed by [`SyncRestrictedResolver`] and [`AsyncRestrictedResolver`]. + /// + /// [`HostPattern`]: crate::http::restricted::HostPattern + /// [`SyncRestrictedResolver`]: crate::http::restricted::SyncRestrictedResolver + /// [`AsyncRestrictedResolver`]: crate::http::restricted::AsyncRestrictedResolver + pub allowed_network_hosts: Option>, } impl Default for Core { @@ -228,6 +253,7 @@ impl Default for Core { merkle_tree_max_proofs: 5, backing_store_memory_threshold_in_mb: 512, decode_identity_assertions: true, + allowed_network_hosts: None, } } } From 7b07d4c45f928865005acb36b459b0e76595c452 Mon Sep 17 00:00:00 2001 From: ok-nick Date: Mon, 24 Nov 2025 11:42:32 -0500 Subject: [PATCH 2/9] docs: document when outbound network requests occur --- sdk/src/http/mod.rs | 26 ++++++++++++++++++++++++++ sdk/src/settings/mod.rs | 3 +++ 2 files changed, 29 insertions(+) diff --git a/sdk/src/http/mod.rs b/sdk/src/http/mod.rs index a2bb92ad1..2e333873f 100644 --- a/sdk/src/http/mod.rs +++ b/sdk/src/http/mod.rs @@ -11,6 +11,32 @@ // specific language governing permissions and limitations under // each license. +//! HTTP abstraction layer. +//! +//! This module defines generic traits and helpers for performing HTTP requests +//! without hard-wiring a specific HTTP client. It allows host applications to +//! plug in their own HTTP implementation, restrict where the SDK may connect, +//! or disable networking entirely. +//! +//! # When do outbound network requests occur? +//! +//! The SDK may issue outbound HTTP/S requests in the following scenarios: +//! - [`Reader`]: +//! - Fetching remote manifests +//! - Validating CAWG identity assertions +//! - Fetching OCSP revocation status +//! - [`Builder`]: +//! - Fetching ingredient remote manifests +//! - Fetching timestamps +//! - Fetching [`TimeStamp`] assertions +//! - Fetching OCSP staples +//! - Fetching [`CertificateStatus`] assertions +//! +//! [`Reader`]: crate::Reader +//! [`Builder`]: crate::Builder +//! [`TimeStamp`]: crate::assertions::TimeStamp +//! [`CertificateStatus`]: crate::assertions::CertificateStatus + use std::io::{self, Read}; use async_trait::async_trait; diff --git a/sdk/src/settings/mod.rs b/sdk/src/settings/mod.rs index 155189071..2f28ee5bb 100644 --- a/sdk/src/settings/mod.rs +++ b/sdk/src/settings/mod.rs @@ -238,8 +238,11 @@ pub struct Core { /// - `Some(vec)` where `vec` is empty, all outbound traffic is blocked. /// - `Some(vec)` with at least one pattern, filtering enabled for only those patterns. /// + /// For information on when the SDK might perform an outbound network request, see "[When do outbound network requests occur?]" + /// /// These settings are consumed by [`SyncRestrictedResolver`] and [`AsyncRestrictedResolver`]. /// + /// [When do outbound network requests occur?]: crate::http#when-do-outbound-network-requests-occur /// [`HostPattern`]: crate::http::restricted::HostPattern /// [`SyncRestrictedResolver`]: crate::http::restricted::SyncRestrictedResolver /// [`AsyncRestrictedResolver`]: crate::http::restricted::AsyncRestrictedResolver From c364773f9ef1477ee5680bdd5ed0d3f7d5e7ccb3 Mon Sep 17 00:00:00 2001 From: ok-nick Date: Mon, 24 Nov 2025 12:27:04 -0500 Subject: [PATCH 3/9] fix: consolidate sync and async restricted resolver --- sdk/src/builder.rs | 15 ++++------ sdk/src/http/mod.rs | 4 +-- sdk/src/http/restricted.rs | 61 ++++++++++++-------------------------- sdk/src/ingredient.rs | 12 ++++---- sdk/src/reader.rs | 23 +++++++------- sdk/src/settings/mod.rs | 4 +-- 6 files changed, 45 insertions(+), 74 deletions(-) diff --git a/sdk/src/builder.rs b/sdk/src/builder.rs index db3e63425..a579c9691 100644 --- a/sdk/src/builder.rs +++ b/sdk/src/builder.rs @@ -37,10 +37,7 @@ use crate::{ }, claim::Claim, error::{Error, Result}, - http::{ - restricted::{AsyncRestrictedResolver, SyncRestrictedResolver}, - AsyncGenericResolver, SyncGenericResolver, - }, + http::{restricted::RestrictedResolver, AsyncGenericResolver, SyncGenericResolver}, jumbf_io, resource_store::{ResourceRef, ResourceResolver, ResourceStore}, settings::{self, Settings}, @@ -626,12 +623,12 @@ impl Builder { .as_deref() .unwrap_or_default(); let http_resolver = if _sync { - SyncRestrictedResolver::with_allowed_hosts( + RestrictedResolver::with_allowed_hosts( SyncGenericResolver::new(), allowed_network_hosts.to_vec(), ) } else { - AsyncRestrictedResolver::with_allowed_hosts( + RestrictedResolver::with_allowed_hosts( AsyncGenericResolver::new(), allowed_network_hosts.to_vec(), ) @@ -886,7 +883,7 @@ impl Builder { .allowed_network_hosts .as_deref() .unwrap_or_default(); - let http_resolver = SyncRestrictedResolver::with_allowed_hosts( + let http_resolver = RestrictedResolver::with_allowed_hosts( SyncGenericResolver::new(), allowed_network_hosts.to_vec(), ); @@ -1610,12 +1607,12 @@ impl Builder { .as_deref() .unwrap_or_default(); let http_resolver = if _sync { - SyncRestrictedResolver::with_allowed_hosts( + RestrictedResolver::with_allowed_hosts( SyncGenericResolver::new(), allowed_network_hosts.to_vec(), ) } else { - AsyncRestrictedResolver::with_allowed_hosts( + RestrictedResolver::with_allowed_hosts( AsyncGenericResolver::new(), allowed_network_hosts.to_vec(), ) diff --git a/sdk/src/http/mod.rs b/sdk/src/http/mod.rs index 2e333873f..76173ffab 100644 --- a/sdk/src/http/mod.rs +++ b/sdk/src/http/mod.rs @@ -179,9 +179,9 @@ pub enum HttpResolverError { /// The remote URI is blocked by the allowed list. /// - /// The allowed list is normally set in a [`SyncRestrictedResolver`]. + /// The allowed list is normally set in a [`RestrictedResolver`]. /// - /// [`SyncRestrictedResolver`]: restricted::SyncRestrictedResolver + /// [`RestrictedResolver`]: restricted::RestrictedResolver #[error("remote URI \"{uri}\" is not permitted by the allowed list")] UriDisallowed { uri: String }, diff --git a/sdk/src/http/restricted.rs b/sdk/src/http/restricted.rs index 1371518da..67fc02acc 100644 --- a/sdk/src/http/restricted.rs +++ b/sdk/src/http/restricted.rs @@ -26,12 +26,12 @@ use crate::{ }; #[derive(Debug)] -pub struct SyncRestrictedResolver { +pub struct RestrictedResolver { inner: T, allowed_hosts: Vec, } -impl SyncRestrictedResolver { +impl RestrictedResolver { pub fn new(inner: T) -> Self { Self { inner, @@ -51,7 +51,7 @@ impl SyncRestrictedResolver { } } -impl Default for SyncRestrictedResolver { +impl Default for RestrictedResolver { fn default() -> Self { Self { inner: SyncGenericResolver::new(), @@ -60,7 +60,16 @@ impl Default for SyncRestrictedResolver { } } -impl SyncHttpResolver for SyncRestrictedResolver { +impl Default for RestrictedResolver { + fn default() -> Self { + Self { + inner: AsyncGenericResolver::new(), + allowed_hosts: Vec::new(), + } + } +} + +impl SyncHttpResolver for RestrictedResolver { fn http_resolve( &self, request: Request>, @@ -74,44 +83,9 @@ impl SyncHttpResolver for SyncRestrictedResolver { } } -#[derive(Debug)] -pub struct AsyncRestrictedResolver { - inner: T, - allowed_hosts: Vec, -} - -impl AsyncRestrictedResolver { - pub fn new(inner: T) -> Self { - Self { - inner, - allowed_hosts: Vec::new(), - } - } - - pub fn with_allowed_hosts(inner: T, allowed_hosts: Vec) -> Self { - Self { - inner, - allowed_hosts, - } - } - - pub fn allowed_hosts(&self) -> &[HostPattern] { - &self.allowed_hosts - } -} - -impl Default for AsyncRestrictedResolver { - fn default() -> Self { - Self { - inner: AsyncGenericResolver::new(), - allowed_hosts: Vec::new(), - } - } -} - #[cfg_attr(not(target_arch = "wasm32"), async_trait)] #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] -impl AsyncHttpResolver for AsyncRestrictedResolver { +impl AsyncHttpResolver for RestrictedResolver { async fn http_resolve_async( &self, request: Request>, @@ -125,8 +99,11 @@ impl AsyncHttpResolver for AsyncRestrictedResolver< } } -#[cfg_attr(feature = "json_schema", derive(schemars::JsonSchema))] -#[schemars(with = "String")] +#[cfg_attr( + feature = "json_schema", + derive(schemars::JsonSchema), + schemars(with = "String") +)] #[derive(Debug, Clone, PartialEq)] pub struct HostPattern { uri: Uri, diff --git a/sdk/src/ingredient.rs b/sdk/src/ingredient.rs index 8b4e29403..793b806c8 100644 --- a/sdk/src/ingredient.rs +++ b/sdk/src/ingredient.rs @@ -36,8 +36,8 @@ use crate::{ error::{Error, Result}, hashed_uri::HashedUri, http::{ - restricted::{AsyncRestrictedResolver, SyncRestrictedResolver}, - AsyncGenericResolver, AsyncHttpResolver, SyncGenericResolver, SyncHttpResolver, + restricted::RestrictedResolver, AsyncGenericResolver, AsyncHttpResolver, + SyncGenericResolver, SyncHttpResolver, }, jumbf::{ self, @@ -741,7 +741,7 @@ impl Ingredient { .allowed_network_hosts .as_deref() .unwrap_or_default(); - let http_resolver = SyncRestrictedResolver::with_allowed_hosts( + let http_resolver = RestrictedResolver::with_allowed_hosts( SyncGenericResolver::new(), allowed_network_hosts.to_vec(), ); @@ -858,7 +858,7 @@ impl Ingredient { .allowed_network_hosts .as_deref() .unwrap_or_default(); - let http_resolver = SyncRestrictedResolver::with_allowed_hosts( + let http_resolver = RestrictedResolver::with_allowed_hosts( SyncGenericResolver::new(), allowed_network_hosts.to_vec(), ); @@ -1049,7 +1049,7 @@ impl Ingredient { .allowed_network_hosts .as_deref() .unwrap_or_default(); - let http_resolver = AsyncRestrictedResolver::with_allowed_hosts( + let http_resolver = RestrictedResolver::with_allowed_hosts( AsyncGenericResolver::new(), allowed_network_hosts.to_vec(), ); @@ -1524,7 +1524,7 @@ impl Ingredient { .allowed_network_hosts .as_deref() .unwrap_or_default(); - let http_resolver = AsyncRestrictedResolver::with_allowed_hosts( + let http_resolver = RestrictedResolver::with_allowed_hosts( AsyncGenericResolver::new(), allowed_network_hosts.to_vec(), ); diff --git a/sdk/src/reader.rs b/sdk/src/reader.rs index 7dc7315cb..88536d174 100644 --- a/sdk/src/reader.rs +++ b/sdk/src/reader.rs @@ -35,10 +35,7 @@ use crate::{ claim::Claim, dynamic_assertion::PartialClaim, error::{Error, Result}, - http::{ - restricted::{AsyncRestrictedResolver, SyncRestrictedResolver}, - AsyncGenericResolver, SyncGenericResolver, - }, + http::{restricted::RestrictedResolver, AsyncGenericResolver, SyncGenericResolver}, jumbf::labels::{manifest_label_from_uri, to_absolute_uri, to_relative_uri}, jumbf_io, log_item, manifest::StoreOptions, @@ -150,12 +147,12 @@ impl Reader { .as_deref() .unwrap_or_default(); let http_resolver = if _sync { - SyncRestrictedResolver::with_allowed_hosts( + RestrictedResolver::with_allowed_hosts( SyncGenericResolver::new(), allowed_network_hosts.to_vec(), ) } else { - AsyncRestrictedResolver::with_allowed_hosts( + RestrictedResolver::with_allowed_hosts( AsyncGenericResolver::new(), allowed_network_hosts.to_vec(), ) @@ -204,12 +201,12 @@ impl Reader { .as_deref() .unwrap_or_default(); let http_resolver = if _sync { - SyncRestrictedResolver::with_allowed_hosts( + RestrictedResolver::with_allowed_hosts( SyncGenericResolver::new(), allowed_network_hosts.to_vec(), ) } else { - AsyncRestrictedResolver::with_allowed_hosts( + RestrictedResolver::with_allowed_hosts( AsyncGenericResolver::new(), allowed_network_hosts.to_vec(), ) @@ -342,12 +339,12 @@ impl Reader { .as_deref() .unwrap_or_default(); let http_resolver = if _sync { - SyncRestrictedResolver::with_allowed_hosts( + RestrictedResolver::with_allowed_hosts( SyncGenericResolver::new(), allowed_network_hosts.to_vec(), ) } else { - AsyncRestrictedResolver::with_allowed_hosts( + RestrictedResolver::with_allowed_hosts( AsyncGenericResolver::new(), allowed_network_hosts.to_vec(), ) @@ -407,12 +404,12 @@ impl Reader { .as_deref() .unwrap_or_default(); let http_resolver = if _sync { - SyncRestrictedResolver::with_allowed_hosts( + RestrictedResolver::with_allowed_hosts( SyncGenericResolver::new(), allowed_network_hosts.to_vec(), ) } else { - AsyncRestrictedResolver::with_allowed_hosts( + RestrictedResolver::with_allowed_hosts( AsyncGenericResolver::new(), allowed_network_hosts.to_vec(), ) @@ -458,7 +455,7 @@ impl Reader { .allowed_network_hosts .as_deref() .unwrap_or_default(); - let http_resolver = SyncRestrictedResolver::with_allowed_hosts( + let http_resolver = RestrictedResolver::with_allowed_hosts( SyncGenericResolver::new(), allowed_network_hosts.to_vec(), ); diff --git a/sdk/src/settings/mod.rs b/sdk/src/settings/mod.rs index 2f28ee5bb..7cf936c74 100644 --- a/sdk/src/settings/mod.rs +++ b/sdk/src/settings/mod.rs @@ -238,9 +238,9 @@ pub struct Core { /// - `Some(vec)` where `vec` is empty, all outbound traffic is blocked. /// - `Some(vec)` with at least one pattern, filtering enabled for only those patterns. /// - /// For information on when the SDK might perform an outbound network request, see "[When do outbound network requests occur?]" + /// These settings are consumed by [`RestrictedResolver`]. /// - /// These settings are consumed by [`SyncRestrictedResolver`] and [`AsyncRestrictedResolver`]. + /// For information on when the SDK might perform an outbound network request, see "[When do outbound network requests occur?]" /// /// [When do outbound network requests occur?]: crate::http#when-do-outbound-network-requests-occur /// [`HostPattern`]: crate::http::restricted::HostPattern From 3888fccd261efcf04410ef55f546557a186641ef Mon Sep 17 00:00:00 2001 From: ok-nick Date: Mon, 24 Nov 2025 13:17:52 -0500 Subject: [PATCH 4/9] docs: document restricted resolvers and patterns --- sdk/src/http/mod.rs | 4 +++- sdk/src/http/restricted.rs | 26 ++++++++++++++++++++++++++ sdk/src/settings/mod.rs | 3 +-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/sdk/src/http/mod.rs b/sdk/src/http/mod.rs index 76173ffab..df28f2b8b 100644 --- a/sdk/src/http/mod.rs +++ b/sdk/src/http/mod.rs @@ -45,10 +45,12 @@ use http::{Request, Response}; use crate::Result; mod reqwest; -pub mod restricted; mod ureq; mod wasi; +/// Structs for restricting outbound network requests. +pub mod restricted; + // Since we use `http::Request` and `http::Response` we also expose the `http` crate. pub use http; diff --git a/sdk/src/http/restricted.rs b/sdk/src/http/restricted.rs index 67fc02acc..1c37fc362 100644 --- a/sdk/src/http/restricted.rs +++ b/sdk/src/http/restricted.rs @@ -25,6 +25,11 @@ use crate::{ Result, }; +/// HTTP resolver wrapper that enforces an allowed-list of outbound hosts. +/// +/// If the allowed list is empty, no filtering is applied and all outbound requests are allowed. +/// +/// When a URI is not permitted, the resolver returns [`HttpResolverError::UriDisallowed`]. #[derive(Debug)] pub struct RestrictedResolver { inner: T, @@ -32,6 +37,7 @@ pub struct RestrictedResolver { } impl RestrictedResolver { + /// Creates a new `RestrictedResolver` with an empty allowed list. pub fn new(inner: T) -> Self { Self { inner, @@ -39,6 +45,7 @@ impl RestrictedResolver { } } + /// Creates a new `RestrictedResolver` with the specified allowed list. pub fn with_allowed_hosts(inner: T, allowed_hosts: Vec) -> Self { Self { inner, @@ -46,6 +53,12 @@ impl RestrictedResolver { } } + /// Replaces the current allowed list with the given allowed list. + pub fn set_allowed_hosts(&mut self, allowed_hosts: Vec) { + self.allowed_hosts = allowed_hosts; + } + + /// Returns a reference to the allowed list. pub fn allowed_hosts(&self) -> &[HostPattern] { &self.allowed_hosts } @@ -99,6 +112,16 @@ impl AsyncHttpResolver for RestrictedResolver { } } +/// A host/scheme pattern used to restrict outbound network requests. +/// +/// Each pattern may include: +/// - A scheme (e.g. `https://` or `http://`) +/// - A hostname, which may have a single leading wildcard (e.g. `*.contentauthenticity.org`) +/// +/// Matching is case-insensitive. A wildcard pattern such as `*.contentauthenticity.org` matches +/// `sub.contentauthenticity.org`, but does not match `contentauthenticity.org` or `fakecontentauthenticity.org`. +/// If a scheme is present in the pattern, only URIs using the same scheme are considered a match. If the scheme +/// is omitted, any scheme is allowed as long as the host matches. #[cfg_attr( feature = "json_schema", derive(schemars::JsonSchema), @@ -111,10 +134,12 @@ pub struct HostPattern { impl HostPattern { // TODO: validate it doesn't have more than a scheme and a host and that it has at least 1 + /// Creates a new `HostPattern` with the given URI. pub fn new(uri: Uri) -> Self { Self { uri } } + /// Returns if the given URI matches the `HostPattern`. pub fn matches(&self, uri: &Uri) -> bool { if let Some(allowed_host_pattern) = self.uri.host() { if let Some(host) = uri.host() { @@ -173,6 +198,7 @@ impl<'de> Deserialize<'de> for HostPattern { } } +/// Returns if the given URI matches at least one of the [`HostPattern`]s. fn is_uri_allowed(patterns: &[HostPattern], uri: &Uri) -> bool { if patterns.is_empty() { return true; diff --git a/sdk/src/settings/mod.rs b/sdk/src/settings/mod.rs index 7cf936c74..c816e4ab2 100644 --- a/sdk/src/settings/mod.rs +++ b/sdk/src/settings/mod.rs @@ -244,8 +244,7 @@ pub struct Core { /// /// [When do outbound network requests occur?]: crate::http#when-do-outbound-network-requests-occur /// [`HostPattern`]: crate::http::restricted::HostPattern - /// [`SyncRestrictedResolver`]: crate::http::restricted::SyncRestrictedResolver - /// [`AsyncRestrictedResolver`]: crate::http::restricted::AsyncRestrictedResolver + /// [`RestrictedResolver`]: crate::http::restricted::RestrictedResolver pub allowed_network_hosts: Option>, } From d8c2e6671e234d3a7b9759cd6b4677500fcddd72 Mon Sep 17 00:00:00 2001 From: ok-nick Date: Mon, 24 Nov 2025 14:00:59 -0500 Subject: [PATCH 5/9] test: add basic restricted resolver tests --- sdk/src/http/restricted.rs | 66 +++++++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/sdk/src/http/restricted.rs b/sdk/src/http/restricted.rs index 1c37fc362..af4fed5ea 100644 --- a/sdk/src/http/restricted.rs +++ b/sdk/src/http/restricted.rs @@ -38,6 +38,7 @@ pub struct RestrictedResolver { impl RestrictedResolver { /// Creates a new `RestrictedResolver` with an empty allowed list. + #[allow(dead_code)] // TODO: temp until http module is public pub fn new(inner: T) -> Self { Self { inner, @@ -54,6 +55,7 @@ impl RestrictedResolver { } /// Replaces the current allowed list with the given allowed list. + #[allow(dead_code)] // TODO: temp until http module is public pub fn set_allowed_hosts(&mut self, allowed_hosts: Vec) { self.allowed_hosts = allowed_hosts; } @@ -178,6 +180,12 @@ impl HostPattern { } } +impl From for HostPattern { + fn from(uri: Uri) -> Self { + Self { uri } + } +} + impl Serialize for HostPattern { fn serialize(&self, serializer: S) -> Result where @@ -215,10 +223,60 @@ fn is_uri_allowed(patterns: &[HostPattern], uri: &Uri) -> bool { #[cfg(test)] mod test { + #![allow(clippy::panic, clippy::unwrap_used)] + use super::*; + struct NoopHttpResolver; + + impl SyncHttpResolver for NoopHttpResolver { + fn http_resolve( + &self, + _request: Request>, + ) -> Result>, HttpResolverError> { + Ok(Response::new(Box::new(std::io::empty()) as Box)) + } + } + + #[test] + fn allowed_http_request() { + let allowed_list = vec![ + Uri::from_static("*.prefix.contentauthenticity.org").into(), + Uri::from_static("test.contentauthenticity.org").into(), + Uri::from_static("fakecontentauthenticity.org").into(), + Uri::from_static("https://*.contentauthenticity.org").into(), + Uri::from_static("https://test.contentauthenticity.org").into(), + ]; + let restricted_resolver = + RestrictedResolver::with_allowed_hosts(NoopHttpResolver, allowed_list); + + let result = restricted_resolver.http_resolve( + Request::get(Uri::from_static("fakecontentauthenticity.org")) + .body(Vec::new()) + .unwrap(), + ); + assert!(matches!(result, Ok(..))); + } + + #[test] + fn disallowed_http_request() { + let allowed_list = vec![]; + let restricted_resolver = + RestrictedResolver::with_allowed_hosts(NoopHttpResolver, allowed_list); + + let result = restricted_resolver.http_resolve( + Request::get(Uri::from_static("fakecontentauthenticity.org")) + .body(Vec::new()) + .unwrap(), + ); + assert!(matches!( + result, + Err(HttpResolverError::UriDisallowed { .. }) + )); + } + #[test] - fn basic_wildcard_pattern() { + fn wildcard_pattern() { let pattern = HostPattern::new(Uri::from_static("*.contentauthenticity.org")); let uri = Uri::from_static("test.contentauthenticity.org"); @@ -258,7 +316,7 @@ mod test { } #[test] - fn pattern_case_insensitive() { + fn case_insensitive_pattern() { let pattern = HostPattern::new(Uri::from_static("*.contentAuthenticity.org")); let uri = Uri::from_static("tEst.conTentauthenticity.orG"); @@ -266,7 +324,7 @@ mod test { } #[test] - fn pattern_exact() { + fn exact_pattern() { let pattern = HostPattern::new(Uri::from_static("contentauthenticity.org")); let uri = Uri::from_static("contentauthenticity.org"); @@ -280,7 +338,7 @@ mod test { } #[test] - fn pattern_exact_with_schema() { + fn exact_pattern_with_schema() { let pattern = HostPattern::new(Uri::from_static("https://contentauthenticity.org")); let uri = Uri::from_static("https://contentauthenticity.org"); From e05e9fea7119bf7bb142777019b044f676b24105 Mon Sep 17 00:00:00 2001 From: ok-nick Date: Mon, 24 Nov 2025 16:14:20 -0500 Subject: [PATCH 6/9] fix: optional allowed hosts and refined restricted tests --- sdk/src/builder.rs | 45 +++---------- sdk/src/http/restricted.rs | 135 +++++++++++++++++++++---------------- sdk/src/ingredient.rs | 46 +++---------- sdk/src/reader.rs | 75 ++++++--------------- 4 files changed, 116 insertions(+), 185 deletions(-) diff --git a/sdk/src/builder.rs b/sdk/src/builder.rs index a579c9691..c20e5e607 100644 --- a/sdk/src/builder.rs +++ b/sdk/src/builder.rs @@ -617,22 +617,13 @@ impl Builder { R: Read + Seek + Send, { let settings = crate::settings::get_settings().unwrap_or_default(); - let allowed_network_hosts = settings - .core - .allowed_network_hosts - .as_deref() - .unwrap_or_default(); let http_resolver = if _sync { - RestrictedResolver::with_allowed_hosts( - SyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ) + SyncGenericResolver::new() } else { - RestrictedResolver::with_allowed_hosts( - AsyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ) + AsyncGenericResolver::new() }; + let mut http_resolver = RestrictedResolver::new(http_resolver); + http_resolver.set_allowed_hosts(settings.core.allowed_network_hosts.clone()); let ingredient: Ingredient = Ingredient::from_json(&ingredient_json.into())?; @@ -878,15 +869,8 @@ impl Builder { // so we will read the store directly here //crate::Reader::from_stream("application/c2pa", stream).and_then(|r| r.into_builder()) let settings = crate::settings::get_settings().unwrap_or_default(); - let allowed_network_hosts = settings - .core - .allowed_network_hosts - .as_deref() - .unwrap_or_default(); - let http_resolver = RestrictedResolver::with_allowed_hosts( - SyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ); + let mut http_resolver = RestrictedResolver::new(SyncGenericResolver::new()); + http_resolver.set_allowed_hosts(settings.core.allowed_network_hosts.clone()); let mut validation_log = crate::status_tracker::StatusTracker::default(); stream.rewind()?; // Ensure stream is at the start @@ -1601,22 +1585,13 @@ impl Builder { W: Write + Read + Seek + Send, { let settings = crate::settings::get_settings().unwrap_or_default(); - let allowed_network_hosts = settings - .core - .allowed_network_hosts - .as_deref() - .unwrap_or_default(); let http_resolver = if _sync { - RestrictedResolver::with_allowed_hosts( - SyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ) + SyncGenericResolver::new() } else { - RestrictedResolver::with_allowed_hosts( - AsyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ) + AsyncGenericResolver::new() }; + let mut http_resolver = RestrictedResolver::new(http_resolver); + http_resolver.set_allowed_hosts(settings.core.allowed_network_hosts.clone()); let format = format_to_mime(format); self.definition.format.clone_from(&format); diff --git a/sdk/src/http/restricted.rs b/sdk/src/http/restricted.rs index af4fed5ea..5d468dbac 100644 --- a/sdk/src/http/restricted.rs +++ b/sdk/src/http/restricted.rs @@ -18,14 +18,11 @@ use http::{Request, Response, Uri}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::{ - http::{ - AsyncGenericResolver, AsyncHttpResolver, HttpResolverError, SyncGenericResolver, - SyncHttpResolver, - }, + http::{AsyncHttpResolver, HttpResolverError, SyncHttpResolver}, Result, }; -/// HTTP resolver wrapper that enforces an allowed-list of outbound hosts. +/// HTTP resolver wrapper that enforces an allowed list of outbound hosts. /// /// If the allowed list is empty, no filtering is applied and all outbound requests are allowed. /// @@ -33,54 +30,35 @@ use crate::{ #[derive(Debug)] pub struct RestrictedResolver { inner: T, - allowed_hosts: Vec, + allowed_hosts: Option>, } impl RestrictedResolver { /// Creates a new `RestrictedResolver` with an empty allowed list. - #[allow(dead_code)] // TODO: temp until http module is public pub fn new(inner: T) -> Self { Self { inner, - allowed_hosts: Vec::new(), + allowed_hosts: None, } } /// Creates a new `RestrictedResolver` with the specified allowed list. + #[allow(dead_code)] // TODO: temp until http module is public pub fn with_allowed_hosts(inner: T, allowed_hosts: Vec) -> Self { Self { inner, - allowed_hosts, + allowed_hosts: Some(allowed_hosts), } } - /// Replaces the current allowed list with the given allowed list. - #[allow(dead_code)] // TODO: temp until http module is public - pub fn set_allowed_hosts(&mut self, allowed_hosts: Vec) { + /// Replaces the current allowed list with the given allowed list if specified. + pub fn set_allowed_hosts(&mut self, allowed_hosts: Option>) { self.allowed_hosts = allowed_hosts; } /// Returns a reference to the allowed list. - pub fn allowed_hosts(&self) -> &[HostPattern] { - &self.allowed_hosts - } -} - -impl Default for RestrictedResolver { - fn default() -> Self { - Self { - inner: SyncGenericResolver::new(), - allowed_hosts: Vec::new(), - } - } -} - -impl Default for RestrictedResolver { - fn default() -> Self { - Self { - inner: AsyncGenericResolver::new(), - allowed_hosts: Vec::new(), - } + pub fn allowed_hosts(&self) -> Option<&[HostPattern]> { + self.allowed_hosts.as_deref() } } @@ -89,11 +67,15 @@ impl SyncHttpResolver for RestrictedResolver { &self, request: Request>, ) -> Result>, HttpResolverError> { - match is_uri_allowed(self.allowed_hosts(), request.uri()) { - true => self.inner.http_resolve(request), - false => Err(HttpResolverError::UriDisallowed { + if self + .allowed_hosts() + .is_none_or(|hosts| is_uri_allowed(hosts, request.uri())) + { + self.inner.http_resolve(request) + } else { + Err(HttpResolverError::UriDisallowed { uri: request.uri().to_string(), - }), + }) } } } @@ -105,11 +87,15 @@ impl AsyncHttpResolver for RestrictedResolver { &self, request: Request>, ) -> Result>, HttpResolverError> { - match is_uri_allowed(self.allowed_hosts(), request.uri()) { - true => self.inner.http_resolve_async(request).await, - false => Err(HttpResolverError::UriDisallowed { + if self + .allowed_hosts() + .is_none_or(|hosts| is_uri_allowed(hosts, request.uri())) + { + self.inner.http_resolve_async(request).await + } else { + Err(HttpResolverError::UriDisallowed { uri: request.uri().to_string(), - }), + }) } } } @@ -208,10 +194,6 @@ impl<'de> Deserialize<'de> for HostPattern { /// Returns if the given URI matches at least one of the [`HostPattern`]s. fn is_uri_allowed(patterns: &[HostPattern], uri: &Uri) -> bool { - if patterns.is_empty() { - return true; - } - for pattern in patterns { if pattern.matches(uri) { return true; @@ -238,6 +220,27 @@ mod test { } } + fn assert_allowed_uri(resolver: &impl SyncHttpResolver, uri: &'static str) { + let result = resolver.http_resolve( + Request::get(Uri::from_static(uri)) + .body(Vec::new()) + .unwrap(), + ); + assert!(matches!(result, Ok(..))); + } + + fn assert_disallowed_uri(resolver: &impl SyncHttpResolver, uri: &'static str) { + let result = resolver.http_resolve( + Request::get(Uri::from_static(uri)) + .body(Vec::new()) + .unwrap(), + ); + assert!(matches!( + result, + Err(HttpResolverError::UriDisallowed { .. }) + )); + } + #[test] fn allowed_http_request() { let allowed_list = vec![ @@ -250,29 +253,45 @@ mod test { let restricted_resolver = RestrictedResolver::with_allowed_hosts(NoopHttpResolver, allowed_list); - let result = restricted_resolver.http_resolve( - Request::get(Uri::from_static("fakecontentauthenticity.org")) - .body(Vec::new()) - .unwrap(), + assert_allowed_uri(&restricted_resolver, "fakecontentauthenticity.org"); + assert_allowed_uri(&restricted_resolver, "test.prefix.contentauthenticity.org"); + assert_allowed_uri(&restricted_resolver, "https://test.contentauthenticity.org"); + assert_allowed_uri( + &restricted_resolver, + "https://test2.contentauthenticity.org", ); - assert!(matches!(result, Ok(..))); + + assert_disallowed_uri(&restricted_resolver, "test.test.contentauthenticity.org"); + assert_disallowed_uri( + &restricted_resolver, + "https://test.prefix.fakecontentauthenticity.org", + ); + assert_disallowed_uri( + &restricted_resolver, + "https://test.fakecontentauthenticity.org", + ); + assert_disallowed_uri(&restricted_resolver, "https://contentauthenticity.org"); } #[test] - fn disallowed_http_request() { + fn allowed_none_http_request() { let allowed_list = vec![]; let restricted_resolver = RestrictedResolver::with_allowed_hosts(NoopHttpResolver, allowed_list); - let result = restricted_resolver.http_resolve( - Request::get(Uri::from_static("fakecontentauthenticity.org")) - .body(Vec::new()) - .unwrap(), + assert_disallowed_uri( + &restricted_resolver, + "test.test.fakecontentauthenticity.org", ); - assert!(matches!( - result, - Err(HttpResolverError::UriDisallowed { .. }) - )); + assert_disallowed_uri( + &restricted_resolver, + "https://test.prefix.fakecontentauthenticity.org", + ); + assert_disallowed_uri( + &restricted_resolver, + "https://test.fakecontentauthenticity.org", + ); + assert_disallowed_uri(&restricted_resolver, "https://contentauthenticity.org"); } #[test] diff --git a/sdk/src/ingredient.rs b/sdk/src/ingredient.rs index 793b806c8..8ec46d444 100644 --- a/sdk/src/ingredient.rs +++ b/sdk/src/ingredient.rs @@ -736,15 +736,9 @@ impl Ingredient { options: &dyn IngredientOptions, ) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); - let allowed_network_hosts = settings - .core - .allowed_network_hosts - .as_deref() - .unwrap_or_default(); - let http_resolver = RestrictedResolver::with_allowed_hosts( - SyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ); + let mut http_resolver = RestrictedResolver::new(SyncGenericResolver::new()); + http_resolver.set_allowed_hosts(settings.core.allowed_network_hosts.clone()); + Self::from_file_impl(path.as_ref(), options, &http_resolver, &settings) } @@ -853,15 +847,8 @@ impl Ingredient { /// Thumbnail will be set only if one can be retrieved from a previous valid manifest. pub fn from_stream(format: &str, stream: &mut dyn CAIRead) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); - let allowed_network_hosts = settings - .core - .allowed_network_hosts - .as_deref() - .unwrap_or_default(); - let http_resolver = RestrictedResolver::with_allowed_hosts( - SyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ); + let mut http_resolver = RestrictedResolver::new(SyncGenericResolver::new()); + http_resolver.set_allowed_hosts(settings.core.allowed_network_hosts.clone()); let ingredient = Self::from_stream_info(stream, format, "untitled"); stream.rewind()?; @@ -1044,15 +1031,8 @@ impl Ingredient { /// Thumbnail will be set only if one can be retrieved from a previous valid manifest. pub async fn from_stream_async(format: &str, stream: &mut dyn CAIRead) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); - let allowed_network_hosts = settings - .core - .allowed_network_hosts - .as_deref() - .unwrap_or_default(); - let http_resolver = RestrictedResolver::with_allowed_hosts( - AsyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ); + let mut http_resolver = RestrictedResolver::new(AsyncGenericResolver::new()); + http_resolver.set_allowed_hosts(settings.core.allowed_network_hosts.clone()); Self::from_stream_async_with_settings(format, stream, &http_resolver, &settings).await } @@ -1519,15 +1499,9 @@ impl Ingredient { stream: &mut dyn CAIRead, ) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); - let allowed_network_hosts = settings - .core - .allowed_network_hosts - .as_deref() - .unwrap_or_default(); - let http_resolver = RestrictedResolver::with_allowed_hosts( - AsyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ); + let mut http_resolver = RestrictedResolver::new(AsyncGenericResolver::new()); + http_resolver.set_allowed_hosts(settings.core.allowed_network_hosts.clone()); + let mut ingredient = Self::from_stream_info(stream, format, "untitled"); let mut validation_log = StatusTracker::default(); diff --git a/sdk/src/reader.rs b/sdk/src/reader.rs index 88536d174..9d51b5968 100644 --- a/sdk/src/reader.rs +++ b/sdk/src/reader.rs @@ -141,22 +141,13 @@ impl Reader { pub fn from_stream(format: &str, mut stream: impl Read + Seek + Send) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); - let allowed_network_hosts = settings - .core - .allowed_network_hosts - .as_deref() - .unwrap_or_default(); let http_resolver = if _sync { - RestrictedResolver::with_allowed_hosts( - SyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ) + SyncGenericResolver::new() } else { - RestrictedResolver::with_allowed_hosts( - AsyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ) + AsyncGenericResolver::new() }; + let mut http_resolver = RestrictedResolver::new(http_resolver); + http_resolver.set_allowed_hosts(settings.core.allowed_network_hosts.clone()); // TODO: passing verify is redundant with settings let verify = settings.verify.verify_after_reading; @@ -201,16 +192,13 @@ impl Reader { .as_deref() .unwrap_or_default(); let http_resolver = if _sync { - RestrictedResolver::with_allowed_hosts( - SyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ) + SyncGenericResolver::new() } else { - RestrictedResolver::with_allowed_hosts( - AsyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ) + AsyncGenericResolver::new() }; + let mut http_resolver = RestrictedResolver::new(http_resolver); + http_resolver.set_allowed_hosts(settings.core.allowed_network_hosts.clone()); + // TODO: passing verify is redundant with settings let verify = settings.verify.verify_after_reading; @@ -333,22 +321,13 @@ impl Reader { stream: impl Read + Seek + Send, ) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); - let allowed_network_hosts = settings - .core - .allowed_network_hosts - .as_deref() - .unwrap_or_default(); let http_resolver = if _sync { - RestrictedResolver::with_allowed_hosts( - SyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ) + SyncGenericResolver::new() } else { - RestrictedResolver::with_allowed_hosts( - AsyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ) + AsyncGenericResolver::new() }; + let mut http_resolver = RestrictedResolver::new(http_resolver); + http_resolver.set_allowed_hosts(settings.core.allowed_network_hosts.clone()); let mut validation_log = StatusTracker::default(); @@ -398,22 +377,13 @@ impl Reader { mut fragment: impl Read + Seek + Send, ) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); - let allowed_network_hosts = settings - .core - .allowed_network_hosts - .as_deref() - .unwrap_or_default(); let http_resolver = if _sync { - RestrictedResolver::with_allowed_hosts( - SyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ) + SyncGenericResolver::new() } else { - RestrictedResolver::with_allowed_hosts( - AsyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ) + AsyncGenericResolver::new() }; + let mut http_resolver = RestrictedResolver::new(http_resolver); + http_resolver.set_allowed_hosts(settings.core.allowed_network_hosts.clone()); let mut validation_log = StatusTracker::default(); @@ -450,15 +420,8 @@ impl Reader { fragments: &Vec, ) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); - let allowed_network_hosts = settings - .core - .allowed_network_hosts - .as_deref() - .unwrap_or_default(); - let http_resolver = RestrictedResolver::with_allowed_hosts( - SyncGenericResolver::new(), - allowed_network_hosts.to_vec(), - ); + let mut http_resolver = RestrictedResolver::new(SyncGenericResolver::new()); + http_resolver.set_allowed_hosts(settings.core.allowed_network_hosts.clone()); let verify = settings.verify.verify_after_reading; let mut validation_log = StatusTracker::default(); From 77e5264d8a4a1fadc26a0cfe387ee015e49b5a5f Mon Sep 17 00:00:00 2001 From: ok-nick Date: Mon, 24 Nov 2025 16:15:16 -0500 Subject: [PATCH 7/9] fix: remove unused code WASM --- sdk/src/reader.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/sdk/src/reader.rs b/sdk/src/reader.rs index 9d51b5968..0c7d5e293 100644 --- a/sdk/src/reader.rs +++ b/sdk/src/reader.rs @@ -186,11 +186,6 @@ impl Reader { #[cfg(target_arch = "wasm32")] pub fn from_stream(format: &str, mut stream: impl Read + Seek) -> Result { let settings = crate::settings::get_settings().unwrap_or_default(); - let allowed_network_hosts = settings - .core - .allowed_network_hosts - .as_deref() - .unwrap_or_default(); let http_resolver = if _sync { SyncGenericResolver::new() } else { From 029e2516e800ade375b6b45107c171816a9e679f Mon Sep 17 00:00:00 2001 From: ok-nick Date: Tue, 25 Nov 2025 13:07:43 -0500 Subject: [PATCH 8/9] fix: simplify host pattern implementation --- sdk/src/http/restricted.rs | 68 ++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/sdk/src/http/restricted.rs b/sdk/src/http/restricted.rs index 5d468dbac..650cab98e 100644 --- a/sdk/src/http/restricted.rs +++ b/sdk/src/http/restricted.rs @@ -117,24 +117,38 @@ impl AsyncHttpResolver for RestrictedResolver { )] #[derive(Debug, Clone, PartialEq)] pub struct HostPattern { - uri: Uri, + pattern: String, + scheme: Option, + host: Option, } impl HostPattern { - // TODO: validate it doesn't have more than a scheme and a host and that it has at least 1 - /// Creates a new `HostPattern` with the given URI. - pub fn new(uri: Uri) -> Self { - Self { uri } + /// Creates a new `HostPattern` with the given pattern. + pub fn new(pattern: &str) -> Self { + let pattern = pattern.to_ascii_lowercase(); + let (scheme, host): (Option, Option) = + if let Some(host) = pattern.strip_prefix("https://") { + (Some("https".to_owned()), Some(host.to_owned())) + } else if let Some(host) = pattern.strip_prefix("http://") { + (Some("http".to_owned()), Some(host.to_owned())) + } else { + (None, Some(pattern.clone())) + }; + + Self { + pattern, + scheme, + host, + } } /// Returns if the given URI matches the `HostPattern`. pub fn matches(&self, uri: &Uri) -> bool { - if let Some(allowed_host_pattern) = self.uri.host() { + if let Some(allowed_host_pattern) = &self.host { if let Some(host) = uri.host() { // If there's a wildcard, do an suffix match, otherwise do an exact match. let host_allowed = if let Some(suffix) = allowed_host_pattern.strip_prefix("*.") { let host = host.to_ascii_lowercase(); - let suffix = suffix.to_ascii_lowercase(); if host.len() <= suffix.len() || !host.ends_with(&suffix) { false @@ -147,18 +161,18 @@ impl HostPattern { }; if host_allowed { - if let Some(allowed_scheme) = self.uri.scheme() { + if let Some(allowed_scheme) = &self.scheme { if let Some(scheme) = uri.scheme() { - return scheme == allowed_scheme; + return scheme.as_str() == allowed_scheme; } } else { return true; } } } - } else if let Some(allowed_scheme) = self.uri.scheme() { + } else if let Some(allowed_scheme) = &self.scheme { if let Some(scheme) = uri.scheme() { - return scheme == allowed_scheme; + return scheme.as_str() == allowed_scheme; } } @@ -166,9 +180,9 @@ impl HostPattern { } } -impl From for HostPattern { - fn from(uri: Uri) -> Self { - Self { uri } +impl From<&str> for HostPattern { + fn from(pattern: &str) -> Self { + Self::new(pattern) } } @@ -177,7 +191,7 @@ impl Serialize for HostPattern { where S: Serializer, { - serializer.serialize_str(&self.uri.to_string()) + serializer.serialize_str(&self.pattern.to_string()) } } @@ -186,9 +200,7 @@ impl<'de> Deserialize<'de> for HostPattern { where D: Deserializer<'de>, { - let string = String::deserialize(deserializer)?; - let uri = string.parse::().map_err(serde::de::Error::custom)?; - Ok(HostPattern::new(uri)) + Ok(HostPattern::new(&String::deserialize(deserializer)?)) } } @@ -244,11 +256,11 @@ mod test { #[test] fn allowed_http_request() { let allowed_list = vec![ - Uri::from_static("*.prefix.contentauthenticity.org").into(), - Uri::from_static("test.contentauthenticity.org").into(), - Uri::from_static("fakecontentauthenticity.org").into(), - Uri::from_static("https://*.contentauthenticity.org").into(), - Uri::from_static("https://test.contentauthenticity.org").into(), + "*.prefix.contentauthenticity.org".into(), + "test.contentauthenticity.org".into(), + "fakecontentauthenticity.org".into(), + "https://*.contentauthenticity.org".into(), + "https://test.contentauthenticity.org".into(), ]; let restricted_resolver = RestrictedResolver::with_allowed_hosts(NoopHttpResolver, allowed_list); @@ -296,7 +308,7 @@ mod test { #[test] fn wildcard_pattern() { - let pattern = HostPattern::new(Uri::from_static("*.contentauthenticity.org")); + let pattern = HostPattern::new("*.contentauthenticity.org"); let uri = Uri::from_static("test.contentauthenticity.org"); assert!(pattern.matches(&uri)); @@ -310,7 +322,7 @@ mod test { #[test] fn wildcard_pattern_with_scheme() { - let pattern = HostPattern::new(Uri::from_static("https://*.contentauthenticity.org")); + let pattern = HostPattern::new("https://*.contentauthenticity.org"); let uri = Uri::from_static("test.contentauthenticity.org"); assert!(!pattern.matches(&uri)); @@ -336,7 +348,7 @@ mod test { #[test] fn case_insensitive_pattern() { - let pattern = HostPattern::new(Uri::from_static("*.contentAuthenticity.org")); + let pattern = HostPattern::new("*.contentAuthenticity.org"); let uri = Uri::from_static("tEst.conTentauthenticity.orG"); assert!(pattern.matches(&uri)); @@ -344,7 +356,7 @@ mod test { #[test] fn exact_pattern() { - let pattern = HostPattern::new(Uri::from_static("contentauthenticity.org")); + let pattern = HostPattern::new("contentauthenticity.org"); let uri = Uri::from_static("contentauthenticity.org"); assert!(pattern.matches(&uri)); @@ -358,7 +370,7 @@ mod test { #[test] fn exact_pattern_with_schema() { - let pattern = HostPattern::new(Uri::from_static("https://contentauthenticity.org")); + let pattern = HostPattern::new("https://contentauthenticity.org"); let uri = Uri::from_static("https://contentauthenticity.org"); assert!(pattern.matches(&uri)); From d44ecea8977bf36ed0bdc638c85fceaeaddb6df6 Mon Sep 17 00:00:00 2001 From: ok-nick Date: Wed, 26 Nov 2025 15:30:50 -0500 Subject: [PATCH 9/9] docs: clarify why/how restrict networking and dynamic endpoints --- sdk/src/http/restricted.rs | 20 ++++++++++++++++++++ sdk/src/settings/mod.rs | 4 ++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/sdk/src/http/restricted.rs b/sdk/src/http/restricted.rs index 650cab98e..c82a61474 100644 --- a/sdk/src/http/restricted.rs +++ b/sdk/src/http/restricted.rs @@ -11,6 +11,26 @@ // specific language governing permissions and limitations under // each license. +//! HTTP request restriction layer. +//! +//! # Why restrict network requests? +//! In some environments, you may not want the SDK to talk to arbitrary hosts. Restricting +//! network requests help to: +//! - Reduce SSRF-style risks (e.g. requests to internal services). +//! - Constrain requests to a small, trusted set of domains. +//! +//! # OCSP and other dynamic endpoints +//! Some protocols used by the SDK (like OCSP or CRLs) discover endpoints from certificate +//! metadata at runtime. In a restricted environment, there is no way for the resolver to +//! know that these endpoints are "special" unless you anticipate them in advance and add +//! their hosts to the allow-list. +//! +//! # Disabling networking completely +//! This restriction layer is a runtime control. To turn networking off entirely at compile +//! time, do not enable any of the HTTP features (`http_*`), see ["Features"]. +//! +//! ["Features"]: crate#features + use std::io::Read; use async_trait::async_trait; diff --git a/sdk/src/settings/mod.rs b/sdk/src/settings/mod.rs index c816e4ab2..0109a3767 100644 --- a/sdk/src/settings/mod.rs +++ b/sdk/src/settings/mod.rs @@ -240,9 +240,9 @@ pub struct Core { /// /// These settings are consumed by [`RestrictedResolver`]. /// - /// For information on when the SDK might perform an outbound network request, see "[When do outbound network requests occur?]" + /// For information on when the SDK might perform an outbound network request, see ["When do outbound network requests occur?"] /// - /// [When do outbound network requests occur?]: crate::http#when-do-outbound-network-requests-occur + /// ["When do outbound network requests occur?"]: crate::http#when-do-outbound-network-requests-occur /// [`HostPattern`]: crate::http::restricted::HostPattern /// [`RestrictedResolver`]: crate::http::restricted::RestrictedResolver pub allowed_network_hosts: Option>,