Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions packages/plugins/google-tag-manager/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@emdash-cms/plugin-google-tag-manager",
"version": "0.1.0",
"description": "Google Tag Manager plugin for EmDash CMS",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./sandbox": "./src/sandbox-entry.ts"
},
"files": [
"src"
],
"keywords": [
"emdash",
"cms",
"plugin",
"gtm",
"google tag manager",
"analytics"
],
"author": "Your Name",
"license": "MIT",
"dependencies": {},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The author field contains the placeholder value "Your Name". Update it to the actual author or remove the field.

Suggested change
"dependencies": {},
"author": "Ryota Yamada",

"peerDependencies": {
"emdash": "workspace:*"
},
"devDependencies": {},
"scripts": {
"typecheck": "tsgo --noEmit"
},
"optionalDependencies": {},
"repository": {
"type": "git",
"url": "git+https://github.com/emdash-cms/emdash.git",
"directory": "packages/plugins/google-tag-manager"
}
}
13 changes: 13 additions & 0 deletions packages/plugins/google-tag-manager/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { PluginDescriptor } from "emdash";

export function googleTagManagerPlugin(): PluginDescriptor {
return {
id: "google-tag-manager",
version: "0.1.0",
format: "standard",
entrypoint: "@emdash-cms/plugin-google-tag-manager/sandbox",
options: {},
capabilities: ["page:inject"],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The plugin declares the deprecated capability "page:inject". This name exists only in the deprecation window and is normalized to "hooks.page-fragments:register" at runtime. New plugins should use the current canonical capability name to avoid future breakage.

Suggested change
capabilities: ["page:inject"],
capabilities: ["hooks.page-fragments:register"],

adminPages: [{ path: "/settings", label: "Google Tag Manager", icon: "activity" }],
};
}
135 changes: 135 additions & 0 deletions packages/plugins/google-tag-manager/src/sandbox-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";

export default definePlugin({
hooks: {
"page:fragments": async (_event: unknown, ctx: PluginContext) => {
const containerId = await ctx.kv.get<string>("settings:gtmContainerId");
if (!containerId) return null;

const dataLayerName = (await ctx.kv.get<string>("settings:gtmDataLayerName")) || "dataLayer";
const gtmScriptUrl =
(await ctx.kv.get<string>("settings:gtmScriptUrl")) ||
"https://www.googletagmanager.com/gtm.js";
const gtmNoScriptUrl =
(await ctx.kv.get<string>("settings:gtmNoScriptUrl")) ||
"https://www.googletagmanager.com/ns.html";

return [
{
kind: "inline-script",
placement: "head",
code: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'${gtmScriptUrl}?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','${dataLayerName}','${containerId}');`,
Comment on lines +22 to +26
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The page:fragments hook interpolates user-controlled KV values (gtmScriptUrl, dataLayerName, containerId) into single-quoted JavaScript string literals without escaping. A single quote or backslash in any of these values breaks the string literal and allows arbitrary code execution on every public page. For example, a gtmScriptUrl of https://evil.com/x';alert(1);var x=' would execute alert(1). The core renderFragment helper only escapes </ sequences in inline scripts; it does not escape single quotes. The plugin must sanitize before interpolation—at minimum by escaping \ and ', or by segmenting the values safely, and ideally by validating against expected formats.

},
{
kind: "html",
placement: "body:start",
html: `<noscript><iframe src="${gtmNoScriptUrl}?id=${containerId}" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The html fragment interpolates gtmNoScriptUrl and containerId directly into an <iframe src> attribute without HTML-escaping or URL-encoding. A double quote in either value breaks out of the src attribute and injects arbitrary HTML. An ampersand in containerId injects extra query parameters. The renderFragment helper returns html contributions verbatim, so the plugin must escape attribute metacharacters ("&quot;, &&amp;) and use encodeURIComponent for the query string.

},
];
},
},

routes: {
admin: {
handler: async (
routeCtx: { input: unknown; request: { url: string } },
ctx: PluginContext,
) => {
const interaction = routeCtx.input as {
type: string;
page?: string;
action_id?: string;
values?: Record<string, string>;
};

if (interaction.type === "page_load" && interaction.page === "/settings") {
return buildSettingsBlocks(ctx);
}

if (interaction.type === "form_submit" && interaction.action_id === "save_gtm") {
const values = interaction.values || {};
await ctx.kv.set("settings:gtmContainerId", values.gtm_container_id || "");
await ctx.kv.set("settings:gtmDataLayerName", values.gtm_data_layer_name || "dataLayer");
await ctx.kv.set(
"settings:gtmScriptUrl",
values.gtm_script_url || "https://www.googletagmanager.com/gtm.js",
);
await ctx.kv.set(
"settings:gtmNoScriptUrl",
values.gtm_noscript_url || "https://www.googletagmanager.com/ns.html",
);
Comment on lines +54 to +65
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The admin form submission handler stores arbitrary strings from interaction.values directly into KV without validation. gtm_script_url and gtm_noscript_url should be validated as HTTPS URLs (or at least valid URLs), and gtm_container_id should match the expected GTM-XXXXXXX format. Without validation, invalid or malicious input can break every public page or be exploited as stored XSS via the page:fragments hook.


const response = await buildSettingsBlocks(ctx);
return {
...response,
toast: { message: "GTM settings saved", type: "success" },
};
}

return { blocks: [] };
},
},
},
});

async function buildSettingsBlocks(ctx: PluginContext) {
const containerId = (await ctx.kv.get<string>("settings:gtmContainerId")) || "";
const dataLayerName = (await ctx.kv.get<string>("settings:gtmDataLayerName")) || "dataLayer";
const gtmScriptUrl =
(await ctx.kv.get<string>("settings:gtmScriptUrl")) ||
"https://www.googletagmanager.com/gtm.js";
const gtmNoScriptUrl =
(await ctx.kv.get<string>("settings:gtmNoScriptUrl")) ||
"https://www.googletagmanager.com/ns.html";

return {
blocks: [
{ type: "header", text: "Google Tag Manager" },
{
type: "section",
text: "Configure your Google Tag Manager container and advanced URLs.",
},
{ type: "divider" },
{
type: "form",
block_id: "gtm_settings_form",
fields: [
{
type: "text_input",
action_id: "gtm_container_id",
label: "Container ID",
initial_value: containerId,
placeholder: "GTM-XXXXXXX",
},
{
type: "text_input",
action_id: "gtm_data_layer_name",
label: "Data Layer Name",
initial_value: dataLayerName,
placeholder: "dataLayer",
},
{
type: "text_input",
action_id: "gtm_script_url",
label: "GTM Script URL (gtm.js)",
initial_value: gtmScriptUrl,
placeholder: "https://www.googletagmanager.com/gtm.js",
},
{
type: "text_input",
action_id: "gtm_noscript_url",
label: "GTM NoScript URL (ns.html)",
initial_value: gtmNoScriptUrl,
placeholder: "https://www.googletagmanager.com/ns.html",
},
],
submit: { label: "Save Settings", action_id: "save_gtm" },
},
],
};
}
10 changes: 10 additions & 0 deletions packages/plugins/google-tag-manager/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading