mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-11 16:08:09 +00:00
feat: stop handing the website-tracking identification ticket to a recognised scanner, which filed its walk of a tracked link as the recipient's own page view, read TRACKING_SCANNER_BUILTINS the way the backend's configuration registry does so off no longer means on, and ship the whole-cloud Microsoft and Google ASN entries commented out because a recipient browsing from Azure or Google Cloud sits inside them
This commit is contained in:
+1
-1
@@ -535,7 +535,7 @@ BILLING_PROVIDER=none
|
||||
# client's image fetches, where only clicks may be judged.
|
||||
# TRACKING_SCANNER_BUILTINS=true
|
||||
# TRACKING_SCANNER_NETWORKS=67.231.144.0/20,148.163.128.0/19
|
||||
# TRACKING_SCANNER_CLICK_NETWORKS=asn:8075
|
||||
# TRACKING_SCANNER_CLICK_NETWORKS=asn:8074,asn:8075,asn:12076
|
||||
# ASN entries need the source ASN from your edge, and it is read only from a
|
||||
# proxy listed in TRACKING_TRUSTED_PROXIES. Cloudflare: set a transform rule
|
||||
# writing ip.src.asnum into the header you name here.
|
||||
|
||||
@@ -20,7 +20,7 @@ Six rules govern the counts:
|
||||
|
||||
- **Tracking must be on.** Opens and clicks need open or link tracking enabled on the campaign, and a tracking host on the install. A [custom tracking domain](/guides/mailboxes/#custom-tracking-domain) per mailbox is optional; without one they go through the shared host and still count. With tracking off they stay at zero while sends and replies still count.
|
||||
- **Replies are human replies.** Out-of-office and autoresponders never count, never stamp the contact as replied, and never trip stop-on-reply.
|
||||
- **Bots are filtered.** Crawlers, CLI agents, prefetches, chat link previews, and security gateways that announce themselves are served normally but never counted. The ones that do not announce themselves are caught by where they came from and by what they do. Requests from a known mail-filtering network are treated as automated whatever their user agent claims: Microsoft 365's own filtering layer is recognised out of the box, and the wider Microsoft and Google networks are recognised on clicks, because a person's click always reaches us from the person's own browser while their opens can arrive through those companies' image proxies. The rest are caught by behaviour: an open or click inside ten seconds of the send (nobody reads that fast; the clock starts when the send is handed to the worker, before the mail has even been delivered) and clicks on two or more links of one email from the same source within five seconds (a scanner walking the message). Otherwise one corporate scanner would "click" every link seconds after delivery. A self-hosted instance can name its own scanner networks; see [tracking service configuration](/development/configuration/).
|
||||
- **Bots are filtered.** Crawlers, CLI agents, prefetches, chat link previews, and security gateways that announce themselves are served normally but never counted. The ones that do not announce themselves are caught by where they came from and by what they do. Requests from a known mail-filtering network are treated as automated whatever their user agent claims, and a click from one is still redirected, just without the ticket that would file the visit against the recipient. Microsoft 365's own filtering layer, where Safe Links and delivery-time link scanning run, is recognised out of the box. The rest are caught by behaviour: an open or click inside ten seconds of the send (nobody reads that fast; the clock starts when the send is handed to the worker, before the mail has even been delivered) and clicks on two or more links of one email from the same source within five seconds (a scanner walking the message). Otherwise one corporate scanner would "click" every link seconds after delivery. A self-hosted instance can name its own scanner networks; see [tracking service configuration](/development/configuration/).
|
||||
- **Auto-opens are labeled, not hidden.** Privacy proxies like Apple Mail Privacy Protection, and instant opens, still count (they confirm delivery) but are tagged and shown separately (`12 auto`). A later real open upgrades them to human. **Auto-opens never trigger opened-based branches or automations.**
|
||||
- **A click is an open.** Opens need the mail client to load images, and many clients block them. A click by the person proves the email was read, so it counts as an open as well, and a lead can never read "clicked, not opened".
|
||||
- **Auto-clicks are kept but never counted.** A click classified as automated is logged on the contact's activity with the link it hit and the rule that caught it, and the campaign overview shows how many steps had only automated clicks (`3 auto`). It never makes the step clicked, never fires a clicked branch or automation, and never sends a webhook. A person clicking the same link later counts normally. A burst is only recognisable from its second click, so a click that looks human waits for the burst window plus one second (six seconds) before it fires anything (clicked branches, automations, webhooks, the live feed): if a second link follows inside that window, both clicks are relabeled, the stamp is withdrawn and nothing fires.
|
||||
|
||||
@@ -31,18 +31,21 @@
|
||||
2a01:111:f400::/48 all microsoft-365-protection
|
||||
2a01:111:f403::/48 all microsoft-365-protection
|
||||
|
||||
# The rest of Microsoft's network, by ASN. Defender detonation is not confined
|
||||
# to the ranges above, so this is what catches a scan from Azure. Clicks only:
|
||||
# the same ASNs carry Outlook on the web's image proxy, and treating their
|
||||
# pixel fetches as machines would zero the open rate of every Outlook.com and
|
||||
# Microsoft 365 recipient. A click ticket is different: Safe Links redirects
|
||||
# the browser to the destination rather than fetching it, so a person's own
|
||||
# click always arrives from the person's own address.
|
||||
asn:8075 clicks microsoft
|
||||
asn:8074 clicks microsoft
|
||||
asn:12076 clicks microsoft
|
||||
|
||||
# Google, for the same reason and with the same asymmetry: Gmail's image proxy
|
||||
# is the only open signal Gmail exposes and must keep counting, while a
|
||||
# person's click on a Gmail link comes from their own browser.
|
||||
asn:15169 clicks google
|
||||
# The rest of Microsoft's and Google's networks, by ASN. Defender detonation is
|
||||
# not confined to the ranges above, so these catch a scan that runs from Azure
|
||||
# instead. They are NOT enabled by default and are here to be uncommented, or
|
||||
# set through TRACKING_SCANNER_CLICK_NETWORKS, as a deliberate trade-off:
|
||||
#
|
||||
# - clicks only, never opens. Both companies proxy external images for their
|
||||
# web mail, so counting their pixel fetches as machines would zero the open
|
||||
# rate of every Outlook.com, Microsoft 365 and Gmail recipient.
|
||||
# - even on clicks these are whole cloud allocations, not scanner ranges.
|
||||
# A recipient whose browser egresses through Azure (Windows 365, Azure
|
||||
# Virtual Desktop, a NAT gateway) or Google Cloud is inside them, and their
|
||||
# real click would be recorded as automated and fire nothing. Turn these on
|
||||
# when your recipients' scanner noise costs you more than that.
|
||||
#
|
||||
# asn:8075 clicks microsoft
|
||||
# asn:8074 clicks microsoft
|
||||
# asn:12076 clicks microsoft
|
||||
# asn:15169 clicks google
|
||||
|
||||
+42
-6
@@ -225,9 +225,10 @@ impl Config {
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.unwrap_or_else(|| internal_api_token.clone());
|
||||
|
||||
let scanner_builtins = env::var("TRACKING_SCANNER_BUILTINS")
|
||||
.map(|v| !matches!(v.trim().to_ascii_lowercase().as_str(), "false" | "0" | "no"))
|
||||
.unwrap_or(true);
|
||||
let scanner_builtins = parse_bool(
|
||||
&env::var("TRACKING_SCANNER_BUILTINS").unwrap_or_default(),
|
||||
true,
|
||||
);
|
||||
let scanner_networks = env::var("TRACKING_SCANNER_NETWORKS").unwrap_or_default();
|
||||
let scanner_click_networks =
|
||||
env::var("TRACKING_SCANNER_CLICK_NETWORKS").unwrap_or_default();
|
||||
@@ -360,9 +361,10 @@ impl Config {
|
||||
client_ip_header: env::var("TRACKING_CLIENT_IP_HEADER")
|
||||
.unwrap_or_else(|_| "x-forwarded-for".to_string())
|
||||
.to_ascii_lowercase(),
|
||||
scanner_builtins: env::var("TRACKING_SCANNER_BUILTINS")
|
||||
.map(|v| !matches!(v.trim().to_ascii_lowercase().as_str(), "false" | "0" | "no"))
|
||||
.unwrap_or(true),
|
||||
scanner_builtins: parse_bool(
|
||||
&env::var("TRACKING_SCANNER_BUILTINS").unwrap_or_default(),
|
||||
true,
|
||||
),
|
||||
scanner_networks: env::var("TRACKING_SCANNER_NETWORKS").unwrap_or_default(),
|
||||
scanner_click_networks: env::var("TRACKING_SCANNER_CLICK_NETWORKS").unwrap_or_default(),
|
||||
scanner_asn_header: env::var("TRACKING_SCANNER_ASN_HEADER")
|
||||
@@ -485,3 +487,37 @@ pub fn parse_trusted_proxies(raw: &str) -> Vec<ipnet::IpNet> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reads an on/off variable the way the backend's own configuration registry
|
||||
/// does, so the value the admin panel reports and the value this service acts
|
||||
/// on can never disagree: only those words decide, anything else is the
|
||||
/// default. `off` in particular reads as false there, and used to read as true
|
||||
/// here.
|
||||
fn parse_bool(raw: &str, default: bool) -> bool {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => true,
|
||||
"0" | "false" | "no" | "off" => false,
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_bool;
|
||||
|
||||
#[test]
|
||||
fn parse_bool_agrees_with_the_backend_registry() {
|
||||
for on in ["1", "true", "TRUE", "yes", "on", " On "] {
|
||||
assert!(parse_bool(on, false), "{on:?} should be true");
|
||||
}
|
||||
for off in ["0", "false", "FALSE", "no", "off", " Off "] {
|
||||
assert!(!parse_bool(off, true), "{off:?} should be false");
|
||||
}
|
||||
// Anything else, empty included, leaves the default standing rather
|
||||
// than being read as a value.
|
||||
for other in ["", " ", "maybe", "2"] {
|
||||
assert!(parse_bool(other, true), "{other:?} should keep true");
|
||||
assert!(!parse_bool(other, false), "{other:?} should keep false");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+62
-10
@@ -269,16 +269,6 @@ pub async fn track_click(
|
||||
return Redirect::temporary(&link.destination).into_response();
|
||||
}
|
||||
|
||||
// When the workspace registered the destination's host for website
|
||||
// tracking, the ticket rides along so the snippet can tie the browser to
|
||||
// the recipient. The ticket is opaque and per-recipient: it names no
|
||||
// destination and no secret, only "the click the backend already knows".
|
||||
let target = if link.identify {
|
||||
with_identify_param(&link.destination, &link_id)
|
||||
} else {
|
||||
link.destination.clone()
|
||||
};
|
||||
|
||||
// Safe Links and its peers redirect the browser to the destination rather
|
||||
// than fetching it, so a person's click always reaches us from the
|
||||
// person's own address. A ticket walked from a mail-filtering network is
|
||||
@@ -288,6 +278,13 @@ pub async fn track_click(
|
||||
.classify(&ip, &headers, trusted, Request::Click)
|
||||
.map(|label| label.to_string());
|
||||
|
||||
let target = redirect_target(
|
||||
&link.destination,
|
||||
&link_id,
|
||||
link.identify,
|
||||
scanner.as_deref(),
|
||||
);
|
||||
|
||||
// Dedupe repeat clicks of the same ticket from the same source
|
||||
if state.is_duplicate("CLICK", &link_id, &ip_hash).await {
|
||||
return Redirect::temporary(&target).into_response();
|
||||
@@ -396,6 +393,30 @@ async fn spend_unsubscribe_budget(
|
||||
Some((StatusCode::TOO_MANY_REQUESTS, "Slow down").into_response())
|
||||
}
|
||||
|
||||
/// Where a click is sent. When the workspace registered the destination's host
|
||||
/// for website tracking, the identification ticket rides along so the snippet
|
||||
/// can tie the browser to the recipient; it is opaque and per-recipient,
|
||||
/// naming no destination and no secret, only "the click the backend already
|
||||
/// knows".
|
||||
///
|
||||
/// A recognised scanner is sent to the bare destination, the same as one
|
||||
/// caught by its user agent: the ticket reaches the page's snippet, which
|
||||
/// posts it to the hit endpoint, and the backend files that page view against
|
||||
/// the recipient. A gateway walking the link would show up as the person
|
||||
/// browsing the site.
|
||||
fn redirect_target(
|
||||
destination: &str,
|
||||
ticket: &str,
|
||||
identify: bool,
|
||||
scanner: Option<&str>,
|
||||
) -> String {
|
||||
if identify && scanner.is_none() {
|
||||
with_identify_param(destination, ticket)
|
||||
} else {
|
||||
destination.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Query parameter the click redirect appends and the snippet strips.
|
||||
const IDENTIFY_PARAM: &str = "wbly_t";
|
||||
|
||||
@@ -642,6 +663,37 @@ fn hash_ip(key: &str, ip: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// The ticket identifies the recipient to the destination's own analytics.
|
||||
// Handing it to a security gateway files the gateway's fetch as that
|
||||
// person's visit, which is the same reason the user-agent path above
|
||||
// redirects scanners to the bare destination.
|
||||
#[test]
|
||||
fn a_scanner_is_never_handed_the_identification_ticket() {
|
||||
assert_eq!(
|
||||
redirect_target("https://x.com/p", "abc", true, None),
|
||||
"https://x.com/p?wbly_t=abc"
|
||||
);
|
||||
assert_eq!(
|
||||
redirect_target(
|
||||
"https://x.com/p",
|
||||
"abc",
|
||||
true,
|
||||
Some("microsoft-365-protection")
|
||||
),
|
||||
"https://x.com/p"
|
||||
);
|
||||
// A destination the workspace never registered carries no ticket
|
||||
// either way, and the redirect itself always happens.
|
||||
assert_eq!(
|
||||
redirect_target("https://x.com/p", "abc", false, None),
|
||||
"https://x.com/p"
|
||||
);
|
||||
assert_eq!(
|
||||
redirect_target("https://x.com/p", "abc", false, Some("scanner")),
|
||||
"https://x.com/p"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_param_keeps_query_and_fragment() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -255,13 +255,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// The whole of Microsoft's network is a scanner for click tickets and must
|
||||
// never be one for pixels: Outlook on the web fetches external images
|
||||
// through Microsoft's own proxy, so a genuine open arrives from there.
|
||||
// A whole cloud allocation is too broad to ship on: a recipient whose
|
||||
// browser egresses through Azure or Google Cloud is inside one, and their
|
||||
// real click would be recorded as automated. The catalogue documents them
|
||||
// and leaves them commented; an operator opts in per entry.
|
||||
#[test]
|
||||
fn microsoft_asn_is_clicks_only() {
|
||||
fn whole_cloud_asns_are_not_on_by_default() {
|
||||
let s = builtins();
|
||||
let h = hdr(&[("cf-asn", "8075")]);
|
||||
assert_eq!(s.classify("13.107.128.5", &h, true, Request::Click), None);
|
||||
assert_eq!(s.classify("13.107.128.5", &h, true, Request::Open), None);
|
||||
}
|
||||
|
||||
// Opted into, an ASN is a scanner for click tickets and must never be one
|
||||
// for pixels: Outlook on the web fetches external images through
|
||||
// Microsoft's own proxy, so a genuine open arrives from there.
|
||||
#[test]
|
||||
fn an_opted_in_asn_is_clicks_only() {
|
||||
let s = ScannerNetworks::new(false, "", "asn:8075 microsoft", Some("cf-asn".into()));
|
||||
let h = hdr(&[("cf-asn", "8075")]);
|
||||
assert_eq!(
|
||||
s.classify("13.107.128.5", &h, true, Request::Click)
|
||||
.as_deref(),
|
||||
@@ -290,7 +302,7 @@ mod tests {
|
||||
// not to be labelled at all.
|
||||
#[test]
|
||||
fn asn_header_is_ignored_from_an_untrusted_peer() {
|
||||
let s = builtins();
|
||||
let s = ScannerNetworks::new(false, "", "asn:8075 microsoft", Some("cf-asn".into()));
|
||||
let h = hdr(&[("cf-asn", "8075")]);
|
||||
assert_eq!(s.classify("203.0.113.9", &h, false, Request::Click), None);
|
||||
assert_eq!(
|
||||
@@ -304,7 +316,7 @@ mod tests {
|
||||
// catalogue's ASN entries are inert.
|
||||
#[test]
|
||||
fn asn_entries_need_a_configured_header() {
|
||||
let s = ScannerNetworks::new(true, "", "", None);
|
||||
let s = ScannerNetworks::new(false, "", "asn:8075 microsoft", None);
|
||||
let h = hdr(&[("cf-asn", "8075")]);
|
||||
assert_eq!(s.classify("203.0.113.9", &h, true, Request::Click), None);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user