Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions DOC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions crates/iris-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ pub mod flags;
pub mod stats;
pub mod experiments;
pub mod geo;
pub mod origin;

pub use models::*;
9 changes: 9 additions & 0 deletions crates/iris-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeviceInfo {
pub browser: Option<String>,
Expand Down
80 changes: 80 additions & 0 deletions crates/iris-core/src/origin.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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));
}
}
2 changes: 1 addition & 1 deletion crates/iris-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
32 changes: 32 additions & 0 deletions crates/iris-server/src/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -18,11 +19,30 @@ fn client_ip(headers: &HeaderMap) -> Option<String> {
.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<serde_json::Value>)> {
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<AppState>,
headers: HeaderMap,
Json(req): Json<CaptureRequest>,
) -> (StatusCode, Json<serde_json::Value>) {
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();
Expand All @@ -37,6 +57,14 @@ pub async fn capture_batch(
headers: HeaderMap,
Json(req): Json<BatchCaptureRequest>,
) -> (StatusCode, Json<serde_json::Value>) {
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();
Expand All @@ -57,8 +85,12 @@ pub struct ReplayIngest {

pub async fn capture_replay(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<ReplayIngest>,
) -> (StatusCode, Json<serde_json::Value>) {
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)
Expand Down
19 changes: 14 additions & 5 deletions crates/iris-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,12 +23,19 @@ use tower_http::{cors::CorsLayer, services::ServeDir, trace::TraceLayer};
pub struct AppState {
pub storage: Storage,
pub geo: Arc<GeoLookup>,
pub schema: IrisSchema,
}

async fn graphiql() -> impl IntoResponse {
Html(async_graphql::http::GraphiQLSource::build().endpoint("/graphql").finish())
}

async fn graphql_handler(State(state): State<AppState>, 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"
}
Expand All @@ -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))
Expand Down
38 changes: 38 additions & 0 deletions crates/iris-server/src/schema.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<String>,
}

/// 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::<HeaderMap>().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]
Expand Down Expand Up @@ -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<Option<ProjectGql>> {
let storage = ctx.data::<Storage>()?;
Ok(storage.get_project(&id).await?.map(|p| ProjectGql { id: p.id, name: p.name, allowed_origins: p.allowed_origins }))
}
}

pub struct MutationRoot;
Expand All @@ -305,6 +333,7 @@ pub struct MutationRoot;
impl MutationRoot {
async fn capture(&self, ctx: &Context<'_>, input: CaptureInput) -> Result<bool> {
let storage = ctx.data::<Storage>()?;
check_origin(ctx, &storage.allowed_origins(&input.project_id).await?)?;
let properties = input
.properties
.as_deref()
Expand Down Expand Up @@ -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<String>) -> Result<bool> {
let storage = ctx.data::<Storage>()?;
storage.upsert_project(&id, &name, &allowed_origins).await?;
Ok(true)
}
}

pub type IrisSchema = async_graphql::Schema<QueryRoot, MutationRoot, async_graphql::EmptySubscription>;
Loading
Loading