2024.11.0-yumechinokuni.2 #13
8 changed files with 122 additions and 18 deletions
|
@ -1,3 +1,8 @@
|
|||
## 2024.11.0-yumechinokuni.2
|
||||
|
||||
- Security: CSPの設定を強化
|
||||
- Fix: flaky testの修正
|
||||
|
||||
## 2024.11.0
|
||||
|
||||
### General
|
||||
|
|
|
@ -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<RedisOptions> & {
|
||||
host: string;
|
||||
|
@ -154,6 +155,8 @@ export type Config = {
|
|||
proxyRemoteFiles: boolean | undefined;
|
||||
signToActivityPubGet: boolean | undefined;
|
||||
|
||||
cspPrerenderedContent: Map<string, CSPHashed>,
|
||||
|
||||
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,
|
||||
|
|
|
@ -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,
|
||||
|
|
63
packages/backend/src/server/csp.ts
Normal file
63
packages/backend/src/server/csp.ts
Normal 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);
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'],
|
||||
|
|
Loading…
Reference in a new issue