fix: googletagmanager not included in CSP unless enabled through env (#13195)

* fix: googletagmanager not incuded in CSP unless enabled through ENV

* Remove other points of CSP surgery, hey it proves the value of the refactor
This commit is contained in:
Tom Moor
2026-07-29 20:48:37 -04:00
committed by GitHub
parent 97964071f2
commit f228714c7a
8 changed files with 264 additions and 96 deletions
+66 -31
View File
@@ -1,6 +1,7 @@
import crypto from "node:crypto";
import type { Context, Next } from "koa";
import { contentSecurityPolicy } from "koa-helmet";
import { asyncNoop } from "es-toolkit";
import { uniq } from "es-toolkit/compat";
import env from "@server/env";
@@ -38,6 +39,28 @@ interface CSPOptions {
extraScriptSrc?: string[];
}
/**
* Allow additional origins as script sources in the Content Security Policy of
* the current response. Must be called before the response is sent.
*
* @param ctx The Koa context of the current request.
* @param sources The origins to allow.
*/
export function allowScriptSrc(ctx: Context, sources: string[]) {
ctx.state.cspScriptSrc = [...(ctx.state.cspScriptSrc ?? []), ...sources];
}
/**
* Allow additional origins as style sources in the Content Security Policy of
* the current response. Must be called before the response is sent.
*
* @param ctx The Koa context of the current request.
* @param sources The origins to allow.
*/
export function allowStyleSrc(ctx: Context, sources: string[]) {
ctx.state.cspStyleSrc = [...(ctx.state.cspStyleSrc ?? []), ...sources];
}
/**
* Create a Content Security Policy middleware for the application.
*
@@ -81,39 +104,51 @@ export default function createCSPMiddleware(options?: CSPOptions) {
objectSrc.push(bucketOrigin);
}
return function cspMiddleware(ctx: Context, next: Next) {
return async function cspMiddleware(ctx: Context, next: Next) {
ctx.state.cspNonce = crypto.randomBytes(16).toString("hex");
ctx.state.cspScriptSrc = [];
ctx.state.cspStyleSrc = [];
// Note: workerSrc is included even though it's missing from the koa-helmet
// type definitions — the underlying helmet supports it. The service worker
// is served from the same origin as the document, which may be a custom
// domain that is not present in scriptSrc.
const directives = {
baseUri: ["'none'"],
defaultSrc,
styleSrc,
scriptSrc: [
...uniq(scriptSrc),
// Allow the service worker to importScripts the workbox runtime, which
// is served under /static on the document host.. Scoped to the /static
// path so only immutable build assets are permitted, not ugc served
// elsewhere on the same origin.
`${ctx.host}/static/`,
...(options?.extraScriptSrc ?? []),
env.DEVELOPMENT_UNSAFE_INLINE_CSP
? "'unsafe-inline'"
: `'nonce-${ctx.state.cspNonce}'`,
],
mediaSrc: ["*", "data:", "blob:"],
imgSrc: ["*", "data:", "blob:"],
frameSrc: ["*", "data:"],
workerSrc: ["'self'"],
objectSrc,
// Do not use connect-src: because self + websockets does not work in
// Safari, ref: https://bugs.webkit.org/show_bug.cgi?id=201591
connectSrc: ["*"],
};
try {
await next();
} finally {
// The policy is written once downstream middleware has had the chance to
// allow additional sources for the response, see `allowScriptSrc` and
// `allowStyleSrc`.
if (!ctx.res.headersSent) {
// Note: workerSrc is included even though it's missing from the
// koa-helmet type definitions — the underlying helmet supports it. The
// service worker is served from the same origin as the document, which
// may be a custom domain that is not present in scriptSrc.
const directives = {
baseUri: ["'none'"],
defaultSrc,
styleSrc: uniq([...styleSrc, ...(ctx.state.cspStyleSrc as string[])]),
scriptSrc: uniq([
...scriptSrc,
// Allow the service worker to importScripts the workbox runtime,
// which is served under /static on the document host.. Scoped to
// the /static path so only immutable build assets are permitted,
// not ugc served elsewhere on the same origin.
`${ctx.host}/static/`,
...(options?.extraScriptSrc ?? []),
...(ctx.state.cspScriptSrc as string[]),
env.DEVELOPMENT_UNSAFE_INLINE_CSP
? "'unsafe-inline'"
: `'nonce-${ctx.state.cspNonce}'`,
]),
mediaSrc: ["*", "data:", "blob:"],
imgSrc: ["*", "data:", "blob:"],
frameSrc: ["*", "data:"],
workerSrc: ["'self'"],
objectSrc,
// Do not use connect-src: because self + websockets does not work in
// Safari, ref: https://bugs.webkit.org/show_bug.cgi?id=201591
connectSrc: ["*"],
};
return contentSecurityPolicy({ directives })(ctx, next);
await contentSecurityPolicy({ directives })(ctx, asyncNoop);
}
}
};
}
+21
View File
@@ -35,6 +35,27 @@ class Integration<T = unknown> extends ParanoidModel<
InferAttributes<Integration<T>>,
Partial<InferCreationAttributes<Integration<T>>>
> {
/**
* Load the analytics integrations enabled for a team.
*
* @param teamId The team to load integrations for, if any.
* @returns the team's analytics integrations.
*/
public static async findAnalyticsIntegrationsForTeam(
teamId: string | undefined
): Promise<Integration<IntegrationType.Analytics>[]> {
if (!teamId) {
return [];
}
return this.findAll({
where: {
teamId,
type: IntegrationType.Analytics,
},
});
}
@IsIn([Object.values(IntegrationType)])
@Column(DataType.STRING)
type: IntegrationType;
@@ -0,0 +1,51 @@
import { uniq } from "es-toolkit/compat";
import { IntegrationService, type IntegrationType } from "@shared/types";
import env from "@server/env";
import type Integration from "@server/models/Integration";
/**
* Helper class for working with integrations.
*/
export default class IntegrationHelper {
/**
* Returns the script sources that must be allowed by the Content Security
* Policy for the given analytics integrations to load.
*
* @param integrations The analytics integrations.
* @returns A list of CSP script sources.
*/
public static getAnalyticsScriptSrc(
integrations: Integration<IntegrationType.Analytics>[] = []
): string[] {
// The cloud policy already allows the third-party services that are
// available to hosted teams.
if (env.isCloudHosted) {
return [];
}
return uniq(
integrations.flatMap((integration) =>
IntegrationHelper.scriptSrcForIntegration(integration)
)
);
}
private static scriptSrcForIntegration(
integration: Integration<IntegrationType.Analytics>
): string[] {
if (integration.service === IntegrationService.GoogleAnalytics) {
return ["www.googletagmanager.com", "www.google-analytics.com"];
}
// Self-hosted analytics services are loaded from the configured instance.
if (integration.settings?.instanceUrl) {
try {
return [new URL(integration.settings.instanceUrl).host];
} catch {
return [];
}
}
return [];
}
}
+10 -19
View File
@@ -6,14 +6,16 @@ import { escape } from "es-toolkit/compat";
import { Sequelize } from "sequelize";
import isUUID from "validator/lib/isUUID";
import {
IntegrationType,
TeamPreference,
type IntegrationType,
type NavigationNode,
} from "@shared/types";
import { unicodeCLDRtoISO639 } from "@shared/utils/date";
import env from "@server/env";
import { allowScriptSrc } from "@server/middlewares/csp";
import { Integration } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import IntegrationHelper from "@server/models/helpers/IntegrationHelper";
import presentEnv from "@server/presenters/env";
import { getTeamFromContext } from "@server/utils/passport";
import prefetchTags from "@server/utils/prefetchTags";
@@ -99,18 +101,10 @@ export const renderApp = async (
return next();
}
if (!env.isCloudHosted) {
options.analytics?.forEach((integration) => {
if (integration.settings?.instanceUrl) {
const parsed = new URL(integration.settings?.instanceUrl);
const csp = ctx.response.get("Content-Security-Policy");
ctx.set(
"Content-Security-Policy",
csp.replace("script-src", `script-src ${parsed.host}`)
);
}
});
}
allowScriptSrc(
ctx,
IntegrationHelper.getAnalyticsScriptSrc(options.analytics)
);
const { shareId } = ctx.params;
const page = await readIndexFile();
@@ -235,12 +229,9 @@ export const renderShare = async (ctx: Context, next: Next) => {
return;
}
analytics = await Integration.findAll({
where: {
teamId: share.teamId,
type: IntegrationType.Analytics,
},
});
analytics = await Integration.findAnalyticsIntegrationsForTeam(
share.teamId
);
if (share && !ctx.userAgent.isBot) {
await share.update(
+9 -35
View File
@@ -2,6 +2,7 @@ import escape from "escape-html";
import type { Context, Next } from "koa";
import env from "@server/env";
import { InvalidRequestError } from "@server/errors";
import { allowScriptSrc, allowStyleSrc } from "@server/middlewares/csp";
/**
* Resize observer script that sends a message to the parent window when content is resized. Inject
@@ -58,15 +59,9 @@ export const renderEmbed = async (ctx: Context, next: Next) => {
ctx.path === "/embeds/gitlab"
) {
const snippetLink = `${url}.js`;
const csp = ctx.response.get("Content-Security-Policy");
// Inject gitlab.com into the script-src and style-src directives
ctx.set(
"Content-Security-Policy",
csp
.replace("script-src", "script-src gitlab.com")
.replace("style-src", "style-src gitlab.com")
);
allowScriptSrc(ctx, ["gitlab.com"]);
allowStyleSrc(ctx, ["gitlab.com"]);
ctx.set("X-Frame-Options", "sameorigin");
ctx.type = "html";
@@ -92,15 +87,9 @@ ${resizeObserverScript(ctx)}
) {
const id = parsed.pathname.split("/")[2];
const gistLink = `https://gist.github.com/${id}.js`;
const csp = ctx.response.get("Content-Security-Policy");
// Inject GitHub domains into the script-src and style-src directives
ctx.set(
"Content-Security-Policy",
csp
.replace("script-src", "script-src gist.github.com")
.replace("style-src", "style-src github.githubassets.com")
);
allowScriptSrc(ctx, ["gist.github.com"]);
allowStyleSrc(ctx, ["github.githubassets.com"]);
ctx.set("X-Frame-Options", "sameorigin");
ctx.type = "html";
@@ -125,13 +114,8 @@ ${resizeObserverScript(ctx)}
ctx.path === "/embeds/dropbox"
) {
const dropboxJs = "https://www.dropbox.com/static/api/2/dropins.js";
const csp = ctx.response.get("Content-Security-Policy");
// Inject Dropbox domain into the script-src directive
ctx.set(
"Content-Security-Policy",
csp.replace("script-src", "script-src www.dropbox.com")
);
allowScriptSrc(ctx, ["www.dropbox.com"]);
ctx.set("X-Frame-Options", "sameorigin");
ctx.type = "html";
@@ -159,7 +143,6 @@ ${resizeObserverScript(ctx)}
ctx.path === "/embeds/pinterest"
) {
const pinterestJs = "https://assets.pinterest.com/js/pinit.js";
const csp = ctx.response.get("Content-Security-Policy");
const pathParts = parsed.pathname.split("/").filter(Boolean);
const isProfile =
@@ -167,18 +150,9 @@ ${resizeObserverScript(ctx)}
(pathParts.length === 2 && pathParts[1].startsWith("_"));
const pinType = isProfile ? "embedUser" : "embedBoard";
ctx.set(
"Content-Security-Policy",
csp
.replace(
"script-src",
"script-src assets.pinterest.com widgets.pinterest.com"
)
.replace(
"style-src",
"style-src assets.pinterest.com widgets.pinterest.com"
)
);
const pinterestSrc = ["assets.pinterest.com", "widgets.pinterest.com"];
allowScriptSrc(ctx, pinterestSrc);
allowStyleSrc(ctx, pinterestSrc);
ctx.set("X-Frame-Options", "sameorigin");
ctx.type = "html";
+98 -1
View File
@@ -1,4 +1,10 @@
import { buildShare, buildDocument } from "@server/test/factories";
import { IntegrationService, IntegrationType } from "@shared/types";
import env from "@server/env";
import {
buildShare,
buildDocument,
buildIntegration,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
@@ -164,6 +170,97 @@ describe("/s/:id", () => {
});
});
describe("content security policy", () => {
// Analytics integrations only extend the policy on self-hosted installations,
// the cloud policy already includes these sources.
beforeEach(() => {
env.URL = "https://outline.example.com";
});
it.each([
[
"gitlab",
"https://gitlab.com/gitlab-org/gitlab/-/snippets/1",
"gitlab.com",
],
["github", "https://gist.github.com/user/abc123", "gist.github.com"],
["dropbox", "https://www.dropbox.com/s/abc123/file.pdf", "www.dropbox.com"],
["pinterest", "https://pinterest.com/user", "assets.pinterest.com"],
])("should allow the %s embed script source", async (path, url, source) => {
const res = await server.get(
`/embeds/${path}?url=${encodeURIComponent(url)}`
);
const csp = res.headers.get("content-security-policy") ?? "";
expect(res.status).toEqual(200);
expect(csp).toContain(source);
expect(csp).toContain("nonce-");
});
it("should still send a policy for responses that do not render the app", async () => {
const res = await server.get(`/s/junk`);
const csp = res.headers.get("content-security-policy") ?? "";
expect(res.status).toEqual(404);
expect(csp).toContain("script-src");
expect(csp).toContain("nonce-");
});
it("should allow the Google Tag Manager script source when integration is enabled", async () => {
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await buildIntegration({
teamId: document.teamId,
type: IntegrationType.Analytics,
service: IntegrationService.GoogleAnalytics,
settings: { measurementId: "G-TEST123456" },
});
const res = await server.get(`/s/${share.id}`);
const csp = res.headers.get("content-security-policy") ?? "";
expect(res.status).toEqual(200);
expect(csp).toContain("www.googletagmanager.com");
expect(csp).toContain(`nonce-`);
expect(csp).not.toContain("'unsafe-inline'; script-src");
});
it("should not allow the Google Tag Manager script source without integration", async () => {
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
const res = await server.get(`/s/${share.id}`);
const csp = res.headers.get("content-security-policy") ?? "";
expect(res.status).toEqual(200);
expect(csp).not.toContain("www.googletagmanager.com");
});
it("should allow the instance host for self-hosted analytics integrations", async () => {
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await buildIntegration({
teamId: document.teamId,
type: IntegrationType.Analytics,
service: IntegrationService.Matomo,
settings: {
measurementId: "1",
instanceUrl: "https://matomo.example.com/",
},
});
const res = await server.get(`/s/${share.id}`);
const csp = res.headers.get("content-security-policy") ?? "";
expect(res.status).toEqual(200);
expect(csp).toContain("matomo.example.com");
});
});
describe("scanner path 404s", () => {
it.each([
"/.well-known/gpc.json",
+4 -9
View File
@@ -5,7 +5,7 @@ import Koa from "koa";
import Router from "koa-router";
import send from "koa-send";
import { languages } from "@shared/i18n";
import { IntegrationType, TeamPreference } from "@shared/types";
import { TeamPreference } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import { Day } from "@shared/utils/time";
import env from "@server/env";
@@ -264,14 +264,9 @@ router.get("*", async (ctx, next) => {
}
}
const analytics = team
? await Integration.findAll({
where: {
teamId: team.id,
type: IntegrationType.Analytics,
},
})
: [];
const analytics = await Integration.findAnalyticsIntegrationsForTeam(
team?.id
);
const publicBranding =
team?.getPreference(TeamPreference.PublicBranding) ?? false;
+5 -1
View File
@@ -326,7 +326,11 @@ export type IntegrationSettings<T> = T extends IntegrationType.Embed
};
}
| { serviceTeamId: string }
| { measurementId: string }
| {
measurementId: string;
instanceUrl?: string;
scriptName?: string;
}
| undefined;
export enum UserPreference {