feat: third-party-scripts blockieren (server + client-guard)

Loest die YouTube/Vimeo-API-Luecke: viele dienste laden ueber <script>
(z. B. youtube.com/iframe_api, www-widgetapi.js, analytics) statt iframes -
oft per JavaScript nachgeladen, daher fuer den scanner unsichtbar.

- Pro dienst aktivierbar ueber das (umbenannte) feld "Zugehoerige Skripte
  blockieren (z. B. YouTube-/Vimeo-API)" = das vorhandene loads_script-flag.
  Presets (YouTube, Vimeo, Maps) haben es bereits an.
- Server-seitig: passende <script src> werden zu type="text/plain"
  (src -> data-cb-src) neutralisiert, laden also nicht.
- Client-seitig: winziger guard ganz frueh im <head> patcht
  appendChild/insertBefore/replaceChild und neutralisiert dynamisch
  injizierte scripts VOR dem einfuegen -> kein request. Faengt damit auch
  die per JS nachgeladene iframe_api ab.
- Einwilligung (per-dienst-consent, z. B. ueber den video-platzhalter)
  schaltet die scripts via cbActivateScripts frei und laedt sie nach.
- Neuer shortcode [content_blocker_consent id="…"] als einwilligungs-button
  fuer reine skript-dienste ohne sichtbaren platzhalter.
- guard-logik mit DOM-mock getestet (block + reinject), server-regex
  isoliert geprueft. i18n DE/EN ergaenzt (127 strings).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-06-08 08:24:33 +02:00
parent b6df4c64af
commit ca945d2d80
9 changed files with 203 additions and 21 deletions

View File

@@ -7,7 +7,65 @@ class CB_Renderer {
add_shortcode( 'content_blocker', [ __CLASS__, 'shortcode' ] );
add_shortcode( 'content_blocker_revoke', [ __CLASS__, 'revoke_shortcode' ] );
add_shortcode( 'content_blocker_services', [ __CLASS__, 'services_shortcode' ] );
add_shortcode( 'content_blocker_consent', [ __CLASS__, 'consent_shortcode' ] );
add_action( 'wp_enqueue_scripts', [ __CLASS__, 'enqueue_assets' ] );
// Earliest possible: install the dynamic-script guard before theme/builder
// scripts run, so JS-injected third-party scripts (e.g. YouTube iframe_api)
// are caught before they fetch.
add_action( 'wp_head', [ __CLASS__, 'print_script_guard' ], 0 );
}
/** Enabled services that should also block matching <script> tags. */
public static function script_services(): array {
$out = [];
foreach ( CB_Settings::get_services() as $svc ) {
if ( empty( $svc['match_pattern'] ) || empty( $svc['loads_script'] ) || ! ( $svc['enabled'] ?? true ) ) {
continue;
}
$out[] = [ 'id' => $svc['id'], 'match' => $svc['match_pattern'] ];
}
return $out;
}
/**
* Inline guard printed very early in <head>. It blocks third-party scripts
* (both already in the DOM and dynamically injected) until the matching
* service has consent, by neutralising the <script> node right before it is
* inserted (no network request is made). Re-injection happens on consent.
*/
public static function print_script_guard(): void {
if ( is_admin() ) {
return;
}
$services = self::script_services();
if ( empty( $services ) ) {
return;
}
$json = wp_json_encode( $services );
echo "<script>\n/* gdpr-content-blocker: script guard */\n";
echo '(function(){"use strict";var S=' . $json . ';if(!S.length)return;'
. 'var P="cb_consent_";'
. 'function ok(id){try{return localStorage.getItem(P+id)==="1";}catch(e){return false;}}'
. 'function find(u){u=String(u||"");for(var i=0;i<S.length;i++){if(u.indexOf(S[i].match)!==-1)return S[i];}return null;}'
. 'var blocked=[];'
. 'function neutralize(node){try{if(!node||node.tagName!=="SCRIPT")return;'
. 'var src=node.src||(node.getAttribute?node.getAttribute("src"):"")||"";if(!src)return;'
. 'var s=find(src);if(!s||ok(s.id))return;'
. 'node.type="text/plain";node.setAttribute("data-cb-src",src);node.setAttribute("data-cb-id",s.id);'
. 'if(node.removeAttribute)node.removeAttribute("src");blocked.push(node);}catch(e){}}'
. 'function patch(proto,name){var o=proto[name];if(!o)return;proto[name]=function(n){try{neutralize(n);}catch(e){}return o.apply(this,arguments);};}'
. 'patch(Node.prototype,"appendChild");patch(Node.prototype,"insertBefore");patch(Node.prototype,"replaceChild");'
. 'function rein(el){try{var src=el.getAttribute&&el.getAttribute("data-cb-src");if(!src)return;'
. 'var s=document.createElement("script");for(var i=0;i<el.attributes.length;i++){var a=el.attributes[i];'
. 'if(/^(type|src|data-cb-src|data-cb-id)$/i.test(a.name))continue;try{s.setAttribute(a.name,a.value);}catch(e){}}'
. 's.src=src;var p=el.parentNode;if(p){p.insertBefore(s,el.nextSibling);p.removeChild(el);}}catch(e){}}'
. 'window.cbActivateScripts=function(id){blocked.slice().forEach(function(el){if(el.getAttribute("data-cb-id")===id)rein(el);});'
. 'var n=document.querySelectorAll(\'script[type="text/plain"][data-cb-src][data-cb-id]\');'
. 'Array.prototype.forEach.call(n,function(el){if(el.getAttribute("data-cb-id")===id)rein(el);});};'
. 'function pre(){for(var i=0;i<S.length;i++){if(ok(S[i].id))window.cbActivateScripts(S[i].id);}}'
. 'if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",pre);}else{pre();}'
. '})();';
echo "\n</script>\n";
}
public static function enqueue_assets(): void {
@@ -147,6 +205,31 @@ class CB_Renderer {
return $out;
}
/**
* Shortcode: [content_blocker_consent id="youtube" text="…" style="button|link"]
* A consent control for services that only block scripts (no visible embed
* to click). Grants consent for the given service id and reloads so the
* blocked scripts/embeds load.
*/
public static function consent_shortcode( array|string $atts = [] ): string {
$atts = shortcode_atts( [
'id' => '',
'text' => __( 'Externe Inhalte aktivieren', 'gdpr-content-blocker' ),
'style' => 'button',
], $atts, 'content_blocker_consent' );
$id = sanitize_key( $atts['id'] );
if ( $id === '' ) {
return '';
}
$class = $atts['style'] === 'link' ? 'cb-revoke-link' : 'cb-revoke-btn';
return '<a href="#" class="' . esc_attr( $class ) . '" role="button" '
. 'onclick="cbConsent(\'' . esc_js( $id ) . '\');return false;">'
. esc_html( $atts['text'] )
. '</a>';
}
/**
* Shortcode: [content_blocker_services]
* Lists every configured third-party service with the Art. 13 details