@ecomconsult/consentkit 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +260 -16
- package/npm/core.cjs +1 -1
- package/npm/core.mjs +4 -1
- package/npm/index.cjs +5 -2
- package/npm/index.d.ts +68 -0
- package/npm/index.mjs +14 -2
- package/npm/internal-stub.mjs +7 -1
- package/package.json +1 -1
- package/src/ck-core.js +488 -29
- package/src/ck-debug-loader.js +134 -0
- package/src/ck-debug.js +862 -0
- package/src/ck-saas.js +36 -1
- package/src/ck-ui-branding.js +511 -0
- package/src/ck-ui.js +43 -416
package/src/ck-core.js
CHANGED
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
nativeScriptSrcDesc = Object.getOwnPropertyDescriptor(global.HTMLScriptElement.prototype, 'src');
|
|
23
23
|
}
|
|
24
24
|
} catch (e) { /* noop */ }
|
|
25
|
+
var nativeIframeSrcDesc = null;
|
|
26
|
+
try {
|
|
27
|
+
if (global.HTMLIFrameElement && global.HTMLIFrameElement.prototype) {
|
|
28
|
+
nativeIframeSrcDesc = Object.getOwnPropertyDescriptor(global.HTMLIFrameElement.prototype, 'src');
|
|
29
|
+
}
|
|
30
|
+
} catch (e) { /* noop */ }
|
|
31
|
+
var nativeRemoveAttribute = (global.Element && global.Element.prototype && global.Element.prototype.removeAttribute) || null;
|
|
25
32
|
|
|
26
33
|
// Internal flag: while true, patches let everything through (used when we
|
|
27
34
|
// re-create previously blocked scripts after consent).
|
|
@@ -115,6 +122,15 @@
|
|
|
115
122
|
'tidiochat.com': 'functional'
|
|
116
123
|
};
|
|
117
124
|
|
|
125
|
+
// Runtime overrides fed in by ConsentKit._extendHostDb(map) — the SaaS
|
|
126
|
+
// config's `hostdb`, or a hand call on a standalone page. Kept SEPARATE from
|
|
127
|
+
// HOST_DB on purpose: tools/export-hostdb.mjs extracts the HOST_DB literal
|
|
128
|
+
// out of this file with node:vm, and tools/sync-hostdb.mjs writes into that
|
|
129
|
+
// same literal. A runtime map merged into HOST_DB would be invisible to both
|
|
130
|
+
// (it never exists on disk) yet would blur what "the shipped database" means.
|
|
131
|
+
// Consulted BEFORE HOST_DB, so an override wins over a built-in entry.
|
|
132
|
+
var EXTRA_DB = {};
|
|
133
|
+
|
|
118
134
|
// Path-fragment database: URL substring -> category. Matched against the full
|
|
119
135
|
// resolved URL, so it can distinguish two very different scripts served from
|
|
120
136
|
// the SAME host (googletagmanager.com).
|
|
@@ -207,9 +223,61 @@
|
|
|
207
223
|
},
|
|
208
224
|
consentTtlDays: 365,
|
|
209
225
|
integrations: { gcm: true, gtmDataLayer: true },
|
|
226
|
+
// 'known' — block what the tracker database recognises (the default, and
|
|
227
|
+
// everything ConsentKit did before 0.4.0).
|
|
228
|
+
// 'strict' — additionally hold back EVERY third-party script/iframe that is
|
|
229
|
+
// not same-site, not in `allow` and not in BASE_ALLOW.
|
|
230
|
+
blocking: { mode: 'known', allow: [] },
|
|
210
231
|
cookieTable: []
|
|
211
232
|
};
|
|
212
233
|
|
|
234
|
+
// Hosts strict mode never intercepts, even though they are third-party and
|
|
235
|
+
// unknown to HOST_DB. Two kinds only: asset CDNs that serve the site's own
|
|
236
|
+
// code, and things a page is broken or unusable without (payment, captcha).
|
|
237
|
+
// Deliberately short — everything else is the site owner's `blocking.allow`.
|
|
238
|
+
// Matched with the same suffix semantics as HOST_DB; the recaptcha entries
|
|
239
|
+
// are hosts because www.google.com/recaptcha and www.gstatic.com/recaptcha
|
|
240
|
+
// cannot be expressed host-wise without also allowing the whole of google.com,
|
|
241
|
+
// so they are handled by BASE_ALLOW_PATH below instead.
|
|
242
|
+
var BASE_ALLOW = [
|
|
243
|
+
'cdn.jsdelivr.net',
|
|
244
|
+
'unpkg.com',
|
|
245
|
+
'cdnjs.cloudflare.com',
|
|
246
|
+
'code.jquery.com',
|
|
247
|
+
'fonts.googleapis.com',
|
|
248
|
+
'fonts.gstatic.com',
|
|
249
|
+
'hcaptcha.com',
|
|
250
|
+
'js.stripe.com',
|
|
251
|
+
'pay.google.com',
|
|
252
|
+
'checkout.creem.io'
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
// Path-scoped members of the base allowlist: allowed only on this exact path
|
|
256
|
+
// prefix, because the host at large is not something to wave through.
|
|
257
|
+
var BASE_ALLOW_PATH = [
|
|
258
|
+
{ host: 'www.google.com', path: '/recaptcha' },
|
|
259
|
+
{ host: 'www.gstatic.com', path: '/recaptcha' }
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
// Multi-label public suffixes: without these, `bbc.co.uk` and `itv.co.uk`
|
|
263
|
+
// would share the registrable domain `co.uk` and count as same-site. The list
|
|
264
|
+
// only ever WIDENS the registrable domain (two labels -> three), so a missing
|
|
265
|
+
// entry can only make strict mode treat a sibling as first-party and let it
|
|
266
|
+
// through — never make it block a genuine first-party asset. A full public
|
|
267
|
+
// suffix list is ~10k entries and has no place in a zero-dependency client.
|
|
268
|
+
var PSL_TWO_LABEL = {
|
|
269
|
+
'co.uk': 1, 'org.uk': 1, 'me.uk': 1, 'ac.uk': 1, 'gov.uk': 1, 'net.uk': 1, 'sch.uk': 1,
|
|
270
|
+
'com.au': 1, 'net.au': 1, 'org.au': 1, 'edu.au': 1, 'gov.au': 1,
|
|
271
|
+
'co.nz': 1, 'net.nz': 1, 'org.nz': 1,
|
|
272
|
+
'com.br': 1, 'net.br': 1, 'org.br': 1,
|
|
273
|
+
'co.jp': 1, 'ne.jp': 1, 'or.jp': 1, 'ac.jp': 1,
|
|
274
|
+
'co.za': 1, 'org.za': 1,
|
|
275
|
+
'com.cn': 1, 'net.cn': 1, 'org.cn': 1,
|
|
276
|
+
'co.in': 1, 'net.in': 1, 'org.in': 1,
|
|
277
|
+
'com.tr': 1, 'com.mx': 1, 'com.ar': 1, 'com.sg': 1, 'com.hk': 1,
|
|
278
|
+
'com.ua': 1, 'com.pl': 1, 'com.ru': 1, 'co.il': 1, 'co.kr': 1
|
|
279
|
+
};
|
|
280
|
+
|
|
213
281
|
// ---------------------------------------------------------------------------
|
|
214
282
|
// Small utilities (all defensive — core must never throw)
|
|
215
283
|
// ---------------------------------------------------------------------------
|
|
@@ -585,9 +653,25 @@
|
|
|
585
653
|
// ---------------------------------------------------------------------------
|
|
586
654
|
// Blocking engine — URL classification
|
|
587
655
|
// ---------------------------------------------------------------------------
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
656
|
+
// host === key, or host is a subdomain of key. The one matching rule in the
|
|
657
|
+
// engine: HOST_DB, EXTRA_DB and BASE_ALLOW all use it.
|
|
658
|
+
function hostMatches(host, key) {
|
|
659
|
+
if (!host || !key) { return false; }
|
|
660
|
+
return host === key || (host.length > key.length && host.slice(-(key.length + 1)) === '.' + key);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function lookupHostMap(map, host) {
|
|
664
|
+
var keys = Object.keys(map);
|
|
665
|
+
for (var i = 0; i < keys.length; i++) {
|
|
666
|
+
if (hostMatches(host, keys[i])) { return map[keys[i]]; }
|
|
667
|
+
}
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Splits a URL into { host, url } with the page as the resolution base, so a
|
|
672
|
+
// relative src resolves to the first-party host rather than to nothing.
|
|
673
|
+
function urlParts(src) {
|
|
674
|
+
var s = String(src);
|
|
591
675
|
var host = '';
|
|
592
676
|
try {
|
|
593
677
|
var base = (global.location && global.location.href) || 'http://localhost/';
|
|
@@ -595,19 +679,25 @@
|
|
|
595
679
|
host = (u.hostname || '').toLowerCase();
|
|
596
680
|
s = u.href;
|
|
597
681
|
} catch (e) {
|
|
598
|
-
var m = /^(?:[a-z]+:)?\/\/([^/?#]+)/i.exec(src);
|
|
682
|
+
var m = /^(?:[a-z]+:)?\/\/([^/?#]+)/i.exec(String(src));
|
|
599
683
|
host = m ? m[1].toLowerCase().replace(/:\d+$/, '') : '';
|
|
600
684
|
}
|
|
685
|
+
return { host: host, url: s };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function categoryForUrl(src) {
|
|
689
|
+
if (!src || typeof src !== 'string') { return null; }
|
|
690
|
+
var parts = urlParts(src);
|
|
691
|
+
var host = parts.host;
|
|
601
692
|
if (host) {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
}
|
|
693
|
+
// Service overrides first: an override exists precisely to correct or
|
|
694
|
+
// extend what the shipped table says about a host.
|
|
695
|
+
var over = lookupHostMap(EXTRA_DB, host);
|
|
696
|
+
if (over) { return over; }
|
|
697
|
+
var built = lookupHostMap(HOST_DB, host);
|
|
698
|
+
if (built) { return built; }
|
|
609
699
|
}
|
|
610
|
-
var low = String(
|
|
700
|
+
var low = String(parts.url).toLowerCase();
|
|
611
701
|
var pkeys = Object.keys(PATH_DB);
|
|
612
702
|
for (var j = 0; j < pkeys.length; j++) {
|
|
613
703
|
if (low.indexOf(pkeys[j]) > -1) { return PATH_DB[pkeys[j]]; }
|
|
@@ -615,21 +705,202 @@
|
|
|
615
705
|
return null;
|
|
616
706
|
}
|
|
617
707
|
|
|
708
|
+
// Merges { host: category } into the runtime database. Safe before and after
|
|
709
|
+
// init(): after init nothing already inserted is re-evaluated (a script that
|
|
710
|
+
// has loaded cannot be unloaded), but every later insertion sees the new map.
|
|
711
|
+
function extendHostDb(map) {
|
|
712
|
+
var added = 0;
|
|
713
|
+
try {
|
|
714
|
+
if (!isPlainObject(map)) { return 0; }
|
|
715
|
+
Object.keys(map).forEach(function (rawHost) {
|
|
716
|
+
// The map arrives over the network in the SaaS path: validate both
|
|
717
|
+
// halves rather than trusting the server's shape.
|
|
718
|
+
if (typeof rawHost !== 'string') { return; }
|
|
719
|
+
var host = rawHost.trim().toLowerCase().replace(/:\d+$/, '').replace(/^\.+|\.+$/g, '');
|
|
720
|
+
if (!host || host.indexOf('.') === -1 || /[^a-z0-9.\-]/.test(host)) { return; }
|
|
721
|
+
var cat = map[rawHost];
|
|
722
|
+
if (typeof cat !== 'string' || CATEGORIES.indexOf(cat) === -1) { return; }
|
|
723
|
+
if (EXTRA_DB[host] === cat) { return; }
|
|
724
|
+
EXTRA_DB[host] = cat;
|
|
725
|
+
added++;
|
|
726
|
+
});
|
|
727
|
+
} catch (e) { /* noop */ }
|
|
728
|
+
return added;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ---------------------------------------------------------------------------
|
|
732
|
+
// Blocking engine — strict mode (§2)
|
|
733
|
+
// ---------------------------------------------------------------------------
|
|
734
|
+
// Everything strict intercepts is filed under the strictest category, so it
|
|
735
|
+
// is released only by a consent that covers marketing.
|
|
736
|
+
var STRICT_CATEGORY = 'marketing';
|
|
737
|
+
|
|
738
|
+
// Registrable domain, best effort: last two labels, or last three when the
|
|
739
|
+
// last two form a known multi-label public suffix.
|
|
740
|
+
function registrable(host) {
|
|
741
|
+
if (!host) { return ''; }
|
|
742
|
+
var labels = String(host).split('.');
|
|
743
|
+
if (labels.length <= 2) { return host; }
|
|
744
|
+
var lastTwo = labels.slice(-2).join('.');
|
|
745
|
+
if (PSL_TWO_LABEL[lastTwo] && labels.length >= 3) { return labels.slice(-3).join('.'); }
|
|
746
|
+
return lastTwo;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Conservative on purpose: whenever the answer is not clearly "third party",
|
|
750
|
+
// this says same-site. A wrong "third party" verdict breaks a live site in
|
|
751
|
+
// strict mode; a wrong "same-site" verdict merely lets one unknown script
|
|
752
|
+
// through, which is exactly what every version before 0.4.0 did.
|
|
753
|
+
function isSameSite(host) {
|
|
754
|
+
if (!host) { return true; } // unparseable -> do not intercept
|
|
755
|
+
var page = hostname().toLowerCase();
|
|
756
|
+
if (!page) { return true; } // no location (SSR/about:blank) -> strict is inert
|
|
757
|
+
if (host === page) { return true; }
|
|
758
|
+
var r = registrable(host);
|
|
759
|
+
var pr = registrable(page);
|
|
760
|
+
return !!r && r === pr;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function baseAllowed(host, url) {
|
|
764
|
+
for (var i = 0; i < BASE_ALLOW.length; i++) {
|
|
765
|
+
if (hostMatches(host, BASE_ALLOW[i])) { return true; }
|
|
766
|
+
}
|
|
767
|
+
for (var j = 0; j < BASE_ALLOW_PATH.length; j++) {
|
|
768
|
+
var e = BASE_ALLOW_PATH[j];
|
|
769
|
+
if (host !== e.host) { continue; }
|
|
770
|
+
var path = '';
|
|
771
|
+
try { path = new URL(String(url), 'http://localhost/').pathname || ''; } catch (e2) { path = ''; }
|
|
772
|
+
if (path.indexOf(e.path) === 0) { return true; }
|
|
773
|
+
}
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function siteAllowed(host) {
|
|
778
|
+
try {
|
|
779
|
+
var list = config.blocking && config.blocking.allow;
|
|
780
|
+
if (!list || typeof list.length !== 'number') { return false; }
|
|
781
|
+
for (var i = 0; i < list.length; i++) {
|
|
782
|
+
var entry = list[i];
|
|
783
|
+
if (typeof entry !== 'string') { continue; }
|
|
784
|
+
var key = entry.trim().toLowerCase().replace(/^\.+/, '');
|
|
785
|
+
if (key && hostMatches(host, key)) { return true; }
|
|
786
|
+
}
|
|
787
|
+
} catch (e) { /* noop */ }
|
|
788
|
+
return false;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function strictMode() {
|
|
792
|
+
try { return !!(config.blocking && config.blocking.mode === 'strict'); } catch (e) { return false; }
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// True when strict mode should hold this URL back. Reached only for URLs the
|
|
796
|
+
// tracker database does NOT recognise: a known host has a real category and
|
|
797
|
+
// is decided by allowed() long before this runs, which is why a HOST_DB
|
|
798
|
+
// `necessary` or `functional` host passes strict without a special case.
|
|
799
|
+
function strictBlocks(src) {
|
|
800
|
+
if (!strictMode()) { return false; }
|
|
801
|
+
var s = String(src || '');
|
|
802
|
+
// Non-network schemes carry no third party. A bare "//host/x" has no scheme
|
|
803
|
+
// and is protocol-relative, so it is deliberately not caught here.
|
|
804
|
+
if (/^\s*(?:data|blob|javascript|about|mailto|tel):/i.test(s)) { return false; }
|
|
805
|
+
var parts = urlParts(s);
|
|
806
|
+
var host = parts.host;
|
|
807
|
+
if (!host) { return false; }
|
|
808
|
+
if (isSameSite(host)) { return false; }
|
|
809
|
+
if (baseAllowed(host, parts.url)) { return false; }
|
|
810
|
+
if (siteAllowed(host)) { return false; }
|
|
811
|
+
// Intercepted unknowns are treated as marketing — the strictest category —
|
|
812
|
+
// so they come back only when the visitor accepts marketing.
|
|
813
|
+
return !allowed(STRICT_CATEGORY);
|
|
814
|
+
}
|
|
815
|
+
|
|
618
816
|
function allowed(cat) {
|
|
619
817
|
if (cat === 'necessary' || !cat) { return true; }
|
|
620
818
|
if (CATEGORIES.indexOf(cat) === -1) { return true; }
|
|
621
819
|
return state.categories[cat] === true;
|
|
622
820
|
}
|
|
623
821
|
|
|
624
|
-
// True when the URL
|
|
822
|
+
// True when the URL must be held back: a known tracker whose category is not
|
|
823
|
+
// yet granted, or — in strict mode — an unknown third party.
|
|
625
824
|
function shouldBlock(src) {
|
|
626
825
|
if (bypass) { return false; }
|
|
627
826
|
var cat = categoryForUrl(src);
|
|
628
|
-
if (
|
|
629
|
-
return
|
|
827
|
+
if (cat) { return !allowed(cat); }
|
|
828
|
+
return strictBlocks(src);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// The category an interception is filed under. Known hosts keep their own;
|
|
832
|
+
// a strict interception is marketing.
|
|
833
|
+
function blockCategory(src) {
|
|
834
|
+
return categoryForUrl(src) || STRICT_CATEGORY;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Was this particular interception a strict-mode one (i.e. the URL is not in
|
|
838
|
+
// the tracker database at all)? Drives the «strict» label in the debug panel.
|
|
839
|
+
function isStrictHit(src) {
|
|
840
|
+
return !categoryForUrl(src) && strictMode();
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Registry of everything the engine intercepted, for the debug panel (§8.1
|
|
844
|
+
// item 3). Kept deliberately small: host + path only, никаких query strings —
|
|
845
|
+
// a tracker URL's query carries ids and, on badly built sites, PII.
|
|
846
|
+
// Capped so a page that injects trackers in a loop cannot grow it without
|
|
847
|
+
// bound; the panel shows the first BLOCKED_MAX, which is always enough to see
|
|
848
|
+
// what is happening.
|
|
849
|
+
var BLOCKED_MAX = 200;
|
|
850
|
+
var blockedLog = [];
|
|
851
|
+
|
|
852
|
+
// host + path, query and fragment dropped.
|
|
853
|
+
function safeUrlParts(src) {
|
|
854
|
+
var host = '';
|
|
855
|
+
var path = '';
|
|
856
|
+
try {
|
|
857
|
+
var base = (global.location && global.location.href) || 'http://localhost/';
|
|
858
|
+
var u = new URL(String(src), base);
|
|
859
|
+
host = (u.hostname || '').toLowerCase();
|
|
860
|
+
path = u.pathname || '';
|
|
861
|
+
} catch (e) {
|
|
862
|
+
var s = String(src || '');
|
|
863
|
+
var m = /^(?:[a-z]+:)?\/\/([^/?#]+)([^?#]*)/i.exec(s);
|
|
864
|
+
if (m) {
|
|
865
|
+
host = m[1].toLowerCase().replace(/:\d+$/, '');
|
|
866
|
+
path = m[2] || '';
|
|
867
|
+
} else {
|
|
868
|
+
path = s.split('?')[0].split('#')[0];
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return { host: host, path: path };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function noteBlocked(el, src, cat, origin, strict) {
|
|
875
|
+
try {
|
|
876
|
+
if (blockedLog.length >= BLOCKED_MAX) { return; }
|
|
877
|
+
var kind = 'script';
|
|
878
|
+
try {
|
|
879
|
+
var tag = el && el.tagName ? String(el.tagName).toLowerCase() : '';
|
|
880
|
+
if (tag) { kind = tag === 'img' ? 'img' : tag; }
|
|
881
|
+
} catch (e2) { /* noop */ }
|
|
882
|
+
var parts = safeUrlParts(src);
|
|
883
|
+
for (var i = 0; i < blockedLog.length; i++) {
|
|
884
|
+
var p = blockedLog[i];
|
|
885
|
+
if (p.host === parts.host && p.path === parts.path && p.kind === kind) { return; }
|
|
886
|
+
}
|
|
887
|
+
blockedLog.push({
|
|
888
|
+
host: parts.host,
|
|
889
|
+
path: parts.path,
|
|
890
|
+
kind: kind,
|
|
891
|
+
category: cat || null,
|
|
892
|
+
origin: origin || 'engine',
|
|
893
|
+
strict: strict === true,
|
|
894
|
+
// Flipped by noteRevived() when applyConsentToDom() actually brings the
|
|
895
|
+
// element back. An entry that never flips is one the visitor consented
|
|
896
|
+
// to and that still did not load — worth showing in the debug panel.
|
|
897
|
+
revived: false
|
|
898
|
+
});
|
|
899
|
+
} catch (e) { /* noop */ }
|
|
630
900
|
}
|
|
631
901
|
|
|
632
902
|
function markBlocked(el, src, cat) {
|
|
903
|
+
noteBlocked(el, src, cat, 'engine', isStrictHit(src));
|
|
633
904
|
try {
|
|
634
905
|
if (nativeSetAttribute) {
|
|
635
906
|
nativeSetAttribute.call(el, 'data-ck-blocked', '1');
|
|
@@ -647,6 +918,36 @@
|
|
|
647
918
|
} catch (e) { /* noop */ }
|
|
648
919
|
}
|
|
649
920
|
|
|
921
|
+
// An intercepted iframe is left in EXACTLY the shape applyConsentToDom()
|
|
922
|
+
// already revives — data-ck + data-src and NO src attribute — so revival is
|
|
923
|
+
// the one code path for hand-marked, plugin-rewritten and engine-blocked
|
|
924
|
+
// iframes alike. `type="text/plain"` is script-only and must not be set here.
|
|
925
|
+
// data-ck-blocked also keeps _blocked()'s markup sweep from listing this
|
|
926
|
+
// element a second time: it matches iframe[data-ck][data-src] too.
|
|
927
|
+
// Flags the registry entry for this URL+kind as revived, so _blocked() can
|
|
928
|
+
// tell "came back after consent" from "was intercepted and never returned".
|
|
929
|
+
function noteRevived(src, kind) {
|
|
930
|
+
try {
|
|
931
|
+
var parts = safeUrlParts(src);
|
|
932
|
+
for (var i = 0; i < blockedLog.length; i++) {
|
|
933
|
+
var b = blockedLog[i];
|
|
934
|
+
if (b.host === parts.host && b.path === parts.path && b.kind === kind) { b.revived = true; }
|
|
935
|
+
}
|
|
936
|
+
} catch (e) { /* noop */ }
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function markBlockedIframe(el, src, cat) {
|
|
940
|
+
noteBlocked(el, src, cat, 'engine', isStrictHit(src));
|
|
941
|
+
try {
|
|
942
|
+
if (!nativeSetAttribute) { return; }
|
|
943
|
+
nativeSetAttribute.call(el, 'data-ck-blocked', '1');
|
|
944
|
+
nativeSetAttribute.call(el, 'data-src', src);
|
|
945
|
+
nativeSetAttribute.call(el, 'data-ck', cat || STRICT_CATEGORY);
|
|
946
|
+
// Any src already on the element must go, or revival skips it.
|
|
947
|
+
try { if (nativeRemoveAttribute) { nativeRemoveAttribute.call(el, 'src'); } } catch (e2) { /* noop */ }
|
|
948
|
+
} catch (e) { /* noop */ }
|
|
949
|
+
}
|
|
950
|
+
|
|
650
951
|
// ---------------------------------------------------------------------------
|
|
651
952
|
// Blocking engine — patches (installed at parse time)
|
|
652
953
|
// ---------------------------------------------------------------------------
|
|
@@ -671,16 +972,44 @@
|
|
|
671
972
|
}
|
|
672
973
|
} catch (e) { /* noop */ }
|
|
673
974
|
|
|
674
|
-
//
|
|
975
|
+
// 1b. HTMLIFrameElement.prototype.src setter. Strict mode holds back
|
|
976
|
+
// third-party frames too (§2), and a known tracker embedded as an iframe
|
|
977
|
+
// was never caught before either.
|
|
978
|
+
try {
|
|
979
|
+
if (nativeIframeSrcDesc && nativeIframeSrcDesc.set && nativeIframeSrcDesc.configurable !== false) {
|
|
980
|
+
Object.defineProperty(global.HTMLIFrameElement.prototype, 'src', {
|
|
981
|
+
configurable: true,
|
|
982
|
+
enumerable: nativeIframeSrcDesc.enumerable,
|
|
983
|
+
get: function () {
|
|
984
|
+
try { return nativeIframeSrcDesc.get.call(this); } catch (e) { return ''; }
|
|
985
|
+
},
|
|
986
|
+
set: function (v) {
|
|
987
|
+
if (shouldBlock(v)) {
|
|
988
|
+
markBlockedIframe(this, String(v), blockCategory(v));
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
try { nativeIframeSrcDesc.set.call(this, v); } catch (e) { /* noop */ }
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
} catch (e) { /* noop */ }
|
|
996
|
+
|
|
997
|
+
// 2. Element.prototype.setAttribute — covers setAttribute('src', ...) on
|
|
998
|
+
// both scripts and iframes.
|
|
675
999
|
try {
|
|
676
1000
|
if (nativeSetAttribute && global.Element && global.Element.prototype) {
|
|
677
1001
|
global.Element.prototype.setAttribute = function (name, value) {
|
|
678
1002
|
try {
|
|
679
|
-
if (!bypass && typeof name === 'string' && name.toLowerCase() === 'src' &&
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
1003
|
+
if (!bypass && typeof name === 'string' && name.toLowerCase() === 'src' && this && this.tagName) {
|
|
1004
|
+
var t = String(this.tagName).toUpperCase();
|
|
1005
|
+
if ((t === 'SCRIPT' || t === 'IFRAME') && shouldBlock(value)) {
|
|
1006
|
+
if (t === 'IFRAME') {
|
|
1007
|
+
markBlockedIframe(this, String(value), blockCategory(value));
|
|
1008
|
+
} else {
|
|
1009
|
+
markBlocked(this, String(value), blockCategory(value));
|
|
1010
|
+
}
|
|
1011
|
+
return undefined;
|
|
1012
|
+
}
|
|
684
1013
|
}
|
|
685
1014
|
} catch (e) { /* fall through to native */ }
|
|
686
1015
|
return nativeSetAttribute.apply(this, arguments);
|
|
@@ -694,10 +1023,10 @@
|
|
|
694
1023
|
doc.createElement = function (tag) {
|
|
695
1024
|
var el = nativeCreateElement.apply(null, arguments);
|
|
696
1025
|
try {
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
1026
|
+
var name = (!bypass && typeof tag === 'string') ? tag.toLowerCase() : '';
|
|
1027
|
+
// Own-property guard so the element is covered even if the
|
|
1028
|
+
// prototype patch was reverted by another library.
|
|
1029
|
+
if (name === 'script' && nativeScriptSrcDesc && nativeScriptSrcDesc.set) {
|
|
701
1030
|
Object.defineProperty(el, 'src', {
|
|
702
1031
|
configurable: true,
|
|
703
1032
|
enumerable: false,
|
|
@@ -706,12 +1035,27 @@
|
|
|
706
1035
|
},
|
|
707
1036
|
set: function (v) {
|
|
708
1037
|
if (shouldBlock(v)) {
|
|
709
|
-
markBlocked(this, String(v),
|
|
1038
|
+
markBlocked(this, String(v), blockCategory(v));
|
|
710
1039
|
return;
|
|
711
1040
|
}
|
|
712
1041
|
try { nativeScriptSrcDesc.set.call(this, v); } catch (e) { /* noop */ }
|
|
713
1042
|
}
|
|
714
1043
|
});
|
|
1044
|
+
} else if (name === 'iframe' && nativeIframeSrcDesc && nativeIframeSrcDesc.set) {
|
|
1045
|
+
Object.defineProperty(el, 'src', {
|
|
1046
|
+
configurable: true,
|
|
1047
|
+
enumerable: false,
|
|
1048
|
+
get: function () {
|
|
1049
|
+
try { return nativeIframeSrcDesc.get.call(this); } catch (e) { return ''; }
|
|
1050
|
+
},
|
|
1051
|
+
set: function (v) {
|
|
1052
|
+
if (shouldBlock(v)) {
|
|
1053
|
+
markBlockedIframe(this, String(v), blockCategory(v));
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
try { nativeIframeSrcDesc.set.call(this, v); } catch (e) { /* noop */ }
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
715
1059
|
}
|
|
716
1060
|
} catch (e) { /* noop */ }
|
|
717
1061
|
return el;
|
|
@@ -743,13 +1087,30 @@
|
|
|
743
1087
|
if (!node || node.nodeType !== 1) { return; }
|
|
744
1088
|
var tag = node.tagName ? String(node.tagName).toUpperCase() : '';
|
|
745
1089
|
if (tag === 'SCRIPT') { inspectScript(node); }
|
|
1090
|
+
if (tag === 'IFRAME') { inspectIframe(node); }
|
|
746
1091
|
if (typeof node.querySelectorAll === 'function') {
|
|
747
1092
|
var kids = node.querySelectorAll('script');
|
|
748
1093
|
for (var i = 0; i < kids.length; i++) { inspectScript(kids[i]); }
|
|
1094
|
+
var frames = node.querySelectorAll('iframe');
|
|
1095
|
+
for (var j = 0; j < frames.length; j++) { inspectIframe(frames[j]); }
|
|
749
1096
|
}
|
|
750
1097
|
} catch (e) { /* noop */ }
|
|
751
1098
|
}
|
|
752
1099
|
|
|
1100
|
+
// Late net for iframes that arrived as markup. Same honesty as inspectScript:
|
|
1101
|
+
// once the element is connected the request may already be in flight — the
|
|
1102
|
+
// src/createElement patches are the reliable path.
|
|
1103
|
+
function inspectIframe(el) {
|
|
1104
|
+
try {
|
|
1105
|
+
if (!el || el.getAttribute === undefined) { return; }
|
|
1106
|
+
if (el.getAttribute('data-ck-blocked')) { return; }
|
|
1107
|
+
if (el.getAttribute('data-ck') && el.getAttribute('data-src')) { return; } // manual markup
|
|
1108
|
+
var src = el.getAttribute('src');
|
|
1109
|
+
if (!src) { return; }
|
|
1110
|
+
if (shouldBlock(src)) { markBlockedIframe(el, src, blockCategory(src)); }
|
|
1111
|
+
} catch (e) { /* noop */ }
|
|
1112
|
+
}
|
|
1113
|
+
|
|
753
1114
|
function inspectScript(el) {
|
|
754
1115
|
try {
|
|
755
1116
|
if (!el || el.getAttribute === undefined) { return; }
|
|
@@ -759,7 +1120,7 @@
|
|
|
759
1120
|
var src = el.getAttribute('src');
|
|
760
1121
|
if (!src) { return; }
|
|
761
1122
|
if (shouldBlock(src)) {
|
|
762
|
-
var cat =
|
|
1123
|
+
var cat = blockCategory(src);
|
|
763
1124
|
markBlocked(el, src, cat);
|
|
764
1125
|
// Clear the attribute. Note: if the element was already connected the
|
|
765
1126
|
// request may already be in flight — the createElement/src patches are
|
|
@@ -807,6 +1168,7 @@
|
|
|
807
1168
|
try { nativeSetAttribute.call(fresh, 'data-ck-restored', '1'); } catch (e) { /* noop */ }
|
|
808
1169
|
old.parentNode.insertBefore(fresh, old);
|
|
809
1170
|
try { old.parentNode.removeChild(old); } catch (e) { /* noop */ }
|
|
1171
|
+
if (src) { noteRevived(src, 'script'); }
|
|
810
1172
|
} catch (e) {
|
|
811
1173
|
/* noop */
|
|
812
1174
|
} finally {
|
|
@@ -847,6 +1209,7 @@
|
|
|
847
1209
|
var prev = bypass;
|
|
848
1210
|
bypass = true;
|
|
849
1211
|
try { nativeSetAttribute.call(el, 'src', src); } finally { bypass = prev; }
|
|
1212
|
+
noteRevived(src, 'iframe');
|
|
850
1213
|
} catch (e) { /* noop */ }
|
|
851
1214
|
});
|
|
852
1215
|
}
|
|
@@ -854,6 +1217,7 @@
|
|
|
854
1217
|
// Initial sweep for scripts already parsed before the observer attached.
|
|
855
1218
|
function initialScan() {
|
|
856
1219
|
qsa('script[src]').forEach(inspectScript);
|
|
1220
|
+
qsa('iframe[src]').forEach(inspectIframe);
|
|
857
1221
|
}
|
|
858
1222
|
|
|
859
1223
|
// ---------------------------------------------------------------------------
|
|
@@ -961,7 +1325,7 @@
|
|
|
961
1325
|
// Public API
|
|
962
1326
|
// ---------------------------------------------------------------------------
|
|
963
1327
|
var ConsentKit = {
|
|
964
|
-
version: '0.
|
|
1328
|
+
version: '0.4.0',
|
|
965
1329
|
config: config,
|
|
966
1330
|
|
|
967
1331
|
init: function (userConfig) {
|
|
@@ -969,6 +1333,12 @@
|
|
|
969
1333
|
config = mergeConfig(config, userConfig);
|
|
970
1334
|
ConsentKit.config = config;
|
|
971
1335
|
|
|
1336
|
+
// Service / author overrides, merged BEFORE initialScan() below so the
|
|
1337
|
+
// scripts already in the markup are classified against them. In SaaS
|
|
1338
|
+
// mode ck-saas.js has usually applied these already; extendHostDb is
|
|
1339
|
+
// idempotent, so doing it twice costs nothing.
|
|
1340
|
+
if (userConfig && isPlainObject(userConfig.hostdb)) { extendHostDb(userConfig.hostdb); }
|
|
1341
|
+
|
|
972
1342
|
if (initialized) {
|
|
973
1343
|
// Idempotent: merge config, no re-restore, no duplicate ck:init.
|
|
974
1344
|
return publicState();
|
|
@@ -1017,7 +1387,96 @@
|
|
|
1017
1387
|
|
|
1018
1388
|
// Introspection helpers for the demo status panel (read-only).
|
|
1019
1389
|
_categoryForUrl: categoryForUrl,
|
|
1020
|
-
_categories: CATEGORIES.slice()
|
|
1390
|
+
_categories: CATEGORIES.slice(),
|
|
1391
|
+
|
|
1392
|
+
// Merges { host: category } into the runtime tracker database (§1.3).
|
|
1393
|
+
// Works before AND after init(): after init nothing already inserted is
|
|
1394
|
+
// re-evaluated — a script that has loaded cannot be unloaded — but every
|
|
1395
|
+
// later insertion is classified against the extended map. Returns the
|
|
1396
|
+
// number of entries actually added; unknown categories and malformed hosts
|
|
1397
|
+
// are dropped, because this map arrives over the network in the SaaS path.
|
|
1398
|
+
_extendHostDb: function (map) {
|
|
1399
|
+
try { return extendHostDb(map); } catch (e) { return 0; }
|
|
1400
|
+
},
|
|
1401
|
+
|
|
1402
|
+
// The built-in strict-mode allowlist, exported for docs and tests so the
|
|
1403
|
+
// list a site owner reads is the list the engine actually uses.
|
|
1404
|
+
//
|
|
1405
|
+
// A GETTER, not a plain array: a plain property is evaluated once, and the
|
|
1406
|
+
// single array it produced would be handed to every caller — one
|
|
1407
|
+
// `ConsentKit._baseAllow.push(...)` from page code would then silently
|
|
1408
|
+
// widen what strict mode lets through for the rest of the page load.
|
|
1409
|
+
// Each read returns a fresh copy, so the list is readable and inert.
|
|
1410
|
+
get _baseAllow() {
|
|
1411
|
+
return BASE_ALLOW.slice().concat(BASE_ALLOW_PATH.map(function (e) { return e.host + e.path; }));
|
|
1412
|
+
},
|
|
1413
|
+
|
|
1414
|
+
// What is being held back until consent: everything the engine intercepted
|
|
1415
|
+
// (origin 'engine') plus what the site author marked up by hand (origin
|
|
1416
|
+
// 'markup'), which never goes through markBlocked. Read-only, host+path
|
|
1417
|
+
// only — no query strings, so no ids and no PII. Used by src/ck-debug.js.
|
|
1418
|
+
_blocked: function () {
|
|
1419
|
+
var out = [];
|
|
1420
|
+
var seen = {};
|
|
1421
|
+
function add(rec) {
|
|
1422
|
+
var key = rec.kind + '|' + rec.host + '|' + rec.path;
|
|
1423
|
+
if (seen[key]) { return; }
|
|
1424
|
+
seen[key] = 1;
|
|
1425
|
+
out.push(rec);
|
|
1426
|
+
}
|
|
1427
|
+
try {
|
|
1428
|
+
for (var i = 0; i < blockedLog.length; i++) {
|
|
1429
|
+
var b = blockedLog[i];
|
|
1430
|
+
// Interceptions are kept for the life of the page. Once the category
|
|
1431
|
+
// is granted the element has USUALLY been revived and is no longer
|
|
1432
|
+
// held back, so reporting it as blocked would be false — but not
|
|
1433
|
+
// always: applyConsentToDom() can only revive an element that is in
|
|
1434
|
+
// the document, and a script created and given a src without ever
|
|
1435
|
+
// being appended stays dead for the life of the page.
|
|
1436
|
+
//
|
|
1437
|
+
// That case is exactly the strict-mode support ticket ("my widget did
|
|
1438
|
+
// not come back after I accepted"), and both README and INSTALL.ru
|
|
1439
|
+
// send the site owner to this panel to diagnose it. So an entry is
|
|
1440
|
+
// dropped only when something on the page actually came back for it;
|
|
1441
|
+
// otherwise it stays, marked `revived: false`.
|
|
1442
|
+
if (allowed(b.category) && b.revived !== false) { continue; }
|
|
1443
|
+
add({
|
|
1444
|
+
host: b.host, path: b.path, kind: b.kind,
|
|
1445
|
+
category: b.category, origin: b.origin, strict: b.strict === true,
|
|
1446
|
+
revived: b.revived !== false
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
} catch (e) { /* noop */ }
|
|
1450
|
+
// Hand-marked tags still waiting for their category.
|
|
1451
|
+
try {
|
|
1452
|
+
// Only what applyConsentToDom() can actually revive. An img is never
|
|
1453
|
+
// revived by the core, so listing hand-marked images here would show a
|
|
1454
|
+
// pending state that never clears after the visitor accepts.
|
|
1455
|
+
qsa('script[type="text/plain"][data-ck], iframe[data-ck][data-src]')
|
|
1456
|
+
.forEach(function (el) {
|
|
1457
|
+
try {
|
|
1458
|
+
if (el.getAttribute('data-ck-restored')) { return; }
|
|
1459
|
+
if (el.getAttribute('data-ck-blocked')) { return; } // already in the registry
|
|
1460
|
+
var tag = String(el.tagName || '').toLowerCase();
|
|
1461
|
+
if (tag === 'iframe' && el.getAttribute('src')) { return; }
|
|
1462
|
+
var src = el.getAttribute('data-src') || el.getAttribute('data-ck-src') || '';
|
|
1463
|
+
if (!src) { return; }
|
|
1464
|
+
var cat = el.getAttribute('data-ck') || categoryForUrl(src);
|
|
1465
|
+
if (allowed(cat)) { return; }
|
|
1466
|
+
var parts = safeUrlParts(src);
|
|
1467
|
+
add({
|
|
1468
|
+
host: parts.host, path: parts.path,
|
|
1469
|
+
kind: tag === 'iframe' ? 'iframe' : 'script',
|
|
1470
|
+
// revived: true means "not applicable" here, not "came back":
|
|
1471
|
+
// this sweep only ever lists hand-marked tags still waiting for
|
|
1472
|
+
// their category, so none of them can be a failed revival.
|
|
1473
|
+
category: cat || null, origin: 'markup', strict: false, revived: true
|
|
1474
|
+
});
|
|
1475
|
+
} catch (e2) { /* noop */ }
|
|
1476
|
+
});
|
|
1477
|
+
} catch (e3) { /* noop */ }
|
|
1478
|
+
return out;
|
|
1479
|
+
}
|
|
1021
1480
|
};
|
|
1022
1481
|
|
|
1023
1482
|
// ---------------------------------------------------------------------------
|