# Postback URL redaction rules

**Tickets** TW-314 (documented) · TW-315 (enforced in code)
**Spec ref** §5.3 item 5 — "Define redaction rules for postback URLs so tokens and secrets never reach ChatGPT"
**Status** Proposed — needs Security sign-off on §7

---

## 1. What we are protecting

`Shorty.publisher_postback_YYYYMMDD.postback_url` stores the **full conversion callback URL** fired
to a publisher. `AffiliateReportModel::getAffPostBackStats` (line 459) returns it verbatim to the
cake UI:

```php
$temp['postback_url'] = $value['postback_url'];
```

Such URLs routinely carry:

| Component | Example | Risk |
| --- | --- | --- |
| Signing token / secret | `?sig=9f2c...`, `&token=`, `&key=` | **Credential.** Replayable — forged conversions |
| Click / transaction id | `?clickid=`, `&txid=` | Correlatable identifier |
| Payout amount | `&payout=4.50` | Commercially sensitive |
| Sub-ids | `&sub1=`, `&s2=` | Free-text; publishers put anything here, including PII |
| Embedded credentials | `https://user:pass@host/...` | **Credential** |
| Token in path | `/pb/7f3a9c2e5b1d/conv` | Same, harder to spot |

Spec §7 item 7 requires sensitive fields be redacted before results reach ChatGPT, and §5.3 names
this field specifically. Once a token reaches a model context it is outside our control — it may be
logged, retained, or reproduced in an answer. **Redaction here is not defence in depth, it is the
boundary.**

## 2. Non-goal

We are not making the URL unrecognisable. The investigative questions are:

- *Did a postback fire for this conversion?*
- *Where was it sent?*
- *Did it carry a payload, or fire bare?*

All three are answerable from scheme, host, path shape, and a parameter **count**. None requires a
single parameter value. The rules preserve exactly that much and nothing more.

---

## 3. Two independent layers

| Layer | Where | Removes | Fails how |
| --- | --- | --- | --- |
| **L1 — SQL** | `Shorty.lucos_ro_postback_YYYYMMDD` (TW-314) | The entire query string, at the `?` | Structurally — no granted object exposes `postback_url` |
| **L2 — application** | `redaction/postback-url.ts` | userinfo, fragment, token-shaped path segments; re-strips the query | Closed — returns `null`, never the input |

L2 **does not assume L1 ran.** It re-strips the query string itself. If the view changes, a new
caller bypasses it, or someone tests the redactor against a raw URL, the application layer still
holds. Two layers that each independently suffice, so neither one failing is a breach.

## 4. L1 — the SQL layer

In `Shorty.lucos_ro_postback_YYYYMMDD`:

```sql
SUBSTRING_INDEX(p.postback_url, '?', 1)   AS postback_url_base,
```

There is no column through which the raw URL can be selected, and the Lucos RO user has no privilege
on `Shorty.publisher_postback_*` at all.

Also published, because they answer real questions with zero leakage:

- `query_param_count` — how many parameters were present
- `postback_url_length` — length of the original, for spotting anomalies
- `ip_prefix` — client IP truncated to /24 (IPv4) or /48 (IPv6); see [OD-7](CAKE-OPEN-DECISIONS.md)

## 5. L2 — the application layer

Applied to `postback_url_base` by `redactPostbackUrl()`.

**R1 — Re-strip.** Everything from the first `?` or `#` is discarded before parsing.

**R2 — Drop userinfo.** `https://user:pass@host/p` → `https://host/p`. Always. Counts as a redaction.

**R3 — Keep scheme, host, port.** The destination is evidence, not a secret. Host is lowercased.

**R4 — Mask token-shaped path segments.** A segment becomes `{redacted}` when it matches **any** of:

| # | Pattern | Rationale |
| --- | --- | --- |
| R4a | `^[0-9a-f]{12,}$` (case-insensitive) | Hex digest — MD5/SHA fragment |
| R4b | `^[A-Za-z0-9+/=_-]{20,}$` | Base64 / URL-safe base64 blob |
| R4c | length ≥ 16, token charset, **and** contains both a letter and a digit | Generic opaque token |
| R4d | `^[0-9]{12,}$` | Very long digit run — timestamped id, not a resource id. Subsumed by R4a, since digits are hex |
| R4e | Contains `:` or `@` after decoding | Smuggled credential |

**R5 — Keep everything else.** `/pb`, `/conv`, `/v2`, `/12345` survive. Short numeric segments are
resource ids and are safe and useful.

**R6 — Fail closed.** If the value is empty, unparseable, not http(s), or the parse throws, return
`null` with an `error`. **Never** return the input on a failure path. A redactor that emits its input
when confused is not a redactor.

**R7 — Report the count.** The number of redactions is returned so a caller can see that masking
happened rather than assuming a clean URL.

### Worked examples

| Input `postback_url_base` | Output |
| --- | --- |
| `https://partner.example.com/pb/conv` | `https://partner.example.com/pb/conv` |
| `https://partner.example.com/pb/7f3a9c2e5b1d4a8f/conv` | `https://partner.example.com/pb/{redacted}/conv` |
| `https://u:p@partner.example.com/pb` | `https://partner.example.com/pb` |
| `https://partner.example.com/pb/12345/conv` | `https://partner.example.com/pb/12345/conv` |
| `https://partner.example.com/t/eyJhbGciOiJIUzI1NiJ9` | `https://partner.example.com/t/{redacted}` |
| `https://partner.example.com/pb?token=abc` | `https://partner.example.com/pb` |
| `not a url` | `null` (`unparseable`) |

These are the test vectors in `tests/cake-redaction.test.ts`.

---

## 6. What `get_postback_report` returns

```jsonc
{
  "shard_date": "2026-08-11",
  "campaign_id": 4471,
  "campaign_name": "Demo Campaign",
  "adv_id": 19880,
  "adv_name": "Demo Advertiser",
  "publisher_id": 1001,
  "affiliate_name": "Demo Publisher",
  "event_time": "2026-08-11T14:03:22Z",
  "ip_prefix": "203.0.113.0/24",
  "postback_url": "https://partner.example.com/pb/{redacted}/conv",
  "postback_url_redactions": 1,
  "query_param_count": 4
}
```

## 7. Deny list — never returned, under any parameter combination

Declared in `catalogs/queries/get_postback_report.json` under `deny_fields`, and asserted by
`applyFieldPolicy`, which **throws** rather than silently dropping — a denied field appearing in a
row means a view drifted, and that should be loud.

- `postback_url_base` (pre-redaction) — the whole point
- `ip` (full address) — only `ip_prefix` is published
- Query parameters by name or value — `token`, `sig`, `secret`, `payout`, `clickid`, `txid`
- `rev_share` — commercially sensitive, and not needed by this tool

**Open for Security ([OD-7](CAKE-OPEN-DECISIONS.md)):** is /24 sufficient de-identification, or
should `ip_prefix` be dropped entirely? Truncation is implemented; removal is a one-line view change.

---

## 8. Applicability beyond postbacks

R1–R7 are URL rules, not postback rules. Any future cake tool returning a URL — referrer breakdowns
from `cleartrust_raw_date_pubisher_*`, creative click-through URLs, tracking pixels — must route
through `redactPostbackUrl()`, or state in its catalog entry why it does not.
