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:
@@ -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( '&', '&', $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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user