Skip to content

Commit 1024f4d

Browse files
Address Fable review: ctx lifetime, race, fail-closed, caps, wire masking
Correctness batch first (Fable found the module was silently broken): - Blocker #1 (fan-out ctx dies on hook return). The framework cancels each hook's own ctx the moment the hook returns (hooks/hookexecution/execution.go), so a fan-out rooted in an entrypoint hook's ctx was Done before the goroutine spawned. Consequence: every provider call started already-cancelled, zero segments landed, and the failure was silent (errors stayed inside providerResult.Errs and analytics reported Success). Removed the entrypoint hook entirely; the async holder now allocates inside HandleProcessedAuctionHook with a Background-rooted ctx, cancelled via defer async.cancel() in the response hook. That also fixes finding prebid#6 (response hook stalling for its full group timeout when processed-auction never ran) — no holder ever means the response hook short-circuits cleanly. - Blocker prebid#2 (data race on live BidRequest). The fan-out goroutine read Site/Imp/Device.Geo/User.EIDs and unmarshalled User.Ext while the auction continued to mutate the wrapper (RebuildRequest, privacy scrubbing, other modules). deriveInputs now runs synchronously on the caller's stack; the goroutine only sees the tmpInputs value snapshot and never touches the BidRequest. - Finding prebid#10 (no hooks-level test). New hooks_test.go exercises HandleProcessedAuctionHook → HandleAuctionResponseHook through real hookstage plumbing, including simulating the framework's per-hook ctx cancellation. This is the test that would have caught #1 in the first place; it fails against the pre-fix code. Then the small independent correctness fixes: - prebid#4 registry bare-record parse: fall back to decoding the raw payload as PropertyRecord when the {"property": {...}} envelope isn't found. The old code negative-cached bare records as "not found" for 300 s, silently. - prebid#7 per-provider request IDs: build ctxReq and idReq inside callProvider so two colluding providers don't get the same request_id pair for the same auction. - prebid#8 hostile domain: cap site.domain / app.bundle at 253 chars and restrict to `[a-z0-9._-]`. Rejects invalid keys before touching the LRU or the registry. Then the design/product calls user directed on: - prebid#3 fail-closed on identity error: providerResult tracks IdentityAttempted (URL configured AND tokens present). When IdentityAttempted is true but the call returned no response, mergeSegments drops all offers for that provider. A hostile or flaky identity endpoint can no longer convert identity-gated packages into unconditionally-served packages. - prebid#5 cap segments + batch per-bid targeting write. New config knobs MaxSegments (default 128) and MaxSegmentValueLen (default 256) bound both the total segment count and each segment's length, regardless of what a provider returns. Response-hook per-bid targeting now builds one map from segments and writes it via a single sjson.SetBytes per bid at ext.prebid.targeting instead of O(bids × segments) rewrites. Provider names are also validated in Config.validated() to prevent them from colliding with Prebid's reserved targeting prefixes (hb_*). - prebid#9 wire the masking config. coarseGeo now takes cfg and honors PreserveMetro / PreserveZip / PreserveCity / LatLongPrecision (with math.Trunc for lat/lon precision); extractIdentities honors PreserveMobileIds (drops maid-typed tokens when masking enabled and false) and PreserveEids (allowlist over the default set). Deleted the now-empty masking.go — every knob is now wired through adapter.go at input-derivation time. Nits also addressed: - Analytics no longer reports Success when every provider errored; errCount from routerResult drives Status/ResultStatus explicitly. - site.page's query and fragment components are stripped before emission as an artifact ref — gclid / click IDs / occasional email leak into the identity-free context path. - Ordering test reframed: with DecorrelationMaxDelayMs > 0 the second-to-spawn call is deterministically delayed, so HTTP arrival order actually reflects the shuffle instead of scheduler noise. - Panic-recovery test replaced with one that injects a panicking RoundTripper — actually exercises the recover paths in callProvider's inner goroutines. - Nil-guard on payload.BidResponse in the response-hook mutation. - Removed unused asyncRequest.err field.
1 parent 004f9ca commit 1024f4d

10 files changed

Lines changed: 809 additions & 306 deletions

File tree

