Skip to content

Commit f5f8eda

Browse files
Vijay Pandeymeta-codesync[bot]
authored andcommitted
VVPManager: scrub fb_content[].id in enforce mode
Summary: The existing enforcement sanitizes top-level fb_content_ids / fb_content_id keys but does NOT touch the bare `id` field inside each object of the `fb_content` JSON array. That nested id carries the same video identifier and flows downstream into standard_properties.contents[].content_id via OffsiteSignalsStandardDataMapper::parseContents(), leaking the identifier to the deid signals tables. Adds `scrubIdInContentsArray` — walks the JSON-encoded `fb_content` value, replaces each entry's `id` with `_removed_`, and writes it back. Other fields (quantity, item_price, brand, content_type) are preserved. No-op on missing, malformed, or id-less entries. Skipped in shadow mode (the call lives inside the existing `!cfg.isShadowEnabled` guard). Mirrors the JS pixel plugin (D107189859), server-side SignalsIntegrityVVPPreprocessor::scrubIdInContentsArray (D107189861), and iOS VVPConfigManager (D107329488). Differential Revision: D107401234 fbshipit-source-id: eb1096c4d7b5e0cf58524d833718b2ef0f632dd2
1 parent bd8014b commit f5f8eda

2 files changed

Lines changed: 182 additions & 0 deletions

File tree

