use std::convert::Infallible;
use std::fmt;
use std::net::{IpAddr, SocketAddr};
use std::str::FromStr;
use axum::extract::rejection::PathRejection;
use axum::extract::{ConnectInfo, FromRequestParts, Path};
use axum::http::header::{self, AsHeaderName};
use axum::http::request::Parts;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::RequestPartsExt;
use chrono::{DateTime, Utc};
use data_encoding::BASE64;
use relay_auth::RelayId;
use relay_base_schema::organization::OrganizationId;
use relay_base_schema::project::{ParseProjectKeyError, ProjectId, ProjectKey};
use relay_common::{Auth, Dsn, ParseAuthError, ParseDsnError, Scheme};
use relay_config::UpstreamDescriptor;
use relay_event_normalization::{ClientHints, RawUserAgentInfo};
use relay_quotas::Scoping;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::extractors::{ForwardedFor, ReceivedAt};
use crate::service::ServiceState;
use crate::statsd::{ClientName, RelayCounters};
use crate::utils::ApiErrorResponse;
#[derive(Debug, thiserror::Error)]
pub enum BadEventMeta {
#[error("missing authorization information")]
MissingAuth,
#[error("multiple authorization payloads detected")]
MultipleAuth,
#[error("unsupported protocol version ({0})")]
UnsupportedProtocolVersion(u16),
#[error("bad envelope authentication header")]
BadEnvelopeAuth(#[source] serde_json::Error),
#[error("bad project path parameter")]
BadProject(#[from] PathRejection),
#[error("bad x-sentry-auth header")]
BadAuth(#[from] ParseAuthError),
#[error("bad sentry DSN public key")]
BadPublicKey(#[from] ParseProjectKeyError),
}
impl From<Infallible> for BadEventMeta {
fn from(infallible: Infallible) -> Self {
match infallible {}
}
}
impl IntoResponse for BadEventMeta {
fn into_response(self) -> Response {
let code = match self {
Self::MissingAuth
| Self::MultipleAuth
| Self::BadAuth(_)
| Self::BadEnvelopeAuth(_) => StatusCode::UNAUTHORIZED,
Self::UnsupportedProtocolVersion(_) | Self::BadProject(_) | Self::BadPublicKey(_) => {
StatusCode::BAD_REQUEST
}
};
(code, ApiErrorResponse::from_error(&self)).into_response()
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct PartialDsn {
pub scheme: Scheme,
pub public_key: ProjectKey,
pub host: String,
pub port: u16,
pub path: String,
pub project_id: Option<ProjectId>,
}
impl PartialDsn {
fn from_dsn(dsn: Dsn) -> Result<Self, ParseDsnError> {
let project_id = dsn
.project_id()
.value()
.parse()
.map_err(|_| ParseDsnError::NoProjectId)?;
let public_key = dsn
.public_key()
.parse()
.map_err(|_| ParseDsnError::NoUsername)?;
Ok(Self {
scheme: dsn.scheme(),
public_key,
host: dsn.host().to_owned(),
port: dsn.port(),
path: dsn.path().to_owned(),
project_id: Some(project_id),
})
}
pub fn outbound(scoping: &Scoping, upstream: &UpstreamDescriptor<'_>) -> Self {
Self {
scheme: upstream.scheme(),
public_key: scoping.project_key,
host: upstream.host().to_owned(),
port: upstream.port(),
path: "".to_owned(),
project_id: Some(scoping.project_id),
}
}
pub fn project_id(&self) -> Option<ProjectId> {
self.project_id
}
pub fn public_key(&self) -> ProjectKey {
self.public_key
}
}
impl fmt::Display for PartialDsn {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}://{}:@{}", self.scheme, self.public_key, self.host)?;
if self.port != self.scheme.default_port() {
write!(f, ":{}", self.port)?;
}
let project_id = self.project_id.unwrap_or_else(|| ProjectId::new(0));
write!(f, "/{}{}", self.path.trim_start_matches('/'), project_id)
}
}
impl FromStr for PartialDsn {
type Err = ParseDsnError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_dsn(Dsn::from_str(s)?)
}
}
impl<'de> Deserialize<'de> for PartialDsn {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let dsn = Dsn::deserialize(deserializer)?;
Self::from_dsn(dsn).map_err(serde::de::Error::custom)
}
}
impl Serialize for PartialDsn {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.collect_str(self)
}
}
const fn default_version() -> u16 {
relay_event_schema::protocol::PROTOCOL_VERSION
}
fn is_false(value: &bool) -> bool {
!*value
}
fn make_false() -> bool {
false
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RequestMeta<D = PartialDsn> {
dsn: D,
#[serde(default, skip_serializing_if = "Option::is_none")]
client: Option<String>,
#[serde(default = "default_version")]
version: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
origin: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
remote_addr: Option<IpAddr>,
#[serde(default, skip_serializing_if = "String::is_empty")]
forwarded_for: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
user_agent: Option<String>,
#[serde(default, skip_serializing_if = "ClientHints::is_empty")]
client_hints: ClientHints<String>,
#[serde(default = "make_false", skip_serializing_if = "is_false")]
no_cache: bool,
#[serde(skip, default = "Utc::now")]
received_at: DateTime<Utc>,
#[serde(skip)]
from_internal_relay: bool,
}
impl<D> RequestMeta<D> {
pub fn client(&self) -> Option<&str> {
self.client.as_deref()
}
pub fn client_name(&self) -> ClientName {
self.client()
.and_then(|client| client.split_once('/'))
.map(|(client, _)| client)
.map_or(ClientName::Other("proprietary"), ClientName::from)
}
#[allow(dead_code)] pub fn version(&self) -> u16 {
self.version
}
pub fn origin(&self) -> Option<&Url> {
self.origin.as_ref()
}
#[allow(unused)]
pub fn remote_addr(&self) -> Option<IpAddr> {
self.remote_addr
}
pub fn client_addr(&self) -> Option<IpAddr> {
let client = self.forwarded_for().split(',').next()?;
client.trim().parse().ok()
}
pub fn forwarded_for(&self) -> &str {
&self.forwarded_for
}
pub fn user_agent(&self) -> Option<&str> {
self.user_agent.as_deref()
}
pub fn client_hints(&self) -> &ClientHints<String> {
&self.client_hints
}
pub fn no_cache(&self) -> bool {
self.no_cache
}
pub fn received_at(&self) -> DateTime<Utc> {
self.received_at
}
pub fn set_received_at(&mut self, received_at: DateTime<Utc>) {
self.received_at = received_at
}
pub fn is_from_internal_relay(&self) -> bool {
self.from_internal_relay
}
pub fn set_from_internal_relay(&mut self, value: bool) {
self.from_internal_relay = value;
}
pub fn set_client(&mut self, client: String) {
self.client = Some(client);
}
}
impl RequestMeta {
pub fn outbound(dsn: PartialDsn) -> Self {
Self {
dsn,
client: Some(crate::constants::CLIENT.to_owned()),
version: default_version(),
origin: None,
remote_addr: None,
forwarded_for: "".to_string(),
user_agent: Some(crate::constants::SERVER.to_owned()),
no_cache: false,
received_at: Utc::now(),
client_hints: ClientHints::default(),
from_internal_relay: false,
}
}
pub fn dsn(&self) -> &PartialDsn {
&self.dsn
}
pub fn project_id(&self) -> Option<ProjectId> {
self.dsn.project_id
}
pub fn set_project_id(&mut self, project_id: ProjectId) {
self.dsn.project_id = Some(project_id);
}
pub fn public_key(&self) -> ProjectKey {
self.dsn.public_key
}
pub fn auth_header(&self) -> String {
let mut auth = format!(
"Sentry sentry_key={}, sentry_version={}",
self.public_key(),
self.version
);
if let Some(ref client) = self.client {
use std::fmt::Write;
write!(auth, ", sentry_client={client}").ok();
}
auth
}
pub fn get_partial_scoping(&self) -> Scoping {
Scoping {
organization_id: OrganizationId::new(0),
project_id: self.project_id().unwrap_or_else(|| ProjectId::new(0)),
project_key: self.public_key(),
key_id: None,
}
}
}
pub type PartialMeta = RequestMeta<Option<PartialDsn>>;
impl PartialMeta {
pub fn dsn(&self) -> Option<&PartialDsn> {
self.dsn.as_ref()
}
pub fn copy_to(self, mut complete: RequestMeta) -> RequestMeta {
if self.client.is_some() {
complete.client = self.client;
}
if self.version != default_version() {
complete.version = self.version;
}
if self.origin.is_some() {
complete.origin = self.origin;
}
if self.remote_addr.is_some() {
complete.remote_addr = self.remote_addr;
}
if !self.forwarded_for.is_empty() {
complete.forwarded_for = self.forwarded_for;
}
if self.user_agent.is_some() {
complete.user_agent = self.user_agent;
}
if self.from_internal_relay {
complete.from_internal_relay = self.from_internal_relay;
}
complete.client_hints.copy_from(self.client_hints);
if self.no_cache {
complete.no_cache = true;
}
complete
}
}
#[axum::async_trait]
impl FromRequestParts<ServiceState> for PartialMeta {
type Rejection = Infallible;
async fn from_request_parts(
parts: &mut Parts,
state: &ServiceState,
) -> Result<Self, Self::Rejection> {
let mut ua = RawUserAgentInfo::default();
for (key, value) in &parts.headers {
ua.set_ua_field_from_header(key.as_str(), value.to_str().ok().map(str::to_string));
}
let mut from_internal_relay = false;
let relay_id = parts
.headers
.get("x-sentry-relay-id")
.and_then(|h| h.to_str().ok())
.and_then(|h| h.parse::<RelayId>().ok());
if let Some(relay_id) = relay_id {
relay_log::configure_scope(|s| s.set_tag("relay_id", relay_id));
from_internal_relay = state
.config()
.static_relays()
.get(&relay_id)
.map_or(false, |ri| ri.internal);
}
let ReceivedAt(received_at) = ReceivedAt::from_request_parts(parts, state).await?;
Ok(RequestMeta {
dsn: None,
version: default_version(),
client: None,
origin: parse_header_url(parts, header::ORIGIN)
.or_else(|| parse_header_url(parts, header::REFERER)),
remote_addr: ConnectInfo::<SocketAddr>::from_request_parts(parts, state)
.await
.map(|ConnectInfo(peer)| peer.ip())
.ok(),
forwarded_for: ForwardedFor::from_request_parts(parts, state)
.await?
.into_inner(),
user_agent: ua.user_agent,
no_cache: false,
received_at,
client_hints: ua.client_hints,
from_internal_relay,
})
}
}
fn get_auth_header(req: &Parts, header_name: impl AsHeaderName) -> Option<&str> {
req.headers
.get(header_name)
.and_then(|x| x.to_str().ok())
.filter(|h| h.len() >= 7 && h[..7].eq_ignore_ascii_case("sentry "))
}
fn auth_from_parts(req: &Parts, path_key: Option<String>) -> Result<Auth, BadEventMeta> {
let mut auth = None;
if let Some(header) = get_auth_header(req, "x-sentry-auth") {
auth = Some(header.parse::<Auth>()?);
}
if let Some(header) = get_auth_header(req, header::AUTHORIZATION) {
if auth.is_some() {
return Err(BadEventMeta::MultipleAuth);
}
auth = Some(header.parse::<Auth>()?);
}
if let Some(basic_auth) = req
.headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.and_then(|x| {
if x.len() >= 6 && x[..6].eq_ignore_ascii_case("basic ") {
x.get(6..)
} else {
None
}
})
.and_then(|value| {
let decoded = String::from_utf8(BASE64.decode(value.as_bytes()).ok()?).ok()?;
let (public_key, _) = decoded.split_once(':')?;
Auth::from_pairs([("sentry_key", public_key)]).ok()
})
{
if auth.is_some() {
return Err(BadEventMeta::MultipleAuth);
}
auth = Some(basic_auth);
}
let query = req.uri.query().unwrap_or_default();
if query.contains("sentry_") {
if auth.is_some() {
return Err(BadEventMeta::MultipleAuth);
}
auth = Some(Auth::from_querystring(query.as_bytes())?);
}
if let Some(sentry_key) = path_key {
if auth.is_some() {
return Err(BadEventMeta::MultipleAuth);
}
auth = Some(
Auth::from_pairs(std::iter::once(("sentry_key", sentry_key)))
.map_err(|_| BadEventMeta::MissingAuth)?,
);
}
auth.ok_or(BadEventMeta::MissingAuth)
}
fn parse_header_url(req: &Parts, header: impl AsHeaderName) -> Option<Url> {
req.headers
.get(header)
.and_then(|h| h.to_str().ok())
.and_then(|s| s.parse::<Url>().ok())
.and_then(|u| match u.scheme() {
"http" | "https" => Some(u),
_ => None,
})
}
#[derive(Debug, serde::Deserialize)]
struct StorePath {
project_id: Option<ProjectId>,
sentry_key: Option<String>,
}
#[axum::async_trait]
impl FromRequestParts<ServiceState> for RequestMeta {
type Rejection = BadEventMeta;
async fn from_request_parts(
parts: &mut Parts,
state: &ServiceState,
) -> Result<Self, Self::Rejection> {
let Path(store_path): Path<StorePath> =
parts.extract().await.map_err(BadEventMeta::BadProject)?;
let auth = auth_from_parts(parts, store_path.sentry_key)?;
let partial_meta: PartialMeta = parts.extract_with_state(state).await?;
let (public_key, key_flags) = ProjectKey::parse_with_flags(auth.public_key())?;
let config = state.config();
let upstream = config.upstream_descriptor();
let dsn = PartialDsn {
scheme: upstream.scheme(),
public_key,
host: upstream.host().to_owned(),
port: upstream.port(),
path: String::new(),
project_id: store_path.project_id,
};
let version = auth.version();
if version > relay_event_schema::protocol::PROTOCOL_VERSION {
return Err(BadEventMeta::UnsupportedProtocolVersion(version));
}
relay_statsd::metric!(
counter(RelayCounters::EventProtocol) += 1,
version = &version.to_string()
);
Ok(RequestMeta {
dsn,
version,
client: auth.client_agent().map(str::to_owned),
origin: partial_meta.origin,
remote_addr: partial_meta.remote_addr,
forwarded_for: partial_meta.forwarded_for,
user_agent: partial_meta.user_agent,
no_cache: key_flags.contains(&"no-cache"),
received_at: partial_meta.received_at,
client_hints: partial_meta.client_hints,
from_internal_relay: partial_meta.from_internal_relay,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
impl RequestMeta {
pub fn new(dsn: relay_common::Dsn) -> Self {
Self {
dsn: PartialDsn::from_dsn(dsn).expect("invalid DSN"),
client: Some("sentry/client".to_string()),
version: 7,
origin: Some("http://origin/".parse().unwrap()),
remote_addr: Some("192.168.0.1".parse().unwrap()),
forwarded_for: String::new(),
user_agent: Some("sentry/agent".to_string()),
no_cache: false,
received_at: Utc::now(),
client_hints: ClientHints::default(),
from_internal_relay: false,
}
}
}
#[test]
fn test_request_meta_roundtrip() {
let json = r#"{
"dsn": "https://e12d836b15bb49d7bbf99e64295d995b:@sentry.io/42",
"client": "sentry-client",
"version": 7,
"origin": "http://origin/",
"remote_addr": "192.168.0.1",
"forwarded_for": "8.8.8.8",
"user_agent": "0x8000",
"no_cache": false,
"client_hints": {
"sec_ch_ua_platform": "macOS",
"sec_ch_ua_platform_version": "13.1.0",
"sec_ch_ua": "\"Not_A Brand\";v=\"99\", \"Google Chrome\";v=\"109\", \"Chromium\";v=\"109\""
}
}"#;
let mut deserialized: RequestMeta = serde_json::from_str(json).unwrap();
let reqmeta = RequestMeta {
dsn: PartialDsn::from_str("https://e12d836b15bb49d7bbf99e64295d995b:@sentry.io/42")
.unwrap(),
client: Some("sentry-client".to_owned()),
version: 7,
origin: Some(Url::parse("http://origin/").unwrap()),
remote_addr: Some(IpAddr::from_str("192.168.0.1").unwrap()),
forwarded_for: "8.8.8.8".to_string(),
user_agent: Some("0x8000".to_string()),
no_cache: false,
received_at: Utc::now(),
client_hints: ClientHints {
sec_ch_ua_platform: Some("macOS".to_owned()),
sec_ch_ua_platform_version: Some("13.1.0".to_owned()),
sec_ch_ua: Some(
"\"Not_A Brand\";v=\"99\", \"Google Chrome\";v=\"109\", \"Chromium\";v=\"109\""
.to_owned(),
),
sec_ch_ua_model: None,
},
from_internal_relay: false,
};
deserialized.received_at = reqmeta.received_at;
assert_eq!(deserialized, reqmeta);
}
}