Skip to main content

max / makenotwork

3.6 KB · 93 lines History Blame Raw
1 //! Cloudflare edge-cache purge client.
2 //!
3 //! Deleting a quarantined object from origin stops origin serving, but
4 //! Cloudflare caches `cdn.makenot.work/{key}` immutably for up to a year, so an
5 //! already-edge-cached malicious object keeps serving from the edge until its
6 //! TTL lapses (ultra-fuzz CDN scan-serve residual). Purging the specific URL
7 //! closes that window immediately.
8 //!
9 //! Optional by design: when `CF_API_TOKEN` / `CF_ZONE_ID` are not configured,
10 //! [`CloudflarePurger::from_env`] returns `None` and every purge becomes a
11 //! logged no-op, the malware-quarantine WAM ticket remains the manual
12 //! fallback, exactly the posture before this wiring existed.
13
14 use crate::helpers::HTTP_CLIENT;
15
16 /// Cloudflare's documented per-request file cap for `purge_cache` by URL.
17 const PURGE_FILES_PER_REQUEST: usize = 30;
18
19 /// Holds the credentials needed to call the Cloudflare cache-purge API for one
20 /// zone. Cheap to clone (two owned strings); intended to live in shared context.
21 #[derive(Clone)]
22 pub struct CloudflarePurger {
23 zone_id: String,
24 api_token: String,
25 }
26
27 impl std::fmt::Debug for CloudflarePurger {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 f.debug_struct("CloudflarePurger")
30 .field("zone_id", &self.zone_id)
31 .field("api_token", &"[REDACTED]")
32 .finish()
33 }
34 }
35
36 impl CloudflarePurger {
37 /// Build from `CF_API_TOKEN` + `CF_ZONE_ID`. Returns `None` if either is
38 /// unset or empty, in which case purges become logged no-ops.
39 pub fn from_env() -> Option<Self> {
40 let api_token = std::env::var("CF_API_TOKEN")
41 .ok()
42 .filter(|s| !s.is_empty())?;
43 let zone_id = std::env::var("CF_ZONE_ID").ok().filter(|s| !s.is_empty())?;
44 Some(Self { zone_id, api_token })
45 }
46
47 /// Purge specific absolute URLs from Cloudflare's edge cache.
48 ///
49 /// Fire-and-forget at the call site (callers should not block enforcement on
50 /// it); failures are logged, not propagated, the object is already gone from
51 /// origin, so a failed edge purge degrades to the prior TTL-bounded residual
52 /// rather than a serving failure. Batches to Cloudflare's per-request cap.
53 pub async fn purge_urls(&self, urls: Vec<String>) {
54 for chunk in urls.chunks(PURGE_FILES_PER_REQUEST) {
55 self.purge_chunk(chunk).await;
56 }
57 }
58
59 async fn purge_chunk(&self, urls: &[String]) {
60 if urls.is_empty() {
61 return;
62 }
63 let endpoint = format!(
64 "https://api.cloudflare.com/client/v4/zones/{}/purge_cache",
65 self.zone_id
66 );
67 let body = serde_json::json!({ "files": urls });
68 match HTTP_CLIENT
69 .post(&endpoint)
70 .bearer_auth(&self.api_token)
71 .json(&body)
72 .send()
73 .await
74 {
75 Ok(resp) if resp.status().is_success() => {
76 tracing::info!(count = urls.len(), "cloudflare edge-cache purge succeeded");
77 }
78 Ok(resp) => {
79 let status = resp.status();
80 let detail = resp.text().await.unwrap_or_default();
81 tracing::error!(
82 %status, detail = %detail, count = urls.len(),
83 "cloudflare edge-cache purge returned an error; quarantined URL may stay edge-cached until TTL"
84 );
85 }
86 Err(e) => tracing::error!(
87 error = ?e, count = urls.len(),
88 "cloudflare edge-cache purge request failed; quarantined URL may stay edge-cached until TTL"
89 ),
90 }
91 }
92 }
93