From b8025124695c25e42f44b0a5a343bf28ec3feb2d Mon Sep 17 00:00:00 2001 From: eternal-flame-AD Date: Mon, 11 Nov 2024 04:18:58 -0600 Subject: [PATCH] implement CSP Signed-off-by: eternal-flame-AD --- CHANGELOG.md | 5 ++ packages/backend/src/config.ts | 13 ++++ packages/backend/src/server/ServerService.ts | 13 ++++ packages/backend/src/server/csp.ts | 63 +++++++++++++++++++ .../src/server/web/ClientServerService.ts | 15 ++++- .../src/server/web/views/base-embed.pug | 15 +++-- .../backend/src/server/web/views/base.pug | 14 ++--- packages/frontend/vite.config.ts | 2 +- 8 files changed, 122 insertions(+), 18 deletions(-) create mode 100644 packages/backend/src/server/csp.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e5bbda36fa..93e5880723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2024.11.0-yumechinokuni.2 + +- Security: CSPの設定を強化 +- Fix: flaky testの修正 + ## 2024.11.0 ### General diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 54f77764f0..28915e2648 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -9,6 +9,7 @@ import { dirname, resolve } from 'node:path'; import * as yaml from 'js-yaml'; import * as Sentry from '@sentry/node'; import type { RedisOptions } from 'ioredis'; +import { type CSPHashed, hashResource, hashSourceFile } from './server/csp.js'; type RedisOptionsSource = Partial & { host: string; @@ -154,6 +155,8 @@ export type Config = { proxyRemoteFiles: boolean | undefined; signToActivityPubGet: boolean | undefined; + cspPrerenderedContent: Map, + version: string; gitDescribe: string; gitCommit: string; @@ -235,6 +238,15 @@ export function loadConfig(): Config { : null; const internalMediaProxy = `${scheme}://${host}/proxy`; const redis = convertRedisOptions(config.redis, host); + const htmlScriptPrelude = `var VERSION = ${JSON.stringify(version)}; var CLIENT_ENTRY = ${JSON.stringify(frontendManifest['src/_boot_.ts'].file)};`; + const cspPrerenderedContent = new Map([ + [ + '.prelude.js', hashResource(htmlScriptPrelude) + ], + ...['boot.js', 'style.css', 'style.embed.css', 'boot.embed.js', + 'bios.css', 'bios.js', 'cli.css', 'cli.js', 'error.css' + ].map((file) => [file, hashSourceFile(`${_dirname}/server/web/${file}`)] as [string, CSPHashed]), + ]); return { version, @@ -248,6 +260,7 @@ export function loadConfig(): Config { chmodSocket: config.chmodSocket, disableHsts: config.disableHsts, hstsPreload: config.hstsPreload ?? false, + cspPrerenderedContent, host, hostname, scheme, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index a733adbc47..df67a728ef 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -32,6 +32,7 @@ import { ClientServerService } from './web/ClientServerService.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; import { makeHstsHook } from './hsts.js'; +import { generateCSP } from './csp.js'; const _dirname = fileURLToPath(new URL('.', import.meta.url)); @@ -88,6 +89,18 @@ export class ServerService implements OnApplicationShutdown { fastify.addHook('onRequest', makeHstsHook(host, preload)); } + // CSP + if (process.env.NODE_ENV === 'production') { + console.debug('cspPrerenderedContent', this.config.cspPrerenderedContent); + const generatedCSP = generateCSP(this.config.cspPrerenderedContent, { + mediaProxy: this.config.mediaProxy ? `https://${new URL(this.config.mediaProxy).host}` : undefined, + }); + fastify.addHook('onRequest', (_, reply, done) => { + reply.header('Content-Security-Policy', generatedCSP); + done(); + }); + } + // Register raw-body parser for ActivityPub HTTP signature validation. await fastify.register(fastifyRawBody, { global: false, diff --git a/packages/backend/src/server/csp.ts b/packages/backend/src/server/csp.ts new file mode 100644 index 0000000000..2b816ff202 --- /dev/null +++ b/packages/backend/src/server/csp.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { createHash } from 'crypto'; +import { readFileSync } from 'fs'; + +export type CSPHashed = { + content: string, + integrity: string, +}; + +export function generateCSP(hashedMap: Map, options: { + mediaProxy?: string, +}) { + const keys = Array.from(hashedMap.keys()); + const scripts = keys + .filter(name => name.endsWith('.js')) + .map(name => `'${hashedMap.get(name)!.integrity}'`); + const styles = keys + .filter(name => name.endsWith('.css')) + .map(name => `'${hashedMap.get(name)!.integrity}'`); + + return ([ + ['default-src', ['\'self\'']], + ['img-src', ['\'self\'', 'data:', options.mediaProxy].filter(Boolean)], + ['media-src', ['\'self\'', 'data:', options.mediaProxy].filter(Boolean)], + ['font-src', ['\'self\'']], + ['style-src', ['\'self\'', ...styles]], + ['script-src', ['\'self\'', '\'wasm-unsafe-eval\'', ...scripts]], + ['object-src', ['\'none\'']], + ['frame-src', ['\'none\'']], + ['base-uri', ['\'self\'']], + ['form-action', ['\'self\'']], + ['child-src', ['\'self\'']], + ['form-action', ['\'self\'']], + ['manifest-src', ['\'self\'']], + ...(process.env.NODE_ENV === 'production' ? + [ + ['upgrade-insecure-requests', []], + ] : []), + ] as [string, string[]][]) + .map(([name, values]) => { + return `${name} ${values.join(' ')}`; + }).join('; '); +} + +export function hashResource(res: string): CSPHashed { + const sha256 = createHash('sha256'); + + sha256.update(res); + + const content = res; + const integrity = `sha256-${sha256.digest('base64')}`; + return { content, integrity }; +} + +export function hashSourceFile(file: string): CSPHashed { + const content = readFileSync(file, 'utf8'); + + return hashResource(content); +} diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index b75e6df044..215bc95db6 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -67,6 +67,7 @@ import { UrlPreviewService } from './UrlPreviewService.js'; import { ClientLoggerService } from './ClientLoggerService.js'; import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify'; import { makeHstsHook } from '../hsts.js'; +import { generateCSP } from '../csp.js'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -234,6 +235,18 @@ export class ClientServerService { fastify.addHook('onRequest', makeHstsHook(host, preload)); } + // CSP + if (process.env.NODE_ENV === 'production') { + console.debug('cspPrerenderedContent', this.config.cspPrerenderedContent); + const generatedCSP = generateCSP(this.config.cspPrerenderedContent, { + mediaProxy: this.config.mediaProxy ? `https://${new URL(this.config.mediaProxy).host}` : undefined, + }); + fastify.addHook('onRequest', (_, reply, done) => { + reply.header('Content-Security-Policy', generatedCSP); + done(); + }); + } + // Authenticate fastify.addHook('onRequest', async (request, reply) => { if (request.routeOptions.url == null) { @@ -619,7 +632,7 @@ export class ClientServerService { vary(reply.raw, 'Accept'); - reply.redirect(`/@${user.username}${ user.host == null ? '' : '@' + user.host}`); + reply.redirect(`/@${user.username}${user.host == null ? '' : '@' + user.host}`); }); // Note diff --git a/packages/backend/src/server/web/views/base-embed.pug b/packages/backend/src/server/web/views/base-embed.pug index baa0909676..1da6776fad 100644 --- a/packages/backend/src/server/web/views/base-embed.pug +++ b/packages/backend/src/server/web/views/base-embed.pug @@ -1,7 +1,10 @@ block vars block loadClientEntry - - const entry = config.frontendEmbedEntry; + - const entry = config.frontendEntry + - const styleCSS = config.cspPrerenderedContent['style.embed.css'] + - const bootJS = config.cspPrerenderedContent['boot.embed.js'] + - const jsPrelude = config.cspPrerenderedContent['baseHtmlJSPrelude'] doctype html @@ -35,12 +38,9 @@ html(class='embed') block meta meta(name='robots' content='noindex') - style - include ../style.embed.css + style(integrity=styleCSS.integrity) !{styleCSS.content} - script. - var VERSION = "#{version}"; - var CLIENT_ENTRY = "#{entry.file}"; + script(integrity=jsPrelude.integrity) !{jsPrelude.content} script(type='application/json' id='misskey_meta' data-generated-at=now) != metaJson @@ -48,8 +48,7 @@ html(class='embed') script(type='application/json' id='misskey_embedCtx' data-generated-at=now) != embedCtx - script - include ../boot.embed.js + script(integrity=bootJS.integrity) !{bootJS.content} body noscript: p diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index 280a5923c2..22114b9799 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -2,7 +2,9 @@ block vars block loadClientEntry - const entry = config.frontendEntry; - - const baseUrl = config.url; + - const styleCSS = config.cspPrerenderedContent.get('style.css'); + - const jsPrelude = config.cspPrerenderedContent.get('.prelude.js'); + - const bootJS = config.cspPrerenderedContent.get('boot.js'); doctype html @@ -64,18 +66,14 @@ html meta(property='og:image' content= img) meta(property='twitter:card' content='summary') - style - include ../style.css + style(integrity=styleCSS.integrity) !{styleCSS.content} - script. - var VERSION = "#{version}"; - var CLIENT_ENTRY = "#{entry.file}"; + script(integrity=jsPrelude.integrity) !{jsPrelude.content} script(type='application/json' id='misskey_meta' data-generated-at=now) != metaJson - script - include ../boot.js + script(integrity=bootJS.integrity) !{bootJS.content} body noscript: p diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index 647fe4cf9b..1f22dbf2d4 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -140,7 +140,7 @@ export function getConfig(): UserConfig { input: { app: './src/_boot_.ts', }, - external: externalPackages.map(p => p.match), + // external: externalPackages.map(p => p.match), output: { manualChunks: { vue: ['vue'],