fix: comment scripts bypass cookie consent (#1308)
* fix: comment scripts bypass cookie consent When GDPR cookie consent is enabled, the comments container is simply hidden with CSS. However, this still allows all third-party script tags within the container to be evaluated and downloaded by the browser. This PR uses the <template> tag which stops inner scripts from evaluating. Once consent is granted, the template content is cloned and inserted into the container, and the scripts are manually loaded in order. * refactor(comments): move consent-gated loader to TS asset * fix(comments): add integrity attribute to comments consent script * style: align comments consent docs and naming * refactor(comments): simplify script handling --------- Co-authored-by: Jimmy Cai <jimmy@cai.im>
This commit is contained in:
@@ -0,0 +1,141 @@
|
|||||||
|
/*
|
||||||
|
* Consent-gated comments bootstrap.
|
||||||
|
*
|
||||||
|
* This module controls whether third-party comments are rendered based on
|
||||||
|
* functional cookie consent, and injects template scripts in sequence to
|
||||||
|
* avoid eager or duplicate script execution.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consent event payload used by the comments gate.
|
||||||
|
* It mirrors cookie consent state shape but keeps optional fields for safety.
|
||||||
|
*/
|
||||||
|
interface CommentsConsentState {
|
||||||
|
necessary?: boolean;
|
||||||
|
analytics?: boolean;
|
||||||
|
functional?: boolean;
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve functional consent with layered fallback:
|
||||||
|
* 1) explicit event detail, 2) CookieConsent public API, 3) dataset mirror.
|
||||||
|
*/
|
||||||
|
const hasFunctionalConsent = (consentDetail?: CommentsConsentState | null): boolean => {
|
||||||
|
const cookieConsent = (window as Window & {
|
||||||
|
cookieConsent?: {
|
||||||
|
hasConsent?: (category: 'necessary' | 'analytics' | 'functional') => boolean;
|
||||||
|
};
|
||||||
|
}).cookieConsent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
consentDetail?.functional ??
|
||||||
|
cookieConsent?.hasConsent?.('functional') ??
|
||||||
|
document.documentElement.dataset.consentFunctional === 'true'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DeferredScript {
|
||||||
|
placeholder: Comment;
|
||||||
|
script: HTMLScriptElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepareDeferredScripts = (fragment: DocumentFragment): DeferredScript[] => {
|
||||||
|
return Array.from(fragment.querySelectorAll('script')).map(script => {
|
||||||
|
const placeholder = document.createComment('comments-script-placeholder');
|
||||||
|
script.replaceWith(placeholder);
|
||||||
|
return { placeholder, script };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const activateDeferredScripts = async (scripts: DeferredScript[]): Promise<void> => {
|
||||||
|
// Inject scripts sequentially so third-party embeds can rely on execution order.
|
||||||
|
for (const { placeholder, script } of scripts) {
|
||||||
|
if (!placeholder.parentNode) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newScript = document.createElement('script');
|
||||||
|
Array.from(script.attributes).forEach(attr => {
|
||||||
|
newScript.setAttribute(attr.name, attr.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (script.textContent) {
|
||||||
|
newScript.text = script.textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let done: Promise<void> = Promise.resolve();
|
||||||
|
if (newScript.src) {
|
||||||
|
done = new Promise<void>(resolve => {
|
||||||
|
newScript.onload = newScript.onerror = () => resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholder.replaceWith(newScript);
|
||||||
|
await done;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initCommentsConsent = (): void => {
|
||||||
|
// Main entry: gate comments rendering by functional cookie consent.
|
||||||
|
const placeholder = document.getElementById('comments-consent-placeholder') as HTMLElement | null;
|
||||||
|
const container = document.getElementById('comments-container') as HTMLElement | null;
|
||||||
|
const template = document.getElementById('comments-template') as HTMLTemplateElement | null;
|
||||||
|
|
||||||
|
if (!placeholder || !container || !template) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let commentsLoaded = false;
|
||||||
|
let commentsLoading = false;
|
||||||
|
|
||||||
|
const showComments = async (): Promise<void> => {
|
||||||
|
placeholder.style.display = 'none';
|
||||||
|
container.style.display = 'block';
|
||||||
|
|
||||||
|
if (commentsLoaded || commentsLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent duplicate template hydration while scripts are still loading.
|
||||||
|
commentsLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clone = template.content.cloneNode(true) as DocumentFragment;
|
||||||
|
const scripts = prepareDeferredScripts(clone);
|
||||||
|
|
||||||
|
container.appendChild(clone);
|
||||||
|
await activateDeferredScripts(scripts);
|
||||||
|
commentsLoaded = true;
|
||||||
|
} finally {
|
||||||
|
commentsLoading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideComments = (): void => {
|
||||||
|
placeholder.style.display = 'block';
|
||||||
|
container.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('onCookieConsentChange', (event: Event) => {
|
||||||
|
// Cross-module contract: this event is dispatched by cookies.ts.
|
||||||
|
const customEvent = event as CustomEvent<CommentsConsentState | null>;
|
||||||
|
if (hasFunctionalConsent(customEvent.detail)) {
|
||||||
|
showComments();
|
||||||
|
} else {
|
||||||
|
hideComments();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasFunctionalConsent()) {
|
||||||
|
showComments();
|
||||||
|
} else {
|
||||||
|
hideComments();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initCommentsConsent, { once: true });
|
||||||
|
} else {
|
||||||
|
initCommentsConsent();
|
||||||
|
}
|
||||||
@@ -175,6 +175,7 @@ class CookieConsent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private dispatchConsentEvent(): void {
|
private dispatchConsentEvent(): void {
|
||||||
|
// Cross-module event consumed by consent-gated features (for example commentsConsent.ts).
|
||||||
const event = new CustomEvent('onCookieConsentChange', {
|
const event = new CustomEvent('onCookieConsentChange', {
|
||||||
detail: this.state
|
detail: this.state
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
{{/*
|
||||||
|
Comments include entry.
|
||||||
|
- If functional cookie consent is required, render a placeholder + template and load commentsConsent.ts.
|
||||||
|
- Otherwise, render provider partial directly.
|
||||||
|
*/}}
|
||||||
{{ if .Site.Params.comments.enabled }}
|
{{ if .Site.Params.comments.enabled }}
|
||||||
{{- $needsConsent := and .Site.Params.cookies.enabled .Site.Params.cookies.categories.functional -}}
|
{{- $needsConsent := and .Site.Params.cookies.enabled .Site.Params.cookies.categories.functional -}}
|
||||||
{{- if $needsConsent -}}
|
{{- if $needsConsent -}}
|
||||||
@@ -8,40 +13,16 @@
|
|||||||
{{ T "cookies.managePreferences" }}
|
{{ T "cookies.managePreferences" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="comments-container" style="display: none;">
|
<div id="comments-container" style="display: none;"></div>
|
||||||
|
<template id="comments-template">
|
||||||
{{ partial (printf "comments/provider/%s" .Site.Params.comments.provider) . }}
|
{{ partial (printf "comments/provider/%s" .Site.Params.comments.provider) . }}
|
||||||
</div>
|
</template>
|
||||||
<script>
|
{{- $opts := dict "minify" hugo.IsProduction -}}
|
||||||
(function() {
|
{{/* commentsConsent.ts is intentionally built and loaded here as an independent entry */}}
|
||||||
var placeholder = document.getElementById('comments-consent-placeholder');
|
{{- $commentsScript := resources.Get "ts/commentsConsent.ts" | js.Build $opts | fingerprint -}}
|
||||||
var container = document.getElementById('comments-container');
|
<script type="text/javascript" src="{{ $commentsScript.RelPermalink }}" integrity="{{ $commentsScript.Data.Integrity }}" defer></script>
|
||||||
|
|
||||||
function showComments() {
|
|
||||||
if (placeholder) placeholder.style.display = 'none';
|
|
||||||
if (container) container.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideComments() {
|
|
||||||
if (placeholder) placeholder.style.display = 'block';
|
|
||||||
if (container) container.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('onCookieConsentChange', function(e) {
|
|
||||||
if (e.detail && e.detail.functional) {
|
|
||||||
showComments();
|
|
||||||
} else {
|
|
||||||
hideComments();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if already consented
|
|
||||||
if (window.cookieConsent && window.cookieConsent.hasConsent('functional')) {
|
|
||||||
showComments();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{{- else -}}
|
{{- else -}}
|
||||||
{{/* No consent required - load comments normally */}}
|
{{/* No consent required - load comments normally */}}
|
||||||
{{ partial (printf "comments/provider/%s" .Site.Params.comments.provider) . }}
|
{{ partial (printf "comments/provider/%s" .Site.Params.comments.provider) . }}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
Reference in New Issue
Block a user