modules/adcontextprotocol/tmp/README.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,24 @@ hooks:
5151
decorrelation_max_delay_ms: 0
5252
targeting_key: adcp
5353
add_to_targeting: false
54+
# Caps on the segment set surfaced onto the response ext. Guards
55+
# against a misbehaving or hostile provider bloating the bid
56+
# response.
57+
max_segments: 128
58+
max_segment_value_len: 256
59+
# Masking gates optional finer-grained fields into the context
60+
# payload (zip / city / lat-long) and controls which EID sources
61+
# / mobile IDs flow into the identity payload. Defaults are
62+
# strict: only country / region / metro on the context path;
63+
# a small hardcoded EID whitelist on the identity path unless
64+
# `enabled: true` and `preserve_eids` narrows or widens it.
5465
masking:
5566
enabled: true
5667
geo:
5768
preserve_metro: true
5869
preserve_zip: false
5970
preserve_city: false
60-
lat_long_precision: 2
71+
lat_long_precision: 0
6172
user:
6273
preserve_eids:
6374
- liveramp.com
@@ -70,12 +81,6 @@ hooks:
7081
endpoints:
7182
/openrtb2/auction:
7283
stages:
73-
entrypoint:
74-
groups:
75-
- timeout: 5
76-
hook_sequence:
77-
- module_code: "adcontextprotocol.tmp"
78-
hook_impl_code: "HandleEntrypointHook"
7984
auction_processed:
8085
groups:
8186
- timeout: 500

modules/adcontextprotocol/tmp/adapter.go

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package tmp
22

