Skip to content

bridge_impression/bridge_click store client-supplied dest_site verbatim — scanner probes pollute dest_site aggregation #117

Description

@chubes4

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-multisiteassets/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-apiinc/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-analyticsinc/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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions