Web security configuration #15

Merged
yume merged 1 commit from configurable-web-security into develop 2024-11-14 08:47:01 -06:00
8 changed files with 154 additions and 33 deletions

View file

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

View file

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

View file

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

View file

@ -20,6 +20,18 @@ type RedisOptionsSource = Partial<RedisOptions> & {
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<string, CSPHashed>,
browserSandboxing: BrowserSandboxing;
cspPrerenderedContent: Map<string, CSPHashed>;
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,

View file

@ -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<MiDriveFile> {
// 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}`);

View file

@ -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();
});
}

View file

@ -13,6 +13,8 @@ export type CSPHashed = {
export function generateCSP(hashedMap: Map<string, CSPHashed>, 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<string, CSPHashed>, 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<string, CSPHashed>, 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<string, CSPHashed>, 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('; ');

View file

@ -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();
});
}