implement CSP
Some checks failed
Lint / lint (backend) (push) Blocked by required conditions
Lint / lint (frontend) (push) Blocked by required conditions
Lint / lint (frontend-embed) (push) Blocked by required conditions
Lint / lint (frontend-shared) (push) Blocked by required conditions
Lint / lint (misskey-bubble-game) (push) Blocked by required conditions
Lint / lint (misskey-js) (push) Blocked by required conditions
Lint / lint (misskey-reversi) (push) Blocked by required conditions
Lint / lint (sw) (push) Blocked by required conditions
Lint / typecheck (backend) (push) Blocked by required conditions
Lint / typecheck (misskey-js) (push) Blocked by required conditions
Lint / typecheck (sw) (push) Blocked by required conditions
Test (production install and build) / production (20.16.0) (push) Waiting to run
Publish Docker image / Build (push) Has been cancelled
Lint / pnpm_install (push) Has been cancelled
Test (backend) / unit (20.16.0) (push) Has been cancelled
Test (backend) / e2e (20.16.0) (push) Failing after 11m28s

Signed-off-by: eternal-flame-AD <yume@yumechi.jp>
This commit is contained in:
ゆめ 2024-11-11 04:18:58 -06:00
parent e75d885a2c
commit 353003a3d7
No known key found for this signature in database
10 changed files with 175 additions and 40 deletions

View file

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

View file

@ -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,

View file

@ -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,

View file

@ -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<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:',
// '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\'', ...styles]], // 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);
}

View file

@ -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}"></${options.tagName}>`;
}
}
}
});
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

View file

@ -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
<svg class="spinner bg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
<svg class="spinner fg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
:dataTag(tagName='img' mimeType='image/svg+xml')
<svg class="spinner bg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
:dataTag(tagName='img' mimeType='image/svg+xml')
<svg class="spinner fg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
block content

View file

@ -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
<svg class="spinner bg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
<svg class="spinner fg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
:dataTag(tagName='img' mimeType='image/svg+xml')
<svg class="spinner bg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
:dataTag(tagName='img' mimeType='image/svg+xml')
<svg class="spinner fg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
block content

View file

@ -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%;
}

View file

@ -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';

View file

@ -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'],