2024.11.0-yumechinokuni.2 #13

Merged
yume merged 4 commits from develop into master 2024-11-11 06:24:47 -06:00
11 changed files with 139 additions and 28 deletions

View file

@ -1,3 +1,8 @@
## 2024.11.0-yumechinokuni.2
- Security: CSPの設定を強化
- Fix: flaky testの修正
## 2024.11.0 ## 2024.11.0
### General ### General

View file

@ -9,6 +9,7 @@ import { dirname, resolve } from 'node:path';
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import type { RedisOptions } from 'ioredis'; import type { RedisOptions } from 'ioredis';
import { type CSPHashed, hashResource, hashSourceFile } from './server/csp.js';
type RedisOptionsSource = Partial<RedisOptions> & { type RedisOptionsSource = Partial<RedisOptions> & {
host: string; host: string;
@ -154,6 +155,8 @@ export type Config = {
proxyRemoteFiles: boolean | undefined; proxyRemoteFiles: boolean | undefined;
signToActivityPubGet: boolean | undefined; signToActivityPubGet: boolean | undefined;
cspPrerenderedContent: Map<string, CSPHashed>,
version: string; version: string;
gitDescribe: string; gitDescribe: string;
gitCommit: string; gitCommit: string;
@ -235,6 +238,15 @@ export function loadConfig(): Config {
: null; : null;
const internalMediaProxy = `${scheme}://${host}/proxy`; const internalMediaProxy = `${scheme}://${host}/proxy`;
const redis = convertRedisOptions(config.redis, host); 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 { return {
version, version,
@ -248,6 +260,7 @@ export function loadConfig(): Config {
chmodSocket: config.chmodSocket, chmodSocket: config.chmodSocket,
disableHsts: config.disableHsts, disableHsts: config.disableHsts,
hstsPreload: config.hstsPreload ?? false, hstsPreload: config.hstsPreload ?? false,
cspPrerenderedContent,
host, host,
hostname, hostname,
scheme, scheme,

View file

@ -30,7 +30,8 @@ export class DriveFolderEntityService {
public async pack( public async pack(
src: MiDriveFolder['id'] | MiDriveFolder, src: MiDriveFolder['id'] | MiDriveFolder,
options?: { options?: {
detail: boolean detail: boolean,
maxDepth?: number,
}, },
): Promise<Packed<'DriveFolder'>> { ): Promise<Packed<'DriveFolder'>> {
const opts = Object.assign({ const opts = Object.assign({
@ -55,7 +56,8 @@ export class DriveFolderEntityService {
...(folder.parentId ? { ...(folder.parentId ? {
parent: this.pack(folder.parentId, { parent: this.pack(folder.parentId, {
detail: true, detail: (options?.maxDepth ? options.maxDepth > 0 : true),
maxDepth: options?.maxDepth || 32,
}), }),
} : {}), } : {}),
} : {}), } : {}),

View file

@ -32,6 +32,7 @@ import { ClientServerService } from './web/ClientServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import { makeHstsHook } from './hsts.js'; import { makeHstsHook } from './hsts.js';
import { generateCSP } from './csp.js';
const _dirname = fileURLToPath(new URL('.', import.meta.url)); const _dirname = fileURLToPath(new URL('.', import.meta.url));
@ -88,6 +89,18 @@ export class ServerService implements OnApplicationShutdown {
fastify.addHook('onRequest', makeHstsHook(host, preload)); 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. // Register raw-body parser for ActivityPub HTTP signature validation.
await fastify.register(fastifyRawBody, { await fastify.register(fastifyRawBody, {
global: false, global: false,

View file

@ -32,7 +32,7 @@ export const meta = {
}, },
recursiveNesting: { recursiveNesting: {
message: 'It can not be structured like nesting folders recursively.', message: 'Folders are linked recursively or too deeply.',
code: 'RECURSIVE_NESTING', code: 'RECURSIVE_NESTING',
id: 'dbeb024837894013aed44279f9199740', id: 'dbeb024837894013aed44279f9199740',
}, },
@ -94,7 +94,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
// Check if the circular reference will occur // Check if the circular reference will occur
const checkCircle = async (folderId: string): Promise<boolean> => { const checkCircle = async (folderId: string, limit: number = 32): Promise<boolean> => {
if (limit <= 0) {
return false;
}
const folder2 = await this.driveFoldersRepository.findOneByOrFail({ const folder2 = await this.driveFoldersRepository.findOneByOrFail({
id: folderId, id: folderId,
}); });
@ -102,7 +105,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (folder2.id === folder.id) { if (folder2.id === folder.id) {
return true; return true;
} else if (folder2.parentId) { } else if (folder2.parentId) {
return await checkCircle(folder2.parentId); return await checkCircle(folder2.parentId, limit - 1);
} else { } else {
return false; return false;
} }

View file

@ -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<string, CSPHashed>, 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);
}

View file

@ -67,6 +67,7 @@ import { UrlPreviewService } from './UrlPreviewService.js';
import { ClientLoggerService } from './ClientLoggerService.js'; import { ClientLoggerService } from './ClientLoggerService.js';
import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify'; import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify';
import { makeHstsHook } from '../hsts.js'; import { makeHstsHook } from '../hsts.js';
import { generateCSP } from '../csp.js';
const _filename = fileURLToPath(import.meta.url); const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename); const _dirname = dirname(_filename);
@ -234,6 +235,18 @@ export class ClientServerService {
fastify.addHook('onRequest', makeHstsHook(host, preload)); 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 // Authenticate
fastify.addHook('onRequest', async (request, reply) => { fastify.addHook('onRequest', async (request, reply) => {
if (request.routeOptions.url == null) { if (request.routeOptions.url == null) {

View file

@ -1,7 +1,10 @@
block vars block vars
block loadClientEntry 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 doctype html
@ -35,12 +38,9 @@ html(class='embed')
block meta block meta
meta(name='robots' content='noindex') meta(name='robots' content='noindex')
style style(integrity=styleCSS.integrity) !{styleCSS.content}
include ../style.embed.css
script. script(integrity=jsPrelude.integrity) !{jsPrelude.content}
var VERSION = "#{version}";
var CLIENT_ENTRY = "#{entry.file}";
script(type='application/json' id='misskey_meta' data-generated-at=now) script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson != metaJson
@ -48,8 +48,7 @@ html(class='embed')
script(type='application/json' id='misskey_embedCtx' data-generated-at=now) script(type='application/json' id='misskey_embedCtx' data-generated-at=now)
!= embedCtx != embedCtx
script script(integrity=bootJS.integrity) !{bootJS.content}
include ../boot.embed.js
body body
noscript: p noscript: p

View file

@ -2,7 +2,9 @@ block vars
block loadClientEntry block loadClientEntry
- const entry = config.frontendEntry; - 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 doctype html
@ -64,18 +66,14 @@ html
meta(property='og:image' content= img) meta(property='og:image' content= img)
meta(property='twitter:card' content='summary') meta(property='twitter:card' content='summary')
style style(integrity=styleCSS.integrity) !{styleCSS.content}
include ../style.css
script. script(integrity=jsPrelude.integrity) !{jsPrelude.content}
var VERSION = "#{version}";
var CLIENT_ENTRY = "#{entry.file}";
script(type='application/json' id='misskey_meta' data-generated-at=now) script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson != metaJson
script script(integrity=bootJS.integrity) !{bootJS.content}
include ../boot.js
body body
noscript: p noscript: p

View file

@ -927,14 +927,15 @@ describe('Endpoints', () => {
const folderC = (await api('drive/folders/create', { const folderC = (await api('drive/folders/create', {
name: 'test', name: 'test',
}, alice)).body; }, 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, folderId: folderB.id,
parentId: folderA.id, parentId: folderA.id,
}, alice); }, alice)).status, 200);
await api('drive/folders/update', { assert.strictEqual((await api('drive/folders/update', {
folderId: folderC.id, folderId: folderC.id,
parentId: folderB.id, parentId: folderB.id,
}, alice); }, alice)).status, 200);
const res = await api('drive/folders/update', { const res = await api('drive/folders/update', {
folderId: folderA.id, folderId: folderA.id,

View file

@ -140,7 +140,7 @@ export function getConfig(): UserConfig {
input: { input: {
app: './src/_boot_.ts', app: './src/_boot_.ts',
}, },
external: externalPackages.map(p => p.match), // external: externalPackages.map(p => p.match),
output: { output: {
manualChunks: { manualChunks: {
vue: ['vue'], vue: ['vue'],
@ -148,6 +148,7 @@ export function getConfig(): UserConfig {
}, },
chunkFileNames: process.env.NODE_ENV === 'production' ? '[hash:8].js' : '[name]-[hash:8].js', 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]', assetFileNames: process.env.NODE_ENV === 'production' ? '[hash:8][extname]' : '[name]-[hash:8][extname]',
sourcemap: true,
paths(id) { paths(id) {
for (const p of externalPackages) { for (const p of externalPackages) {
if (p.match.test(id)) { if (p.match.test(id)) {
@ -163,7 +164,7 @@ export function getConfig(): UserConfig {
outDir: __dirname + '/../../built/_frontend_vite_', outDir: __dirname + '/../../built/_frontend_vite_',
assetsDir: '.', assetsDir: '.',
emptyOutDir: false, emptyOutDir: false,
sourcemap: process.env.NODE_ENV === 'development', sourcemap: true,
reportCompressedSize: false, reportCompressedSize: false,
// https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies // https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies