use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use pagesift_net::{DnsLookup, SsrfPolicy, SystemLookup};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use tokio::sync::mpsc;
use crate::error::{ApiError, ErrorCode};
use crate::metrics::Metrics;
use crate::types::Document;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WebhookConfig {
pub url: String,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub metadata: Map<String, Value>,
#[serde(default = "all_events")]
pub events: Vec<String>,
}
const EVENTS: [&str; 4] = ["started", "page", "completed", "failed"];
fn all_events() -> Vec<String> {
EVENTS.iter().map(|e| e.to_string()).collect()
}
impl WebhookConfig {
pub(crate) fn parse(v: &Value, policy: &SsrfPolicy) -> Result<Self, ApiError> {
let cfg: Self = match v {
Value::String(u) => Self {
url: u.clone(),
headers: BTreeMap::new(),
metadata: Map::new(),
events: all_events(),
},
Value::Object(_) => serde_json::from_value(v.clone())
.map_err(|e| ApiError::invalid(format!("invalid webhook: {e}")))?,
_ => {
return Err(ApiError::invalid(
"webhook must be a URL or an object with a url",
));
}
};
if let Some(e) = cfg.events.iter().find(|e| !EVENTS.contains(&e.as_str())) {
return Err(ApiError::invalid(format!(
"unknown webhook event {e:?}: use started, page, completed or failed"
)));
}
for (k, v) in &cfg.headers {
if reqwest::header::HeaderName::from_bytes(k.as_bytes()).is_err()
|| reqwest::header::HeaderValue::from_str(v).is_err()
{
return Err(ApiError::invalid(format!("invalid webhook header {k:?}")));
}
}
policy.check_url(&cfg.url).map_err(|e| {
ApiError::new(
ErrorCode::SsrfBlocked,
format!("webhook URL is not allowed: {e}"),
)
})?;
Ok(cfg)
}
fn wants(&self, event: Event) -> bool {
self.events.iter().any(|e| e == event.name())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Event {
Started,
Page,
Completed,
Failed,
}
impl Event {
fn name(self) -> &'static str {
match self {
Event::Started => "started",
Event::Page => "page",
Event::Completed => "completed",
Event::Failed => "failed",
}
}
}
#[derive(Clone)]
pub struct WebhookSettings {
pub ssrf: SsrfPolicy,
pub dns: Arc<dyn DnsLookup>,
pub max_attempts: u32,
pub initial_backoff: Duration,
pub max_backoff: Duration,
pub timeout: Duration,
pub queue: usize,
pub secret: Option<String>,
}
impl Default for WebhookSettings {
fn default() -> Self {
Self {
ssrf: SsrfPolicy::default(),
dns: Arc::new(SystemLookup),
max_attempts: 5,
initial_backoff: Duration::from_secs(1),
max_backoff: Duration::from_secs(60),
timeout: Duration::from_secs(10),
queue: 1024,
secret: None,
}
}
}
impl std::fmt::Debug for WebhookSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebhookSettings")
.field("ssrf", &self.ssrf)
.field("max_attempts", &self.max_attempts)
.field("initial_backoff", &self.initial_backoff)
.field("max_backoff", &self.max_backoff)
.field("timeout", &self.timeout)
.field("queue", &self.queue)
.field("secret", &self.secret.is_some())
.finish_non_exhaustive()
}
}
#[derive(Clone)]
pub(crate) struct Webhooks {
client: reqwest::Client,
settings: Arc<WebhookSettings>,
metrics: Arc<Metrics>,
}
#[derive(Debug)]
struct Blocked(String);
impl std::fmt::Display for Blocked {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "webhook address blocked: {}", self.0)
}
}
impl std::error::Error for Blocked {}
struct GuardedDns {
dns: Arc<dyn DnsLookup>,
policy: SsrfPolicy,
}
impl reqwest::dns::Resolve for GuardedDns {
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
let (dns, policy) = (self.dns.clone(), self.policy.clone());
Box::pin(async move {
let host = name.as_str().to_string();
let ips = dns.lookup(&host).await?;
let blocked = |why: String| -> Box<dyn std::error::Error + Send + Sync> {
Box::new(Blocked(why))
};
if ips.is_empty() {
return Err(blocked(format!("no address for {host}")));
}
for ip in &ips {
policy.check_ip(*ip).map_err(|e| blocked(e.to_string()))?;
}
let addrs: reqwest::dns::Addrs =
Box::new(ips.into_iter().map(|ip| SocketAddr::new(ip, 0)));
Ok(addrs)
})
}
}
fn is_blocked(e: &reqwest::Error) -> bool {
let mut src: Option<&(dyn std::error::Error + 'static)> = Some(e);
while let Some(s) = src {
if s.is::<Blocked>() {
return true;
}
src = s.source();
}
false
}
impl Webhooks {
pub(crate) fn new(settings: WebhookSettings, metrics: Arc<Metrics>) -> Self {
let client = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.dns_resolver(Arc::new(GuardedDns {
dns: settings.dns.clone(),
policy: settings.ssrf.clone(),
}))
.user_agent(pagesift_net::DEFAULT_USER_AGENT)
.timeout(settings.timeout)
.build()
.expect("webhook client builds with static settings");
Self {
client,
settings: Arc::new(settings),
metrics,
}
}
pub(crate) fn ssrf(&self) -> &SsrfPolicy {
&self.settings.ssrf
}
pub(crate) fn start(&self, job_id: &str, kind: &'static str, cfg: WebhookConfig) -> Hook {
let (tx, mut rx) = mpsc::channel::<Value>(self.settings.queue.max(1));
let this = self.clone();
let cfg = Arc::new(cfg);
let worker_cfg = cfg.clone();
tokio::spawn(async move {
while let Some(body) = rx.recv().await {
this.deliver(&worker_cfg, &body).await;
}
});
Hook {
tx,
cfg,
kind,
job_id: job_id.into(),
metrics: self.metrics.clone(),
}
}
async fn deliver(&self, cfg: &WebhookConfig, body: &Value) {
let s = &self.settings;
if let Err(e) = s.ssrf.check_url(&cfg.url) {
self.metrics.webhook("blocked");
tracing::warn!(url = %cfg.url, error = %e, "webhook target blocked by the SSRF policy");
return;
}
let payload = body.to_string();
let signature = s.secret.as_deref().map(|k| sign(k, payload.as_bytes()));
let mut backoff = s.initial_backoff;
for attempt in 1..=s.max_attempts.max(1) {
let mut req = self
.client
.post(&cfg.url)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(payload.clone());
if let Some(sig) = &signature {
req = req.header("x-firecrawl-signature", sig);
}
for (k, v) in &cfg.headers {
req = req.header(k, v);
}
let (retry, why) = match req.send().await {
Ok(r) if r.status().is_success() => {
self.metrics.webhook("delivered");
return;
}
Ok(r) => {
let st = r.status();
let retry = st.is_server_error()
|| st == reqwest::StatusCode::TOO_MANY_REQUESTS
|| st == reqwest::StatusCode::REQUEST_TIMEOUT;
(retry, format!("status {st}"))
}
Err(e) if is_blocked(&e) => {
self.metrics.webhook("blocked");
tracing::warn!(url = %cfg.url, error = %e, "webhook target blocked by the SSRF policy");
return;
}
Err(e) => (true, e.to_string()),
};
if !retry || attempt == s.max_attempts {
self.metrics.webhook("failed");
tracing::warn!(url = %cfg.url, attempt, reason = %why, "webhook delivery failed");
return;
}
self.metrics.webhook("retried");
tracing::debug!(url = %cfg.url, attempt, reason = %why, ?backoff, "retrying webhook");
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(s.max_backoff);
}
}
}
fn sign(secret: &str, payload: &[u8]) -> String {
let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, secret.as_bytes());
let tag = ring::hmac::sign(&key, payload);
let mut out = String::from("sha256=");
for b in tag.as_ref() {
out.push_str(&format!("{b:02x}"));
}
out
}
#[derive(Clone)]
pub(crate) struct Hook {
tx: mpsc::Sender<Value>,
cfg: Arc<WebhookConfig>,
kind: &'static str,
job_id: Arc<str>,
metrics: Arc<Metrics>,
}
impl Hook {
pub(crate) fn send(&self, event: Event, data: Vec<Document>, error: Option<String>) {
if !self.cfg.wants(event) {
return;
}
let mut body = json!({
"success": error.is_none(),
"type": format!("{}.{}", self.kind, event.name()),
"id": &*self.job_id,
"data": data,
"metadata": self.cfg.metadata,
});
if let Some(e) = error {
body["error"] = e.into();
}
match self.tx.try_send(body) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(body)) if event != Event::Page => {
let tx = self.tx.clone();
tokio::spawn(async move {
let _ = tx.send(body).await;
});
}
Err(_) => {
self.metrics.webhook("dropped");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signatures_match_rfc_4231() {
assert_eq!(
sign("Jefe", b"what do ya want for nothing?"),
"sha256=5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
);
}
#[test]
fn parses_strings_objects_and_rejects_private_targets() {
let policy = SsrfPolicy::default();
let c = WebhookConfig::parse(&json!("https://hooks.example.com/x"), &policy).unwrap();
assert_eq!(c.events, all_events());
let c = WebhookConfig::parse(
&json!({"url": "https://hooks.example.com/x", "headers": {"X-Token": "t"},
"metadata": {"run": "1"}, "events": ["completed"], "future": true}),
&policy,
)
.unwrap();
assert!(c.wants(Event::Completed) && !c.wants(Event::Page));
for (v, code) in [
(json!("http://127.0.0.1:9000/hook"), ErrorCode::SsrfBlocked),
(
json!("http://169.254.169.254/latest"),
ErrorCode::SsrfBlocked,
),
(json!("http://metadata.internal/"), ErrorCode::SsrfBlocked),
(json!("file:///etc/passwd"), ErrorCode::SsrfBlocked),
(
json!({"url": "https://a.example", "events": ["done"]}),
ErrorCode::InvalidRequest,
),
(json!(42), ErrorCode::InvalidRequest),
] {
assert_eq!(
WebhookConfig::parse(&v, &policy).unwrap_err().code,
code,
"{v}"
);
}
}
}