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

@@ -47,23 +47,78 @@ class CB_Autodetect {
}
public static function process( string $html, array $services ): string {
if ( $html === '' || stripos( $html, '<iframe' ) === false ) {
if ( $html === '' ) {
return $html;
}
// Locate iframe blocks only. Attribute parsing happens via DOMDocument below.
$out = preg_replace_callback(
'#<iframe\b[^>]*>.*?</iframe>#is',
function ( array $m ) use ( $services ): string {
return self::maybe_replace_iframe( $m[0], $services );
},
$html
);
// Pass 1 — iframes: replace matched embeds with a consent placeholder.
if ( stripos( $html, '<iframe' ) !== false ) {
$out = preg_replace_callback(
'#<iframe\b[^>]*>.*?</iframe>#is',
function ( array $m ) use ( $services ): string {
return self::maybe_replace_iframe( $m[0], $services );
},
$html
);
// On a PCRE error (e.g. backtrack/recursion limit on a huge page),
// preg_replace_callback returns null. Never blank the page.
if ( $out !== null ) {
$html = $out;
}
}
// On a PCRE error (e.g. backtrack/recursion limit on a huge page),
// preg_replace_callback returns null. Never blank the page — fall back to
// the original, unmodified HTML.
return $out === null ? $html : $out;
// Pass 2 — scripts: neutralise <script src> of services that block scripts
// so they don't execute/fetch until consent (the JS guard re-injects them).
$script_services = array_values( array_filter(
$services,
static fn( $s ) => ! empty( $s['loads_script'] )
) );
if ( $script_services && stripos( $html, '<script' ) !== false ) {
$out = preg_replace_callback(
'#<script\b[^>]*\bsrc\s*=\s*["\'][^"\']+["\'][^>]*>#i',
function ( array $m ) use ( $script_services ): string {
return self::maybe_block_script( $m[0], $script_services );
},
$html
);
if ( $out !== null ) {
$html = $out;
}
}
return $html;
}
/**
* Neutralise a <script src="…"> opening tag if its src matches a script-
* blocking service: rename src→data-cb-src, force type="text/plain", and tag
* it with the service id. The early head guard re-injects it on consent.
*/
private static function maybe_block_script( string $tag, array $services ): string {
if ( ! preg_match( '/\bsrc\s*=\s*["\']([^"\']+)["\']/i', $tag, $m ) ) {
return $tag;
}
$src = str_replace( '&amp;', '&', $m[1] );
foreach ( $services as $svc ) {
$pattern = $svc['match_pattern'] ?? '';
if ( $pattern === '' || ! str_contains( $src, $pattern ) ) {
continue;
}
$id = (string) ( $svc['id'] ?? '' );
// src → data-cb-src (first occurrence), drop any existing type, then
// force type="text/plain" + the service id on the opening tag.
$t = preg_replace( '#\bsrc(\s*=\s*)#i', 'data-cb-src$1', $tag, 1 );
$t = preg_replace( '#\btype\s*=\s*("[^"]*"|\'[^\']*\'|\S+)#i', '', $t );
$t = preg_replace(
'#^<script\b#i',
'<script type="text/plain" data-cb-id="' . esc_attr( $id ) . '"',
$t,
1
);
return $t ?? $tag;
}
return $tag;
}
/**

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

View File

@@ -616,10 +616,10 @@ class CB_Settings {
value="1" <?php checked( $b( 'sets_cookie' ) ); ?>>
<?php esc_html_e( 'Setzt Cookies', 'gdpr-content-blocker' ); ?>
</label>
<label>
<label title="<?php esc_attr_e( 'Blockiert zusätzlich passende <script>-Einbindungen (auch per JavaScript nachgeladene, z. B. die YouTube-/Vimeo-API) bis zur Einwilligung.', 'gdpr-content-blocker' ); ?>">
<input type="checkbox" name="cb_services[<?php echo esc_attr( (string) $index ); ?>][loads_script]"
value="1" <?php checked( $b( 'loads_script' ) ); ?>>
<?php esc_html_e( 'Lädt externe Skripte', 'gdpr-content-blocker' ); ?>
<?php esc_html_e( 'Zugehörige Skripte blockieren (z. B. YouTube-/Vimeo-API)', 'gdpr-content-blocker' ); ?>
</label>
</div>
</div>
@@ -674,6 +674,11 @@ class CB_Settings {
<?php echo $code( '[content_blocker_services]' ); ?></td>
<td><?php esc_html_e( 'Listet alle konfigurierten Dienste mit Empfänger, Drittland-Hinweis, Zweck und Datenschutz-Link auf. Ideal zum Einbinden in die Datenschutzerklärung (Art. 13 DSGVO).', 'gdpr-content-blocker' ); ?></td>
</tr>
<tr>
<td style="vertical-align:top;"><strong><?php esc_html_e( 'Einwilligungs-Button (für reine Skript-Dienste)', 'gdpr-content-blocker' ); ?></strong><br>
<?php echo $code( '[content_blocker_consent id="youtube" text="…"]' ); ?></td>
<td><?php esc_html_e( 'Erteilt die Einwilligung für einen Dienst und lädt die Seite neu. Gedacht für Dienste, die nur Skripte blockieren und keinen sichtbaren Platzhalter haben. Optionen: id="…" (Dienst-ID, Pflicht), text="…", style="button|link".', 'gdpr-content-blocker' ); ?></td>
</tr>
</tbody>
</table>
<?php