Implement replay-safe browser synchronization
This commit is contained in:
parent
06a7ac56b7
commit
459ffa04c6
5 changed files with 2734 additions and 25 deletions
10
crates/remora-api/migrations/0002_live_event_tickets.sql
Normal file
10
crates/remora-api/migrations/0002_live_event_tickets.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE live_event_tickets (
|
||||
ticket_digest BYTEA PRIMARY KEY CHECK (octet_length(ticket_digest) = 32),
|
||||
principal JSONB NOT NULL,
|
||||
origin TEXT NOT NULL CHECK (origin <> ''),
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL CHECK (expires_at > created_at)
|
||||
);
|
||||
|
||||
CREATE INDEX live_event_tickets_expiry_idx
|
||||
ON live_event_tickets (expires_at);
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
use std::time::Duration;
|
||||
use std::{collections::HashSet, time::Duration};
|
||||
|
||||
use actix_web::{
|
||||
HttpRequest, HttpResponse, ResponseError,
|
||||
|
|
@ -51,6 +51,8 @@ pub struct PublicApiConfig {
|
|||
pub event_retention: Duration,
|
||||
pub event_poll_interval: Duration,
|
||||
pub event_heartbeat_interval: Duration,
|
||||
pub event_ticket_ttl: Duration,
|
||||
pub event_allowed_origins: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -68,6 +70,8 @@ pub struct PublicApiState {
|
|||
event_retention: Duration,
|
||||
event_poll_interval: Duration,
|
||||
event_heartbeat_interval: Duration,
|
||||
event_ticket_ttl: Duration,
|
||||
event_allowed_origins: HashSet<String>,
|
||||
}
|
||||
|
||||
pub struct PublicApiServices {
|
||||
|
|
@ -93,9 +97,28 @@ impl PublicApiState {
|
|||
if config.event_retention.is_zero()
|
||||
|| config.event_poll_interval.is_zero()
|
||||
|| config.event_heartbeat_interval.is_zero()
|
||||
|| config.event_ticket_ttl.is_zero()
|
||||
{
|
||||
return Err(DurableError::Config(
|
||||
"event retention and intervals must be positive".into(),
|
||||
"event retention, intervals, and ticket expiry must be positive".into(),
|
||||
));
|
||||
}
|
||||
if config.event_ticket_ttl > Duration::from_secs(60) {
|
||||
return Err(DurableError::Config(
|
||||
"event ticket expiry must not exceed 60 seconds".into(),
|
||||
));
|
||||
}
|
||||
let event_allowed_origins = config
|
||||
.event_allowed_origins
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>();
|
||||
if event_allowed_origins.is_empty()
|
||||
|| event_allowed_origins
|
||||
.iter()
|
||||
.any(|origin| origin.is_empty() || origin.trim() != origin)
|
||||
{
|
||||
return Err(DurableError::Config(
|
||||
"at least one exact event origin is required".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
|
|
@ -112,6 +135,8 @@ impl PublicApiState {
|
|||
event_retention: config.event_retention,
|
||||
event_poll_interval: config.event_poll_interval,
|
||||
event_heartbeat_interval: config.event_heartbeat_interval,
|
||||
event_ticket_ttl: config.event_ticket_ttl,
|
||||
event_allowed_origins,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -193,6 +218,7 @@ pub fn configure_public_api(config: &mut web::ServiceConfig) {
|
|||
"/deletions/{operation_id}",
|
||||
web::get().to(get_deletion_operation),
|
||||
)
|
||||
.route("/event-tickets", web::post().to(events::issue_ticket))
|
||||
.route("/events", web::get().to(events::connect)),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
use actix_web::{HttpRequest, HttpResponse, http::header, web};
|
||||
use futures_util::StreamExt as _;
|
||||
use remora_contracts::{
|
||||
CursorDto, DateTimeDto, DeletionChangedDto, DeletionStateDto, EventAttemptDto, EventFrameDto,
|
||||
EventProgressDto, HeartbeatDto, JobChangedDto, NullableDto, PositiveIntegerDto,
|
||||
EventProgressDto, EventTicketDto, HeartbeatDto, JobChangedDto, NullableDto, PositiveIntegerDto,
|
||||
ResyncReasonDto, ResyncRequiredDto, SafeIntegerDto, SchemaVersionDto,
|
||||
};
|
||||
use remora_domain::{JobLifecycle, JobPhase, OpaqueId};
|
||||
use remora_domain::{
|
||||
BucketName, JobLifecycle, JobPhase, OpaqueId, StorageScope, TargetPrefix, TargetStoreId,
|
||||
};
|
||||
use remora_durable::{DurableError, KeysetCursor};
|
||||
use remora_identity::{ManagementOperation, Principal};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sqlx::{FromRow, PgPool};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use sqlx::{FromRow, PgPool, types::Json};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use super::{
|
||||
|
|
@ -24,6 +27,60 @@ const EVENT_BATCH: i64 = 100;
|
|||
#[derive(Deserialize)]
|
||||
pub(super) struct EventQuery {
|
||||
cursor: Option<String>,
|
||||
ticket: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct TicketPrincipal {
|
||||
id: String,
|
||||
issuer: String,
|
||||
subject: String,
|
||||
administrator: bool,
|
||||
scopes: Vec<TicketScope>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct TicketScope {
|
||||
store: String,
|
||||
bucket: String,
|
||||
prefix: String,
|
||||
}
|
||||
|
||||
pub(super) async fn issue_ticket(
|
||||
state: web::Data<PublicApiState>,
|
||||
request: HttpRequest,
|
||||
) -> Result<HttpResponse, PublicApiError> {
|
||||
let principal = state.principal(&request).await?;
|
||||
let origin = allowed_origin(&state, &request)?;
|
||||
let ticket = random_ticket();
|
||||
let digest = ticket_digest(ticket.as_str());
|
||||
let mut tx = state.pool.begin().await?;
|
||||
sqlx::query("DELETE FROM live_event_tickets WHERE expires_at <= now()")
|
||||
.execute(tx.as_mut())
|
||||
.await?;
|
||||
let expires_at: OffsetDateTime = sqlx::query_scalar(
|
||||
"INSERT INTO live_event_tickets (
|
||||
ticket_digest, principal, origin, created_at, expires_at
|
||||
) VALUES (
|
||||
$1, $2, $3, now(),
|
||||
now() + $4 * interval '1 second'
|
||||
)
|
||||
RETURNING expires_at",
|
||||
)
|
||||
.bind(digest.as_slice())
|
||||
.bind(Json(ticket_principal_projection(&principal)))
|
||||
.bind(&origin)
|
||||
.bind(state.event_ticket_ttl.as_secs_f64())
|
||||
.fetch_one(tx.as_mut())
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(HttpResponse::Created()
|
||||
.insert_header((header::CACHE_CONTROL, "no-store"))
|
||||
.insert_header((header::PRAGMA, "no-cache"))
|
||||
.json(EventTicketDto {
|
||||
ticket,
|
||||
expires_at: event_time(expires_at)?,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn connect(
|
||||
|
|
@ -32,7 +89,13 @@ pub(super) async fn connect(
|
|||
body: web::Payload,
|
||||
query: web::Query<EventQuery>,
|
||||
) -> Result<HttpResponse, PublicApiError> {
|
||||
let principal = state.principal(&request).await?;
|
||||
let bearer = request.headers().contains_key(header::AUTHORIZATION);
|
||||
let principal = match (bearer, query.ticket.as_deref()) {
|
||||
(true, None) => state.principal(&request).await?,
|
||||
(false, Some(ticket)) => redeem_ticket(&state, &request, ticket).await?,
|
||||
(false, None) => return Err(PublicApiError::unauthenticated()),
|
||||
(true, Some(_)) => return Err(PublicApiError::invalid_request()),
|
||||
};
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let retention =
|
||||
time::Duration::try_from(state.event_retention).map_err(|_| PublicApiError::internal())?;
|
||||
|
|
@ -54,6 +117,94 @@ pub(super) async fn connect(
|
|||
Ok(response)
|
||||
}
|
||||
|
||||
fn allowed_origin(state: &PublicApiState, request: &HttpRequest) -> Result<String, PublicApiError> {
|
||||
let mut origins = request.headers().get_all(header::ORIGIN);
|
||||
let origin = origins
|
||||
.next()
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or_else(PublicApiError::forbidden)?;
|
||||
if origins.next().is_some() || !state.event_allowed_origins.contains(origin) {
|
||||
return Err(PublicApiError::forbidden());
|
||||
}
|
||||
Ok(origin.to_owned())
|
||||
}
|
||||
|
||||
async fn redeem_ticket(
|
||||
state: &PublicApiState,
|
||||
request: &HttpRequest,
|
||||
ticket: &str,
|
||||
) -> Result<Principal, PublicApiError> {
|
||||
let origin = allowed_origin(state, request)?;
|
||||
let digest = ticket_digest(ticket);
|
||||
let projection: Option<Json<TicketPrincipal>> = sqlx::query_scalar(
|
||||
"DELETE FROM live_event_tickets
|
||||
WHERE ticket_digest = $1 AND origin = $2 AND expires_at > now()
|
||||
RETURNING principal",
|
||||
)
|
||||
.bind(digest.as_slice())
|
||||
.bind(origin)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
projection
|
||||
.ok_or_else(PublicApiError::unauthenticated)
|
||||
.and_then(|projection| ticket_principal(projection.0))
|
||||
}
|
||||
|
||||
fn ticket_principal_projection(principal: &Principal) -> TicketPrincipal {
|
||||
TicketPrincipal {
|
||||
id: principal.id.as_str().to_owned(),
|
||||
issuer: principal.issuer.clone(),
|
||||
subject: principal.subject.clone(),
|
||||
administrator: principal.administrator,
|
||||
scopes: principal
|
||||
.scopes
|
||||
.iter()
|
||||
.map(|scope| TicketScope {
|
||||
store: scope.store.as_str().to_owned(),
|
||||
bucket: scope.bucket.as_str().to_owned(),
|
||||
prefix: scope.prefix.canonical(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_principal(projection: TicketPrincipal) -> Result<Principal, PublicApiError> {
|
||||
let scopes = projection
|
||||
.scopes
|
||||
.into_iter()
|
||||
.map(|scope| {
|
||||
Ok(StorageScope {
|
||||
store: TargetStoreId::parse(scope.store).map_err(|_| PublicApiError::internal())?,
|
||||
bucket: BucketName::parse(scope.bucket).map_err(|_| PublicApiError::internal())?,
|
||||
prefix: TargetPrefix::parse(&scope.prefix, std::iter::empty())
|
||||
.map_err(|_| PublicApiError::internal())?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, PublicApiError>>()?;
|
||||
Ok(Principal {
|
||||
id: OpaqueId::parse(projection.id).map_err(|_| PublicApiError::internal())?,
|
||||
issuer: projection.issuer,
|
||||
subject: projection.subject,
|
||||
administrator: projection.administrator,
|
||||
scopes,
|
||||
})
|
||||
}
|
||||
|
||||
fn random_ticket() -> OpaqueId {
|
||||
let mut bytes = [0_u8; 32];
|
||||
getrandom::fill(&mut bytes).expect("operating system randomness is required");
|
||||
let mut value = String::from("t");
|
||||
for byte in bytes {
|
||||
use std::fmt::Write as _;
|
||||
write!(value, "{byte:02x}").expect("writing to String cannot fail");
|
||||
}
|
||||
OpaqueId::parse(value).expect("random event ticket is valid")
|
||||
}
|
||||
|
||||
fn ticket_digest(ticket: &str) -> [u8; 32] {
|
||||
Sha256::digest(ticket.as_bytes()).into()
|
||||
}
|
||||
|
||||
pub(super) async fn prune_history(
|
||||
pool: &PgPool,
|
||||
before: OffsetDateTime,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue