diff --git a/DOC.md b/DOC.md index 5c6a55b..c352642 100644 --- a/DOC.md +++ b/DOC.md @@ -114,3 +114,13 @@ automatiquement une base de données gratuite au démarrage (aucune inscription et la garde en local — l'adresse IP d'un visiteur n'est jamais transmise à un service externe pour être localisée. Sans accès Internet au démarrage, la géolocalisation reste simplement désactivée, le reste d'Iris continue de fonctionner normalement. + +Par défaut, n'importe quel site connaissant l'identifiant d'un projet pourrait lui +envoyer des faux événements. Pour l'empêcher, on peut déclarer dans l'admin GraphQL +les domaines sur lesquels un projet est réellement installé (`upsertProject`) : une +fois ça fait, le serveur refuse les événements qui n'arrivent pas depuis un de ces +domaines. C'est le même principe que chez Plausible ou PostHog — ça bloque un site +tiers qui essaierait discrètement d'envoyer des statistiques au nom d'un autre site +depuis le navigateur d'un visiteur. Ce n'est pas un secret pour autant : quelqu'un qui +appelle directement le serveur (pas depuis un navigateur) peut toujours falsifier +l'en-tête d'origine s'il connaît déjà l'identifiant du projet et son domaine attendu. diff --git a/README.md b/README.md index 311e00c..aa3c7b1 100644 --- a/README.md +++ b/README.md @@ -90,12 +90,37 @@ query { Queries: `events`, `eventCounts`, `sessions`, `replay`, `funnel`, `retention`, `featureFlags`, `isFeatureEnabled`, `experimentVariant`, `experiments`, `experimentResults`, `cohorts`, `groups`, `surveys`, `errors`, `destinations`, `exportEventsCsv`, `runQuery`, -`sessionGeo`, `sessionsByCountry`. +`sessionGeo`, `sessionsByCountry`, `project`. Mutations: `capture` (prefer `/capture`/`/batch` REST routes for the SDK's own traffic — GraphQL mutation is for server-side/backend integrations), `upsertFeatureFlag`, `upsertExperiment`, `createCohort`, `groupIdentify`, -`setPersonProperties`, `createDestination`, `createSurvey`. +`setPersonProperties`, `createDestination`, `createSurvey`, `upsertProject`. + +## Per-project origin allowlisting + +By default a `projectId` is just a free-form string: any site knowing it can post events +under it. To stop site B from posting events under site A's `projectId`, register the +origins A is actually embedded on: + +```graphql +mutation { + upsertProject(id: "your-project", name: "My Site", allowedOrigins: ["https://example.com"]) +} +``` + +Once a project has at least one registered origin, `/capture`, `/batch`, `/replay`, and the +`capture` GraphQL mutation reject requests whose `Origin` (or `Referer`, as a fallback) header +doesn't match one of them, with `403`. A project that's never been registered (or registered +with an empty `allowedOrigins`) stays open, so this is opt-in and doesn't break existing +deployments. + +This is the same mechanism Plausible/PostHog call "site verification" — it stops a page on +another site from silently posting analytics in a visitor's browser. It is **not** a secret: +`Origin`/`Referer` are just headers, so a non-browser client (curl, a script) that already +knows your `projectId` and expected origin can still set them to match. Treat it as raising +the bar against browser-context abuse, not as authentication — the GraphQL API has no auth at +all, by design, and is meant to be reached only by trusted/admin callers. ## Performance diff --git a/crates/iris-core/src/lib.rs b/crates/iris-core/src/lib.rs index f0d90a1..b6e59b5 100644 --- a/crates/iris-core/src/lib.rs +++ b/crates/iris-core/src/lib.rs @@ -6,5 +6,6 @@ pub mod flags; pub mod stats; pub mod experiments; pub mod geo; +pub mod origin; pub use models::*; diff --git a/crates/iris-core/src/models.rs b/crates/iris-core/src/models.rs index eb55381..06f4f3d 100644 --- a/crates/iris-core/src/models.rs +++ b/crates/iris-core/src/models.rs @@ -97,6 +97,15 @@ pub struct Survey { pub active: bool, } +/// A registered project. `allowed_origins` is empty until an admin configures it — see +/// `iris_core::origin::is_origin_allowed` for how it gates the ingestion endpoints. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Project { + pub id: String, + pub name: String, + pub allowed_origins: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct DeviceInfo { pub browser: Option, diff --git a/crates/iris-core/src/origin.rs b/crates/iris-core/src/origin.rs new file mode 100644 index 0000000..c1a27d0 --- /dev/null +++ b/crates/iris-core/src/origin.rs @@ -0,0 +1,80 @@ +//! Per-project Origin/Referer allowlisting for the public ingestion endpoints +//! (`/capture`, `/batch`, `/replay`, and the `capture` GraphQL mutation). +//! +//! Mirrors the "site verification" every hosted analytics tool (Plausible, PostHog) does: +//! a project registers the browser origins it's embedded on, and ingestion is rejected if the +//! request's Origin/Referer doesn't match one of them. This stops a page on site B from +//! silently posting events under site A's project_id in a visitor's browser. +//! +//! It is NOT a secret: Origin/Referer are just headers, so a non-browser client (curl, a +//! script) can set them to anything. This only raises the bar against browser-context abuse — +//! same limitation every pixel/JS-based analytics tool that does this kind of check has. + +/// A project with no registered origins is left open (unconfigured = backwards compatible), +/// matching this codebase's existing pattern for optional features. +pub fn is_origin_allowed(allowed: &[String], origin_header: Option<&str>, referer_header: Option<&str>) -> bool { + if allowed.is_empty() { + return true; + } + let candidate = origin_header + .map(|s| s.to_string()) + .or_else(|| referer_header.and_then(origin_from_referer)); + match candidate { + Some(origin) => allowed.iter().any(|a| a.eq_ignore_ascii_case(&origin)), + None => false, + } +} + +fn origin_from_referer(referer: &str) -> Option { + let scheme_end = referer.find("://")?; + let after_scheme = &referer[scheme_end + 3..]; + let authority_end = after_scheme.find(['/', '?', '#']).unwrap_or(after_scheme.len()); + Some(format!("{}{}", &referer[..scheme_end + 3], &after_scheme[..authority_end])) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unconfigured_project_is_open() { + assert!(is_origin_allowed(&[], None, None)); + assert!(is_origin_allowed(&[], Some("https://anything.example"), None)); + } + + #[test] + fn matching_origin_header_passes() { + let allowed = vec!["https://example.com".to_string()]; + assert!(is_origin_allowed(&allowed, Some("https://example.com"), None)); + } + + #[test] + fn mismatched_origin_header_is_rejected() { + let allowed = vec!["https://example.com".to_string()]; + assert!(!is_origin_allowed(&allowed, Some("https://evil.com"), None)); + } + + #[test] + fn falls_back_to_referer_when_origin_missing() { + let allowed = vec!["https://example.com".to_string()]; + assert!(is_origin_allowed(&allowed, None, Some("https://example.com/page?x=1#frag"))); + } + + #[test] + fn referer_with_port_is_preserved() { + let allowed = vec!["http://localhost:5173".to_string()]; + assert!(is_origin_allowed(&allowed, None, Some("http://localhost:5173/app"))); + } + + #[test] + fn missing_origin_and_referer_is_rejected_once_configured() { + let allowed = vec!["https://example.com".to_string()]; + assert!(!is_origin_allowed(&allowed, None, None)); + } + + #[test] + fn subdomain_is_not_implicitly_allowed() { + let allowed = vec!["https://example.com".to_string()]; + assert!(!is_origin_allowed(&allowed, Some("https://evil.example.com"), None)); + } +} diff --git a/crates/iris-server/Cargo.toml b/crates/iris-server/Cargo.toml index 39b8080..e4da9e9 100644 --- a/crates/iris-server/Cargo.toml +++ b/crates/iris-server/Cargo.toml @@ -20,7 +20,7 @@ tracing.workspace = true tracing-subscriber.workspace = true anyhow.workspace = true -axum = "0.7" +axum = "0.8" tower = "0.5" tower-http = { version = "0.6", features = ["cors", "trace", "fs"] } async-graphql = { version = "7", features = ["chrono", "uuid"] } diff --git a/crates/iris-server/src/capture.rs b/crates/iris-server/src/capture.rs index 74a261c..cc8293b 100644 --- a/crates/iris-server/src/capture.rs +++ b/crates/iris-server/src/capture.rs @@ -6,6 +6,7 @@ use axum::{ http::{HeaderMap, StatusCode}, Json, }; +use iris_core::origin::is_origin_allowed; use iris_core::{BatchCaptureRequest, CaptureRequest}; use serde_json::json; @@ -18,11 +19,30 @@ fn client_ip(headers: &HeaderMap) -> Option { .map(|s| s.split(',').next().unwrap_or(s).trim().to_string()) } +fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name).and_then(|v| v.to_str().ok()) +} + +/// Rejects the request if `project_id` has registered origins and this request's +/// Origin/Referer isn't one of them. See `iris_core::origin` for what this does and doesn't +/// protect against. +async fn check_origin(state: &AppState, headers: &HeaderMap, project_id: &str) -> Result<(), (StatusCode, Json)> { + let allowed = state.storage.allowed_origins(project_id).await.unwrap_or_default(); + if is_origin_allowed(&allowed, header_str(headers, "origin"), header_str(headers, "referer")) { + Ok(()) + } else { + Err((StatusCode::FORBIDDEN, Json(json!({"error": "origin not allowed for this project"})))) + } +} + pub async fn capture_one( State(state): State, headers: HeaderMap, Json(req): Json, ) -> (StatusCode, Json) { + if let Err(rejected) = check_origin(&state, &headers, &req.project_id).await { + return rejected; + } let ua = headers.get("user-agent").and_then(|v| v.to_str().ok()).map(String::from); let ip = client_ip(&headers); let geo = ip.as_deref().map(|ip| state.geo.lookup(ip)).unwrap_or_default(); @@ -37,6 +57,14 @@ pub async fn capture_batch( headers: HeaderMap, Json(req): Json, ) -> (StatusCode, Json) { + let mut project_ids: Vec<&str> = req.batch.iter().map(|e| e.project_id.as_str()).collect(); + project_ids.sort_unstable(); + project_ids.dedup(); + for project_id in project_ids { + if let Err(rejected) = check_origin(&state, &headers, project_id).await { + return rejected; + } + } let ua = headers.get("user-agent").and_then(|v| v.to_str().ok()).map(String::from); let ip = client_ip(&headers); let geo = ip.as_deref().map(|ip| state.geo.lookup(ip)).unwrap_or_default(); @@ -57,8 +85,12 @@ pub struct ReplayIngest { pub async fn capture_replay( State(state): State, + headers: HeaderMap, Json(req): Json, ) -> (StatusCode, Json) { + if let Err(rejected) = check_origin(&state, &headers, &req.project_id).await { + return rejected; + } match state .storage .insert_replay_chunk(&req.project_id, &req.session_id, &req.distinct_id, req.seq, &req.data) diff --git a/crates/iris-server/src/main.rs b/crates/iris-server/src/main.rs index 8e2e2c9..6752a53 100644 --- a/crates/iris-server/src/main.rs +++ b/crates/iris-server/src/main.rs @@ -6,8 +6,10 @@ mod storage; use std::sync::Arc; use async_graphql::{EmptySubscription, Schema}; -use async_graphql_axum::GraphQL; +use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; use axum::{ + extract::State, + http::HeaderMap, response::{Html, IntoResponse}, routing::{get, post}, Router, @@ -21,12 +23,19 @@ use tower_http::{cors::CorsLayer, services::ServeDir, trace::TraceLayer}; pub struct AppState { pub storage: Storage, pub geo: Arc, + pub schema: IrisSchema, } async fn graphiql() -> impl IntoResponse { Html(async_graphql::http::GraphiQLSource::build().endpoint("/graphql").finish()) } +async fn graphql_handler(State(state): State, headers: HeaderMap, req: GraphQLRequest) -> GraphQLResponse { + // Headers ride along in the request's context data so resolvers (e.g. `capture`) can read + // Origin/Referer for the same per-project check the REST ingestion routes apply. + state.schema.execute(req.into_inner().data(headers)).await.into() +} + async fn health() -> &'static str { "ok" } @@ -48,16 +57,16 @@ async fn main() -> anyhow::Result<()> { tracing::warn!("GeoIP enrichment disabled: no usable database (auto-download may have failed, or GEOIP_DB_PATH points at an unreadable file)"); } - let state = AppState { storage: storage.clone(), geo }; - let schema: IrisSchema = Schema::build(QueryRoot, MutationRoot, EmptySubscription) - .data(storage) + .data(storage.clone()) .finish(); + let state = AppState { storage, geo, schema }; + let cors = CorsLayer::very_permissive(); let app = Router::new() - .route("/graphql", get(graphiql).post_service(GraphQL::new(schema))) + .route("/graphql", get(graphiql).post(graphql_handler)) .route("/capture", post(capture::capture_one)) .route("/batch", post(capture::capture_batch)) .route("/replay", post(capture::capture_replay)) diff --git a/crates/iris-server/src/schema.rs b/crates/iris-server/src/schema.rs index b26a058..221c193 100644 --- a/crates/iris-server/src/schema.rs +++ b/crates/iris-server/src/schema.rs @@ -1,7 +1,9 @@ use async_graphql::{Context, Object, Result, SimpleObject, InputObject}; +use axum::http::HeaderMap; use iris_core::experiments::{self, Variant}; use iris_core::flags::{self, FeatureFlag}; use iris_core::funnel::{compute_funnel, compute_retention}; +use iris_core::origin::is_origin_allowed; use iris_core::stats; use iris_core::{CaptureRequest, Cohort, Destination, Experiment, GeoInfo, Group}; use uuid::Uuid; @@ -131,6 +133,27 @@ pub struct CountryCountGql { pub count: i64, } +#[derive(SimpleObject)] +pub struct ProjectGql { + pub id: String, + pub name: String, + pub allowed_origins: Vec, +} + +/// Pulls Origin/Referer out of the request headers stashed in GraphQL context data (see +/// `graphql_handler` in main.rs) and rejects if `project_id` has registered origins that +/// don't match — the same check the REST ingestion routes apply. +fn check_origin(ctx: &Context<'_>, allowed: &[String]) -> Result<()> { + let headers = ctx.data::().ok(); + let origin = headers.and_then(|h| h.get("origin")).and_then(|v| v.to_str().ok()); + let referer = headers.and_then(|h| h.get("referer")).and_then(|v| v.to_str().ok()); + if is_origin_allowed(allowed, origin, referer) { + Ok(()) + } else { + Err(async_graphql::Error::new("origin not allowed for this project")) + } +} + pub struct QueryRoot; #[Object] @@ -297,6 +320,11 @@ impl QueryRoot { .map(|(country_code, country_name, count)| CountryCountGql { country_code, country_name, count }) .collect()) } + + async fn project(&self, ctx: &Context<'_>, id: String) -> Result> { + let storage = ctx.data::()?; + Ok(storage.get_project(&id).await?.map(|p| ProjectGql { id: p.id, name: p.name, allowed_origins: p.allowed_origins })) + } } pub struct MutationRoot; @@ -305,6 +333,7 @@ pub struct MutationRoot; impl MutationRoot { async fn capture(&self, ctx: &Context<'_>, input: CaptureInput) -> Result { let storage = ctx.data::()?; + check_origin(ctx, &storage.allowed_origins(&input.project_id).await?)?; let properties = input .properties .as_deref() @@ -372,6 +401,15 @@ impl MutationRoot { storage.set_person_properties(&project_id, &distinct_id, &props).await?; Ok(true) } + + /// Registers the browser origins allowed to send events for `project_id`. Leaving + /// `allowed_origins` empty leaves the project open to any Origin/Referer (or none) — the + /// same as never calling this mutation at all. + async fn upsert_project(&self, ctx: &Context<'_>, id: String, name: String, allowed_origins: Vec) -> Result { + let storage = ctx.data::()?; + storage.upsert_project(&id, &name, &allowed_origins).await?; + Ok(true) + } } pub type IrisSchema = async_graphql::Schema; diff --git a/crates/iris-server/src/storage.rs b/crates/iris-server/src/storage.rs index f03af42..ceba773 100644 --- a/crates/iris-server/src/storage.rs +++ b/crates/iris-server/src/storage.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, TimeZone, Utc}; use iris_core::experiments::Variant; use iris_core::flags::FeatureFlag; use iris_core::funnel::EventRow; -use iris_core::{CaptureRequest, Cohort, Destination, Event, Experiment, GeoInfo, Group, ReplayChunk, Survey}; +use iris_core::{CaptureRequest, Cohort, Destination, Event, Experiment, GeoInfo, Group, Project, ReplayChunk, Survey}; use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; use uuid::Uuid; @@ -177,6 +177,16 @@ impl Storage { .execute(&self.pool) .await?; + sqlx::query( + r#"CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + allowed_origins TEXT NOT NULL DEFAULT '[]' + )"#, + ) + .execute(&self.pool) + .await?; + Ok(()) } @@ -195,6 +205,42 @@ impl Storage { Ok(()) } + // ---- Projects ---- + /// A project with no `allowed_origins` stays open to any Origin/Referer (or none at all) — + /// only registering at least one origin turns on enforcement for it. + pub async fn upsert_project(&self, id: &str, name: &str, allowed_origins: &[String]) -> Result<()> { + sqlx::query( + r#"INSERT INTO projects (id, name, allowed_origins) VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET name = excluded.name, allowed_origins = excluded.allowed_origins"#, + ) + .bind(id) + .bind(name) + .bind(serde_json::to_string(allowed_origins)?) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn get_project(&self, id: &str) -> Result> { + let row = sqlx::query_as::<_, (String, String, String)>( + "SELECT id, name, allowed_origins FROM projects WHERE id = ?", + ) + .bind(id) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|(id, name, allowed_origins)| Project { + id, + name, + allowed_origins: serde_json::from_str(&allowed_origins).unwrap_or_default(), + })) + } + + /// `allowed_origins` for `project_id`, or empty if the project was never registered + /// (an unregistered project is open, same as one registered with no origins). + pub async fn allowed_origins(&self, project_id: &str) -> Result> { + Ok(self.get_project(project_id).await?.map(|p| p.allowed_origins).unwrap_or_default()) + } + // ---- Cohorts ---- pub async fn create_cohort(&self, c: &Cohort) -> Result<()> { sqlx::query("INSERT INTO cohorts (id, project_id, name, event, property_key, property_value) VALUES (?, ?, ?, ?, ?, ?)")