20260903 同步 RRDP RFC9674 同源防护
This commit is contained in:
parent
2a0b5067bd
commit
5d18fa8992
@ -3,9 +3,12 @@ use std::io::Write;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use reqwest::header::HeaderMap;
|
use reqwest::header::{HeaderMap, LOCATION};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
use crate::sync::rrdp::Fetcher;
|
use crate::sync::rrdp::{Fetcher, RrdpError, RrdpFetchError, RrdpOrigin, RrdpResourceKind};
|
||||||
|
|
||||||
|
const MAX_RRDP_REDIRECTS: usize = 10;
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
static HTTP_TIMEOUT_OVERRIDE: RefCell<Option<Duration>> = const { RefCell::new(None) };
|
static HTTP_TIMEOUT_OVERRIDE: RefCell<Option<Duration>> = const { RefCell::new(None) };
|
||||||
@ -77,6 +80,9 @@ pub struct BlockingHttpFetcher {
|
|||||||
short_client: Client,
|
short_client: Client,
|
||||||
large_body_client: Client,
|
large_body_client: Client,
|
||||||
retry_short_client: Client,
|
retry_short_client: Client,
|
||||||
|
rrdp_short_client: Client,
|
||||||
|
rrdp_large_body_client: Client,
|
||||||
|
rrdp_retry_short_client: Client,
|
||||||
short_timeout: Duration,
|
short_timeout: Duration,
|
||||||
large_body_timeout: Duration,
|
large_body_timeout: Duration,
|
||||||
}
|
}
|
||||||
@ -110,10 +116,37 @@ impl BlockingHttpFetcher {
|
|||||||
)?
|
)?
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rrdp_short_client = Self::rrdp_client_builder(
|
||||||
|
&config,
|
||||||
|
connect_timeout,
|
||||||
|
config.timeout,
|
||||||
|
config.user_agent.clone(),
|
||||||
|
)?
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rrdp_large_body_client = Self::rrdp_client_builder(
|
||||||
|
&config,
|
||||||
|
connect_timeout,
|
||||||
|
large_body_timeout,
|
||||||
|
config.user_agent.clone(),
|
||||||
|
)?
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rrdp_retry_short_client = Self::rrdp_client_builder(
|
||||||
|
&config,
|
||||||
|
Duration::from_secs(1),
|
||||||
|
Duration::from_secs(1),
|
||||||
|
config.user_agent.clone(),
|
||||||
|
)?
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
short_client,
|
short_client,
|
||||||
large_body_client,
|
large_body_client,
|
||||||
retry_short_client,
|
retry_short_client,
|
||||||
|
rrdp_short_client,
|
||||||
|
rrdp_large_body_client,
|
||||||
|
rrdp_retry_short_client,
|
||||||
short_timeout,
|
short_timeout,
|
||||||
large_body_timeout,
|
large_body_timeout,
|
||||||
})
|
})
|
||||||
@ -137,6 +170,18 @@ impl BlockingHttpFetcher {
|
|||||||
Ok(builder)
|
Ok(builder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rrdp_client_builder(
|
||||||
|
config: &HttpFetcherConfig,
|
||||||
|
connect_timeout: Duration,
|
||||||
|
timeout: Duration,
|
||||||
|
user_agent: String,
|
||||||
|
) -> Result<reqwest::blocking::ClientBuilder, String> {
|
||||||
|
Ok(
|
||||||
|
Self::client_builder(config, connect_timeout, timeout, user_agent)?
|
||||||
|
.redirect(reqwest::redirect::Policy::none()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn fetch_bytes(&self, uri: &str) -> Result<Vec<u8>, String> {
|
pub fn fetch_bytes(&self, uri: &str) -> Result<Vec<u8>, String> {
|
||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
let (client, timeout_profile, timeout_value) = self.client_for_uri(uri);
|
let (client, timeout_profile, timeout_value) = self.client_for_uri(uri);
|
||||||
@ -260,6 +305,143 @@ impl BlockingHttpFetcher {
|
|||||||
(&self.short_client, "short", self.short_timeout)
|
(&self.short_client, "short", self.short_timeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rrdp_client_for_uri(&self, uri: &str) -> (&Client, &'static str, Duration) {
|
||||||
|
if let Some(timeout) = HTTP_TIMEOUT_OVERRIDE.with(|cell| *cell.borrow()) {
|
||||||
|
return (&self.rrdp_retry_short_client, "retry_short", timeout);
|
||||||
|
}
|
||||||
|
if uses_large_body_timeout(uri) {
|
||||||
|
(
|
||||||
|
&self.rrdp_large_body_client,
|
||||||
|
"large_body",
|
||||||
|
self.large_body_timeout,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(&self.rrdp_short_client, "short", self.short_timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rrdp_response(
|
||||||
|
&self,
|
||||||
|
resource: RrdpResourceKind,
|
||||||
|
uri: &str,
|
||||||
|
notification_origin: &RrdpOrigin,
|
||||||
|
) -> Result<(reqwest::blocking::Response, &'static str, Duration), RrdpFetchError> {
|
||||||
|
let mut current = Url::parse(uri).map_err(|e| RrdpError::InvalidRrdpUri {
|
||||||
|
resource,
|
||||||
|
detail: e.to_string(),
|
||||||
|
})?;
|
||||||
|
let current_origin = RrdpOrigin::from_url(¤t, resource)?;
|
||||||
|
if ¤t_origin != notification_origin {
|
||||||
|
return Err(RrdpError::CrossOriginReference {
|
||||||
|
resource,
|
||||||
|
notification_origin: notification_origin.to_string(),
|
||||||
|
resource_origin: current_origin.to_string(),
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
for redirects in 0..=MAX_RRDP_REDIRECTS {
|
||||||
|
let (client, profile, timeout) = self.rrdp_client_for_uri(current.as_str());
|
||||||
|
let response = client
|
||||||
|
.get(current.clone())
|
||||||
|
.send()
|
||||||
|
.map_err(|e| RrdpFetchError::Fetch(format!("http request failed: {e:?}")))?;
|
||||||
|
if !response.status().is_redirection() {
|
||||||
|
return Ok((response, profile, timeout));
|
||||||
|
}
|
||||||
|
if redirects == MAX_RRDP_REDIRECTS {
|
||||||
|
return Err(RrdpError::RedirectLimitExceeded {
|
||||||
|
resource,
|
||||||
|
max: MAX_RRDP_REDIRECTS,
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let location = response
|
||||||
|
.headers()
|
||||||
|
.get(LOCATION)
|
||||||
|
.ok_or(RrdpError::RedirectLocationMissing { resource })?
|
||||||
|
.to_str()
|
||||||
|
.map_err(|_| RrdpError::InvalidRedirectLocation {
|
||||||
|
resource,
|
||||||
|
location: "<non-UTF-8 header>".to_string(),
|
||||||
|
})?;
|
||||||
|
let next = current
|
||||||
|
.join(location)
|
||||||
|
.map_err(|_| RrdpError::InvalidRedirectLocation {
|
||||||
|
resource,
|
||||||
|
location: location.to_string(),
|
||||||
|
})?;
|
||||||
|
let next_origin = RrdpOrigin::from_url(&next, resource)?;
|
||||||
|
crate::progress_log::emit(
|
||||||
|
"rrdp_redirect_checked",
|
||||||
|
serde_json::json!({
|
||||||
|
"resource": resource.to_string(),
|
||||||
|
"from": current.as_str(),
|
||||||
|
"to": next.as_str(),
|
||||||
|
"notification_origin": notification_origin.to_string(),
|
||||||
|
"same_origin": &next_origin == notification_origin,
|
||||||
|
"rfc": "RFC 9674 §3.2",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if &next_origin != notification_origin {
|
||||||
|
crate::progress_log::emit(
|
||||||
|
"rrdp_cross_origin_rejected",
|
||||||
|
serde_json::json!({
|
||||||
|
"resource": resource.to_string(),
|
||||||
|
"notification_origin": notification_origin.to_string(),
|
||||||
|
"resource_origin": next_origin.to_string(),
|
||||||
|
"reason": "redirect",
|
||||||
|
"rfc": "RFC 9674 §3.2",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Err(RrdpError::CrossOriginRedirect {
|
||||||
|
resource,
|
||||||
|
notification_origin: notification_origin.to_string(),
|
||||||
|
redirect_origin: next_origin.to_string(),
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
unreachable!("redirect loop either returns or reaches its limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rrdp_fetch_bytes(
|
||||||
|
&self,
|
||||||
|
resource: RrdpResourceKind,
|
||||||
|
uri: &str,
|
||||||
|
notification_origin: &RrdpOrigin,
|
||||||
|
) -> Result<Vec<u8>, RrdpFetchError> {
|
||||||
|
let (response, _profile, _timeout) =
|
||||||
|
self.rrdp_response(resource, uri, notification_origin)?;
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(RrdpFetchError::Fetch(format!("http status {status}")));
|
||||||
|
}
|
||||||
|
response
|
||||||
|
.bytes()
|
||||||
|
.map(|b| b.to_vec())
|
||||||
|
.map_err(|e| RrdpFetchError::Fetch(format!("http read body failed: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rrdp_fetch_to_writer(
|
||||||
|
&self,
|
||||||
|
resource: RrdpResourceKind,
|
||||||
|
uri: &str,
|
||||||
|
notification_origin: &RrdpOrigin,
|
||||||
|
out: &mut dyn Write,
|
||||||
|
) -> Result<u64, RrdpFetchError> {
|
||||||
|
let (mut response, _profile, _timeout) =
|
||||||
|
self.rrdp_response(resource, uri, notification_origin)?;
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(RrdpFetchError::Fetch(format!("http status {status}")));
|
||||||
|
}
|
||||||
|
response
|
||||||
|
.copy_to(out)
|
||||||
|
.map_err(|e| RrdpFetchError::Fetch(format!("http stream body failed: {e}")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Fetcher for BlockingHttpFetcher {
|
impl Fetcher for BlockingHttpFetcher {
|
||||||
@ -376,6 +558,25 @@ impl Fetcher for BlockingHttpFetcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fetch_rrdp(
|
||||||
|
&self,
|
||||||
|
resource: RrdpResourceKind,
|
||||||
|
uri: &str,
|
||||||
|
notification_origin: &RrdpOrigin,
|
||||||
|
) -> Result<Vec<u8>, RrdpFetchError> {
|
||||||
|
self.rrdp_fetch_bytes(resource, uri, notification_origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_rrdp_to_writer(
|
||||||
|
&self,
|
||||||
|
resource: RrdpResourceKind,
|
||||||
|
uri: &str,
|
||||||
|
notification_origin: &RrdpOrigin,
|
||||||
|
out: &mut dyn Write,
|
||||||
|
) -> Result<u64, RrdpFetchError> {
|
||||||
|
self.rrdp_fetch_to_writer(resource, uri, notification_origin, out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn header_value(headers: &HeaderMap, name: &str) -> String {
|
fn header_value(headers: &HeaderMap, name: &str) -> String {
|
||||||
@ -398,6 +599,8 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration as StdDuration;
|
use std::time::Duration as StdDuration;
|
||||||
|
|
||||||
@ -649,4 +852,85 @@ mod tests {
|
|||||||
"{request}"
|
"{request}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rrdp_same_origin_redirect_is_followed_manually() {
|
||||||
|
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
thread::spawn(move || {
|
||||||
|
for response in [
|
||||||
|
b"HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||||
|
.as_slice(),
|
||||||
|
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".as_slice(),
|
||||||
|
] {
|
||||||
|
let (mut stream, _) = listener.accept().expect("accept");
|
||||||
|
let mut buf = [0u8; 1024];
|
||||||
|
let _ = stream.read(&mut buf);
|
||||||
|
stream.write_all(response).expect("response");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let uri = format!("http://{addr}/start");
|
||||||
|
let origin = RrdpOrigin::parse(&uri, RrdpResourceKind::Notification).expect("origin");
|
||||||
|
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
|
||||||
|
timeout: Duration::from_secs(2),
|
||||||
|
..HttpFetcherConfig::default()
|
||||||
|
})
|
||||||
|
.expect("http");
|
||||||
|
let body = http
|
||||||
|
.fetch_rrdp(RrdpResourceKind::Notification, &uri, &origin)
|
||||||
|
.expect("same-origin redirect accepted");
|
||||||
|
assert_eq!(body, b"ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rrdp_cross_origin_redirect_is_rejected_before_target_request() {
|
||||||
|
let target = TcpListener::bind(("127.0.0.1", 0)).expect("bind target");
|
||||||
|
target.set_nonblocking(true).expect("nonblocking target");
|
||||||
|
let target_addr = target.local_addr().expect("target addr");
|
||||||
|
let target_hits = Arc::new(AtomicUsize::new(0));
|
||||||
|
let target_hits_worker = Arc::clone(&target_hits);
|
||||||
|
let target_thread = thread::spawn(move || {
|
||||||
|
let deadline = std::time::Instant::now() + StdDuration::from_millis(400);
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
match target.accept() {
|
||||||
|
Ok((mut stream, _)) => {
|
||||||
|
target_hits_worker.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
|
std::thread::sleep(StdDuration::from_millis(5));
|
||||||
|
}
|
||||||
|
Err(e) => panic!("target accept: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let source = TcpListener::bind(("127.0.0.1", 0)).expect("bind source");
|
||||||
|
let source_addr = source.local_addr().expect("source addr");
|
||||||
|
thread::spawn(move || {
|
||||||
|
let (mut stream, _) = source.accept().expect("accept source");
|
||||||
|
let mut buf = [0u8; 1024];
|
||||||
|
let _ = stream.read(&mut buf);
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 302 Found\r\nLocation: http://{target_addr}/foreign\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||||
|
);
|
||||||
|
stream.write_all(response.as_bytes()).expect("redirect");
|
||||||
|
});
|
||||||
|
let uri = format!("http://{source_addr}/start");
|
||||||
|
let origin = RrdpOrigin::parse(&uri, RrdpResourceKind::Notification).expect("origin");
|
||||||
|
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
|
||||||
|
timeout: Duration::from_secs(2),
|
||||||
|
..HttpFetcherConfig::default()
|
||||||
|
})
|
||||||
|
.expect("http");
|
||||||
|
let err = http
|
||||||
|
.fetch_rrdp(RrdpResourceKind::Notification, &uri, &origin)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
RrdpFetchError::Rrdp(RrdpError::CrossOriginRedirect { .. })
|
||||||
|
));
|
||||||
|
target_thread.join().expect("target thread");
|
||||||
|
assert_eq!(target_hits.load(Ordering::SeqCst), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,10 @@ use crate::replay::delta_archive::{ReplayDeltaArchiveIndex, ReplayDeltaRrdpKind}
|
|||||||
use crate::report::{RfcRef, Warning};
|
use crate::report::{RfcRef, Warning};
|
||||||
use crate::storage::RocksStore;
|
use crate::storage::RocksStore;
|
||||||
use crate::sync::rrdp::sync_from_notification_with_timing_and_download_log;
|
use crate::sync::rrdp::sync_from_notification_with_timing_and_download_log;
|
||||||
use crate::sync::rrdp::{Fetcher as HttpFetcher, RrdpSyncError, load_rrdp_local_state};
|
use crate::sync::rrdp::{
|
||||||
|
Fetcher as HttpFetcher, RrdpFetchError, RrdpOrigin, RrdpResourceKind, RrdpSyncError,
|
||||||
|
load_rrdp_local_state,
|
||||||
|
};
|
||||||
use crate::sync::store_projection::{
|
use crate::sync::store_projection::{
|
||||||
build_repository_view_present_entry, build_repository_view_withdrawn_entry,
|
build_repository_view_present_entry, build_repository_view_withdrawn_entry,
|
||||||
prepare_repo_bytes_batch_owned,
|
prepare_repo_bytes_batch_owned,
|
||||||
@ -127,9 +130,13 @@ pub fn sync_publication_point(
|
|||||||
"rrdp_error": err.to_string(),
|
"rrdp_error": err.to_string(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
let mut rfc_refs = vec![RfcRef("RFC 8182 §3.4.5")];
|
||||||
|
if matches!(&err, RrdpSyncError::Rrdp(e) if e.is_cross_origin_violation()) {
|
||||||
|
rfc_refs.push(RfcRef("RFC 9674 §3.2"));
|
||||||
|
}
|
||||||
let warnings = vec![
|
let warnings = vec![
|
||||||
Warning::new(format!("RRDP failed; falling back to rsync: {err}"))
|
Warning::new(format!("RRDP failed; falling back to rsync: {err}"))
|
||||||
.with_rfc_refs(&[RfcRef("RFC 8182 §3.4.5")])
|
.with_rfc_refs(&rfc_refs)
|
||||||
.with_context(notification_uri),
|
.with_context(notification_uri),
|
||||||
];
|
];
|
||||||
let written = rsync_sync_into_current_store(
|
let written = rsync_sync_into_current_store(
|
||||||
@ -419,6 +426,7 @@ fn try_rrdp_sync(
|
|||||||
timing: Option<&TimingHandle>,
|
timing: Option<&TimingHandle>,
|
||||||
download_log: Option<&DownloadLogHandle>,
|
download_log: Option<&DownloadLogHandle>,
|
||||||
) -> Result<usize, RrdpSyncError> {
|
) -> Result<usize, RrdpSyncError> {
|
||||||
|
let notification_origin = RrdpOrigin::parse(notification_uri, RrdpResourceKind::Notification)?;
|
||||||
let notification_xml = {
|
let notification_xml = {
|
||||||
let _step = timing
|
let _step = timing
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@ -428,7 +436,11 @@ fn try_rrdp_sync(
|
|||||||
.map(|t| t.span_phase("rrdp_fetch_notification_total"));
|
.map(|t| t.span_phase("rrdp_fetch_notification_total"));
|
||||||
let mut dl_span = download_log
|
let mut dl_span = download_log
|
||||||
.map(|dl| dl.span_download(AuditDownloadKind::RrdpNotification, notification_uri));
|
.map(|dl| dl.span_download(AuditDownloadKind::RrdpNotification, notification_uri));
|
||||||
match http_fetcher.fetch(notification_uri) {
|
match http_fetcher.fetch_rrdp(
|
||||||
|
RrdpResourceKind::Notification,
|
||||||
|
notification_uri,
|
||||||
|
¬ification_origin,
|
||||||
|
) {
|
||||||
Ok(v) => {
|
Ok(v) => {
|
||||||
if let Some(t) = timing.as_ref() {
|
if let Some(t) = timing.as_ref() {
|
||||||
t.record_count("rrdp_notification_fetch_ok_total", 1);
|
t.record_count("rrdp_notification_fetch_ok_total", 1);
|
||||||
@ -439,7 +451,7 @@ fn try_rrdp_sync(
|
|||||||
}
|
}
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(RrdpFetchError::Fetch(e)) => {
|
||||||
if let Some(t) = timing.as_ref() {
|
if let Some(t) = timing.as_ref() {
|
||||||
t.record_count("rrdp_notification_fetch_fail_total", 1);
|
t.record_count("rrdp_notification_fetch_fail_total", 1);
|
||||||
}
|
}
|
||||||
@ -448,6 +460,18 @@ fn try_rrdp_sync(
|
|||||||
}
|
}
|
||||||
return Err(RrdpSyncError::Fetch(e));
|
return Err(RrdpSyncError::Fetch(e));
|
||||||
}
|
}
|
||||||
|
Err(RrdpFetchError::Rrdp(e)) => {
|
||||||
|
if let Some(t) = timing.as_ref() {
|
||||||
|
t.record_count("rrdp_notification_fetch_fail_total", 1);
|
||||||
|
if e.is_cross_origin_violation() {
|
||||||
|
t.record_count("rrdp_rejected_cross_origin_total", 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(s) = dl_span.as_mut() {
|
||||||
|
s.set_err(e.to_string());
|
||||||
|
}
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(t) = timing.as_ref() {
|
if let Some(t) = timing.as_ref() {
|
||||||
|
|||||||
@ -114,6 +114,86 @@ fn rrdp_protocol_error_does_not_retry_and_falls_back_to_rsync() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rrdp_cross_origin_reference_falls_back_to_rsync_without_fetching_foreign_uri() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let store = RocksStore::open(&temp.path().join("db")).expect("open rocksdb");
|
||||||
|
let notification_uri = "https://origin.example/notification.xml";
|
||||||
|
let foreign_snapshot_uri = "https://foreign.example/snapshot.xml";
|
||||||
|
let notification = notification_xml(
|
||||||
|
"9df4b597-af9e-4dca-bdda-719cce2c4e28",
|
||||||
|
1,
|
||||||
|
foreign_snapshot_uri,
|
||||||
|
&"00".repeat(32),
|
||||||
|
);
|
||||||
|
struct RecordingHttp {
|
||||||
|
notification: Vec<u8>,
|
||||||
|
notification_calls: AtomicUsize,
|
||||||
|
foreign_calls: AtomicUsize,
|
||||||
|
}
|
||||||
|
impl HttpFetcher for RecordingHttp {
|
||||||
|
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
|
||||||
|
if uri == "https://origin.example/notification.xml" {
|
||||||
|
self.notification_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
return Ok(self.notification.clone());
|
||||||
|
}
|
||||||
|
if uri == "https://foreign.example/snapshot.xml" {
|
||||||
|
self.foreign_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
Err(format!("unexpected HTTP fetch: {uri}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct OneObjectRsync;
|
||||||
|
impl RsyncFetcher for OneObjectRsync {
|
||||||
|
fn fetch_objects(
|
||||||
|
&self,
|
||||||
|
_rsync_base_uri: &str,
|
||||||
|
) -> Result<Vec<(String, Vec<u8>)>, RsyncFetchError> {
|
||||||
|
Ok(vec![(
|
||||||
|
"rsync://origin.example/repo/a.mft".to_string(),
|
||||||
|
b"mft".to_vec(),
|
||||||
|
)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let http = RecordingHttp {
|
||||||
|
notification,
|
||||||
|
notification_calls: AtomicUsize::new(0),
|
||||||
|
foreign_calls: AtomicUsize::new(0),
|
||||||
|
};
|
||||||
|
let policy = Policy {
|
||||||
|
sync_preference: SyncPreference::RrdpThenRsync,
|
||||||
|
..Policy::default()
|
||||||
|
};
|
||||||
|
let out = sync_publication_point(
|
||||||
|
&store,
|
||||||
|
&policy,
|
||||||
|
Some(notification_uri),
|
||||||
|
"rsync://origin.example/repo/",
|
||||||
|
&http,
|
||||||
|
&OneObjectRsync,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("rsync fallback succeeds");
|
||||||
|
|
||||||
|
assert_eq!(out.phase, RepoSyncPhase::RrdpFailedRsyncOk);
|
||||||
|
assert_eq!(http.notification_calls.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(http.foreign_calls.load(Ordering::SeqCst), 0);
|
||||||
|
assert!(
|
||||||
|
out.warnings[0]
|
||||||
|
.message
|
||||||
|
.contains("crosses notification origin")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.warnings[0]
|
||||||
|
.rfc_refs
|
||||||
|
.iter()
|
||||||
|
.any(|reference| reference.0 == "RFC 9674 §3.2")
|
||||||
|
);
|
||||||
|
assert_current_object(&store, "rsync://origin.example/repo/a.mft", b"mft");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rrdp_delta_fetches_are_logged_even_if_snapshot_fallback_is_used() {
|
fn rrdp_delta_fetches_are_logged_even_if_snapshot_fallback_is_used() {
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
|||||||
@ -17,12 +17,34 @@ use crate::sync::store_projection::{
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::Digest;
|
use sha2::Digest;
|
||||||
|
use std::fmt;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
use url::Url;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
const RRDP_XMLNS: &str = "http://www.ripe.net/rpki/rrdp";
|
const RRDP_XMLNS: &str = "http://www.ripe.net/rpki/rrdp";
|
||||||
const RRDP_SNAPSHOT_APPLY_BATCH_SIZE: usize = 1024;
|
const RRDP_SNAPSHOT_APPLY_BATCH_SIZE: usize = 1024;
|
||||||
|
|
||||||
|
/// The RRDP object currently being fetched. Keeping this in protocol errors
|
||||||
|
/// makes an origin rejection actionable without treating it as a transient
|
||||||
|
/// transport failure.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum RrdpResourceKind {
|
||||||
|
Notification,
|
||||||
|
Snapshot,
|
||||||
|
Delta,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for RrdpResourceKind {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
Self::Notification => "notification",
|
||||||
|
Self::Snapshot => "snapshot",
|
||||||
|
Self::Delta => "delta",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
include!("rrdp/models_and_parsing.rs");
|
include!("rrdp/models_and_parsing.rs");
|
||||||
include!("rrdp/snapshot_sync.rs");
|
include!("rrdp/snapshot_sync.rs");
|
||||||
include!("rrdp/notification_sync.rs");
|
include!("rrdp/notification_sync.rs");
|
||||||
|
|||||||
@ -154,6 +154,110 @@ pub enum RrdpError {
|
|||||||
|
|
||||||
#[error("delta file contains unexpected element <{0}> (RFC 8182 §3.5.3.3)")]
|
#[error("delta file contains unexpected element <{0}> (RFC 8182 §3.5.3.3)")]
|
||||||
DeltaUnexpectedElement(String),
|
DeltaUnexpectedElement(String),
|
||||||
|
|
||||||
|
#[error("invalid RRDP {resource} URI: {detail} (RFC 9674 §3.2)")]
|
||||||
|
InvalidRrdpUri {
|
||||||
|
resource: RrdpResourceKind,
|
||||||
|
detail: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error(
|
||||||
|
"RRDP {resource} URI crosses notification origin: notification={notification_origin}, resource={resource_origin} (RFC 9674 §3.2)"
|
||||||
|
)]
|
||||||
|
CrossOriginReference {
|
||||||
|
resource: RrdpResourceKind,
|
||||||
|
notification_origin: String,
|
||||||
|
resource_origin: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error(
|
||||||
|
"RRDP {resource} redirect crosses notification origin: notification={notification_origin}, redirect={redirect_origin} (RFC 9674 §3.2)"
|
||||||
|
)]
|
||||||
|
CrossOriginRedirect {
|
||||||
|
resource: RrdpResourceKind,
|
||||||
|
notification_origin: String,
|
||||||
|
redirect_origin: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("RRDP {resource} redirect has no Location header (RFC 9674 §3.2)")]
|
||||||
|
RedirectLocationMissing { resource: RrdpResourceKind },
|
||||||
|
|
||||||
|
#[error("RRDP {resource} redirect Location is invalid: {location} (RFC 9674 §3.2)")]
|
||||||
|
InvalidRedirectLocation {
|
||||||
|
resource: RrdpResourceKind,
|
||||||
|
location: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("RRDP {resource} redirect limit exceeded ({max}) (RFC 9674 §3.2)")]
|
||||||
|
RedirectLimitExceeded {
|
||||||
|
resource: RrdpResourceKind,
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RrdpError {
|
||||||
|
pub fn is_cross_origin_violation(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::CrossOriginReference { .. } | Self::CrossOriginRedirect { .. }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC 9674 §3.2 compares scheme, host name, and port. `Url` normalizes an
|
||||||
|
/// omitted well-known port, so `https://example/` and `https://example:443/`
|
||||||
|
/// intentionally have the same effective origin.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct RrdpOrigin {
|
||||||
|
scheme: String,
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for RrdpOrigin {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}://{}:{}", self.scheme, self.host, self.port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RrdpOrigin {
|
||||||
|
pub fn parse(uri: &str, resource: RrdpResourceKind) -> Result<Self, RrdpError> {
|
||||||
|
let url = Url::parse(uri).map_err(|e| RrdpError::InvalidRrdpUri {
|
||||||
|
resource,
|
||||||
|
detail: e.to_string(),
|
||||||
|
})?;
|
||||||
|
Self::from_url(&url, resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_url(url: &Url, resource: RrdpResourceKind) -> Result<Self, RrdpError> {
|
||||||
|
if !matches!(url.scheme(), "http" | "https") {
|
||||||
|
return Err(RrdpError::InvalidRrdpUri {
|
||||||
|
resource,
|
||||||
|
detail: format!("unsupported scheme {}", url.scheme()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !url.username().is_empty() || url.password().is_some() {
|
||||||
|
return Err(RrdpError::InvalidRrdpUri {
|
||||||
|
resource,
|
||||||
|
detail: "userinfo is not permitted".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let host = url.host_str().ok_or_else(|| RrdpError::InvalidRrdpUri {
|
||||||
|
resource,
|
||||||
|
detail: "host is missing".to_string(),
|
||||||
|
})?;
|
||||||
|
let port = url
|
||||||
|
.port_or_known_default()
|
||||||
|
.ok_or_else(|| RrdpError::InvalidRrdpUri {
|
||||||
|
resource,
|
||||||
|
detail: "port is missing or unknown".to_string(),
|
||||||
|
})?;
|
||||||
|
Ok(Self {
|
||||||
|
scheme: url.scheme().to_ascii_lowercase(),
|
||||||
|
host: host.to_ascii_lowercase(),
|
||||||
|
port,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@ -168,6 +272,15 @@ pub enum RrdpSyncError {
|
|||||||
Storage(String),
|
Storage(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum RrdpFetchError {
|
||||||
|
#[error("{0}")]
|
||||||
|
Rrdp(#[from] RrdpError),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Fetch(String),
|
||||||
|
}
|
||||||
|
|
||||||
pub type RrdpSyncResult<T> = Result<T, RrdpSyncError>;
|
pub type RrdpSyncResult<T> = Result<T, RrdpSyncError>;
|
||||||
|
|
||||||
pub trait Fetcher: Send + Sync {
|
pub trait Fetcher: Send + Sync {
|
||||||
@ -179,6 +292,29 @@ pub trait Fetcher: Send + Sync {
|
|||||||
.map_err(|e| format!("write sink failed: {e}"))?;
|
.map_err(|e| format!("write sink failed: {e}"))?;
|
||||||
Ok(bytes.len() as u64)
|
Ok(bytes.len() as u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// RRDP-only fetch path. Implementations that do not perform HTTP may use
|
||||||
|
/// the compatibility default; HTTP implementations must enforce redirect
|
||||||
|
/// origin checks before issuing the next request.
|
||||||
|
fn fetch_rrdp(
|
||||||
|
&self,
|
||||||
|
_resource: RrdpResourceKind,
|
||||||
|
uri: &str,
|
||||||
|
_notification_origin: &RrdpOrigin,
|
||||||
|
) -> Result<Vec<u8>, RrdpFetchError> {
|
||||||
|
self.fetch(uri).map_err(RrdpFetchError::Fetch)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_rrdp_to_writer(
|
||||||
|
&self,
|
||||||
|
_resource: RrdpResourceKind,
|
||||||
|
uri: &str,
|
||||||
|
_notification_origin: &RrdpOrigin,
|
||||||
|
out: &mut dyn Write,
|
||||||
|
) -> Result<u64, RrdpFetchError> {
|
||||||
|
self.fetch_to_writer(uri, out)
|
||||||
|
.map_err(RrdpFetchError::Fetch)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@ -385,6 +521,51 @@ pub fn parse_notification_snapshot(xml: &[u8]) -> Result<NotificationSnapshot, R
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate every URI embedded in a notification before reading mutable local
|
||||||
|
/// RRDP state or fetching a snapshot/delta. RFC 9674 §3.2 requires the
|
||||||
|
/// notification, snapshot, and all delta URIs to share one origin.
|
||||||
|
pub fn validate_notification_references(
|
||||||
|
notification_uri: &str,
|
||||||
|
notification: &Notification,
|
||||||
|
) -> Result<RrdpOrigin, RrdpError> {
|
||||||
|
let notification_origin = RrdpOrigin::parse(notification_uri, RrdpResourceKind::Notification)?;
|
||||||
|
validate_reference_origin(
|
||||||
|
¬ification_origin,
|
||||||
|
¬ification.snapshot_uri,
|
||||||
|
RrdpResourceKind::Snapshot,
|
||||||
|
)?;
|
||||||
|
for delta in ¬ification.deltas {
|
||||||
|
validate_reference_origin(¬ification_origin, &delta.uri, RrdpResourceKind::Delta)?;
|
||||||
|
}
|
||||||
|
Ok(notification_origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_reference_origin(
|
||||||
|
notification_origin: &RrdpOrigin,
|
||||||
|
uri: &str,
|
||||||
|
resource: RrdpResourceKind,
|
||||||
|
) -> Result<(), RrdpError> {
|
||||||
|
let resource_origin = RrdpOrigin::parse(uri, resource)?;
|
||||||
|
if &resource_origin != notification_origin {
|
||||||
|
crate::progress_log::emit(
|
||||||
|
"rrdp_cross_origin_rejected",
|
||||||
|
serde_json::json!({
|
||||||
|
"resource": resource.to_string(),
|
||||||
|
"notification_origin": notification_origin.to_string(),
|
||||||
|
"resource_origin": resource_origin.to_string(),
|
||||||
|
"reason": "direct_reference",
|
||||||
|
"rfc": "RFC 9674 §3.2",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Err(RrdpError::CrossOriginReference {
|
||||||
|
resource,
|
||||||
|
notification_origin: notification_origin.to_string(),
|
||||||
|
resource_origin: resource_origin.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn parse_delta_file(xml: &[u8]) -> Result<DeltaFile, RrdpError> {
|
pub fn parse_delta_file(xml: &[u8]) -> Result<DeltaFile, RrdpError> {
|
||||||
let doc = parse_rrdp_xml(xml)?;
|
let doc = parse_rrdp_xml(xml)?;
|
||||||
let root = doc.root_element();
|
let root = doc.root_element();
|
||||||
|
|||||||
@ -71,6 +71,18 @@ fn sync_from_notification_inner(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|t| t.span_phase("rrdp_parse_notification_total"));
|
.map(|t| t.span_phase("rrdp_parse_notification_total"));
|
||||||
let notif = parse_notification(notification_xml)?;
|
let notif = parse_notification(notification_xml)?;
|
||||||
|
let notification_origin = match validate_notification_references(notification_uri, ¬if) {
|
||||||
|
Ok(origin) => origin,
|
||||||
|
Err(e) => {
|
||||||
|
if let Some(t) = timing.as_ref() {
|
||||||
|
t.record_count(
|
||||||
|
"rrdp_rejected_cross_origin_total",
|
||||||
|
e.is_cross_origin_violation() as u64,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
drop(_parse_step);
|
drop(_parse_step);
|
||||||
drop(_parse_total);
|
drop(_parse_total);
|
||||||
if let Some(t) = timing.as_ref() {
|
if let Some(t) = timing.as_ref() {
|
||||||
@ -161,7 +173,11 @@ fn sync_from_notification_inner(
|
|||||||
|
|
||||||
let mut dl_span = download_log
|
let mut dl_span = download_log
|
||||||
.map(|dl| dl.span_download(AuditDownloadKind::RrdpDelta, &dref.uri));
|
.map(|dl| dl.span_download(AuditDownloadKind::RrdpDelta, &dref.uri));
|
||||||
match fetcher.fetch(&dref.uri) {
|
match fetcher.fetch_rrdp(
|
||||||
|
RrdpResourceKind::Delta,
|
||||||
|
&dref.uri,
|
||||||
|
¬ification_origin,
|
||||||
|
) {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
if let Some(t) = timing.as_ref() {
|
if let Some(t) = timing.as_ref() {
|
||||||
t.record_count("rrdp_delta_fetch_ok_total", 1);
|
t.record_count("rrdp_delta_fetch_ok_total", 1);
|
||||||
@ -173,7 +189,7 @@ fn sync_from_notification_inner(
|
|||||||
}
|
}
|
||||||
fetched.push((serial, dref.hash_sha256, bytes))
|
fetched.push((serial, dref.hash_sha256, bytes))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(RrdpFetchError::Fetch(e)) => {
|
||||||
if let Some(t) = timing.as_ref() {
|
if let Some(t) = timing.as_ref() {
|
||||||
t.record_count("rrdp_delta_fetch_fail_total", 1);
|
t.record_count("rrdp_delta_fetch_fail_total", 1);
|
||||||
}
|
}
|
||||||
@ -183,6 +199,7 @@ fn sync_from_notification_inner(
|
|||||||
fetch_ok = false;
|
fetch_ok = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
Err(RrdpFetchError::Rrdp(e)) => return Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drop(_fetch_d_step);
|
drop(_fetch_d_step);
|
||||||
@ -281,6 +298,7 @@ fn sync_from_notification_inner(
|
|||||||
fetcher,
|
fetcher,
|
||||||
¬if.snapshot_uri,
|
¬if.snapshot_uri,
|
||||||
¬if.snapshot_hash_sha256,
|
¬if.snapshot_hash_sha256,
|
||||||
|
¬ification_origin,
|
||||||
) {
|
) {
|
||||||
Ok(v) => {
|
Ok(v) => {
|
||||||
if let Some(t) = timing.as_ref() {
|
if let Some(t) = timing.as_ref() {
|
||||||
|
|||||||
@ -15,8 +15,8 @@ use crate::sync::store_projection::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
Fetcher, RRDP_SNAPSHOT_APPLY_BATCH_SIZE, RRDP_XMLNS, RrdpError, RrdpSyncError, parse_u64_str,
|
Fetcher, RRDP_SNAPSHOT_APPLY_BATCH_SIZE, RRDP_XMLNS, RrdpError, RrdpFetchError, RrdpOrigin,
|
||||||
strip_all_ascii_whitespace,
|
RrdpResourceKind, RrdpSyncError, parse_u64_str, strip_all_ascii_whitespace,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -422,14 +422,23 @@ pub(super) fn fetch_snapshot_into_tempfile(
|
|||||||
fetcher: &dyn Fetcher,
|
fetcher: &dyn Fetcher,
|
||||||
snapshot_uri: &str,
|
snapshot_uri: &str,
|
||||||
expected_hash_sha256: &[u8; 32],
|
expected_hash_sha256: &[u8; 32],
|
||||||
|
notification_origin: &RrdpOrigin,
|
||||||
) -> Result<(tempfile::NamedTempFile, u64), RrdpSyncError> {
|
) -> Result<(tempfile::NamedTempFile, u64), RrdpSyncError> {
|
||||||
let mut tmp = tempfile::NamedTempFile::new()
|
let mut tmp = tempfile::NamedTempFile::new()
|
||||||
.map_err(|e| RrdpSyncError::Fetch(format!("tempfile create failed: {e}")))?;
|
.map_err(|e| RrdpSyncError::Fetch(format!("tempfile create failed: {e}")))?;
|
||||||
let mut spool = SnapshotSpoolWriter::new(tmp.as_file_mut());
|
let mut spool = SnapshotSpoolWriter::new(tmp.as_file_mut());
|
||||||
let bytes_written = match fetcher.fetch_to_writer(snapshot_uri, &mut spool) {
|
let bytes_written = match fetcher.fetch_rrdp_to_writer(
|
||||||
|
RrdpResourceKind::Snapshot,
|
||||||
|
snapshot_uri,
|
||||||
|
notification_origin,
|
||||||
|
&mut spool,
|
||||||
|
) {
|
||||||
Ok(bytes) => bytes,
|
Ok(bytes) => bytes,
|
||||||
Err(e) if e.contains(SNAPSHOT_NON_ASCII_ERROR) => return Err(RrdpError::NotAscii.into()),
|
Err(RrdpFetchError::Fetch(e)) if e.contains(SNAPSHOT_NON_ASCII_ERROR) => {
|
||||||
Err(e) => return Err(RrdpSyncError::Fetch(e)),
|
return Err(RrdpError::NotAscii.into());
|
||||||
|
}
|
||||||
|
Err(RrdpFetchError::Fetch(e)) => return Err(RrdpSyncError::Fetch(e)),
|
||||||
|
Err(RrdpFetchError::Rrdp(e)) => return Err(e.into()),
|
||||||
};
|
};
|
||||||
let computed = spool.finalize_hash();
|
let computed = spool.finalize_hash();
|
||||||
if computed.as_slice() != expected_hash_sha256.as_slice() {
|
if computed.as_slice() != expected_hash_sha256.as_slice() {
|
||||||
|
|||||||
@ -65,7 +65,19 @@ fn sync_from_notification_snapshot_inner(
|
|||||||
let _parse_total = timing
|
let _parse_total = timing
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|t| t.span_phase("rrdp_parse_notification_total"));
|
.map(|t| t.span_phase("rrdp_parse_notification_total"));
|
||||||
let notif = parse_notification_snapshot(notification_xml)?;
|
let notif = parse_notification(notification_xml)?;
|
||||||
|
let notification_origin = match validate_notification_references(notification_uri, ¬if) {
|
||||||
|
Ok(origin) => origin,
|
||||||
|
Err(e) => {
|
||||||
|
if let Some(t) = timing.as_ref() {
|
||||||
|
t.record_count(
|
||||||
|
"rrdp_rejected_cross_origin_total",
|
||||||
|
e.is_cross_origin_violation() as u64,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
drop(_parse_step);
|
drop(_parse_step);
|
||||||
drop(_parse_total);
|
drop(_parse_total);
|
||||||
|
|
||||||
@ -81,6 +93,7 @@ fn sync_from_notification_snapshot_inner(
|
|||||||
fetcher,
|
fetcher,
|
||||||
¬if.snapshot_uri,
|
¬if.snapshot_uri,
|
||||||
¬if.snapshot_hash_sha256,
|
¬if.snapshot_hash_sha256,
|
||||||
|
¬ification_origin,
|
||||||
) {
|
) {
|
||||||
Ok(v) => {
|
Ok(v) => {
|
||||||
if let Some(t) = timing.as_ref() {
|
if let Some(t) = timing.as_ref() {
|
||||||
|
|||||||
@ -131,9 +131,10 @@ fn fetch_snapshot_into_tempfile_streams_and_validates_hash() {
|
|||||||
let fetcher = WriterOnlyFetcher {
|
let fetcher = WriterOnlyFetcher {
|
||||||
map: HashMap::from([(snapshot_uri.to_string(), snapshot.clone())]),
|
map: HashMap::from([(snapshot_uri.to_string(), snapshot.clone())]),
|
||||||
};
|
};
|
||||||
|
let origin = RrdpOrigin::parse(snapshot_uri, RrdpResourceKind::Notification).expect("origin");
|
||||||
|
|
||||||
let (mut file, bytes_written) =
|
let (mut file, bytes_written) =
|
||||||
fetch_snapshot_into_tempfile(&fetcher, snapshot_uri, &expected_hash)
|
fetch_snapshot_into_tempfile(&fetcher, snapshot_uri, &expected_hash, &origin)
|
||||||
.expect("fetch snapshot into tempfile");
|
.expect("fetch snapshot into tempfile");
|
||||||
assert_eq!(bytes_written, snapshot.len() as u64);
|
assert_eq!(bytes_written, snapshot.len() as u64);
|
||||||
let mut got = Vec::new();
|
let mut got = Vec::new();
|
||||||
@ -144,7 +145,8 @@ fn fetch_snapshot_into_tempfile_streams_and_validates_hash() {
|
|||||||
|
|
||||||
let mut wrong_hash = expected_hash;
|
let mut wrong_hash = expected_hash;
|
||||||
wrong_hash[0] ^= 0xff;
|
wrong_hash[0] ^= 0xff;
|
||||||
let err = fetch_snapshot_into_tempfile(&fetcher, snapshot_uri, &wrong_hash).unwrap_err();
|
let err =
|
||||||
|
fetch_snapshot_into_tempfile(&fetcher, snapshot_uri, &wrong_hash, &origin).unwrap_err();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
err,
|
err,
|
||||||
RrdpSyncError::Rrdp(RrdpError::SnapshotHashMismatch)
|
RrdpSyncError::Rrdp(RrdpError::SnapshotHashMismatch)
|
||||||
@ -157,6 +159,11 @@ fn fetch_snapshot_into_tempfile_maps_stream_non_ascii_error() {
|
|||||||
&NonAsciiWriterFetcher,
|
&NonAsciiWriterFetcher,
|
||||||
"https://example.test/snapshot.xml",
|
"https://example.test/snapshot.xml",
|
||||||
&[0u8; 32],
|
&[0u8; 32],
|
||||||
|
&RrdpOrigin::parse(
|
||||||
|
"https://example.test/notification.xml",
|
||||||
|
RrdpResourceKind::Notification,
|
||||||
|
)
|
||||||
|
.expect("origin"),
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, RrdpSyncError::Rrdp(RrdpError::NotAscii)));
|
assert!(matches!(err, RrdpSyncError::Rrdp(RrdpError::NotAscii)));
|
||||||
|
|||||||
@ -35,6 +35,95 @@ fn sync_from_notification_snapshot_rejects_cross_source_owner_conflict() {
|
|||||||
assert!(err.to_string().contains("owner conflict"), "{err}");
|
assert!(err.to_string().contains("owner conflict"), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PanicOnRrdpFetch;
|
||||||
|
|
||||||
|
impl Fetcher for PanicOnRrdpFetch {
|
||||||
|
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
|
||||||
|
panic!("cross-origin notification must be rejected before fetch: {uri}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cross_origin_snapshot_reference_is_rejected_before_fetch_or_state_write() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let store = RocksStore::open(temp.path()).expect("open rocksdb");
|
||||||
|
let notification_uri = "https://origin.example.test/notification.xml";
|
||||||
|
let notification = notification_xml(
|
||||||
|
"550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
1,
|
||||||
|
"https://foreign.example.test/snapshot.xml",
|
||||||
|
&"00".repeat(32),
|
||||||
|
);
|
||||||
|
|
||||||
|
let err =
|
||||||
|
sync_from_notification_snapshot(&store, notification_uri, ¬ification, &PanicOnRrdpFetch)
|
||||||
|
.expect_err("cross-origin snapshot must be rejected");
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
RrdpSyncError::Rrdp(RrdpError::CrossOriginReference {
|
||||||
|
resource: RrdpResourceKind::Snapshot,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
load_rrdp_local_state(&store, notification_uri)
|
||||||
|
.expect("read state")
|
||||||
|
.is_none(),
|
||||||
|
"rejection must precede RRDP state writes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cross_origin_delta_reference_is_rejected_even_when_delta_would_not_be_used() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let store = RocksStore::open(temp.path()).expect("open rocksdb");
|
||||||
|
let notification_uri = "https://origin.example.test/notification.xml";
|
||||||
|
let notification = notification_xml_with_deltas(
|
||||||
|
"550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
1,
|
||||||
|
"https://origin.example.test/snapshot.xml",
|
||||||
|
&"00".repeat(32),
|
||||||
|
&[(
|
||||||
|
"delta-1",
|
||||||
|
1,
|
||||||
|
"https://foreign.example.test/delta-1.xml",
|
||||||
|
&"11".repeat(32),
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = sync_from_notification(&store, notification_uri, ¬ification, &PanicOnRrdpFetch)
|
||||||
|
.expect_err("cross-origin delta must be rejected before state/fetch");
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
RrdpSyncError::Rrdp(RrdpError::CrossOriginReference {
|
||||||
|
resource: RrdpResourceKind::Delta,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
load_rrdp_local_state(&store, notification_uri)
|
||||||
|
.expect("read state")
|
||||||
|
.is_none(),
|
||||||
|
"all delta references are checked before state reads or writes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rrdp_origin_treats_https_default_port_as_equivalent() {
|
||||||
|
let notification = parse_notification(¬ification_xml(
|
||||||
|
"550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
1,
|
||||||
|
"https://origin.example.test:443/snapshot.xml",
|
||||||
|
&"00".repeat(32),
|
||||||
|
))
|
||||||
|
.expect("notification");
|
||||||
|
validate_notification_references(
|
||||||
|
"https://origin.example.test/notification.xml",
|
||||||
|
¬ification,
|
||||||
|
)
|
||||||
|
.expect("explicit HTTPS default port must remain same-origin");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sync_from_notification_snapshot_applies_snapshot_and_stores_state() {
|
fn sync_from_notification_snapshot_applies_snapshot_and_stores_state() {
|
||||||
let tmp = tempfile::tempdir().expect("tempdir");
|
let tmp = tempfile::tempdir().expect("tempdir");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user