From 174c3ef096b27004edc5696311fa5c3ec7b0ab4b Mon Sep 17 00:00:00 2001 From: eternal-flame-AD Date: Tue, 12 Nov 2024 02:18:25 -0600 Subject: [PATCH] Web security configuration Signed-off-by: eternal-flame-AD --- .config/cypress-devcontainer.yml | 22 +++++++++- .config/docker_example.yml | 22 +++++++++- .config/example.yml | 22 +++++++++- packages/backend/src/config.ts | 19 ++++++++- packages/backend/src/core/DriveService.ts | 40 +++++++++---------- packages/backend/src/server/ServerService.ts | 18 ++++++++- packages/backend/src/server/csp.ts | 28 +++++++++++-- .../src/server/web/ClientServerService.ts | 16 +++++++- 8 files changed, 154 insertions(+), 33 deletions(-) diff --git a/.config/cypress-devcontainer.yml b/.config/cypress-devcontainer.yml index e5bc1afa51..179ce77dc9 100644 --- a/.config/cypress-devcontainer.yml +++ b/.config/cypress-devcontainer.yml @@ -168,8 +168,8 @@ id: 'aidx' # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' -# ┌─────────────────────┐ -#───┘ Other configuration └───────────────────────────────────── +# ┌──────────────┐ +#──┘ Web Security └────────────────────────────────────── # Whether disable HSTS #disableHsts: true @@ -180,6 +180,24 @@ id: 'aidx' # - https://hstspreload.org/ #hstsPreload: false +# Enable additional security headers that reduce the risk of XSS attacks or privacy leaks. +# browserSandboxing: +# # Do not send the Referrer header to other domains. The default when browserSandboxing is missing is true. +# strictOriginReferrer: true +# csp: +# # Do not send a CSP header. The default is a strict CSP header that prevents any form of external fetching or execution. +# disable: false +# # Merge additional directives into the CSP header. The default is an empty object. +# # You may want to list your CDN or other trusted domains here. +# # Media proxies are automatically added to the CSP header. This is an exception, things like Sentry will not be automatically added. +# appendDirectives: +# 'script-src': +# - "'unsafe-eval'" # do not use this ... just an example +# - 'https://example.com' + +# ┌─────────────────────┐ +#───┘ Other configuration └───────────────────────────────────── + # Number of worker processes #clusterLimit: 1 diff --git a/.config/docker_example.yml b/.config/docker_example.yml index 7d51ab0443..35dfa5ded3 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -162,8 +162,8 @@ id: 'aidx' # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' -# ┌─────────────────────┐ -#───┘ Other configuration └───────────────────────────────────── +# ┌──────────────┐ +#──┘ Web Security └────────────────────────────────────── # Whether disable HSTS #disableHsts: true @@ -174,6 +174,24 @@ id: 'aidx' # - https://hstspreload.org/ #hstsPreload: false +# Enable additional security headers that reduce the risk of XSS attacks or privacy leaks. +# browserSandboxing: +# # Do not send the Referrer header to other domains. The default when browserSandboxing is missing is true. +# strictOriginReferrer: true +# csp: +# # Do not send a CSP header. The default is a strict CSP header that prevents any form of external fetching or execution. +# disable: false +# # Merge additional directives into the CSP header. The default is an empty object. +# # You may want to list your CDN or other trusted domains here. +# # Media proxies are automatically added to the CSP header. This is an exception, things like Sentry will not be automatically added. +# appendDirectives: +# 'script-src': +# - "'unsafe-eval'" # do not use this ... just an example +# - 'https://example.com' + +# ┌─────────────────────┐ +#───┘ Other configuration └───────────────────────────────────── + # Number of worker processes #clusterLimit: 1 diff --git a/.config/example.yml b/.config/example.yml index ce336d9f75..ef0e30d4e1 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -244,8 +244,8 @@ id: 'aidx' # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' -# ┌─────────────────────┐ -#───┘ Other configuration └───────────────────────────────────── +# ┌──────────────┐ +#──┘ Web Security └────────────────────────────────────── # Whether disable HSTS #disableHsts: true @@ -256,6 +256,24 @@ id: 'aidx' # - https://hstspreload.org/ #hstsPreload: false +# Enable additional security headers that reduce the risk of XSS attacks or privacy leaks. +# browserSandboxing: +# # Do not send the Referrer header to other domains. The default when browserSandboxing is missing is true. +# strictOriginReferrer: true +# csp: +# # Do not send a CSP header. The default is a strict CSP header that prevents any form of external fetching or execution. +# disable: false +# # Merge additional directives into the CSP header. The default is an empty object. +# # You may want to list your CDN or other trusted domains here. +# # Media proxies are automatically added to the CSP header. This is an exception, things like Sentry will not be automatically added. +# appendDirectives: +# 'script-src': +# - "'unsafe-eval'" # do not use this ... just an example +# - 'https://example.com' + +# ┌─────────────────────┐ +#───┘ Other configuration └───────────────────────────────────── + # Number of worker processes #clusterLimit: 1 diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 0ac357be77..035c9c6331 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -20,6 +20,18 @@ type RedisOptionsSource = Partial & { prefix?: string; }; +type BrowserSandboxing = { + // send Referrer-Policy: strict-origin + strictOriginReferrer?: boolean; + csp?: { + disable?: boolean; + + appendDirectives?: { + [directive: string]: string | string[]; + } + }; +}; + /** * 設定ファイルの型 */ @@ -65,6 +77,8 @@ type Source = { publishTarballInsteadOfProvideRepositoryUrl?: boolean; + browserSandboxing?: BrowserSandboxing; + setupPassword?: string; proxy?: string; @@ -155,7 +169,9 @@ export type Config = { proxyRemoteFiles: boolean | undefined; signToActivityPubGet: boolean | undefined; - cspPrerenderedContent: Map, + browserSandboxing: BrowserSandboxing; + + cspPrerenderedContent: Map; version: string; gitDescribe: string; @@ -252,6 +268,7 @@ export function loadConfig(): Config { version, gitCommit, gitDescribe, + browserSandboxing: config.browserSandboxing ?? { strictOriginReferrer: true }, publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl, setupPassword: config.setupPassword, url: url.origin, diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index c332e5a0a8..495d67a93b 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -87,9 +87,9 @@ type UploadFromUrlArgs = { @Injectable() export class DriveService { - public static NoSuchFolderError = class extends Error {}; - public static InvalidFileNameError = class extends Error {}; - public static CannotUnmarkSensitiveError = class extends Error {}; + public static NoSuchFolderError = class extends Error { }; + public static InvalidFileNameError = class extends Error { }; + public static CannotUnmarkSensitiveError = class extends Error { }; private registerLogger: Logger; private downloaderLogger: Logger; private deleteLogger: Logger; @@ -147,11 +147,11 @@ export class DriveService { */ @bindThis private async save(file: MiDriveFile, path: string, name: string, type: string, hash: string, size: number): Promise { - // thunbnail, webpublic を必要なら生成 + // thunbnail, webpublic を必要なら生成 const alts = await this.generateAlts(path, type, !file.uri); if (this.meta.useObjectStorage) { - //#region ObjectStorage params + //#region ObjectStorage params let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']); if (ext === '') { @@ -170,11 +170,11 @@ export class DriveService { } const baseUrl = this.meta.objectStorageBaseUrl - ?? `${ this.meta.objectStorageUseSSL ? 'https' : 'http' }://${ this.meta.objectStorageEndpoint }${ this.meta.objectStoragePort ? `:${this.meta.objectStoragePort}` : '' }/${ this.meta.objectStorageBucket }`; + ?? `${this.meta.objectStorageUseSSL ? 'https' : 'http'}://${this.meta.objectStorageEndpoint}${this.meta.objectStoragePort ? `:${this.meta.objectStoragePort}` : ''}/${this.meta.objectStorageBucket}`; // for original const key = `${this.meta.objectStoragePrefix}/${randomUUID()}${ext}`; - const url = `${ baseUrl }/${ key }`; + const url = `${baseUrl}/${key}`; // for alts let webpublicKey: string | null = null; @@ -191,7 +191,7 @@ export class DriveService { if (alts.webpublic) { webpublicKey = `${this.meta.objectStoragePrefix}/webpublic-${randomUUID()}.${alts.webpublic.ext}`; - webpublicUrl = `${ baseUrl }/${ webpublicKey }`; + webpublicUrl = `${baseUrl}/${webpublicKey}`; this.registerLogger.info(`uploading webpublic: ${webpublicKey}`); uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, alts.webpublic.ext, name)); @@ -199,7 +199,7 @@ export class DriveService { if (alts.thumbnail) { thumbnailKey = `${this.meta.objectStoragePrefix}/thumbnail-${randomUUID()}.${alts.thumbnail.ext}`; - thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; + thumbnailUrl = `${baseUrl}/${thumbnailKey}`; this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext, `${name}.thumbnail`)); @@ -311,9 +311,9 @@ export class DriveService { satisfyWebpublic = !!( type !== 'image/svg+xml' && // security reason type !== 'image/avif' && // not supported by Mastodon and MS Edge - !(metadata.exif ?? metadata.iptc ?? metadata.xmp ?? metadata.tifftagPhotoshop) && - metadata.width && metadata.width <= 2048 && - metadata.height && metadata.height <= 2048 + !(metadata.exif ?? metadata.iptc ?? metadata.xmp ?? metadata.tifftagPhotoshop) && + metadata.width && metadata.width <= 2048 && + metadata.height && metadata.height <= 2048 ); } catch (err) { this.registerLogger.warn(`sharp failed: ${err}`); @@ -470,11 +470,11 @@ export class DriveService { const info = await this.fileInfoService.getFileInfo(path, { skipSensitiveDetection: skipNsfwCheck, sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる - this.meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 : - this.meta.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 : - this.meta.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 : - this.meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 : - 0.5, + this.meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 : + this.meta.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 : + this.meta.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 : + this.meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 : + 0.5, sensitiveThresholdForPorn: 0.75, enableSensitiveMediaDetectionForVideos: this.meta.enableSensitiveMediaDetectionForVideos, }); @@ -494,7 +494,7 @@ export class DriveService { ); if (user && !force) { - // Check if there is a file with the same hash + // Check if there is a file with the same hash const matched = await this.driveFilesRepository.findOneBy({ md5: info.md5, userId: user.id, @@ -582,7 +582,7 @@ export class DriveService { file.maybePorn = info.porn; file.isSensitive = user ? this.userEntityService.isLocalUser(user) && profile!.alwaysMarkNsfw ? true : - sensitive ?? false + sensitive ?? false : false; if (user && this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, user.host)) file.isSensitive = true; @@ -616,7 +616,7 @@ export class DriveService { file = await this.driveFilesRepository.insertOne(file); } catch (err) { - // duplicate key error (when already registered) + // duplicate key error (when already registered) if (isDuplicateKeyValueError(err)) { this.registerLogger.info(`already registered ${file.uri}`); diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index df67a728ef..be02274600 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -89,14 +89,28 @@ export class ServerService implements OnApplicationShutdown { fastify.addHook('onRequest', makeHstsHook(host, preload)); } + // Other Security/Privacy Headers + fastify.addHook('onRequest', (_, reply, done) => { + reply.header('x-content-type-options', 'nosniff'); + reply.header('permissions-policy', 'interest-cohort=()'); // Disable FLoC + if (this.config.browserSandboxing.strictOriginReferrer) { + reply.header('referrer-policy', 'strict-origin'); + } + done(); + }); + // CSP - if (process.env.NODE_ENV === 'production') { + if (process.env.NODE_ENV === 'production' && !this.config.browserSandboxing.csp?.disable) { console.debug('cspPrerenderedContent', this.config.cspPrerenderedContent); const generatedCSP = generateCSP(this.config.cspPrerenderedContent, { mediaProxy: this.config.mediaProxy ? `https://${new URL(this.config.mediaProxy).host}` : undefined, + script_src: [ + `https://${new URL(this.config.url).host}/embed_vite/`, + `https://${new URL(this.config.url).host}/vite/`, + ], }); fastify.addHook('onRequest', (_, reply, done) => { - reply.header('Content-Security-Policy', generatedCSP); + reply.header('content-security-policy', generatedCSP); done(); }); } diff --git a/packages/backend/src/server/csp.ts b/packages/backend/src/server/csp.ts index 45b37fbd06..aeee4eab3a 100644 --- a/packages/backend/src/server/csp.ts +++ b/packages/backend/src/server/csp.ts @@ -13,6 +13,8 @@ export type CSPHashed = { export function generateCSP(hashedMap: Map, options: { mediaProxy?: string, + script_src?: string[], + append?: { [key: string]: string | string[] }, }) { const keys = Array.from(hashedMap.keys()); const scripts = keys @@ -22,7 +24,7 @@ export function generateCSP(hashedMap: Map, options: { .filter(name => name.endsWith('.css')) .map(name => `'${hashedMap.get(name)!.integrity}'`); - return ([ + const cpolicy = [ ['default-src', ['\'self\'']], ['img-src', [ @@ -42,7 +44,11 @@ export function generateCSP(hashedMap: Map, options: { // // ref: https://github.com/shikijs/shiki/issues/671 ['style-src-attr', ['\'self\'', '\'unsafe-inline\'']], - ['script-src', ['\'self\'', '\'wasm-unsafe-eval\'', ...scripts]], + ['script-src', [ + ...(options.script_src ? options.script_src : ['\'self\'']), + '\'wasm-unsafe-eval\'', + ...scripts + ]], ['object-src', ['\'none\'']], ['base-uri', ['\'self\'']], ['form-action', ['\'self\'']], @@ -52,7 +58,23 @@ export function generateCSP(hashedMap: Map, options: { [ ['upgrade-insecure-requests', []], ] : []), - ] as [string, string[]][]) + ] as [string, string[]][]; + + if (options.append) { + for (const [name, values] of Object.entries(options.append)) { + if (!values) { + continue; + } + const found = cpolicy.find(([n]) => n === name); + if (found) { + found[1].push(...(Array.isArray(values) ? values : [values])); + } else { + cpolicy.push([name, Array.isArray(values) ? values : [values]]); + } + } + } + + return cpolicy .map(([name, values]) => { return `${name} ${values.join(' ')}`; }).join('; '); diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index e2f227323f..b107fdc7f9 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -248,14 +248,28 @@ export class ClientServerService { fastify.addHook('onRequest', makeHstsHook(host, preload)); } + // Other Security/Privacy Headers + fastify.addHook('onRequest', (_, reply, done) => { + reply.header('x-content-type-options', 'nosniff'); + reply.header('permissions-policy', 'interest-cohort=()'); // Disable FLoC + if (this.config.browserSandboxing.strictOriginReferrer ?? true) { + reply.header('referrer-policy', 'strict-origin'); + } + done(); + }); + // 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, + script_src: [ + `https://${new URL(this.config.url).host}/embed_vite/`, + `https://${new URL(this.config.url).host}/vite/`, + ], }); fastify.addHook('onRequest', (_, reply, done) => { - reply.header('Content-Security-Policy', generatedCSP); + reply.header('content-security-policy', generatedCSP); done(); }); }