Browse Source

Merge pull request #10155 from weseek/feat/167610-prevent-inline-mime-sniffing-backend-solution

feat: Prevent inline mime sniffing backend API part
Yuki Takei 8 months ago
parent
commit
a07e2349a7

+ 46 - 12
apps/app/bin/openapi/definition-apiv3.js

@@ -50,6 +50,52 @@ module.exports = {
         name: 'x-growi-transfer-key',
       },
     },
+    parameters: {
+      MimeTypePathParam: {
+        name: 'mimeType',
+        in: 'path',
+        required: true,
+        description: 'Configurable MIME type (e.g., image/png, application/pdf)',
+        schema: {
+          type: 'string',
+          enum: [
+            'image/jpeg',
+            'image/png',
+            'image/gif',
+            'image/webp',
+            'image/bmp',
+            'image/tiff',
+            'image/x-icon',
+            'application/pdf',
+            'text/plain',
+            'video/mp4',
+            'video/webm',
+            'video/ogg',
+            'audio/mpeg',
+            'audio/ogg',
+            'audio/wav',
+            'text/html',
+            'text/javascript',
+            'application/javascript',
+            'image/svg+xml',
+            'application/xml',
+            'application/json',
+            'application/x-sh',
+            'application/x-msdownload',
+            'application/octet-stream',
+            'application/msword',
+            'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+            'application/vnd.ms-excel',
+            'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+            'application/vnd.ms-powerpoint',
+            'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+            'application/zip',
+            'application/x-rar-compressed',
+            'text/csv',
+          ],
+        },
+      },
+    },
   },
   'x-tagGroups': [
     {
@@ -65,7 +111,6 @@ module.exports = {
         'ShareLinks',
         'Users',
         'UserUISettings',
-        '',
       ],
     },
     {
@@ -74,11 +119,6 @@ module.exports = {
         'GeneralSetting',
         'EditorSetting',
         'InAppNotificationSettings',
-        '',
-        '',
-        '',
-        '',
-        '',
       ],
     },
     {
@@ -120,12 +160,6 @@ module.exports = {
       tags: [
         'Healthcheck',
         'Statistics',
-        '',
-        '',
-        '',
-        '',
-        '',
-        '',
       ],
     },
   ],

+ 3 - 0
apps/app/src/interfaces/activity.ts

@@ -78,6 +78,7 @@ const ACTION_ADMIN_MAIL_SMTP_UPDATE = 'ADMIN_MAIL_SMTP_UPDATE';
 const ACTION_ADMIN_MAIL_SES_UPDATE = 'ADMIN_MAIL_SES_UPDATE';
 const ACTION_ADMIN_MAIL_TEST_SUBMIT = 'ADMIN_MAIL_TEST_SUBMIT';
 const ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE = 'ADMIN_FILE_UPLOAD_CONFIG_UPDATE';
+const ACTION_ADMIN_ATTACHMENT_DISPOSITION_UPDATE = 'ADMIN_ATTACHMENT_DISPOSITION_UPDATE';
 const ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE = 'ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE';
 const ACTION_ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE = 'ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE';
 const ACTION_ADMIN_MAINTENANCEMODE_ENABLED = 'ADMIN_MAINTENANCEMODE_ENABLED';
@@ -259,6 +260,7 @@ export const SupportedAction = {
   ACTION_ADMIN_MAIL_SES_UPDATE,
   ACTION_ADMIN_MAIL_TEST_SUBMIT,
   ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
+  ACTION_ADMIN_ATTACHMENT_DISPOSITION_UPDATE,
   ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE,
   ACTION_ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE,
   ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
@@ -454,6 +456,7 @@ export const LargeActionGroup = {
   ACTION_ADMIN_MAIL_SES_UPDATE,
   ACTION_ADMIN_MAIL_TEST_SUBMIT,
   ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
+  ACTION_ADMIN_ATTACHMENT_DISPOSITION_UPDATE,
   ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE,
   ACTION_ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE,
   ACTION_ADMIN_MAINTENANCEMODE_ENABLED,

+ 170 - 0
apps/app/src/server/routes/apiv3/content-disposition-settings.ts

@@ -0,0 +1,170 @@
+import { ErrorV3 } from '@growi/core/dist/models';
+import { body, param } from 'express-validator';
+
+import { SupportedAction } from '~/interfaces/activity';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:routes:apiv3:content-disposition-settings');
+const express = require('express');
+
+const router = express.Router();
+
+const validator = {
+  updateContentDisposition: [
+    param('mimeType')
+      .exists()
+      .notEmpty()
+      .withMessage('MIME type is required')
+      .bail()
+      .matches(/^.+\/.+$/)
+      .custom((value) => {
+        const mimeTypeDefaults = configManager.getConfig('attachments:contentDisposition:mimeTypeOverrides');
+        return Object.keys(mimeTypeDefaults).includes(value);
+      })
+      .withMessage('Invalid or unconfigurable MIME type specified.'),
+
+    body('disposition')
+      .isIn(['inline', 'attachment']) // Validate that it's one of these two strings
+      .withMessage('`disposition` must be either "inline" or "attachment".'),
+  ],
+};
+
+/*
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *    parameters:
+ *      - $ref: '#/components/parameters/MimeTypePathParam'
+  */
+module.exports = (crowi) => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware();
+  const activityEvent = crowi.event('activity');
+
+  /**
+ * @swagger
+ *
+ * /content-disposition-settings:
+ *   get:
+ *     tags: [Content-Disposition Settings]
+ *     summary: Get content disposition settings for configurable MIME types
+ *     security:
+ *       - cookieAuth: []
+ *     responses:
+ *       200:
+ *         description: Successfully retrieved content disposition settings.
+ *         content:
+ *           application/json:
+ *             schema:
+ *               type: object
+ *               properties:
+ *                 contentDispositionSettings:
+ *                   type: object
+ *                   additionalProperties:
+ *                     type: string
+ *                     description: inline or attachment
+ *
+ */
+  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+    try {
+
+      const mimeTypeDefaults = configManager.getConfig('attachments:contentDisposition:mimeTypeOverrides');
+      const contentDispositionSettings: Record<string, 'inline' | 'attachment'> = mimeTypeDefaults;
+
+      return res.apiv3({ contentDispositionSettings });
+    }
+    catch (err) {
+      logger.error('Error retrieving content disposition settings:', err);
+      return res.apiv3Err(new ErrorV3('Failed to retrieve content disposition settings', 'get-content-disposition-failed'));
+    }
+  });
+
+  /**
+ * @swagger
+ *
+ * paths:
+ *    /content-disposition-settings/{mimeType}:
+ *      put:
+ *        tags: [Content Disposition Settings]
+ *        summary: Update content disposition setting for a specific MIME type
+ *        security:
+ *          - cookieAuth: []
+ *        parameters:
+ *          - $ref: '#/components/parameters/MimeTypePathParam'
+ *        requestBody:
+ *          required: true
+ *          content:
+ *            application/json:
+ *              schema:
+ *                type: object
+ *                required:
+ *                  - disposition
+ *                properties:
+ *                  disposition:
+ *                     type: string
+ *        responses:
+ *          200:
+ *            description: Successfully updated content disposition setting
+ *            content:
+ *              application/json:
+ *                schema:
+ *                  type: object
+ *                  properties:
+ *                    setting:
+ *                       type: object
+ *                       properties:
+ *                         mimeType:
+ *                           type: string
+ *                         disposition:
+ *                           type: string
+ */
+  router.put(
+    '/:mimeType(*)',
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.updateContentDisposition,
+    apiV3FormValidator,
+    async(req, res) => {
+      const { mimeType } = req.params;
+      const { disposition } = req.body;
+
+      try {
+        const currentMimeTypeDefaults = configManager.getConfig('attachments:contentDisposition:mimeTypeOverrides');
+
+        const newDisposition: 'inline' | 'attachment' = disposition;
+
+        const updatedMimeTypeDefaults = {
+          ...currentMimeTypeDefaults,
+          [mimeType]: newDisposition,
+        };
+
+        await configManager.updateConfigs({ 'attachments:contentDisposition:mimeTypeOverrides': updatedMimeTypeDefaults });
+        const updatedDispositionFromDb = configManager.getConfig('attachments:contentDisposition:mimeTypeOverrides')[mimeType];
+
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_ATTACHMENT_DISPOSITION_UPDATE,
+          mimeType,
+          disposition: updatedDispositionFromDb,
+        };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ mimeType, disposition: updatedDispositionFromDb });
+      }
+      catch (err) {
+        const msg = `Error occurred in updating content disposition for MIME type: ${mimeType}`;
+        logger.error(msg, err);
+        return res.apiv3Err(
+          new ErrorV3(msg, 'update-content-disposition-failed'),
+        );
+      }
+    },
+  );
+
+  return router;
+};

