diff --git a/endpoints/openrtb2/auction.go b/endpoints/openrtb2/auction.go index b25df1f8ead..072ac7694cb 100644 --- a/endpoints/openrtb2/auction.go +++ b/endpoints/openrtb2/auction.go @@ -356,6 +356,10 @@ func (deps *endpointDeps) validateRequest(req *openrtb.BidRequest) []error { if err := validateSChains(bidExt); err != nil { return []error{err} } + + if err := deps.validateEidPermissions(bidExt, aliases); err != nil { + return []error{err} + } } if (req.Site == nil && req.App == nil) || (req.Site != nil && req.App != nil) { @@ -444,6 +448,51 @@ func validateSChains(req *openrtb_ext.ExtRequest) error { return err } +func (deps *endpointDeps) validateEidPermissions(req *openrtb_ext.ExtRequest, aliases map[string]string) error { + if req == nil || req.Prebid.Data == nil { + return nil + } + + uniqueSources := make(map[string]struct{}, len(req.Prebid.Data.EidPermissions)) + for i, eid := range req.Prebid.Data.EidPermissions { + if len(eid.Source) == 0 { + return fmt.Errorf(`request.ext.prebid.data.eidpermissions[%d] missing required field: "source"`, i) + } + + if _, exists := uniqueSources[eid.Source]; exists { + return fmt.Errorf(`request.ext.prebid.data.eidpermissions[%d] duplicate entry with field: "source"`, i) + } + uniqueSources[eid.Source] = struct{}{} + + if len(eid.Bidders) == 0 { + return fmt.Errorf(`request.ext.prebid.data.eidpermissions[%d] missing or empty required field: "bidders"`, i) + } + + if err := validateBidders(eid.Bidders, deps.bidderMap, aliases); err != nil { + return fmt.Errorf(`request.ext.prebid.data.eidpermissions[%d] contains %v`, i, err) + } + } + + return nil +} + +func validateBidders(bidders []string, knownBidders map[string]openrtb_ext.BidderName, knownAliases map[string]string) error { + for _, bidder := range bidders { + if bidder == "*" { + if len(bidders) > 1 { + return errors.New(`bidder wildcard "*" mixed with specific bidders`) + } + } else { + _, isCoreBidder := knownBidders[bidder] + _, isAlias := knownAliases[bidder] + if !isCoreBidder && !isAlias { + return fmt.Errorf(`unrecognized bidder "%v"`, bidder) + } + } + } + return nil +} + func (deps *endpointDeps) validateImp(imp *openrtb.Imp, aliases map[string]string, index int) []error { if imp.ID == "" { return []error{fmt.Errorf("request.imp[%d] missing required field: \"id\"", index)} diff --git a/endpoints/openrtb2/auction_test.go b/endpoints/openrtb2/auction_test.go index 9a8c511fd00..73a6f9deb8a 100644 --- a/endpoints/openrtb2/auction_test.go +++ b/endpoints/openrtb2/auction_test.go @@ -1453,19 +1453,19 @@ func TestCurrencyTrunc(t *testing.T) { ui := uint64(1) req := openrtb.BidRequest{ - ID: "someID", + ID: "anyRequestID", Imp: []openrtb.Imp{ { - ID: "imp-ID", + ID: "anyImpID", Banner: &openrtb.Banner{ W: &ui, H: &ui, }, - Ext: json.RawMessage("{\"appnexus\": {\"placementId\": 5667}}"), + Ext: json.RawMessage(`{"appnexus": {"placementId": 5667}}`), }, }, Site: &openrtb.Site{ - ID: "myID", + ID: "anySiteID", }, Cur: []string{"USD", "EUR"}, } @@ -1497,10 +1497,10 @@ func TestCCPAInvalid(t *testing.T) { ui := uint64(1) req := openrtb.BidRequest{ - ID: "someID", + ID: "anyRequestID", Imp: []openrtb.Imp{ { - ID: "imp-ID", + ID: "anyImpID", Banner: &openrtb.Banner{ W: &ui, H: &ui, @@ -1509,10 +1509,10 @@ func TestCCPAInvalid(t *testing.T) { }, }, Site: &openrtb.Site{ - ID: "myID", + ID: "anySiteID", }, Regs: &openrtb.Regs{ - Ext: json.RawMessage(`{"us_privacy":"invalid by length"}`), + Ext: json.RawMessage(`{"us_privacy": "invalid by length"}`), }, } @@ -1545,10 +1545,10 @@ func TestNoSaleInvalid(t *testing.T) { ui := uint64(1) req := openrtb.BidRequest{ - ID: "someID", + ID: "anyRequestID", Imp: []openrtb.Imp{ { - ID: "imp-ID", + ID: "anyImpID", Banner: &openrtb.Banner{ W: &ui, H: &ui, @@ -1557,12 +1557,12 @@ func TestNoSaleInvalid(t *testing.T) { }, }, Site: &openrtb.Site{ - ID: "myID", + ID: "anySiteID", }, Regs: &openrtb.Regs{ - Ext: json.RawMessage(`{"us_privacy":"1NYN"}`), + Ext: json.RawMessage(`{"us_privacy": "1NYN"}`), }, - Ext: json.RawMessage(`{"prebid":{"nosale":["*", "appnexus"]}}`), + Ext: json.RawMessage(`{"prebid": {"nosale": ["*", "appnexus"]} }`), } errL := deps.validateRequest(&req) @@ -1596,10 +1596,10 @@ func TestValidateSourceTID(t *testing.T) { ui := uint64(1) req := openrtb.BidRequest{ - ID: "someID", + ID: "anyRequestID", Imp: []openrtb.Imp{ { - ID: "imp-ID", + ID: "anyImpID", Banner: &openrtb.Banner{ W: &ui, H: &ui, @@ -1608,10 +1608,7 @@ func TestValidateSourceTID(t *testing.T) { }, }, Site: &openrtb.Site{ - ID: "myID", - }, - Regs: &openrtb.Regs{ - Ext: json.RawMessage(`{"us_privacy":"invalid by length"}`), + ID: "anySiteID", }, } @@ -1640,10 +1637,10 @@ func TestSChainInvalid(t *testing.T) { ui := uint64(1) req := openrtb.BidRequest{ - ID: "someID", + ID: "anyRequestID", Imp: []openrtb.Imp{ { - ID: "imp-ID", + ID: "anyImpID", Banner: &openrtb.Banner{ W: &ui, H: &ui, @@ -1652,17 +1649,14 @@ func TestSChainInvalid(t *testing.T) { }, }, Site: &openrtb.Site{ - ID: "myID", - }, - Regs: &openrtb.Regs{ - Ext: json.RawMessage(`{"us_privacy":"abcd"}`), + ID: "anySiteID", }, Ext: json.RawMessage(`{"prebid":{"schains":[{"bidders":["appnexus"],"schain":{"complete":1,"nodes":[{"asi":"directseller1.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}, {"bidders":["appnexus"],"schain":{"complete":1,"nodes":[{"asi":"directseller2.com","sid":"00002","rid":"BidRequest2","hp":1}],"ver":"1.0"}}]}}`), } errL := deps.validateRequest(&req) - expectedError := fmt.Errorf("request.ext.prebid.schains contains multiple schains for bidder appnexus; it must contain no more than one per bidder.") + expectedError := errors.New("request.ext.prebid.schains contains multiple schains for bidder appnexus; it must contain no more than one per bidder.") assert.ElementsMatch(t, errL, []error{expectedError}) } @@ -1841,6 +1835,260 @@ func TestValidateAndFillSourceTID(t *testing.T) { } } +func TestEidPermissionsInvalid(t *testing.T) { + deps := &endpointDeps{ + &nobidExchange{}, + newParamsValidator(t), + &mockStoredReqFetcher{}, + empty_fetcher.EmptyFetcher{}, + empty_fetcher.EmptyFetcher{}, + &config.Configuration{}, + newTestMetrics(), + analyticsConf.NewPBSAnalytics(&config.Analytics{}), + map[string]string{}, + false, + []byte{}, + openrtb_ext.BuildBidderMap(), + nil, + nil, + hardcodedResponseIPValidator{response: true}, + } + + ui := uint64(1) + req := openrtb.BidRequest{ + ID: "anyRequestID", + Imp: []openrtb.Imp{ + { + ID: "anyImpID", + Banner: &openrtb.Banner{ + W: &ui, + H: &ui, + }, + Ext: json.RawMessage(`{"appnexus": {"placementId": 5667}}`), + }, + }, + Site: &openrtb.Site{ + ID: "anySiteID", + }, + Ext: json.RawMessage(`{"prebid": {"data": {"eidpermissions": [{"source":"a", "bidders":[]}]} } }`), + } + + errL := deps.validateRequest(&req) + + expectedError := errors.New(`request.ext.prebid.data.eidpermissions[0] missing or empty required field: "bidders"`) + assert.ElementsMatch(t, errL, []error{expectedError}) +} + +func TestValidateEidPermissions(t *testing.T) { + knownBidders := map[string]openrtb_ext.BidderName{"a": openrtb_ext.BidderName("a")} + knownAliases := map[string]string{"b": "b"} + + testCases := []struct { + description string + request *openrtb_ext.ExtRequest + expectedError error + }{ + { + description: "Valid - Nil ext", + request: nil, + expectedError: nil, + }, + { + description: "Valid - Empty ext", + request: &openrtb_ext.ExtRequest{}, + expectedError: nil, + }, + { + description: "Valid - Nil ext.prebid.data", + request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{}}, + expectedError: nil, + }, + { + description: "Valid - Empty ext.prebid.data", + request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{}}}, + expectedError: nil, + }, + { + description: "Valid - Nil ext.prebid.data.eidpermissions", + request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: nil}}}, + expectedError: nil, + }, + { + description: "Valid - None", + request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{}}}}, + expectedError: nil, + }, + { + description: "Valid - One", + request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "sourceA", Bidders: []string{"a"}}, + }}}}, + expectedError: nil, + }, + { + description: "Valid - Many", + request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "sourceA", Bidders: []string{"a"}}, + {Source: "sourceB", Bidders: []string{"a"}}, + }}}}, + expectedError: nil, + }, + { + description: "Invalid - Missing Source", + request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "sourceA", Bidders: []string{"a"}}, + {Bidders: []string{"a"}}, + }}}}, + expectedError: errors.New(`request.ext.prebid.data.eidpermissions[1] missing required field: "source"`), + }, + { + description: "Invalid - Duplicate Source", + request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "sourceA", Bidders: []string{"a"}}, + {Source: "sourceA", Bidders: []string{"a"}}, + }}}}, + expectedError: errors.New(`request.ext.prebid.data.eidpermissions[1] duplicate entry with field: "source"`), + }, + { + description: "Invalid - Missing Bidders - Nil", + request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "sourceA", Bidders: []string{"a"}}, + {Source: "sourceB"}, + }}}}, + expectedError: errors.New(`request.ext.prebid.data.eidpermissions[1] missing or empty required field: "bidders"`), + }, + { + description: "Invalid - Missing Bidders - Empty", + request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "sourceA", Bidders: []string{"a"}}, + {Source: "sourceB", Bidders: []string{}}, + }}}}, + expectedError: errors.New(`request.ext.prebid.data.eidpermissions[1] missing or empty required field: "bidders"`), + }, + { + description: "Invalid - Invalid Bidders", + request: &openrtb_ext.ExtRequest{Prebid: openrtb_ext.ExtRequestPrebid{Data: &openrtb_ext.ExtRequestPrebidData{EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "sourceA", Bidders: []string{"a"}}, + {Source: "sourceB", Bidders: []string{"z"}}, + }}}}, + expectedError: errors.New(`request.ext.prebid.data.eidpermissions[1] contains unrecognized bidder "z"`), + }, + } + + endpoint := &endpointDeps{bidderMap: knownBidders} + for _, test := range testCases { + result := endpoint.validateEidPermissions(test.request, knownAliases) + assert.Equal(t, test.expectedError, result, test.description) + } +} + +func TestValidateBidders(t *testing.T) { + testCases := []struct { + description string + bidders []string + knownBidders map[string]openrtb_ext.BidderName + knownAliases map[string]string + expectedError error + }{ + { + description: "Valid - No Bidders", + bidders: []string{}, + knownBidders: map[string]openrtb_ext.BidderName{"a": openrtb_ext.BidderName("a")}, + knownAliases: map[string]string{"c": "c"}, + expectedError: nil, + }, + { + description: "Valid - All Bidders", + bidders: []string{"*"}, + knownBidders: map[string]openrtb_ext.BidderName{"a": openrtb_ext.BidderName("a")}, + knownAliases: map[string]string{"c": "c"}, + expectedError: nil, + }, + { + description: "Valid - One Core Bidder", + bidders: []string{"a"}, + knownBidders: map[string]openrtb_ext.BidderName{"a": openrtb_ext.BidderName("a")}, + knownAliases: map[string]string{"c": "c"}, + expectedError: nil, + }, + { + description: "Valid - Many Core Bidders", + bidders: []string{"a", "b"}, + knownBidders: map[string]openrtb_ext.BidderName{"a": openrtb_ext.BidderName("a"), "b": openrtb_ext.BidderName("b")}, + knownAliases: map[string]string{"c": "c"}, + expectedError: nil, + }, + { + description: "Valid - One Alias Bidder", + bidders: []string{"c"}, + knownBidders: map[string]openrtb_ext.BidderName{"a": openrtb_ext.BidderName("a")}, + knownAliases: map[string]string{"c": "c"}, + expectedError: nil, + }, + { + description: "Valid - Many Alias Bidders", + bidders: []string{"c", "d"}, + knownBidders: map[string]openrtb_ext.BidderName{"a": openrtb_ext.BidderName("a")}, + knownAliases: map[string]string{"c": "c", "d": "d"}, + expectedError: nil, + }, + { + description: "Valid - Mixed Core + Alias Bidders", + bidders: []string{"a", "c"}, + knownBidders: map[string]openrtb_ext.BidderName{"a": openrtb_ext.BidderName("a")}, + knownAliases: map[string]string{"c": "c"}, + expectedError: nil, + }, + { + description: "Invalid - Unknown Bidder", + bidders: []string{"z"}, + knownBidders: map[string]openrtb_ext.BidderName{"a": openrtb_ext.BidderName("a")}, + knownAliases: map[string]string{"c": "c"}, + expectedError: errors.New(`unrecognized bidder "z"`), + }, + { + description: "Invalid - Unknown Bidder Case Sensitive", + bidders: []string{"A"}, + knownBidders: map[string]openrtb_ext.BidderName{"a": openrtb_ext.BidderName("a")}, + knownAliases: map[string]string{"c": "c"}, + expectedError: errors.New(`unrecognized bidder "A"`), + }, + { + description: "Invalid - Unknown Bidder With Known Bidders", + bidders: []string{"a", "c", "z"}, + knownBidders: map[string]openrtb_ext.BidderName{"a": openrtb_ext.BidderName("a")}, + knownAliases: map[string]string{"c": "c"}, + expectedError: errors.New(`unrecognized bidder "z"`), + }, + { + description: "Invalid - All Bidders With Known Bidder", + bidders: []string{"*", "a"}, + knownBidders: map[string]openrtb_ext.BidderName{"a": openrtb_ext.BidderName("a")}, + knownAliases: map[string]string{"c": "c"}, + expectedError: errors.New(`bidder wildcard "*" mixed with specific bidders`), + }, + { + description: "Invalid - Returns First Error - All Bidders", + bidders: []string{"*", "z"}, + knownBidders: map[string]openrtb_ext.BidderName{"a": openrtb_ext.BidderName("a")}, + knownAliases: map[string]string{"c": "c"}, + expectedError: errors.New(`bidder wildcard "*" mixed with specific bidders`), + }, + { + description: "Invalid - Returns First Error - Unknown Bidder", + bidders: []string{"z", "*"}, + knownBidders: map[string]openrtb_ext.BidderName{"a": openrtb_ext.BidderName("a")}, + knownAliases: map[string]string{"c": "c"}, + expectedError: errors.New(`unrecognized bidder "z"`), + }, + } + + for _, test := range testCases { + result := validateBidders(test.bidders, test.knownBidders, test.knownAliases) + assert.Equal(t, test.expectedError, result, test.description) + } +} + // nobidExchange is a well-behaved exchange which always bids "no bid". type nobidExchange struct { gotRequest *openrtb.BidRequest diff --git a/exchange/exchange_test.go b/exchange/exchange_test.go index c013f58743e..5ebe1cc6a6a 100644 --- a/exchange/exchange_test.go +++ b/exchange/exchange_test.go @@ -1494,6 +1494,7 @@ func newExchangeForTests(t *testing.T, filename string, expectations map[string] } } } + for alias, coreBidder := range aliases { if spec, ok := expectations[alias]; ok { if bidder, ok := adapters[openrtb_ext.BidderName(coreBidder)]; ok { @@ -1504,8 +1505,8 @@ func newExchangeForTests(t *testing.T, filename string, expectations map[string] t: t, fileName: filename, bidderName: coreBidder, - expectations: map[string]*bidderRequest{coreBidder: spec.ExpectedRequest}, - mockResponses: map[string]bidderResponse{coreBidder: spec.MockResponse}, + expectations: map[string]*bidderRequest{alias: spec.ExpectedRequest}, + mockResponses: map[string]bidderResponse{alias: spec.MockResponse}, } } } diff --git a/exchange/exchangetest/eidpermissions-allowed-alias.json b/exchange/exchangetest/eidpermissions-allowed-alias.json new file mode 100644 index 00000000000..24c9af7c99b --- /dev/null +++ b/exchange/exchangetest/eidpermissions-allowed-alias.json @@ -0,0 +1,123 @@ +{ + "incomingRequest": { + "ortbRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "user": { + "ext": { + "eids": [{ + "source": "source1", + "id": "anyId" + }] + } + }, + "ext": { + "prebid": { + "aliases": { + "foo": "appnexus" + }, + "data": { + "eidpermissions": [{ + "source": "source1", + "bidders": ["foo"] + }] + } + } + }, + "imp": [{ + "id": "my-imp-id", + "video": { + "mimes": ["video/mp4"] + }, + "ext": { + "foo": { + "placementId": 1 + } + } + }] + } + }, + "outgoingRequests": { + "foo": { + "expectRequest": { + "ortbRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "user": { + "ext": { + "eids": [{ + "source": "source1", + "id": "anyId" + }] + } + }, + "ext": { + "prebid": { + "aliases": { + "foo": "appnexus" + }, + "data": { + "eidpermissions": [{ + "source": "source1", + "bidders": ["foo"] + }] + } + } + }, + "imp": [{ + "id": "my-imp-id", + "video": { + "mimes": ["video/mp4"] + }, + "ext": { + "bidder": { + "placementId": 1 + } + } + }] + }, + "bidAdjustment": 1.0 + }, + "mockResponse": { + "pbsSeatBid": { + "pbsBids": [{ + "ortbBid": { + "id": "apn-bid", + "impid": "my-imp-id", + "price": 0.3, + "w": 200, + "h": 250, + "crid": "creative-1" + }, + "bidType": "video" + }] + } + } + } + }, + "response": { + "bids": { + "id": "some-request-id", + "seatbid": [{ + "seat": "foo", + "bid": [{ + "id": "apn-bid", + "impid": "my-imp-id", + "price": 0.3, + "w": 200, + "h": 250, + "crid": "creative-1", + "ext": { + "prebid": { + "type": "video" + } + } + }] + }] + } + } +} \ No newline at end of file diff --git a/exchange/exchangetest/eidpermissions-allowed.json b/exchange/exchangetest/eidpermissions-allowed.json new file mode 100644 index 00000000000..851c98dde94 --- /dev/null +++ b/exchange/exchangetest/eidpermissions-allowed.json @@ -0,0 +1,117 @@ +{ + "incomingRequest": { + "ortbRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "user": { + "ext": { + "eids": [{ + "source": "source1", + "id": "anyId" + }] + } + }, + "ext": { + "prebid": { + "data": { + "eidpermissions": [{ + "source": "source1", + "bidders": ["appnexus"] + }] + } + } + }, + "imp": [{ + "id": "my-imp-id", + "video": { + "mimes": ["video/mp4"] + }, + "ext": { + "appnexus": { + "placementId": 1 + } + } + }] + } + }, + "outgoingRequests": { + "appnexus": { + "expectRequest": { + "ortbRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "user": { + "ext": { + "eids": [{ + "source": "source1", + "id": "anyId" + }] + } + }, + "ext": { + "prebid": { + "data": { + "eidpermissions": [{ + "source": "source1", + "bidders": ["appnexus"] + }] + } + } + }, + "imp": [{ + "id": "my-imp-id", + "video": { + "mimes": ["video/mp4"] + }, + "ext": { + "bidder": { + "placementId": 1 + } + } + }] + }, + "bidAdjustment": 1.0 + }, + "mockResponse": { + "pbsSeatBid": { + "pbsBids": [{ + "ortbBid": { + "id": "apn-bid", + "impid": "my-imp-id", + "price": 0.3, + "w": 200, + "h": 250, + "crid": "creative-1" + }, + "bidType": "video" + }] + } + } + } + }, + "response": { + "bids": { + "id": "some-request-id", + "seatbid": [{ + "seat": "appnexus", + "bid": [{ + "id": "apn-bid", + "impid": "my-imp-id", + "price": 0.3, + "w": 200, + "h": 250, + "crid": "creative-1", + "ext": { + "prebid": { + "type": "video" + } + } + }] + }] + } + } +} \ No newline at end of file diff --git a/exchange/exchangetest/eidpermissions-denied.json b/exchange/exchangetest/eidpermissions-denied.json new file mode 100644 index 00000000000..38127191995 --- /dev/null +++ b/exchange/exchangetest/eidpermissions-denied.json @@ -0,0 +1,111 @@ +{ + "incomingRequest": { + "ortbRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "user": { + "ext": { + "eids": [{ + "source": "source1", + "id": "anyId" + }] + } + }, + "ext": { + "prebid": { + "data": { + "eidpermissions": [{ + "source": "source1", + "bidders": ["otherBidder"] + }] + } + } + }, + "imp": [{ + "id": "my-imp-id", + "video": { + "mimes": ["video/mp4"] + }, + "ext": { + "appnexus": { + "placementId": 1 + } + } + }] + } + }, + "outgoingRequests": { + "appnexus": { + "expectRequest": { + "ortbRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "user": { + }, + "ext": { + "prebid": { + "data": { + "eidpermissions": [{ + "source": "source1", + "bidders": ["otherBidder"] + }] + } + } + }, + "imp": [{ + "id": "my-imp-id", + "video": { + "mimes": ["video/mp4"] + }, + "ext": { + "bidder": { + "placementId": 1 + } + } + }] + }, + "bidAdjustment": 1.0 + }, + "mockResponse": { + "pbsSeatBid": { + "pbsBids": [{ + "ortbBid": { + "id": "apn-bid", + "impid": "my-imp-id", + "price": 0.3, + "w": 200, + "h": 250, + "crid": "creative-1" + }, + "bidType": "video" + }] + } + } + } + }, + "response": { + "bids": { + "id": "some-request-id", + "seatbid": [{ + "seat": "appnexus", + "bid": [{ + "id": "apn-bid", + "impid": "my-imp-id", + "price": 0.3, + "w": 200, + "h": 250, + "crid": "creative-1", + "ext": { + "prebid": { + "type": "video" + } + } + }] + }] + } + } +} \ No newline at end of file diff --git a/exchange/utils.go b/exchange/utils.go index d574c2a6452..af4dfb051ec 100644 --- a/exchange/utils.go +++ b/exchange/utils.go @@ -197,15 +197,22 @@ func getAuctionBidderRequests(req AuctionRequest, return nil, []error{err} } + var errs []error for bidder, imps := range impsByBidder { coreBidder := resolveBidder(bidder, aliases) reqCopy := *req.BidRequest reqCopy.Imp = imps reqCopy.Ext = reqExt + prepareSource(&reqCopy, bidder, sChainsByBidder) - bidder := BidderRequest{ + if err := removeUnpermissionedEids(&reqCopy, bidder, requestExt); err != nil { + errs = append(errs, fmt.Errorf("unable to enforce request.ext.prebid.data.eidpermissions because %v", err)) + continue + } + + bidderRequest := BidderRequest{ BidderName: openrtb_ext.BidderName(bidder), BidderCoreName: coreBidder, BidRequest: &reqCopy, @@ -218,15 +225,16 @@ func getAuctionBidderRequests(req AuctionRequest, AdapterBids: metrics.AdapterBidPresent, }, } - if hadSync := prepareUser(&reqCopy, bidder.BidderName.String(), coreBidder, explicitBuyerUIDs, req.UserSyncs); !hadSync && req.BidRequest.App == nil { - bidder.BidderLabels.CookieFlag = metrics.CookieFlagNo + + if hadSync := prepareUser(&reqCopy, bidder, coreBidder, explicitBuyerUIDs, req.UserSyncs); !hadSync && req.BidRequest.App == nil { + bidderRequest.BidderLabels.CookieFlag = metrics.CookieFlagNo } else { - bidder.BidderLabels.CookieFlag = metrics.CookieFlagYes + bidderRequest.BidderLabels.CookieFlag = metrics.CookieFlagYes } - bidderRequests = append(bidderRequests, bidder) + bidderRequests = append(bidderRequests, bidderRequest) } - return bidderRequests, nil + return bidderRequests, errs } func getExtJson(req *openrtb.BidRequest, unpackedExt *openrtb_ext.ExtRequest) (json.RawMessage, error) { @@ -292,7 +300,9 @@ func extractBuyerUIDs(user *openrtb.User) (map[string]string, error) { // as long as user.ext.prebid exists. buyerUIDs := userExt.Prebid.BuyerUIDs userExt.Prebid = nil - if userExt.Consent != "" || userExt.DigiTrust != nil { + + // Remarshal (instead of removing) if the ext has other known fields + if userExt.Consent != "" || userExt.DigiTrust != nil || len(userExt.Eids) > 0 { if newUserExtBytes, err := json.Marshal(userExt); err != nil { return nil, err } else { @@ -447,6 +457,100 @@ func copyWithBuyerUID(user *openrtb.User, buyerUID string) *openrtb.User { return user } +// removeUnpermissionedEids modifies the request to remove any request.user.ext.eids not permissions for the specific bidder +func removeUnpermissionedEids(request *openrtb.BidRequest, bidder string, requestExt *openrtb_ext.ExtRequest) error { + // ensure request might have eids (as much as we can check before unmarshalling) + if request.User == nil || len(request.User.Ext) == 0 { + return nil + } + + // ensure request has eid permissions to enforce + if requestExt == nil || requestExt.Prebid.Data == nil || len(requestExt.Prebid.Data.EidPermissions) == 0 { + return nil + } + + // low level unmarshal to preserve other request.user.ext values. prebid server is non-destructive. + var userExt map[string]json.RawMessage + if err := json.Unmarshal(request.User.Ext, &userExt); err != nil { + return err + } + + eidsJSON, eidsSpecified := userExt["eids"] + if !eidsSpecified { + return nil + } + + var eids []openrtb_ext.ExtUserEid + if err := json.Unmarshal(eidsJSON, &eids); err != nil { + return err + } + + // exit early if there are no eids (empty array) + if len(eids) == 0 { + return nil + } + + // translate eid permissions to a map for quick lookup + eidRules := make(map[string][]string) + for _, p := range requestExt.Prebid.Data.EidPermissions { + eidRules[p.Source] = p.Bidders + } + + eidsAllowed := make([]openrtb_ext.ExtUserEid, 0, len(eids)) + for _, eid := range eids { + allowed := false + if rule, hasRule := eidRules[eid.Source]; hasRule { + for _, ruleBidder := range rule { + if ruleBidder == "*" || ruleBidder == bidder { + allowed = true + break + } + } + } else { + allowed = true + } + + if allowed { + eidsAllowed = append(eidsAllowed, eid) + } + } + + // exit early if all eids are allowed and nothing needs to be removed + if len(eids) == len(eidsAllowed) { + return nil + } + + // marshal eidsAllowed back to userExt + if len(eidsAllowed) == 0 { + delete(userExt, "eids") + } else { + eidsRaw, err := json.Marshal(eidsAllowed) + if err != nil { + return err + } + userExt["eids"] = eidsRaw + } + + // exit early if userExt is empty + if len(userExt) == 0 { + setUserExtWithCopy(request, nil) + return nil + } + + userExtJSON, err := json.Marshal(userExt) + if err != nil { + return err + } + setUserExtWithCopy(request, userExtJSON) + return nil +} + +func setUserExtWithCopy(request *openrtb.BidRequest, userExtJSON json.RawMessage) { + userCopy := *request.User + userCopy.Ext = userExtJSON + request.User = &userCopy +} + // resolveBidder returns the known BidderName associated with bidder, if bidder is an alias. If it's not an alias, the bidder is returned. func resolveBidder(bidder string, aliases map[string]string) openrtb_ext.BidderName { if coreBidder, ok := aliases[bidder]; ok { diff --git a/exchange/utils_test.go b/exchange/utils_test.go index e13f956b46d..6dab5b00ea9 100644 --- a/exchange/utils_test.go +++ b/exchange/utils_test.go @@ -1450,3 +1450,254 @@ func TestBidderToPrebidChainsZeroLengthSChains(t *testing.T) { assert.Nil(t, err) assert.Equal(t, len(output), 0) } + +func TestRemoveUnpermissionedEids(t *testing.T) { + bidder := "bidderA" + + testCases := []struct { + description string + userExt json.RawMessage + eidPermissions []openrtb_ext.ExtRequestPrebidDataEidPermission + expectedUserExt json.RawMessage + }{ + { + description: "Extension Nil", + userExt: nil, + eidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "source1", Bidders: []string{"bidderA"}}, + }, + expectedUserExt: nil, + }, + { + description: "Extension Empty", + userExt: json.RawMessage(`{}`), + eidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "source1", Bidders: []string{"bidderA"}}, + }, + expectedUserExt: json.RawMessage(`{}`), + }, + { + description: "Extension Empty - Keep Other Data", + userExt: json.RawMessage(`{"other":42}`), + eidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "source1", Bidders: []string{"bidderA"}}, + }, + expectedUserExt: json.RawMessage(`{"other":42}`), + }, + { + description: "Eids Empty", + userExt: json.RawMessage(`{"eids":[]}`), + eidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "source1", Bidders: []string{"bidderA"}}, + }, + expectedUserExt: json.RawMessage(`{"eids":[]}`), + }, + { + description: "Eids Empty - Keep Other Data", + userExt: json.RawMessage(`{"eids":[],"other":42}`), + eidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "source1", Bidders: []string{"bidderA"}}, + }, + expectedUserExt: json.RawMessage(`{"eids":[],"other":42}`), + }, + { + description: "Allowed By Nil Permissions", + userExt: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"}]}`), + eidPermissions: nil, + expectedUserExt: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"}]}`), + }, + { + description: "Allowed By Empty Permissions", + userExt: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"}]}`), + eidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{}, + expectedUserExt: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"}]}`), + }, + { + description: "Allowed By Specific Bidder", + userExt: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"}]}`), + eidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "source1", Bidders: []string{"bidderA"}}, + }, + expectedUserExt: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"}]}`), + }, + { + description: "Allowed By All Bidders", + userExt: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"}]}`), + eidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "source1", Bidders: []string{"*"}}, + }, + expectedUserExt: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"}]}`), + }, + { + description: "Allowed By Lack Of Matching Source", + userExt: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"}]}`), + eidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "source2", Bidders: []string{"otherBidder"}}, + }, + expectedUserExt: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"}]}`), + }, + { + description: "Allowed - Keep Other Data", + userExt: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"}],"other":42}`), + eidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "source1", Bidders: []string{"bidderA"}}, + }, + expectedUserExt: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"}],"other":42}`), + }, + { + description: "Denied", + userExt: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"}]}`), + eidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "source1", Bidders: []string{"otherBidder"}}, + }, + expectedUserExt: nil, + }, + { + description: "Denied - Keep Other Data", + userExt: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"}],"otherdata":42}`), + eidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "source1", Bidders: []string{"otherBidder"}}, + }, + expectedUserExt: json.RawMessage(`{"otherdata":42}`), + }, + { + description: "Mix Of Allowed By Specific Bidder, Allowed By Lack Of Matching Source, Denied, Keep Other Data", + userExt: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"},{"source":"source2","id":"anyID"},{"source":"source3","id":"anyID"}],"other":42}`), + eidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "source1", Bidders: []string{"bidderA"}}, + {Source: "source3", Bidders: []string{"otherBidder"}}, + }, + expectedUserExt: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"},{"source":"source2","id":"anyID"}],"other":42}`), + }, + } + + for _, test := range testCases { + request := &openrtb.BidRequest{ + User: &openrtb.User{Ext: test.userExt}, + } + + requestExt := &openrtb_ext.ExtRequest{ + Prebid: openrtb_ext.ExtRequestPrebid{ + Data: &openrtb_ext.ExtRequestPrebidData{ + EidPermissions: test.eidPermissions, + }, + }, + } + + expectedRequest := &openrtb.BidRequest{ + User: &openrtb.User{Ext: test.expectedUserExt}, + } + + resultErr := removeUnpermissionedEids(request, bidder, requestExt) + assert.NoError(t, resultErr, test.description) + assert.Equal(t, expectedRequest, request, test.description) + } +} + +func TestRemoveUnpermissionedEidsUnmarshalErrors(t *testing.T) { + testCases := []struct { + description string + userExt json.RawMessage + expectedErr string + }{ + { + description: "Malformed Ext", + userExt: json.RawMessage(`malformed`), + expectedErr: "invalid character 'm' looking for beginning of value", + }, + { + description: "Malformed Eid Array Type", + userExt: json.RawMessage(`{"eids":[42]}`), + expectedErr: "json: cannot unmarshal number into Go value of type openrtb_ext.ExtUserEid", + }, + { + description: "Malformed Eid Item Type", + userExt: json.RawMessage(`{"eids":[{"source":42,"id":"anyID"}]}`), + expectedErr: "json: cannot unmarshal number into Go struct field ExtUserEid.source of type string", + }, + } + + for _, test := range testCases { + request := &openrtb.BidRequest{ + User: &openrtb.User{Ext: test.userExt}, + } + + requestExt := &openrtb_ext.ExtRequest{ + Prebid: openrtb_ext.ExtRequestPrebid{ + Data: &openrtb_ext.ExtRequestPrebidData{ + EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "source1", Bidders: []string{"*"}}, + }, + }, + }, + } + + resultErr := removeUnpermissionedEids(request, "bidderA", requestExt) + assert.EqualError(t, resultErr, test.expectedErr, test.description) + } +} + +func TestRemoveUnpermissionedEidsEmptyValidations(t *testing.T) { + testCases := []struct { + description string + request *openrtb.BidRequest + requestExt *openrtb_ext.ExtRequest + }{ + { + description: "Nil User", + request: &openrtb.BidRequest{ + User: nil, + }, + requestExt: &openrtb_ext.ExtRequest{ + Prebid: openrtb_ext.ExtRequestPrebid{ + Data: &openrtb_ext.ExtRequestPrebidData{ + EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "source1", Bidders: []string{"*"}}, + }, + }, + }, + }, + }, + { + description: "Empty User", + request: &openrtb.BidRequest{ + User: &openrtb.User{}, + }, + requestExt: &openrtb_ext.ExtRequest{ + Prebid: openrtb_ext.ExtRequestPrebid{ + Data: &openrtb_ext.ExtRequestPrebidData{ + EidPermissions: []openrtb_ext.ExtRequestPrebidDataEidPermission{ + {Source: "source1", Bidders: []string{"*"}}, + }, + }, + }, + }, + }, + { + description: "Nil Ext", + request: &openrtb.BidRequest{ + User: &openrtb.User{Ext: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"}]}`)}, + }, + requestExt: nil, + }, + { + description: "Nil Prebid Data", + request: &openrtb.BidRequest{ + User: &openrtb.User{Ext: json.RawMessage(`{"eids":[{"source":"source1","id":"anyID"}]}`)}, + }, + requestExt: &openrtb_ext.ExtRequest{ + Prebid: openrtb_ext.ExtRequestPrebid{ + Data: nil, + }, + }, + }, + } + + for _, test := range testCases { + requestExpected := *test.request + + resultErr := removeUnpermissionedEids(test.request, "bidderA", test.requestExt) + assert.NoError(t, resultErr, test.description+":err") + assert.Equal(t, &requestExpected, test.request, test.description+":request") + } +} diff --git a/openrtb_ext/request.go b/openrtb_ext/request.go index 1199edc70f1..79dc3554be4 100644 --- a/openrtb_ext/request.go +++ b/openrtb_ext/request.go @@ -20,11 +20,12 @@ type ExtRequestPrebid struct { Aliases map[string]string `json:"aliases,omitempty"` BidAdjustmentFactors map[string]float64 `json:"bidadjustmentfactors,omitempty"` Cache *ExtRequestPrebidCache `json:"cache,omitempty"` + Data *ExtRequestPrebidData `json:"data,omitempty"` + Debug bool `json:"debug,omitempty"` SChains []*ExtRequestPrebidSChain `json:"schains,omitempty"` StoredRequest *ExtStoredRequest `json:"storedrequest,omitempty"` - Targeting *ExtRequestTargeting `json:"targeting,omitempty"` SupportDeals bool `json:"supportdeals,omitempty"` - Debug bool `json:"debug,omitempty"` + Targeting *ExtRequestTargeting `json:"targeting,omitempty"` // NoSale specifies bidders with whom the publisher has a legal relationship where the // passing of personally identifiable information doesn't constitute a sale per CCPA law. @@ -289,3 +290,14 @@ var priceGranularityAuto = PriceGranularity{ }, }, } + +// ExtRequestPrebidData defines Prebid's First Party Data (FPD) and related bid request options. +type ExtRequestPrebidData struct { + EidPermissions []ExtRequestPrebidDataEidPermission `json:"eidpermissions"` +} + +// ExtRequestPrebidDataEidPermission defines a filter rule for filter user.ext.eids +type ExtRequestPrebidDataEidPermission struct { + Source string `json:"source"` + Bidders []string `json:"bidders"` +}