33
import (
4+
"math"
5+
"net/url"
46
"strings"
57

68
"github.com/adcontextprotocol/adcp-go/tmproto"
@@ -36,9 +38,13 @@ func deriveInputs(cfg *Config, req *openrtb2.BidRequest) tmpInputs {
3638
if req.Site != nil {
3739
out.Domain = req.Site.Domain
3840
if req.Site.Page != "" {
41+
// Strip the query component before emitting as an artifact
42+
// ref: gclid, click IDs and sometimes emails ride the query
43+
// string, and the context path is supposed to be
44+
// identity-free. Fragment is dropped too — same reasoning.
3945
out.ArtifactRefs = append(out.ArtifactRefs, tmproto.ArtifactRef{
4046
Type: tmproto.ArtifactRefTypeURL,
41-
Value: req.Site.Page,
47+
Value: stripURLQueryAndFragment(req.Site.Page),
4248
})
4349
}
4450
}
@@ -70,48 +76,101 @@ func deriveInputs(cfg *Config, req *openrtb2.BidRequest) tmpInputs {
7076
}
7177

7278
if req.Device != nil && req.Device.Geo != nil {
73-
out.Geo = coarseGeo(req.Device.Geo)
79+
out.Geo = coarseGeo(cfg, req.Device.Geo)
7480
out.Country = req.Device.Geo.Country
7581
} else if req.User != nil && req.User.Geo != nil {
76-
out.Geo = coarseGeo(req.User.Geo)
82+
out.Geo = coarseGeo(cfg, req.User.Geo)
7783
out.Country = req.User.Geo.Country
7884
}
7985

8086
if req.User != nil {
81-
out.Identities = extractIdentities(req.User)
87+
out.Identities = extractIdentities(cfg, req.User)
8288
}
8389
out.Consent = extractConsent(req)
8490

8591
return out
8692
}
8793

88-
// coarseGeo drops fields the TMP context schema forbids (postcode, lat/long,
89-
// accuracy) even after masking. Country / region / metro are the wire allowlist.
90-
func coarseGeo(geo *openrtb2.Geo) map[string]any {
94+
// stripURLQueryAndFragment returns the URL with the query and fragment
95+
// components removed, keeping scheme + host + path. If the input is not
96+
// parseable as a URL, it is returned unchanged (the wire schema
97+
// accepts opaque strings on artifact refs).
98+
func stripURLQueryAndFragment(raw string) string {
99+
u, err := url.Parse(raw)
100+
if err != nil {
101+
return raw
102+
}
103+
u.RawQuery = ""
104+
u.Fragment = ""
105+
return u.String()
106+
}
107+
108+
// coarseGeo emits the geo fields the TMP context payload carries. When
109+
// masking is enabled, per-field flags gate the finer-grained categories
110+
// (city / zip / lat-lon); with masking disabled the default is the same
111+
// strict-mode fields (country / region / metro) that the TMP wire spec
112+
// treats as coarse enough to not identify a user. Operators who
113+
// explicitly opt into zip / city / lat-lon through the masking config
114+
// take responsibility for that being acceptable at their own provider.
115+
func coarseGeo(cfg *Config, geo *openrtb2.Geo) map[string]any {
91116
if geo == nil {
92117
return nil
93118
}
119+
m := cfg.Masking
120+
preserveMetro := true
121+
preserveZip := false
122+
preserveCity := false
123+
latLongPrecision := 0
124+
if m.Enabled {
125+
preserveMetro = m.Geo.PreserveMetro
126+
preserveZip = m.Geo.PreserveZip
127+
preserveCity = m.Geo.PreserveCity
128+
latLongPrecision = m.Geo.LatLongPrecision
129+
}
130+
94131
out := map[string]any{}
95132
if geo.Country != "" {
96133
out["country"] = geo.Country
97134
}
98135
if geo.Region != "" {
99136
out["region"] = geo.Region
100137
}
101-
if geo.Metro != "" {
138+
if preserveMetro && geo.Metro != "" {
102139
out["metro"] = geo.Metro
103140
}
141+
if preserveZip && geo.ZIP != "" {
142+
out["zip"] = geo.ZIP
143+
}
144+
if preserveCity && geo.City != "" {
145+
out["city"] = geo.City
146+
}
147+
if latLongPrecision > 0 && geo.Lat != nil && geo.Lon != nil {
148+
out["lat"] = truncateCoord(*geo.Lat, latLongPrecision)
149+
out["lon"] = truncateCoord(*geo.Lon, latLongPrecision)
150+
}
104151
if len(out) == 0 {
105152
return nil
106153
}
107154
return out
108155
}
109156

157+
// truncateCoord truncates a coordinate to n decimal places using
158+
// math.Trunc so negative coordinates truncate toward zero (matching
159+
// what most operators expect for a "reduce precision" knob).
160+
func truncateCoord(v float64, precision int) float64 {
161+
mult := math.Pow(10, float64(precision))
162+
return math.Trunc(v*mult) / mult
163+
}
164+
110165
// extractIdentities maps openrtb2 user.eids → tmproto.IdentityToken, honoring
111166
// the TMP cap of three tokens. Priority order: rampid, uid2, id5, then whatever
112167
// remains. Publishers that need a different priority should tell us — right now
113168
// this is the most common set.
114-
func extractIdentities(user *openrtb2.User) []tmproto.IdentityToken {
169+
//
170+
// When Masking is enabled and PreserveMobileIds is false, maid-typed
171+
// tokens (mobile advertising IDs) are dropped from the identity set so
172+
// they never reach a TMP provider.
173+
func extractIdentities(cfg *Config, user *openrtb2.User) []tmproto.IdentityToken {
115174
if user == nil {
116175
return nil
117176
}
@@ -121,6 +180,21 @@ func extractIdentities(user *openrtb2.User) []tmproto.IdentityToken {
121180
"id5-sync.com": 2,
122181
"euid.eu": 3,
123182
"adserver.org": 4,
183+
"adid.google": 5,
184+
"idfa.apple": 5,
185+
}
186+
dropMaid := cfg.Masking.Enabled && !cfg.Masking.Device.PreserveMobileIds
187+
// When PreserveEids is set (masking enabled + operator populated it,
188+
// or the default filled in by validated()) it is authoritative: only
189+
// EID sources on the allowlist survive. Publishers who want the
190+
// default hardcoded whitelist just leave masking off; publishers who
191+
// want a narrower allowlist enable masking + populate the field.
192+
var eidAllowlist map[string]bool
193+
if cfg.Masking.Enabled && len(cfg.Masking.User.PreserveEids) > 0 {
194+
eidAllowlist = make(map[string]bool, len(cfg.Masking.User.PreserveEids))
195+
for _, s := range cfg.Masking.User.PreserveEids {
196+
eidAllowlist[strings.ToLower(s)] = true
197+
}
124198
}
125199
type scored struct {
126200
tok tmproto.IdentityToken
@@ -132,10 +206,16 @@ func extractIdentities(user *openrtb2.User) []tmproto.IdentityToken {
132206
if len(eid.UIDs) == 0 || eid.UIDs[0].ID == "" {
133207
continue
134208
}
209+
if eidAllowlist != nil && !eidAllowlist[strings.ToLower(eid.Source)] {
210+
continue
211+
}
135212
uidType := mapEIDToUIDType(eid.Source)
136213
if uidType == "" {
137214
continue
138215
}
216+
if dropMaid && uidType == tmproto.UIDTypeMAID {
217+
continue
218+
}
139219
// Every source that survives mapEIDToUIDType has a priority entry;
140220
// the map fallback is unreachable.
141221
candidates = append(candidates, scored{
@@ -180,6 +260,8 @@ func mapEIDToUIDType(source string) tmproto.UIDType {
180260
return tmproto.UIDTypeEUID
181261
case "adserver.org":
182262
return tmproto.UIDTypePairID
263+
case "adid.google", "idfa.apple":
264+
return tmproto.UIDTypeMAID
183265
}
184266
return ""
185267
}

modules/adcontextprotocol/tmp/config.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"crypto/ed25519"
55
"errors"
66
"fmt"
7+
"regexp"
78

89
"github.com/adcontextprotocol/adcp-go/tmproto"
910
)
@@ -58,6 +59,17 @@ type Config struct {
5859
// AddToTargeting mirrors the response signals into prebid.targeting so
5960
// downstream ad servers (e.g. GAM) can consume them.
6061
AddToTargeting bool `json:"add_to_targeting"`
62+
63+
// MaxSegments caps the total number of segments emitted onto the
64+
// response ext, regardless of how many providers respond or how many
65+
// offers/signals they include. Default 128. A hostile-or-buggy
66+
// provider cannot bloat the bid response past this bound.
67+
MaxSegments int `json:"max_segments"`
68+
69+
// MaxSegmentValueLen bounds each emitted segment string (name +
70+
// separator + value). Default 256. Excess is truncated. A cap of 0
71+
// disables truncation.
72+
MaxSegmentValueLen int `json:"max_segment_value_len"`
6173
}
6274

6375
// SigningConfig carries the private-key material used to sign outbound TMP
@@ -127,6 +139,14 @@ type DeviceMaskingConfig struct {
127139
PreserveMobileIds bool `json:"preserve_mobile_ids"`
128140
}
129141

142+
// providerNameRE constrains provider names so an operator cannot
143+
// accidentally name a provider "hb" (or similar) and have its emitted
144+
// segment keys collide with Prebid's own reserved targeting keys (e.g.
145+
// hb_pb, hb_adid). The prefix in emitted segments is provider name +
146+
// underscore; restricting to a lower-case identifier keeps the prefix
147+
// unambiguous.
148+
var providerNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`)
149+
130150
// validated returns a Config with defaults filled in, along with the parsed
131151
// Ed25519 private key. Invalid configuration is rejected here rather than at
132152
// call sites.
@@ -147,11 +167,19 @@ func (c *Config) validated() (ed25519.PrivateKey, error) {
147167
if len(c.Providers) == 0 {
148168
return nil, errors.New("at least one provider is required")
149169
}
170+
seenNames := make(map[string]bool, len(c.Providers))
150171
for i := range c.Providers {
151172
p := &c.Providers[i]
152173
if p.Name == "" {
153174
return nil, fmt.Errorf("providers[%d].name is required", i)
154175
}
176+
if !providerNameRE.MatchString(p.Name) {
177+
return nil, fmt.Errorf("providers[%d].name %q must match %s (lowercase letters, digits, underscore, hyphen; up to 32 chars)", i, p.Name, providerNameRE)
178+
}
179+
if seenNames[p.Name] {
180+
return nil, fmt.Errorf("providers[%d].name %q is duplicated", i, p.Name)
181+
}
182+
seenNames[p.Name] = true
155183
if p.IdentityURL == "" && p.ContextURL == "" {
156184
return nil, fmt.Errorf("providers[%d] (%s): at least one of identity_url or context_url is required", i, p.Name)
157185
}
@@ -181,6 +209,15 @@ func (c *Config) validated() (ed25519.PrivateKey, error) {
181209
if c.TargetingKey == "" {
182210
c.TargetingKey = "adcp"
183211
}
212+
if c.MaxSegments <= 0 {
213+
c.MaxSegments = 128
214+
}
215+
if c.MaxSegmentValueLen < 0 {
216+
return nil, errors.New("max_segment_value_len cannot be negative")
217+
}
218+
if c.MaxSegmentValueLen == 0 {
219+
c.MaxSegmentValueLen = 256
220+
}
184221
if c.Masking.Enabled {
185222
if c.Masking.Geo.LatLongPrecision > 4 {
186223
return nil, errors.New("masking.geo.lat_long_precision cannot exceed 4")

0 commit comments

Comments
 (0)