+ 1 - 0
apps/app/src/server/routes/apiv3/index.js

@@ -36,6 +36,7 @@ module.exports = (crowi, app) => {
   // admin
   routerForAdmin.use('/admin-home', require('./admin-home')(crowi));
   routerForAdmin.use('/markdown-setting', require('./markdown-setting')(crowi));
+  routerForAdmin.use('/content-disposition-settings', require('./content-disposition-settings')(crowi));
   routerForAdmin.use('/app-settings', require('./app-settings')(crowi));
   routerForAdmin.use('/customize-setting', require('./customize-setting')(crowi));
   routerForAdmin.use('/notification-setting', require('./notification-setting')(crowi));

+ 1 - 3
apps/app/src/server/routes/apiv3/markdown-setting.js

@@ -1,4 +1,5 @@
 import { ErrorV3 } from '@growi/core/dist/models';
+import express from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { configManager } from '~/server/service/config-manager';
@@ -10,8 +11,6 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:markdown-setting');
 
-const express = require('express');
-
 const router = express.Router();
 
 const { body } = require('express-validator');
@@ -119,7 +118,6 @@ const validator = {
  *            type: boolean
  *            description: force indent size
  */
-
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);

+ 3 - 1
apps/app/src/server/routes/attachment/get.ts

@@ -9,6 +9,7 @@ import mongoose from 'mongoose';
 
 import type { CrowiProperties, CrowiRequest } from '~/interfaces/crowi-request';
 import { ResponseMode, type ExpressHttpHeader, type RespondOptions } from '~/server/interfaces/attachment';
+import { configManager } from '~/server/service/config-manager';
 import {
   type FileUploader,
   toExpressHttpHeaders, ContentHeaders, applyHeaders,
@@ -107,7 +108,8 @@ const respondForRedirectMode = async(res: Response, fileUploadService: FileUploa
   }
 };
 
-const respondForRelayMode = async(res: Response, fileUploadService: FileUploader, attachment: IAttachmentDocument, opts?: RespondOptions): Promise<void> => {
+const respondForRelayMode = async(res: Response, fileUploadService: FileUploader,
+    attachment: IAttachmentDocument, opts?: RespondOptions): Promise<void> => {
   // apply content-* headers before response
   const isDownload = opts?.download ?? false;
   const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });

+ 16 - 0
apps/app/src/server/service/config-manager/config-definition.ts

@@ -78,6 +78,9 @@ export const CONFIG_KEYS = [
   'app:openaiThreadDeletionCronMaxMinutesUntilRequest',
   'app:openaiVectorStoreFileDeletionCronMaxMinutesUntilRequest',
 
+  // Content-Disposition settings for MIME types
+  'attachments:contentDisposition:mimeTypeOverrides',
+
   // Security Settings
   'security:wikiMode',
   'security:sessionMaxAge',
@@ -538,6 +541,19 @@ export const CONFIG_DEFINITIONS = {
     defaultValue: 30,
   }),
 
+  // Attachment Content-Disposition settings
+  'attachments:contentDisposition:mimeTypeOverrides': defineConfig<Record<string, 'inline' | 'attachment'>>({
+    defaultValue: {
+      'text/html': 'attachment',
+      'image/svg+xml': 'attachment',
+      'application/pdf': 'attachment',
+      'application/json': 'attachment',
+      'text/csv': 'attachment',
+      'font/*': 'attachment',
+    },
+  }),
+
+
   // Security Settings
   'security:wikiMode': defineConfig<string | undefined>({
     envVarName: 'FORCE_WIKI_MODE',

+ 4 - 5
apps/app/src/server/service/file-uploader/aws/index.ts

@@ -15,6 +15,7 @@ import {
 import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 import type { NonBlankString } from '@growi/core/dist/interfaces';
 import { toNonBlankStringOrUndefined } from '@growi/core/dist/interfaces';
+import type { Response } from 'express';
 import urljoin from 'url-join';
 
 import type Crowi from '~/server/crowi';
@@ -177,6 +178,7 @@ class AwsFileUploader extends AbstractFileUploader {
     const s3 = S3Factory();
 
     const filePath = getFilePathOnStorage(attachment);
+
     const contentHeaders = new ContentHeaders(attachment);
 
     await s3.send(new PutObjectCommand({
@@ -190,10 +192,8 @@ class AwsFileUploader extends AbstractFileUploader {
     }));
   }
 
-  /**
-   * @inheritdoc
-   */
-  override respond(): void {
+
+  override async respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): Promise<void> {
     throw new Error('AwsFileUploader does not support ResponseMode.DELEGATE.');
   }
 
@@ -284,7 +284,6 @@ class AwsFileUploader extends AbstractFileUploader {
       }));
     }
     catch (e) {
-      // allow duplicate abort requests to ensure abortion
       if (e.response?.status !== 404) {
         throw e;
       }

+ 1 - 1
apps/app/src/server/service/file-uploader/gcs/index.ts

@@ -113,6 +113,7 @@ class GcsFileUploader extends AbstractFileUploader {
    * @inheritdoc
    */
   override determineResponseMode() {
+    // This is already correct in your provided code, using this.configManager
     return configManager.getConfig('gcs:referenceFileWithRelayMode')
       ? ResponseMode.RELAY
       : ResponseMode.REDIRECT;
@@ -132,7 +133,6 @@ class GcsFileUploader extends AbstractFileUploader {
     const myBucket = gcs.bucket(getGcsBucket());
     const filePath = getFilePathOnStorage(attachment);
     const contentHeaders = new ContentHeaders(attachment);
-
     const file = myBucket.file(filePath);
 
     await pipeline(readable, file.createWriteStream({

+ 37 - 13
apps/app/src/server/service/file-uploader/utils/headers.ts

@@ -3,7 +3,9 @@ import type { Response } from 'express';
 import type { ExpressHttpHeader, IContentHeaders } from '~/server/interfaces/attachment';
 import type { IAttachmentDocument } from '~/server/models/attachment';
 
-import { INLINE_ALLOWLIST_MIME_TYPES } from './security'; // Adjust path if necessary
+import { configManager } from '../../config-manager';
+
+import { defaultContentDispositionSettings } from './security';
 
 
 export class ContentHeaders implements IContentHeaders {
@@ -18,37 +20,59 @@ export class ContentHeaders implements IContentHeaders {
 
   xContentTypeOptions?: ExpressHttpHeader<'X-Content-Type-Options'>;
 
-  constructor(attachment: IAttachmentDocument, opts?: {
-    inline?: boolean,
-  }) {
+  constructor(
+      attachment: IAttachmentDocument,
+      opts?: {
+        inline?: boolean,
+    },
+  ) {
     const attachmentContentType = attachment.fileFormat;
     const filename = attachment.originalName;
 
-    // Define the final content type value in a local variable.
-    const actualContentTypeString: string = attachmentContentType || 'application/octet-stream';
+    const mimeType: string = attachmentContentType || 'application/octet-stream';
 
     this.contentType = {
       field: 'Content-Type',
-      value: actualContentTypeString,
+      value: mimeType,
     };
 
-    // Determine Content-Disposition based on allowlist and the 'inline' request flag
+    let finalDispositionValue: string;
+
     const requestedInline = opts?.inline ?? false;
+    const mimeTypeOverrides = configManager.getConfig('attachments:contentDisposition:mimeTypeOverrides');
+    const overrideSetting = mimeTypeOverrides[mimeType];
+
+    if (overrideSetting) {
+      finalDispositionValue = overrideSetting;
+    }
+
+    else {
+      const defaultSetting = defaultContentDispositionSettings[mimeType];
+
+      if (defaultSetting === 'inline' && requestedInline) {
+        finalDispositionValue = 'inline';
+      }
+      else {
+        finalDispositionValue = 'attachment';
+      }
+    }
 
-    // Should only be inline if it was requested and MIME type is explicitly in the security allowlist.
-    const shouldBeInline = requestedInline && INLINE_ALLOWLIST_MIME_TYPES.has(actualContentTypeString);
 
     this.contentDisposition = {
       field: 'Content-Disposition',
-      value: shouldBeInline
+      value: finalDispositionValue === 'inline'
         ? 'inline'
         : `attachment;filename*=UTF-8''${encodeURIComponent(filename)}`,
     };
 
     this.contentSecurityPolicy = {
       field: 'Content-Security-Policy',
-      // eslint-disable-next-line max-len
-      value: "script-src 'unsafe-hashes'; style-src 'self' 'unsafe-inline'; object-src 'none'; require-trusted-types-for 'script'; media-src 'self'; default-src 'none';",
+      value: "script-src 'unsafe-hashes';"
+         + " style-src 'self' 'unsafe-inline';"
+         + " object-src 'none';"
+         + " require-trusted-types-for 'script';"
+         + " media-src 'self';"
+         + " default-src 'none';",
     };
 
     this.xContentTypeOptions = {

+ 42 - 13
apps/app/src/server/service/file-uploader/utils/security.ts

@@ -1,13 +1,42 @@
-/**
- * Defines MIME types that are explicitly safe for INLINE display when served
- * from user uploads. All other types will be forced to download, regardless of
- * their file extension or sniffed content.
- */
-export const INLINE_ALLOWLIST_MIME_TYPES = new Set<string>([
-  'image/png',
-  'image/jpeg',
-  'image/gif',
-  'image/webp',
-  'image/bmp',
-  'image/x-icon',
-]);
+export const defaultContentDispositionSettings: Record<string, 'inline' | 'attachment'> = {
+  // Image Types
+  'image/jpeg': 'inline',
+  'image/png': 'inline',
+  'image/gif': 'inline',
+  'image/webp': 'inline',
+  'image/bmp': 'inline',
+  'image/tiff': 'inline',
+  'image/x-icon': 'inline',
+
+  // Document & Media Types
+  'application/pdf': 'inline',
+  'text/plain': 'inline',
+  'video/mp4': 'inline',
+  'video/webm': 'inline',
+  'video/ogg': 'inline',
+  'audio/mpeg': 'inline',
+  'audio/ogg': 'inline',
+  'audio/wav': 'inline',
+
+  // Potentially Dangerous / Executable / Scriptable Types
+  'text/html': 'attachment',
+  'text/javascript': 'attachment',
+  'application/javascript': 'attachment',
+  'image/svg+xml': 'attachment',
+  'application/xml': 'attachment',
+  'application/json': 'attachment',
+  'application/x-sh': 'attachment',
+  'application/x-msdownload': 'attachment',
+  'application/octet-stream': 'attachment',
+
+  // Other Common Document Formats
+  'application/msword': 'attachment',
+  'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'attachment',
+  'application/vnd.ms-excel': 'attachment',
+  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'attachment',
+  'application/vnd.ms-powerpoint': 'attachment',
+  'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'attachment',
+  'application/zip': 'attachment',
+  'application/x-rar-compressed': 'attachment',
+  'text/csv': 'attachment',
+};