mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-23 07:08:05 +02:00
Référencement : Google Analytics et Search Console réglables en backoffice
Deux réglages dans Backend › Paramètres : - `google_analytics_id` pose les balises gtag.js. Vide = rien n'est chargé. `src/lib/analytics.ts` (pur) n'accepte qu'un identifiant GA4 « G-… » : la saisie ne peut donc pas s'échapper du script en ligne. Les deux `<script>` portent le nonce de la CSP, et seules les pages publiques sont mesurées (`isTrackedPath` écarte `PRIVATE_PATHS`, le chemin venant du nouvel en-tête `x-pathname` du middleware — un layout ne connaît pas l'URL demandée). - `google_site_verification` rend la balise de validation Search Console via `verification.google`, après contrôle du jeton. La CSP gagne les hôtes Google Analytics en `connect-src` : elle est posée sur le runtime Edge, qui ne peut pas lire la base pour savoir si la mesure est active. `script-src` est inchangé, `'strict-dynamic'` couvrant les scripts que gtag.js charge ensuite. Le plan du site n'a besoin de rien : `/sitemap.xml` est déjà servi et annoncé dans `/robots.txt`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7v5KcYUo2taDVDgehs7ZH
This commit is contained in:
8 files changed
+272
-2
No files matched your search
@@ -221,6 +221,24 @@ même raison.
|
|||||||
`app/manifest.ts` et `app/opengraph-image.tsx` (vignette 1200×630 générée).
|
`app/manifest.ts` et `app/opengraph-image.tsx` (vignette 1200×630 générée).
|
||||||
- `/backend`, `/login`, `/inscription/*` et les fiches non actives sont en
|
- `/backend`, `/login`, `/inscription/*` et les fiches non actives sont en
|
||||||
`NOINDEX` ; les pages publiques utilisent `<main>` et un seul `<h1>`.
|
`NOINDEX` ; les pages publiques utilisent `<main>` et un seul `<h1>`.
|
||||||
|
- **Mesure d'audience** : `src/lib/analytics.ts` est **pur**
|
||||||
|
(`tests/analytics.test.ts`). `normalizeMeasurementId()` n'accepte qu'un
|
||||||
|
identifiant GA4 (`G-…`, ni `UA-…` ni texte libre) : c'est la garde qui interdit
|
||||||
|
d'injecter une chaîne arbitraire dans le script en ligne. Le réglage
|
||||||
|
`google_analytics_id` (Paramètres) vide = **aucune balise posée**.
|
||||||
|
`<Analytics>` (`src/components/Analytics.tsx`) rend les deux `<script>` avec le
|
||||||
|
nonce de la CSP et n'agit que sur les pages publiques : `isTrackedPath()`
|
||||||
|
écarte `PRIVATE_PATHS`, le chemin venant de l'en-tête `x-pathname` posé par le
|
||||||
|
middleware (un layout ne connaît pas l'URL demandée). La CSP liste les hôtes
|
||||||
|
`*.google-analytics.com` / `*.analytics.google.com` / `*.googletagmanager.com`
|
||||||
|
en `connect-src` sans condition : elle est posée sur le runtime Edge, qui ne
|
||||||
|
peut pas interroger la base. `script-src` n'a pas besoin d'eux —
|
||||||
|
`'strict-dynamic'` couvre les scripts que gtag.js charge ensuite.
|
||||||
|
- **Search Console** : le réglage `google_site_verification` alimente
|
||||||
|
`verification.google` dans `app/layout.tsx` (balise `<meta>`).
|
||||||
|
`normalizeSiteVerification()` refuse la balise `<meta>` complète collée telle
|
||||||
|
quelle. Le plan du site est déjà servi sur `/sitemap.xml` et annoncé dans
|
||||||
|
`/robots.txt` : il n'y a aucun fichier à déposer, seulement l'URL à déclarer.
|
||||||
- **Tags adhérents** : `src/lib/tags.ts` (pur) porte un vocabulaire par métier
|
- **Tags adhérents** : `src/lib/tags.ts` (pur) porte un vocabulaire par métier
|
||||||
(`CATEGORY_TAGS`, un par slug du référentiel, vérifié par `tests/tags.test.ts`)
|
(`CATEGORY_TAGS`, un par slug du référentiel, vérifié par `tests/tags.test.ts`)
|
||||||
et un vocabulaire transversal détecté dans la description. `autoTags()` ne
|
et un vocabulaire transversal détecté dans la description. `autoTags()` ne
|
||||||
|
|||||||
@@ -98,6 +98,32 @@ export default async function ParametresPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section style={panel}>
|
||||||
|
<h2 className="font-display" style={title}>Référencement & mesure d'audience</h2>
|
||||||
|
<div className="grid grid-2" style={{ gap: 16 }}>
|
||||||
|
<Field
|
||||||
|
label="Identifiant Google Analytics (GA4)"
|
||||||
|
name="google_analytics_id"
|
||||||
|
value={settings.google_analytics_id}
|
||||||
|
placeholder="G-XXXXXXXXXX"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Jeton Search Console (balise HTML)"
|
||||||
|
name="google_site_verification"
|
||||||
|
value={settings.google_site_verification}
|
||||||
|
placeholder="Valeur de l'attribut content"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 14, color: "#9a6638", fontSize: 13, lineHeight: 1.6 }}>
|
||||||
|
Laissez vide pour ne rien charger : sans identifiant, aucune balise Google n'est
|
||||||
|
posée sur le site. La mesure d'audience ne couvre que les pages publiques — ni le
|
||||||
|
backoffice, ni l'espace adhérent. Le plan du site est déjà publié sur{" "}
|
||||||
|
<code>/sitemap.xml</code> et annoncé dans <code>/robots.txt</code> : il suffit de le
|
||||||
|
déclarer dans Search Console. Pensez à mentionner Google Analytics dans la page
|
||||||
|
Confidentialité avant de l'activer.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<button type="submit" style={submitButton}>Enregistrer les paramètres</button>
|
<button type="submit" style={submitButton}>Enregistrer les paramètres</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
+11
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { Analytics } from "@/components/Analytics";
|
||||||
import { JsonLd } from "@/components/JsonLd";
|
import { JsonLd } from "@/components/JsonLd";
|
||||||
import {
|
import {
|
||||||
INDEX_FOLLOW,
|
INDEX_FOLLOW,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
organizationJsonLd,
|
organizationJsonLd,
|
||||||
webSiteJsonLd,
|
webSiteJsonLd,
|
||||||
} from "@/lib/seo";
|
} from "@/lib/seo";
|
||||||
|
import { normalizeSiteVerification } from "@/lib/analytics";
|
||||||
import { metadataBase, publicBaseUrl } from "@/lib/seo-server";
|
import { metadataBase, publicBaseUrl } from "@/lib/seo-server";
|
||||||
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
@@ -33,8 +35,14 @@ export const viewport: Viewport = {
|
|||||||
* relatives (canonique, Open Graph, image de partage).
|
* relatives (canonique, Open Graph, image de partage).
|
||||||
*/
|
*/
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const [base, settings] = await Promise.all([
|
||||||
|
metadataBase(),
|
||||||
|
getSiteSettings().catch(() => null),
|
||||||
|
]);
|
||||||
|
// Jeton « balise HTML » de Search Console, collé dans Backend › Paramètres.
|
||||||
|
const google = normalizeSiteVerification(settings?.google_site_verification);
|
||||||
return {
|
return {
|
||||||
metadataBase: await metadataBase(),
|
metadataBase: base,
|
||||||
title: {
|
title: {
|
||||||
default: SITE_TITLE,
|
default: SITE_TITLE,
|
||||||
template: `%s · ${SITE_NAME}`,
|
template: `%s · ${SITE_NAME}`,
|
||||||
@@ -63,6 +71,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
description: SITE_DESCRIPTION,
|
description: SITE_DESCRIPTION,
|
||||||
},
|
},
|
||||||
robots: INDEX_FOLLOW,
|
robots: INDEX_FOLLOW,
|
||||||
|
verification: google ? { google } : undefined,
|
||||||
icons: {
|
icons: {
|
||||||
icon: [{ url: "/assets/logo.png", type: "image/png" }],
|
icon: [{ url: "/assets/logo.png", type: "image/png" }],
|
||||||
apple: [{ url: "/assets/logo.png", type: "image/png" }],
|
apple: [{ url: "/assets/logo.png", type: "image/png" }],
|
||||||
@@ -108,6 +117,7 @@ export default async function RootLayout({
|
|||||||
<body>
|
<body>
|
||||||
{children}
|
{children}
|
||||||
<JsonLd data={structuredData} />
|
<JsonLd data={structuredData} />
|
||||||
|
<Analytics measurementId={settings?.google_analytics_id ?? ""} />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { gtagConfigScript, gtagSrc, isTrackedPath, normalizeMeasurementId } from "@/lib/analytics";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Balises Google Analytics 4, posées uniquement si l'identifiant de mesure est
|
||||||
|
* renseigné dans Backend › Paramètres et si la page est publique.
|
||||||
|
*
|
||||||
|
* Les deux scripts portent le nonce de la CSP (`src/middleware.ts`) ; le
|
||||||
|
* chemin est lu dans l'en-tête `x-pathname` que le middleware recopie, un
|
||||||
|
* layout n'ayant pas accès à l'URL demandée.
|
||||||
|
*/
|
||||||
|
export async function Analytics({ measurementId }: { measurementId: string }) {
|
||||||
|
const id = normalizeMeasurementId(measurementId);
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
if (!isTrackedPath(requestHeaders.get("x-pathname") ?? "/")) return null;
|
||||||
|
const nonce = requestHeaders.get("x-nonce") ?? undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script async src={gtagSrc(id)} nonce={nonce} />
|
||||||
|
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: gtagConfigScript(id) }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { PRIVATE_PATHS } from "@/lib/seo";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mesure d'audience Google Analytics 4.
|
||||||
|
*
|
||||||
|
* Module **pur** (verrouillé par `tests/analytics.test.ts`) : il ne sait ni lire
|
||||||
|
* la base ni rendre du HTML, il décide seulement ce qui part dans la page.
|
||||||
|
* L'identifiant vient du réglage `google_analytics_id` (Backend › Paramètres) :
|
||||||
|
* tant qu'il est vide, aucune balise n'est posée et aucun appel n'est fait.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Identifiant de mesure GA4 : « G- » suivi de l'identifiant du flux. */
|
||||||
|
const MEASUREMENT_ID = /^G-[A-Z0-9]{4,24}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide la saisie de l'administrateur avant qu'elle n'atteigne la page.
|
||||||
|
*
|
||||||
|
* Renvoie `""` pour tout ce qui n'est pas un identifiant GA4 : c'est la garde
|
||||||
|
* qui interdit d'injecter une chaîne arbitraire dans le script en ligne — un
|
||||||
|
* identifiant validé ne contient ni guillemet, ni `<`, ni `</script>`.
|
||||||
|
* Les identifiants Universal Analytics (`UA-…`), arrêtés depuis 2023, sont
|
||||||
|
* refusés : ils ne collectent plus rien.
|
||||||
|
*/
|
||||||
|
export function normalizeMeasurementId(raw: string | null | undefined): string {
|
||||||
|
const value = (raw ?? "").trim().toUpperCase();
|
||||||
|
return MEASUREMENT_ID.test(value) ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL du chargeur gtag.js pour un identifiant **déjà normalisé**. */
|
||||||
|
export function gtagSrc(measurementId: string): string {
|
||||||
|
const id = normalizeMeasurementId(measurementId);
|
||||||
|
return id ? `https://www.googletagmanager.com/gtag/js?id=${id}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script d'amorçage de gtag.js. Il porte le nonce de la CSP comme le reste des
|
||||||
|
* scripts du site ; les scripts que gtag.js charge ensuite passent grâce à
|
||||||
|
* `'strict-dynamic'`.
|
||||||
|
*/
|
||||||
|
export function gtagConfigScript(measurementId: string): string {
|
||||||
|
const id = normalizeMeasurementId(measurementId);
|
||||||
|
if (!id) return "";
|
||||||
|
return [
|
||||||
|
"window.dataLayer=window.dataLayer||[];",
|
||||||
|
"function gtag(){dataLayer.push(arguments);}",
|
||||||
|
"gtag('js',new Date());",
|
||||||
|
`gtag('config','${id}');`,
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seules les pages publiques sont mesurées.
|
||||||
|
*
|
||||||
|
* L'espace adhérent et le backoffice portent des identifiants de membres et de
|
||||||
|
* promotions dans leurs URLs : les envoyer à un tiers n'apporterait rien et
|
||||||
|
* ferait sortir des données personnelles du site.
|
||||||
|
*/
|
||||||
|
export function isTrackedPath(pathname: string): boolean {
|
||||||
|
const path = pathname.split("?")[0] ?? "/";
|
||||||
|
return !PRIVATE_PATHS.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jeton de validation Search Console (balise `google-site-verification`). */
|
||||||
|
export function normalizeSiteVerification(raw: string | null | undefined): string {
|
||||||
|
const value = (raw ?? "").trim();
|
||||||
|
// Google livre un jeton alphanumérique d'une quarantaine de caractères ; on
|
||||||
|
// refuse le reste — dont la balise <meta> complète, collée telle quelle —
|
||||||
|
// plutôt que de recopier dans le <head> une chaîne venue de n'importe où.
|
||||||
|
return /^[A-Za-z0-9_-]{20,128}$/.test(value) ? value : "";
|
||||||
|
}
|
||||||
@@ -44,6 +44,10 @@ export const SITE_SETTING_DEFAULTS = {
|
|||||||
association_publication_director: "",
|
association_publication_director: "",
|
||||||
association_privacy_contact: "",
|
association_privacy_contact: "",
|
||||||
legal_updated: "",
|
legal_updated: "",
|
||||||
|
// Référencement : identifiant de mesure GA4 (« G-… ») et jeton de
|
||||||
|
// validation Search Console. Vides = aucune balise posée sur le site.
|
||||||
|
google_analytics_id: "",
|
||||||
|
google_site_verification: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SiteSettings = typeof SITE_SETTING_DEFAULTS;
|
export type SiteSettings = typeof SITE_SETTING_DEFAULTS;
|
||||||
|
|||||||
+8
-1
@@ -32,7 +32,11 @@ function contentSecurityPolicy(nonce: string, isDev: boolean, isHttps: boolean):
|
|||||||
"font-src 'self' https://fonts.gstatic.com",
|
"font-src 'self' https://fonts.gstatic.com",
|
||||||
// data: pour les images stockées en data-URI, https: pour les vignettes distantes.
|
// data: pour les images stockées en data-URI, https: pour les vignettes distantes.
|
||||||
"img-src 'self' data: https:",
|
"img-src 'self' data: https:",
|
||||||
"connect-src 'self'",
|
// Mesure d'audience GA4 : gtag.js renvoie ses relevés par `fetch`. Les
|
||||||
|
// hôtes sont listés même sans identifiant configuré — la CSP est posée
|
||||||
|
// sur le runtime Edge, qui ne peut pas interroger la base pour savoir si
|
||||||
|
// la mesure est active.
|
||||||
|
"connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com",
|
||||||
// Carte « Nous situer » de la fiche adhérent.
|
// Carte « Nous situer » de la fiche adhérent.
|
||||||
"frame-src https://www.google.com https://maps.google.com",
|
"frame-src https://www.google.com https://maps.google.com",
|
||||||
// Uniquement quand la page est servie en HTTPS : sur une page HTTP (test
|
// Uniquement quand la page est servie en HTTPS : sur une page HTTP (test
|
||||||
@@ -55,6 +59,9 @@ export default auth((request: NextRequest) => {
|
|||||||
// ses balises <script>. Il est ensuite renvoyé sur la réponse au navigateur.
|
// ses balises <script>. Il est ensuite renvoyé sur la réponse au navigateur.
|
||||||
const requestHeaders = new Headers(request.headers);
|
const requestHeaders = new Headers(request.headers);
|
||||||
requestHeaders.set("x-nonce", nonce);
|
requestHeaders.set("x-nonce", nonce);
|
||||||
|
// Un layout ne connaît pas l'URL demandée : `Analytics` s'en sert pour ne
|
||||||
|
// mesurer que les pages publiques.
|
||||||
|
requestHeaders.set("x-pathname", request.nextUrl.pathname);
|
||||||
requestHeaders.set("content-security-policy", csp);
|
requestHeaders.set("content-security-policy", csp);
|
||||||
|
|
||||||
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
gtagConfigScript,
|
||||||
|
gtagSrc,
|
||||||
|
isTrackedPath,
|
||||||
|
normalizeMeasurementId,
|
||||||
|
normalizeSiteVerification,
|
||||||
|
} from "../src/lib/analytics";
|
||||||
|
|
||||||
|
describe("Identifiant de mesure GA4", () => {
|
||||||
|
it("accepte un identifiant de flux", () => {
|
||||||
|
assert.equal(normalizeMeasurementId("G-ABC1234567"), "G-ABC1234567");
|
||||||
|
assert.equal(normalizeMeasurementId(" g-abc1234567 "), "G-ABC1234567");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuse une saisie vide ou absente", () => {
|
||||||
|
assert.equal(normalizeMeasurementId(""), "");
|
||||||
|
assert.equal(normalizeMeasurementId(null), "");
|
||||||
|
assert.equal(normalizeMeasurementId(undefined), "");
|
||||||
|
assert.equal(normalizeMeasurementId(" "), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuse Universal Analytics, arrêté depuis 2023", () => {
|
||||||
|
assert.equal(normalizeMeasurementId("UA-12345-1"), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuse tout ce qui pourrait s'échapper du script en ligne", () => {
|
||||||
|
for (const payload of [
|
||||||
|
"G-ABC12345');alert(1);//",
|
||||||
|
"G-ABC12345</script><script>alert(1)</script>",
|
||||||
|
'G-ABC"12345',
|
||||||
|
"G-ABC 12345",
|
||||||
|
"<script>alert(1)</script>",
|
||||||
|
]) {
|
||||||
|
assert.equal(normalizeMeasurementId(payload), "", `accepté à tort : ${payload}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Balises gtag.js", () => {
|
||||||
|
it("ne rend rien sans identifiant valide", () => {
|
||||||
|
assert.equal(gtagSrc(""), "");
|
||||||
|
assert.equal(gtagSrc("UA-12345-1"), "");
|
||||||
|
assert.equal(gtagConfigScript(""), "");
|
||||||
|
assert.equal(gtagConfigScript("pas-un-identifiant"), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("charge gtag.js depuis Google avec l'identifiant configuré", () => {
|
||||||
|
assert.equal(
|
||||||
|
gtagSrc("G-ABC1234567"),
|
||||||
|
"https://www.googletagmanager.com/gtag/js?id=G-ABC1234567",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produit un script d'amorçage qui ne peut pas être refermé", () => {
|
||||||
|
const script = gtagConfigScript("G-ABC1234567");
|
||||||
|
assert.ok(script.includes("gtag('config','G-ABC1234567');"));
|
||||||
|
assert.ok(!script.includes("</script"));
|
||||||
|
assert.ok(!script.includes("<"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Pages mesurées", () => {
|
||||||
|
it("mesure les pages publiques", () => {
|
||||||
|
for (const path of ["/", "/annuaire", "/promotions", "/adherents/12-au-bon-pain", "/association"]) {
|
||||||
|
assert.equal(isTrackedPath(path), true, `devrait être mesurée : ${path}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ne mesure ni le backoffice, ni l'espace adhérent, ni les URLs privées", () => {
|
||||||
|
for (const path of [
|
||||||
|
"/backend",
|
||||||
|
"/backend/espace/promotions",
|
||||||
|
"/login",
|
||||||
|
"/inscription/adherent",
|
||||||
|
"/api/social/facebook/callback",
|
||||||
|
]) {
|
||||||
|
assert.equal(isTrackedPath(path), false, `ne devrait pas être mesurée : ${path}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ne confond pas un préfixe avec un début de mot", () => {
|
||||||
|
assert.equal(isTrackedPath("/backendeur"), true);
|
||||||
|
assert.equal(isTrackedPath("/apiculteurs"), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Jeton Search Console", () => {
|
||||||
|
it("accepte le jeton livré par Google", () => {
|
||||||
|
assert.equal(
|
||||||
|
normalizeSiteVerification(" aBc123_def-456ghijkl "),
|
||||||
|
"aBc123_def-456ghijkl",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuse une balise complète ou tout autre collage", () => {
|
||||||
|
for (const payload of [
|
||||||
|
'<meta name="google-site-verification" content="abc">',
|
||||||
|
"",
|
||||||
|
"trop-court",
|
||||||
|
"abc123 def456ghij",
|
||||||
|
"abc\"><script>alert(1)</script>",
|
||||||
|
]) {
|
||||||
|
assert.equal(normalizeSiteVerification(payload), "", `accepté à tort : ${payload}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user