facebook-core/src/main/java/com/facebook/appevents/integrity/VVPManager.kt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ object VVPManager {
6666
private val CONTENT_ID_SANITIZE_KEYS = setOf("fb_content_ids", "fb_content_id")
6767
private const val SANITIZED_VALUE = "_removed_"
6868

69+
// The contents-array key whose nested `id` fields are scrubbed. Mirrors
70+
// `CustomEvents::FB_CONTENT` on the app server and the JS pixel's
71+
// `customData.contents`.
72+
private const val CONTENTS_KEY = "fb_content"
73+
6974
/**
7075
* One detection rule, with regexes pre-compiled at parse time. [keyRegex] and [valueRegex] can
7176
* each be null (meaning "no constraint on that side"); a rule with both null is dropped during
@@ -307,6 +312,14 @@ object VVPManager {
307312
parameters.remove(key)
308313
}
309314
}
315+
316+
// Scrub `id` inside each entry of the contents array. `fb_content`
317+
// is in the standardParams allowlist (so the loop above keeps it),
318+
// but each entry's nested `id` carries the same video identifier
319+
// the top-level `fb_content_ids` would. Mirrors the JS pixel
320+
// plugin and server-side
321+
// `SignalsIntegrityVVPPreprocessor::scrubIdInContentsArray`.
322+
scrubIdInContentsArray(parameters)
310323
}
311324

312325
parameters.putString(VVP_IS_APPLIED_KEY, VVP_IS_APPLIED_VALUE)
@@ -326,6 +339,30 @@ object VVPManager {
326339
}
327340
}
328341

342+
/**
343+
* Walk the JSON-encoded `fb_content` array and replace each entry's `id`
344+
* with [SANITIZED_VALUE]. Other fields (quantity, item_price, brand, …)
345+
* are preserved. No-op when the key is missing, not a valid JSON array,
346+
* or no entry has an `id` field.
347+
*/
348+
internal fun scrubIdInContentsArray(parameters: Bundle) {
349+
val raw = parameters.getString(CONTENTS_KEY) ?: return
350+
val arr = try { JSONArray(raw) } catch (_: JSONException) { return }
351+
352+
var didMutate = false
353+
for (i in 0 until arr.length()) {
354+
val entry = arr.optJSONObject(i) ?: continue
355+
if (entry.has("id")) {
356+
entry.put("id", SANITIZED_VALUE)
357+
didMutate = true
358+
}
359+
}
360+
361+
if (didMutate) {
362+
parameters.putString(CONTENTS_KEY, arr.toString())
363+
}
364+
}
365+
329366
/** Reset all state — for tests. */
330367
internal fun clearForTests() {
331368
enabled = false

facebook-core/src/test/kotlin/com/facebook/appevents/integrity/VVPManagerTest.kt

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,151 @@ class VVPManagerTest : FacebookPowerMockTestCase() {
764764
assertThat(params.containsKey("video_title")).isFalse
765765
}
766766

767+
// --- contents[].id sanitization ---
768+
769+
@Test
770+
fun `processParametersForVVP scrubs id in fb_content array entries`() {
771+
val cfg =
772+
"""{
773+
"enabled": true,
774+
"isShadowEnabled": false,
775+
"rules": [{"place": 1, "keyRegex": "", "valueRegex": "tt\\d+"}],
776+
"standardParams": {"fb_currency": true, "fb_content": true}
777+
}"""
778+
initMockFetchedAppSettings(cfg)
779+
VVPManager.enable()
780+
val contentsJson =
781+
"""[{"id":"sku_19738928","quantity":1,"item_price":50.0,"brand":"PUMA"},""" +
782+
"""{"id":"sku_19736278","quantity":2,"item_price":100.0,"brand":"Jordan"}]"""
783+
val params = bundleOf("movie_id" to "tt1234567", "fb_currency" to "USD")
784+
params.putString("fb_content", contentsJson)
785+
786+
VVPManager.processParametersForVVP("Purchase", params)
787+
788+
assertThat(params.getString("vvp")).isEqualTo("1")
789+
assertThat(params.getString("fb_currency")).isEqualTo("USD")
790+
val result = JSONArray(params.getString("fb_content")!!)
791+
assertThat(result.length()).isEqualTo(2)
792+
assertThat(result.getJSONObject(0).getString("id")).isEqualTo("_removed_")
793+
assertThat(result.getJSONObject(0).getInt("quantity")).isEqualTo(1)
794+
assertThat(result.getJSONObject(0).getString("brand")).isEqualTo("PUMA")
795+
assertThat(result.getJSONObject(1).getString("id")).isEqualTo("_removed_")
796+
assertThat(result.getJSONObject(1).getInt("quantity")).isEqualTo(2)
797+
assertThat(result.getJSONObject(1).getString("brand")).isEqualTo("Jordan")
798+
}
799+
800+
@Test
801+
fun `processParametersForVVP contents no-op when fb_content missing`() {
802+
val cfg =
803+
"""{
804+
"enabled": true,
805+
"isShadowEnabled": false,
806+
"rules": [{"place": 1, "keyRegex": "", "valueRegex": "tt\\d+"}],
807+
"standardParams": {"fb_currency": true}
808+
}"""
809+
initMockFetchedAppSettings(cfg)
810+
VVPManager.enable()
811+
val params = bundleOf("movie_id" to "tt1234567", "fb_currency" to "USD")
812+
813+
VVPManager.processParametersForVVP("Purchase", params)
814+
815+
assertThat(params.getString("vvp")).isEqualTo("1")
816+
assertThat(params.getString("fb_content")).isNull()
817+
}
818+
819+
@Test
820+
fun `processParametersForVVP contents no-op on malformed JSON`() {
821+
val cfg =
822+
"""{
823+
"enabled": true,
824+
"isShadowEnabled": false,
825+
"rules": [{"place": 1, "keyRegex": "", "valueRegex": "tt\\d+"}],
826+
"standardParams": {"fb_currency": true, "fb_content": true}
827+
}"""
828+
initMockFetchedAppSettings(cfg)
829+
VVPManager.enable()
830+
val params = bundleOf("movie_id" to "tt1234567")
831+
params.putString("fb_content", "not_json_at_all")
832+
833+
VVPManager.processParametersForVVP("Purchase", params)
834+
835+
assertThat(params.getString("vvp")).isEqualTo("1")
836+
assertThat(params.getString("fb_content")).isEqualTo("not_json_at_all")
837+
}
838+
839+
@Test
840+
fun `processParametersForVVP contents does not add id when entry had none`() {
841+
val cfg =
842+
"""{
843+
"enabled": true,
844+
"isShadowEnabled": false,
845+
"rules": [{"place": 1, "keyRegex": "", "valueRegex": "tt\\d+"}],
846+
"standardParams": {"fb_currency": true, "fb_content": true}
847+
}"""
848+
initMockFetchedAppSettings(cfg)
849+
VVPManager.enable()
850+
val contentsJson = """[{"quantity":1,"item_price":10.0},{"id":"tt9999","quantity":2}]"""
851+
val params = bundleOf("movie_id" to "tt1234567")
852+
params.putString("fb_content", contentsJson)
853+
854+
VVPManager.processParametersForVVP("Purchase", params)
855+
856+
assertThat(params.getString("vvp")).isEqualTo("1")
857+
val result = JSONArray(params.getString("fb_content")!!)
858+
assertThat(result.length()).isEqualTo(2)
859+
assertThat(result.getJSONObject(0).has("id")).isFalse
860+
assertThat(result.getJSONObject(0).getInt("quantity")).isEqualTo(1)
861+
assertThat(result.getJSONObject(1).getString("id")).isEqualTo("_removed_")
862+
assertThat(result.getJSONObject(1).getInt("quantity")).isEqualTo(2)
863+
}
864+
865+
@Test
866+
fun `processParametersForVVP contents not scrubbed in shadow mode`() {
867+
val cfg =
868+
"""{
869+
"enabled": true,
870+
"isShadowEnabled": true,
871+
"rules": [{"place": 1, "keyRegex": "", "valueRegex": "tt\\d+"}],
872+
"standardParams": {"fb_currency": true, "fb_content": true}
873+
}"""
874+
initMockFetchedAppSettings(cfg)
875+
VVPManager.enable()
876+
val contentsJson = """[{"id":"sku_19738928","quantity":1}]"""
877+
val params = bundleOf("movie_id" to "tt1234567")
878+
params.putString("fb_content", contentsJson)
879+
880+
VVPManager.processParametersForVVP("Purchase", params)
881+
882+
assertThat(params.getString("vvp")).isEqualTo("1")
883+
val result = JSONArray(params.getString("fb_content")!!)
884+
assertThat(result.getJSONObject(0).getString("id")).isEqualTo("sku_19738928")
885+
}
886+
887+
@Test
888+
fun `processParametersForVVP scrubs both top-level content_ids and contents id`() {
889+
val cfg =
890+
"""{
891+
"enabled": true,
892+
"isShadowEnabled": false,
893+
"rules": [{"place": 1, "keyRegex": "", "valueRegex": "tt\\d+"}],
894+
"standardParams": {"fb_currency": true, "fb_content": true}
895+
}"""
896+
initMockFetchedAppSettings(cfg)
897+
VVPManager.enable()
898+
val contentsJson = """[{"id":"tt1234567","quantity":1}]"""
899+
val params = bundleOf("fb_content_ids" to "tt1234567", "fb_currency" to "USD")
900+
params.putString("fb_content", contentsJson)
901+
902+
VVPManager.processParametersForVVP("Purchase", params)
903+
904+
assertThat(params.getString("vvp")).isEqualTo("1")
905+
assertThat(params.getString("fb_content_ids")).isEqualTo("_removed_")
906+
assertThat(params.getString("fb_currency")).isEqualTo("USD")
907+
val result = JSONArray(params.getString("fb_content")!!)
908+
assertThat(result.getJSONObject(0).getString("id")).isEqualTo("_removed_")
909+
assertThat(result.getJSONObject(0).getInt("quantity")).isEqualTo(1)
910+
}
911+
767912
// --- keyNegativeRegex detection (event name) ---
768913

769914
@Test

0 commit comments

Comments
 (0)