1use std::collections::BTreeMap;
10use std::net::SocketAddr;
11use std::sync::Arc;
12use std::time::Duration;
13
14use pagesift_net::{DnsLookup, SsrfPolicy, SystemLookup};
15use serde::{Deserialize, Serialize};
16use serde_json::{Map, Value, json};
17use tokio::sync::mpsc;
18
19use crate::error::{ApiError, ErrorCode};
20use crate::metrics::Metrics;
21use crate::types::Document;
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct WebhookConfig {
26 pub url: String,
27 #[serde(default)]
28 pub headers: BTreeMap<String, String>,
29 #[serde(default)]
30 pub metadata: Map<String, Value>,
31 #[serde(default = "all_events")]
32 pub events: Vec<String>,
33}
34
35const EVENTS: [&str; 4] = ["started", "page", "completed", "failed"];
36
37fn all_events() -> Vec<String> {
38 EVENTS.iter().map(|e| e.to_string()).collect()
39}
40
41impl WebhookConfig {
42 pub(crate) fn parse(v: &Value, policy: &SsrfPolicy) -> Result<Self, ApiError> {
44 let cfg: Self = match v {
45 Value::String(u) => Self {
46 url: u.clone(),
47 headers: BTreeMap::new(),
48 metadata: Map::new(),
49 events: all_events(),
50 },
51 Value::Object(_) => serde_json::from_value(v.clone())
52 .map_err(|e| ApiError::invalid(format!("invalid webhook: {e}")))?,
53 _ => {
54 return Err(ApiError::invalid(
55 "webhook must be a URL or an object with a url",
56 ));
57 }
58 };
59 if let Some(e) = cfg.events.iter().find(|e| !EVENTS.contains(&e.as_str())) {
60 return Err(ApiError::invalid(format!(
61 "unknown webhook event {e:?}: use started, page, completed or failed"
62 )));
63 }
64 for (k, v) in &cfg.headers {
65 if reqwest::header::HeaderName::from_bytes(k.as_bytes()).is_err()
66 || reqwest::header::HeaderValue::from_str(v).is_err()
67 {
68 return Err(ApiError::invalid(format!("invalid webhook header {k:?}")));
69 }
70 }
71 policy.check_url(&cfg.url).map_err(|e| {
72 ApiError::new(
73 ErrorCode::SsrfBlocked,
74 format!("webhook URL is not allowed: {e}"),
75 )
76 })?;
77 Ok(cfg)
78 }
79
80 fn wants(&self, event: Event) -> bool {
81 self.events.iter().any(|e| e == event.name())
82 }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub(crate) enum Event {
87 Started,
88 Page,
89 Completed,
90 Failed,
91}
92
93impl Event {
94 fn name(self) -> &'static str {
95 match self {
96 Event::Started => "started",
97 Event::Page => "page",
98 Event::Completed => "completed",
99 Event::Failed => "failed",
100 }
101 }
102}
103
104#[derive(Clone)]
106pub struct WebhookSettings {
107 pub ssrf: SsrfPolicy,
109 pub dns: Arc<dyn DnsLookup>,
110 pub max_attempts: u32,
112 pub initial_backoff: Duration,
114 pub max_backoff: Duration,
115 pub timeout: Duration,
117 pub queue: usize,
119 pub secret: Option<String>,
121}
122
123impl Default for WebhookSettings {
124 fn default() -> Self {
125 Self {
126 ssrf: SsrfPolicy::default(),
127 dns: Arc::new(SystemLookup),
128 max_attempts: 5,
129 initial_backoff: Duration::from_secs(1),
130 max_backoff: Duration::from_secs(60),
131 timeout: Duration::from_secs(10),
132 queue: 1024,
133 secret: None,
134 }
135 }
136}
137
138impl std::fmt::Debug for WebhookSettings {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 f.debug_struct("WebhookSettings")
141 .field("ssrf", &self.ssrf)
142 .field("max_attempts", &self.max_attempts)
143 .field("initial_backoff", &self.initial_backoff)
144 .field("max_backoff", &self.max_backoff)
145 .field("timeout", &self.timeout)
146 .field("queue", &self.queue)
147 .field("secret", &self.secret.is_some())
148 .finish_non_exhaustive()
149 }
150}
151
152#[derive(Clone)]
154pub(crate) struct Webhooks {
155 client: reqwest::Client,
156 settings: Arc<WebhookSettings>,
157 metrics: Arc<Metrics>,
158}
159
160#[derive(Debug)]
162struct Blocked(String);
163
164impl std::fmt::Display for Blocked {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 write!(f, "webhook address blocked: {}", self.0)
167 }
168}
169
170impl std::error::Error for Blocked {}
171
172struct GuardedDns {
174 dns: Arc<dyn DnsLookup>,
175 policy: SsrfPolicy,
176}
177
178impl reqwest::dns::Resolve for GuardedDns {
179 fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
180 let (dns, policy) = (self.dns.clone(), self.policy.clone());
181 Box::pin(async move {
182 let host = name.as_str().to_string();
183 let ips = dns.lookup(&host).await?;
184 let blocked = |why: String| -> Box<dyn std::error::Error + Send + Sync> {
185 Box::new(Blocked(why))
186 };
187 if ips.is_empty() {
188 return Err(blocked(format!("no address for {host}")));
189 }
190 for ip in &ips {
191 policy.check_ip(*ip).map_err(|e| blocked(e.to_string()))?;
192 }
193 let addrs: reqwest::dns::Addrs =
194 Box::new(ips.into_iter().map(|ip| SocketAddr::new(ip, 0)));
195 Ok(addrs)
196 })
197 }
198}
199
200fn is_blocked(e: &reqwest::Error) -> bool {
201 let mut src: Option<&(dyn std::error::Error + 'static)> = Some(e);
202 while let Some(s) = src {
203 if s.is::<Blocked>() {
204 return true;
205 }
206 src = s.source();
207 }
208 false
209}
210
211impl Webhooks {
212 pub(crate) fn new(settings: WebhookSettings, metrics: Arc<Metrics>) -> Self {
213 let client = reqwest::Client::builder()
214 .no_proxy()
215 .redirect(reqwest::redirect::Policy::none())
216 .dns_resolver(Arc::new(GuardedDns {
217 dns: settings.dns.clone(),
218 policy: settings.ssrf.clone(),
219 }))
220 .user_agent(pagesift_net::DEFAULT_USER_AGENT)
221 .timeout(settings.timeout)
222 .build()
223 .expect("webhook client builds with static settings");
224 Self {
225 client,
226 settings: Arc::new(settings),
227 metrics,
228 }
229 }
230
231 pub(crate) fn ssrf(&self) -> &SsrfPolicy {
232 &self.settings.ssrf
233 }
234
235 pub(crate) fn start(&self, job_id: &str, kind: &'static str, cfg: WebhookConfig) -> Hook {
237 let (tx, mut rx) = mpsc::channel::<Value>(self.settings.queue.max(1));
238 let this = self.clone();
239 let cfg = Arc::new(cfg);
240 let worker_cfg = cfg.clone();
241 tokio::spawn(async move {
242 while let Some(body) = rx.recv().await {
243 this.deliver(&worker_cfg, &body).await;
244 }
245 });
246 Hook {
247 tx,
248 cfg,
249 kind,
250 job_id: job_id.into(),
251 metrics: self.metrics.clone(),
252 }
253 }
254
255 async fn deliver(&self, cfg: &WebhookConfig, body: &Value) {
257 let s = &self.settings;
258 if let Err(e) = s.ssrf.check_url(&cfg.url) {
260 self.metrics.webhook("blocked");
261 tracing::warn!(url = %cfg.url, error = %e, "webhook target blocked by the SSRF policy");
262 return;
263 }
264 let payload = body.to_string();
265 let signature = s.secret.as_deref().map(|k| sign(k, payload.as_bytes()));
266 let mut backoff = s.initial_backoff;
267 for attempt in 1..=s.max_attempts.max(1) {
268 let mut req = self
269 .client
270 .post(&cfg.url)
271 .header(reqwest::header::CONTENT_TYPE, "application/json")
272 .body(payload.clone());
273 if let Some(sig) = &signature {
274 req = req.header("x-firecrawl-signature", sig);
275 }
276 for (k, v) in &cfg.headers {
277 req = req.header(k, v);
278 }
279 let (retry, why) = match req.send().await {
280 Ok(r) if r.status().is_success() => {
281 self.metrics.webhook("delivered");
282 return;
283 }
284 Ok(r) => {
285 let st = r.status();
286 let retry = st.is_server_error()
288 || st == reqwest::StatusCode::TOO_MANY_REQUESTS
289 || st == reqwest::StatusCode::REQUEST_TIMEOUT;
290 (retry, format!("status {st}"))
291 }
292 Err(e) if is_blocked(&e) => {
293 self.metrics.webhook("blocked");
294 tracing::warn!(url = %cfg.url, error = %e, "webhook target blocked by the SSRF policy");
295 return;
296 }
297 Err(e) => (true, e.to_string()),
298 };
299 if !retry || attempt == s.max_attempts {
300 self.metrics.webhook("failed");
301 tracing::warn!(url = %cfg.url, attempt, reason = %why, "webhook delivery failed");
302 return;
303 }
304 self.metrics.webhook("retried");
305 tracing::debug!(url = %cfg.url, attempt, reason = %why, ?backoff, "retrying webhook");
306 tokio::time::sleep(backoff).await;
307 backoff = (backoff * 2).min(s.max_backoff);
308 }
309 }
310}
311
312fn sign(secret: &str, payload: &[u8]) -> String {
314 let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, secret.as_bytes());
315 let tag = ring::hmac::sign(&key, payload);
316 let mut out = String::from("sha256=");
317 for b in tag.as_ref() {
318 out.push_str(&format!("{b:02x}"));
319 }
320 out
321}
322
323#[derive(Clone)]
325pub(crate) struct Hook {
326 tx: mpsc::Sender<Value>,
327 cfg: Arc<WebhookConfig>,
328 kind: &'static str,
329 job_id: Arc<str>,
330 metrics: Arc<Metrics>,
331}
332
333impl Hook {
334 pub(crate) fn send(&self, event: Event, data: Vec<Document>, error: Option<String>) {
335 if !self.cfg.wants(event) {
336 return;
337 }
338 let mut body = json!({
339 "success": error.is_none(),
340 "type": format!("{}.{}", self.kind, event.name()),
341 "id": &*self.job_id,
342 "data": data,
343 "metadata": self.cfg.metadata,
344 });
345 if let Some(e) = error {
346 body["error"] = e.into();
347 }
348 match self.tx.try_send(body) {
349 Ok(()) => {}
350 Err(mpsc::error::TrySendError::Full(body)) if event != Event::Page => {
351 let tx = self.tx.clone();
353 tokio::spawn(async move {
354 let _ = tx.send(body).await;
355 });
356 }
357 Err(_) => {
358 self.metrics.webhook("dropped");
359 }
360 }
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn signatures_match_rfc_4231() {
370 assert_eq!(
372 sign("Jefe", b"what do ya want for nothing?"),
373 "sha256=5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
374 );
375 }
376
377 #[test]
378 fn parses_strings_objects_and_rejects_private_targets() {
379 let policy = SsrfPolicy::default();
380 let c = WebhookConfig::parse(&json!("https://hooks.example.com/x"), &policy).unwrap();
381 assert_eq!(c.events, all_events());
382 let c = WebhookConfig::parse(
383 &json!({"url": "https://hooks.example.com/x", "headers": {"X-Token": "t"},
384 "metadata": {"run": "1"}, "events": ["completed"], "future": true}),
385 &policy,
386 )
387 .unwrap();
388 assert!(c.wants(Event::Completed) && !c.wants(Event::Page));
389 for (v, code) in [
390 (json!("http://127.0.0.1:9000/hook"), ErrorCode::SsrfBlocked),
391 (
392 json!("http://169.254.169.254/latest"),
393 ErrorCode::SsrfBlocked,
394 ),
395 (json!("http://metadata.internal/"), ErrorCode::SsrfBlocked),
396 (json!("file:///etc/passwd"), ErrorCode::SsrfBlocked),
397 (
398 json!({"url": "https://a.example", "events": ["done"]}),
399 ErrorCode::InvalidRequest,
400 ),
401 (json!(42), ErrorCode::InvalidRequest),
402 ] {
403 assert_eq!(
404 WebhookConfig::parse(&v, &policy).unwrap_err().code,
405 code,
406 "{v}"
407 );
408 }
409 }
410}