Implement replay-safe browser synchronization

This commit is contained in:
ms2k 2026-07-18 00:16:21 +09:00
commit 459ffa04c6
2 changed files with 2734 additions and 25 deletions

View file

@ -29,9 +29,12 @@ use time::OffsetDateTime;
use tokio_tungstenite::{
MaybeTlsStream, WebSocketStream, connect_async,
tungstenite::{
Message,
Error as WebSocketError, Message,
client::IntoClientRequest,
http::{HeaderValue, header::AUTHORIZATION},
http::{
HeaderValue,
header::{AUTHORIZATION, ORIGIN},
},
},
};
@ -40,6 +43,7 @@ mod support;
use support::{mint_provider_token, oidc_provider, required_env, revoke_remora_grants};
type Socket = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
const BROWSER_ORIGIN: &str = "https://remora.test";
struct Harness {
pool: PgPool,
@ -158,6 +162,8 @@ impl Harness {
event_retention: Duration::from_millis(500),
event_poll_interval: Duration::from_millis(250),
event_heartbeat_interval: Duration::from_millis(100),
event_ticket_ttl: Duration::from_secs(30),
event_allowed_origins: vec!["https://remora.test".into()],
},
)
.unwrap();
@ -227,7 +233,50 @@ impl Harness {
response.json().await.unwrap()
}
async fn issue_ticket(&self, token: &str) -> String {
let response = self
.client
.post(format!("{}/event-tickets", self.http_base))
.bearer_auth(token)
.header("Origin", BROWSER_ORIGIN)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
assert_eq!(response.headers().get("Cache-Control").unwrap(), "no-store");
response.json::<Value>().await.unwrap()["ticket"]
.as_str()
.unwrap()
.to_owned()
}
async fn connect(&self, token: &str, cursor: Option<&str>) -> Socket {
let ticket = self.issue_ticket(token).await;
self.connect_ticket(&ticket, cursor, BROWSER_ORIGIN)
.await
.unwrap()
}
async fn connect_ticket(
&self,
ticket: &str,
cursor: Option<&str>,
origin: &str,
) -> Result<Socket, WebSocketError> {
let url = cursor.map_or_else(
|| format!("{}?ticket={ticket}", self.ws_base),
|cursor| format!("{}?ticket={ticket}&cursor={cursor}", self.ws_base),
);
let mut request = url.into_client_request().unwrap();
request
.headers_mut()
.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
let (socket, response) = connect_async(request).await?;
assert_eq!(response.status(), 101);
Ok(socket)
}
async fn connect_bearer(&self, token: &str, cursor: Option<&str>) -> Socket {
let url = cursor.map_or_else(
|| self.ws_base.clone(),
|cursor| format!("{}?cursor={cursor}", self.ws_base),
@ -255,6 +304,60 @@ async fn live_event_replay_authorization_progress_and_resync_converge() {
return;
};
let ticket = harness.issue_ticket(&harness.token).await;
let forbidden_ticket = harness
.client
.post(format!("{}/event-tickets", harness.http_base))
.bearer_auth(&harness.token)
.header("Origin", "https://attacker.invalid")
.send()
.await
.unwrap();
assert_eq!(forbidden_ticket.status(), StatusCode::FORBIDDEN);
assert!(
harness
.connect_ticket(&ticket, None, "https://attacker.invalid")
.await
.is_err()
);
let valid = harness
.connect_ticket(&ticket, None, BROWSER_ORIGIN)
.await
.unwrap();
drop(valid);
assert!(
harness
.connect_ticket(&ticket, None, BROWSER_ORIGIN)
.await
.is_err()
);
let concurrent_ticket = harness.issue_ticket(&harness.token).await;
let (left, right) = tokio::join!(
harness.connect_ticket(&concurrent_ticket, None, BROWSER_ORIGIN),
harness.connect_ticket(&concurrent_ticket, None, BROWSER_ORIGIN)
);
assert_eq!(usize::from(left.is_ok()) + usize::from(right.is_ok()), 1);
let expired_ticket = harness.issue_ticket(&harness.token).await;
sqlx::query(
"UPDATE live_event_tickets
SET expires_at = created_at + interval '1 microsecond'
WHERE expires_at > now()",
)
.execute(&harness.pool)
.await
.unwrap();
assert!(
harness
.connect_ticket(&expired_ticket, None, BROWSER_ORIGIN)
.await
.is_err()
);
let mut bearer = harness.connect_bearer(&harness.token, None).await;
assert_resync(next_frame(&mut bearer).await, ResyncReasonDto::CursorAbsent);
let mut absent = harness.connect(&harness.token, None).await;
assert_resync(next_frame(&mut absent).await, ResyncReasonDto::CursorAbsent);

View file

@ -144,6 +144,8 @@ impl Harness {
event_retention: Duration::from_secs(60),
event_poll_interval: Duration::from_millis(50),
event_heartbeat_interval: Duration::from_secs(1),
event_ticket_ttl: Duration::from_secs(30),
event_allowed_origins: vec!["https://remora.test".into()],
},
)
.unwrap();