mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
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:
+66
-31
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user