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

@@ -26,8 +26,26 @@
} catch ( e ) {
// localStorage unavailable; allow the load for this session only.
}
// Release any third-party <script> blocked for this service (handled by
// the early head guard, which exposes cbActivateScripts).
if ( typeof window.cbActivateScripts === 'function' ) {
window.cbActivateScripts( serviceId );
}
}
/**
* Public: grant consent for a service from a custom control
* ([content_blocker_consent]) — used for script-only services that have no
* visible embed. Reloads so blocked scripts/embeds load cleanly.
*/
window.cbConsent = function ( serviceId ) {
if ( ! serviceId ) {
return;
}
grantConsent( serviceId );
window.location.reload();
};
/* ───────────────────────── iframe loading ────────────────────────── */
/** Replace a .cb-blocker element with the real iframe. src comes from data-src. */

View File

@@ -3,7 +3,7 @@
* Plugin Name: GDPR Content Blocker
* Plugin URI: https://lucas-orth.de
* Description: DSGVO-konformer Consent-Blocker für externe iframes. Lädt Drittinhalte erst nach aktiver Einwilligung.
* Version: 1.3.0
* Version: 1.4.0
* Author: Lucas Orth
* Author URI: https://lucas-orth.de
* Text Domain: gdpr-content-blocker

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

View File

@@ -114,6 +114,9 @@ msgstr "Withdraw consent for external content"
msgid "Betrifft nur die Freigabe externer Einbettungen (z. B. Karten, Videos). Cookie-Einstellungen werden separat verwaltet."
msgstr "Affects only the release of external embeds (e.g. maps, videos). Cookie settings are managed separately."
msgid "Externe Inhalte aktivieren"
msgstr "Enable external content"
msgid "Übermittlung in ein Drittland außerhalb der EU/des EWR"
msgstr "Transfer to a third country outside the EU/EEA"
@@ -282,8 +285,11 @@ msgstr "Custom placeholder text (empty = default)"
msgid "Datenübermittlung in Drittland (außerhalb EU/EWR)"
msgstr "Data transfer to a third country (outside EU/EEA)"
msgid "Lädt externe Skripte"
msgstr "Loads external scripts"
msgid "Blockiert zusätzlich passende <script>-Einbindungen (auch per JavaScript nachgeladene, z. B. die YouTube-/Vimeo-API) bis zur Einwilligung."
msgstr "Additionally blocks matching <script> includes (including ones injected via JavaScript, e.g. the YouTube/Vimeo API) until consent."
msgid "Zugehörige Skripte blockieren (z. B. YouTube-/Vimeo-API)"
msgstr "Also block related scripts (e.g. YouTube/Vimeo API)"
msgid "Textfarbe Platzhalter"
msgstr "Placeholder text color"
@@ -330,6 +336,12 @@ msgstr "Services overview (privacy policy)"
msgid "Listet alle konfigurierten Dienste mit Empfänger, Drittland-Hinweis, Zweck und Datenschutz-Link auf. Ideal zum Einbinden in die Datenschutzerklärung (Art. 13 DSGVO)."
msgstr "Lists all configured services with recipient, third-country note, purpose and privacy link. Ideal for inclusion in your privacy policy (Art. 13 GDPR)."
msgid "Einwilligungs-Button (für reine Skript-Dienste)"
msgstr "Consent button (for script-only services)"
msgid "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\"."
msgstr "Grants consent for a service and reloads the page. Intended for services that only block scripts and have no visible placeholder. Options: id=\"…\" (service ID, required), text=\"…\", style=\"button|link\"."
msgid "Version %s"
msgstr "Version %s"