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/core/entities/DriveFolderEntityService.ts b/packages/backend/src/core/entities/DriveFolderEntityService.ts index 299f23ad38..e22f99ad74 100644 --- a/packages/backend/src/core/entities/DriveFolderEntityService.ts +++ b/packages/backend/src/core/entities/DriveFolderEntityService.ts @@ -30,7 +30,8 @@ export class DriveFolderEntityService { public async pack( src: MiDriveFolder['id'] | MiDriveFolder, options?: { - detail: boolean + detail: boolean, + maxDepth?: number, }, ): Promise> { const opts = Object.assign({ @@ -55,7 +56,8 @@ export class DriveFolderEntityService { ...(folder.parentId ? { parent: this.pack(folder.parentId, { - detail: true, + detail: (options?.maxDepth ? options.maxDepth > 0 : true), + maxDepth: options?.maxDepth || 32, }), } : {}), } : {}), 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/api/endpoints/drive/folders/update.ts b/packages/backend/src/server/api/endpoints/drive/folders/update.ts index 62b04e1df3..cc45bd8c58 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/update.ts @@ -32,7 +32,7 @@ export const meta = { }, recursiveNesting: { - message: 'It can not be structured like nesting folders recursively.', + message: 'Folders are linked recursively or too deeply.', code: 'RECURSIVE_NESTING', id: 'dbeb024837894013aed44279f9199740', }, @@ -94,7 +94,10 @@ export default class extends Endpoint { // eslint- } // Check if the circular reference will occur - const checkCircle = async (folderId: string): Promise => { + const checkCircle = async (folderId: string, limit: number = 32): Promise => { + if (limit <= 0) { + return false; + } const folder2 = await this.driveFoldersRepository.findOneByOrFail({ id: folderId, }); @@ -102,7 +105,7 @@ export default class extends Endpoint { // eslint- if (folder2.id === folder.id) { return true; } else if (folder2.parentId) { - return await checkCircle(folder2.parentId); + return await checkCircle(folder2.parentId, limit - 1); } else { return 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/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index b91d77c398..a80ad3b087 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -927,14 +927,15 @@ describe('Endpoints', () => { const folderC = (await api('drive/folders/create', { name: 'test', }, alice)).body; - await api('drive/folders/update', { + assert.ok(folderA.id && folderB.id && folderC.id); + assert.strictEqual((await api('drive/folders/update', { folderId: folderB.id, parentId: folderA.id, - }, alice); - await api('drive/folders/update', { + }, alice)).status, 200); + assert.strictEqual((await api('drive/folders/update', { folderId: folderC.id, parentId: folderB.id, - }, alice); + }, alice)).status, 200); const res = await api('drive/folders/update', { folderId: folderA.id, diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index 504562a91e..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'], @@ -148,6 +148,7 @@ export function getConfig(): UserConfig { }, chunkFileNames: process.env.NODE_ENV === 'production' ? '[hash:8].js' : '[name]-[hash:8].js', assetFileNames: process.env.NODE_ENV === 'production' ? '[hash:8][extname]' : '[name]-[hash:8][extname]', + sourcemap: true, paths(id) { for (const p of externalPackages) { if (p.match.test(id)) { @@ -163,7 +164,7 @@ export function getConfig(): UserConfig { outDir: __dirname + '/../../built/_frontend_vite_', assetsDir: '.', emptyOutDir: false, - sourcemap: process.env.NODE_ENV === 'development', + sourcemap: true, reportCompressedSize: false, // https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies