Summary
bridge_impression and bridge_click events store dest_site read client-side from the bridge link's utm_campaign query param, with no server-side allowlist validation against the network's canonical site keys. Automated scanner/injection probes hitting pages with a hostile utm_campaign value get recorded as legitimate bridge events, polluting dest_site GROUP BY reads and the get-bridge-ctr ability's per-destination breakdown.
Evidence (production, c8c_extrachill_analytics_events, trailing 30 days, 2026-07-12)
SELECT JSON_UNQUOTE(JSON_EXTRACT(event_data,'$.dest_site')) as dest_site, COUNT(*) as cnt
FROM c8c_extrachill_analytics_events
WHERE event_type IN ('bridge_click','bridge_impression')
AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY dest_site ORDER BY cnt DESC;
Legit values dominate (main 9190, events 8950, artist 2415, wire 8) but 113 junk rows carry attack-shaped dest_site values, e.g.:
- Path traversal probes:
etcpasswd, windowswinini, fileetcpasswd, web-infwebxml, cwindowswinini, etcpasswd00jpg
- SQLi probes:
events0xorifnowsysdatesleep150xorz, events-1waitfordelay0015--, eventst8mizhahor88select88frompg_sleep15--, select198766667891fromdual, eventsdbms_pipereceive_messagechr98chr98chr9815, select0fromselectsleep15vselect0fromselectsleep15vselect0fromselectsleep15v
- OOB/XSS/RCE probes:
nslookup-qcnamehitoekgtmzvgoceecbbxssmecurlhitoekgtmzvgoceecbbxssme, assertbase64_decodechjpbnqobwq1kdmxmzm3ksk7, httpbxssmetfittxt3fjpg
All 113 junk rows are already stamped is_bot: true by the write-time classifier in extrachill_track_analytics_event() — but nothing currently uses that flag to gate or bucket dest_site before it lands in event_data, and the sanitization gap should be fixed independent of bot detection (per the plugin's own stated intent in security-classifier.php: "never silently drop attacks... classify them").
Where the gap lives
Client (source of the raw value, cannot be trusted — expected, this is normal client behavior):
extrachill-multisite → assets/js/bridge-instrumentation.js — both the impression loop (line ~93) and the click listener (line ~140) read dest_site via param(href, 'utm_campaign') straight off the link's href attribute with zero validation, then POST it as-is to the extrachill-api routes.
Server write path (should validate, currently doesn't):
extrachill-api → inc/routes/analytics/impression.php (dest_site REST arg, line 62) and inc/routes/analytics/click.php (dest_site REST arg, line 81) both register:
'dest_site' => array(
'required' => false,
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_key',
),
sanitize_key() only strips characters outside [a-z0-9_-] and lowercases — it happily passes through etcpasswd, select198766667891fromdual, etc. because those are all valid "key" characters. It was never meant to be a semantic allowlist, so this isn't a bug in sanitize_key() itself — it's the wrong sanitizer for a value that's supposed to be one of a fixed enum.
Both routes then pass dest_site straight through to extrachill/track-analytics-event (extrachill_analytics_ability_track_event() in extrachill-analytics/inc/core/abilities.php), which has a defense-in-depth reclassifier for search events (extrachill_analytics_classify_search_payload(), routes attack-shaped search terms to a separate search_attack event_type) but no equivalent check for dest_site on bridge_click/bridge_impression.
Downstream consumer that inherits the pollution:
extrachill-analytics → inc/core/abilities/get-bridge-ctr.php groups by raw dest_site ($by_dest[$dest_site], line ~145) with no filtering, so every junk value becomes its own row in the by_dest_site breakdown returned by the extrachill/get-bridge-ctr ability.
Proposed fix direction (server-side; never trust the client value)
Validate dest_site against the network's canonical site-key allowlist at write time, in extrachill-api's route registration (the natural validate_callback/sanitize_callback layer, same pattern already used for impression_type and click_type in these same files):
- Canonical keys come from
ec_get_blog_ids() in extrachill-multisite/inc/core/blog-ids.php: main, community, shop, artist, events, newsletter, docs, wire, studio.
- Anything outside that set (plus the existing empty-string "untagged card" case already handled downstream as
(unknown)) should be rejected or coerced to a distinct bucket — do not store the raw attack payload as dest_site verbatim. Rejecting the whole event at the REST layer (matching the existing impression_type/click_type validate_callback convention) is the simplest correct fix and keeps extrachill-analytics from ever seeing the junk.
- Do not gate this on the
is_bot flag — the issue text int prompt correctly note that flag is set independently by the UA/cookie/origin classifier and shouldn't be relied on as the sanitization mechanism, even though it happens to agree on 100% of the current junk rows.
Optional follow-up: a one-time cleanup migration for the 113 existing junk rows, following the existing backfill convention in extrachill-analytics/inc/core/isbot-backfill.php (dry-run default, explicit --live flag, bounded/idempotent).
Impact
extrachill/get-bridge-ctr ability's by_dest_site breakdown is polluted with dozens of noise rows per 30-day window.
- Any future dashboard/report built on raw
dest_site GROUP BY inherits the same pollution.
- Low severity from a data-integrity standpoint (junk
dest_site values can't corrupt SQL — they're bound params — and can't XSS since nothing renders them raw), but it's real garbage-in on a metric the team actively reads, and it's evidence the endpoint is being actively scanned.
Summary
bridge_impressionandbridge_clickevents storedest_siteread client-side from the bridge link'sutm_campaignquery param, with no server-side allowlist validation against the network's canonical site keys. Automated scanner/injection probes hitting pages with a hostileutm_campaignvalue get recorded as legitimate bridge events, pollutingdest_siteGROUP BY reads and theget-bridge-ctrability's per-destination breakdown.Evidence (production,
c8c_extrachill_analytics_events, trailing 30 days, 2026-07-12)Legit values dominate (
main9190,events8950,artist2415,wire8) but 113 junk rows carry attack-shapeddest_sitevalues, e.g.:etcpasswd,windowswinini,fileetcpasswd,web-infwebxml,cwindowswinini,etcpasswd00jpgevents0xorifnowsysdatesleep150xorz,events-1waitfordelay0015--,eventst8mizhahor88select88frompg_sleep15--,select198766667891fromdual,eventsdbms_pipereceive_messagechr98chr98chr9815,select0fromselectsleep15vselect0fromselectsleep15vselect0fromselectsleep15vnslookup-qcnamehitoekgtmzvgoceecbbxssmecurlhitoekgtmzvgoceecbbxssme,assertbase64_decodechjpbnqobwq1kdmxmzm3ksk7,httpbxssmetfittxt3fjpgAll 113 junk rows are already stamped
is_bot: trueby the write-time classifier inextrachill_track_analytics_event()— but nothing currently uses that flag to gate or bucketdest_sitebefore it lands inevent_data, and the sanitization gap should be fixed independent of bot detection (per the plugin's own stated intent insecurity-classifier.php: "never silently drop attacks... classify them").Where the gap lives
Client (source of the raw value, cannot be trusted — expected, this is normal client behavior):
extrachill-multisite→assets/js/bridge-instrumentation.js— both the impression loop (line ~93) and the click listener (line ~140) readdest_siteviaparam(href, 'utm_campaign')straight off the link'shrefattribute with zero validation, then POST it as-is to the extrachill-api routes.Server write path (should validate, currently doesn't):
extrachill-api→inc/routes/analytics/impression.php(dest_siteREST arg, line 62) andinc/routes/analytics/click.php(dest_siteREST arg, line 81) both register:sanitize_key()only strips characters outside[a-z0-9_-]and lowercases — it happily passes throughetcpasswd,select198766667891fromdual, etc. because those are all valid "key" characters. It was never meant to be a semantic allowlist, so this isn't a bug insanitize_key()itself — it's the wrong sanitizer for a value that's supposed to be one of a fixed enum.Both routes then pass
dest_sitestraight through toextrachill/track-analytics-event(extrachill_analytics_ability_track_event()inextrachill-analytics/inc/core/abilities.php), which has a defense-in-depth reclassifier forsearchevents (extrachill_analytics_classify_search_payload(), routes attack-shaped search terms to a separatesearch_attackevent_type) but no equivalent check fordest_siteonbridge_click/bridge_impression.Downstream consumer that inherits the pollution:
extrachill-analytics→inc/core/abilities/get-bridge-ctr.phpgroups by rawdest_site($by_dest[$dest_site], line ~145) with no filtering, so every junk value becomes its own row in theby_dest_sitebreakdown returned by theextrachill/get-bridge-ctrability.Proposed fix direction (server-side; never trust the client value)
Validate
dest_siteagainst the network's canonical site-key allowlist at write time, inextrachill-api's route registration (the naturalvalidate_callback/sanitize_callbacklayer, same pattern already used forimpression_typeandclick_typein these same files):ec_get_blog_ids()inextrachill-multisite/inc/core/blog-ids.php:main,community,shop,artist,events,newsletter,docs,wire,studio.(unknown)) should be rejected or coerced to a distinct bucket — do not store the raw attack payload asdest_siteverbatim. Rejecting the whole event at the REST layer (matching the existingimpression_type/click_typevalidate_callbackconvention) is the simplest correct fix and keepsextrachill-analyticsfrom ever seeing the junk.is_botflag — the issue text int prompt correctly note that flag is set independently by the UA/cookie/origin classifier and shouldn't be relied on as the sanitization mechanism, even though it happens to agree on 100% of the current junk rows.Optional follow-up: a one-time cleanup migration for the 113 existing junk rows, following the existing backfill convention in
extrachill-analytics/inc/core/isbot-backfill.php(dry-run default, explicit--liveflag, bounded/idempotent).Impact
extrachill/get-bridge-ctrability'sby_dest_sitebreakdown is polluted with dozens of noise rows per 30-day window.dest_siteGROUP BY inherits the same pollution.dest_sitevalues can't corrupt SQL — they're bound params — and can't XSS since nothing renders them raw), but it's real garbage-in on a metric the team actively reads, and it's evidence the endpoint is being actively scanned.