From 4250a3a7d2c74d39a74e2caea4d6e2c03eef52d9 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 | 70 +++++++++++++++++++ .../src/server/web/ClientServerService.ts | 29 +++++++- .../src/server/web/views/base-embed.pug | 37 +++++----- .../backend/src/server/web/views/base.pug | 36 +++++----- packages/frontend/src/pages/about-misskey.vue | 7 +- .../frontend/src/scripts/code-highlighter.ts | 3 +- packages/frontend/vite.config.ts | 2 +- 10 files changed, 175 insertions(+), 40 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..ae95851816 --- /dev/null +++ b/packages/backend/src/server/csp.ts @@ -0,0 +1,70 @@ +/* + * 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:', + // 'https://avatars.githubusercontent.com', // uncomment this for contributor avatars to work + options.mediaProxy + ].filter(Boolean)], + ['media-src', ['\'self\'', 'data:', options.mediaProxy].filter(Boolean)], + ['font-src', ['\'self\'']], + ['style-src', ['\'self\'', ...styles]], + ['style-src-attr' , ['\'self\'', '\'unsafe-inline\'']], // remove this if you do not use code highlighting + ['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..950d0cb87b 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) { @@ -296,6 +309,20 @@ export class ClientServerService { version: this.config.version, config: this.config, }, + options: { + filters: { + dataTag: (data: string, options: { tagName: string, mimeType: string }) => { + if (!/^[a-z]+$/.test(options.tagName)) { + throw new Error('Invalid tagName'); + } + if (/[;'"]/.test(options.mimeType)) { + throw new Error('Invalid mimeType'); + } + const dataURI = `data:${options.mimeType};base64,${Buffer.from(data).toString('base64')}`; + return `<${options.tagName} data="${dataURI}">`; + } + } + } }); fastify.addHook('onRequest', (request, reply, done) => { @@ -619,7 +646,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..42ec11fe3a 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 @@ -59,14 +58,16 @@ html(class='embed') div#splash img#splashIcon(src= icon || '/static-assets/splash.png') div#splashSpinner - - - - - - - - - - + :dataTag(tagName='img' mimeType='image/svg+xml') + + + + + + :dataTag(tagName='img' mimeType='image/svg+xml') + + + + + block content diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index 280a5923c2..03eab87233 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 @@ -85,14 +83,16 @@ html div#splash img#splashIcon(src= icon || '/static-assets/splash.png') div#splashSpinner - - - - - - - - - - + :dataTag(tagName='img' mimeType='image/svg+xml') + + + + + + :dataTag(tagName='img' mimeType='image/svg+xml') + + + + + block content diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index fbbfb6ea61..2c65aa9fb3 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -145,6 +145,7 @@ import * as os from '@/os.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js'; import { $i } from '@/account.js'; +import { getProxiedImageUrl } from '@/scripts/media-proxy.js'; const patronsWithIcon = [{ name: 'カイヤン', @@ -272,7 +273,10 @@ const patronsWithIcon = [{ }, { name: 'Yatoigawa', icon: 'https://assets.misskey-hub.net/patrons/505e3568885a4a488431a8f22b4553d0.jpg', -}]; +}].map(patron => ({ + ...patron, + icon: getProxiedImageUrl(patron.icon, 'avatar'), +})); const patrons = [ 'まっちゃとーにゅ', @@ -552,6 +556,7 @@ definePageMetadata(() => ({ } .contributorAvatar { + display: none; // CSP width: 30px; border-radius: 100%; } diff --git a/packages/frontend/src/scripts/code-highlighter.ts b/packages/frontend/src/scripts/code-highlighter.ts index 6710d9826e..7204d00478 100644 --- a/packages/frontend/src/scripts/code-highlighter.ts +++ b/packages/frontend/src/scripts/code-highlighter.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { createHighlighterCore, loadWasm } from 'shiki/core'; +import { createHighlighterCore } from 'shiki/core'; +import { loadWasm } from 'shiki/engine/oniguruma'; import darkPlus from 'shiki/themes/dark-plus.mjs'; import { bundledThemesInfo } from 'shiki/themes'; import { bundledLanguagesInfo } from 'shiki/langs'; 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'],