Просмотр исходного кода

170342-178601 fb responese 1-7

mariko-h 2 недель назад
Родитель
Сommit
459ae59fb3

+ 1 - 10
apps/app/src/server/routes/apiv3/app-settings/index.ts

@@ -18,7 +18,7 @@ import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
-import { isFileNameSafeForBaseDir } from '../../../util/safe-path-utils';
+import { SUPPORTED_LOCALES } from '../../../util/safe-path-utils';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
@@ -585,20 +585,11 @@ module.exports = (crowi: Crowi) => {
     async (req, res) => {
       const { globalLang } = req.body;
       if (globalLang != null) {
-        const SUPPORTED_LOCALES = ['en_US', 'ja_JP', 'zh_CN'];
-
         if (!SUPPORTED_LOCALES.includes(globalLang)) {
           const msg = `Invalid global language settings: '${globalLang}' is not supported.`;
           logger.error(msg, { globalLang });
           return res.apiv3Err(new ErrorV3(msg, 'invalid-globalLang'));
         }
-
-        if (!isFileNameSafeForBaseDir(globalLang, crowi.localeDir)) {
-          const msg =
-            'Invalid global language settings: path traversal detected.';
-          logger.error(msg, { globalLang });
-          return res.apiv3Err(new ErrorV3(msg, 'invalid-globalLang'));
-        }
       }
       const requestAppSettingParams = {
         'app:title': req.body.title,

+ 4 - 33
apps/app/src/server/routes/apiv3/forgot-password.js

@@ -1,8 +1,6 @@
-import nodePath from 'node:path';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { format, subSeconds } from 'date-fns';
-import { join } from 'pathe';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
@@ -14,7 +12,7 @@ import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import httpErrorHandler from '../../middlewares/http-error-handler';
-import { assertFileNameSafeForBaseDir } from '../../util/safe-path-utils';
+import { resolveLocalePath } from '../../util/safe-path-utils';
 import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
 
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword');
@@ -94,38 +92,11 @@ module.exports = (crowi) => {
     url,
     expiredAt,
   ) {
-    const SUPPORTED_LOCALES = ['en_US', 'ja_JP', 'zh_CN'];
-    let safeLocale = locale;
-
-    if (!SUPPORTED_LOCALES.includes(safeLocale)) {
-      logger.warn(
-        `Invalid or untrusted locale detected: '${safeLocale}'. Falling back to 'en_US' for safety.`,
-      );
-      safeLocale = 'en_US';
-    }
-
-    try {
-      assertFileNameSafeForBaseDir(safeLocale, crowi.localeDir);
-    } catch (err) {
-      logger.error(
-        `Path traversal attempt detected in locale: '${safeLocale}'. Fallback to 'en_US'.`,
-      );
-      safeLocale = 'en_US';
-    }
-
-    const templatePath = join(
+    const templatePath = resolveLocalePath(
+      locale,
       crowi.localeDir,
-      `${safeLocale}/notifications/${templateFileName}.ejs`,
+      `notifications/${templateFileName}.ejs`,
     );
-    const normalizedTemplatePath = nodePath.resolve(templatePath);
-    const baseDir = nodePath.resolve(crowi.localeDir);
-
-    if (!normalizedTemplatePath.startsWith(baseDir)) {
-      logger.error(
-        `Security Alert: Path traversal attempted! Resolved path: ${normalizedTemplatePath}`,
-      );
-      throw new Error('Path traversal detected! Blocking template access.');
-    }
 
     return mailService.send({
       to: email,

+ 9 - 65
apps/app/src/server/routes/apiv3/user-activation.ts

@@ -14,7 +14,7 @@ import { growiInfoService } from '~/server/service/growi-info';
 import { getTranslation } from '~/server/service/i18next';
 import loggerFactory from '~/utils/logger';
 
-import { assertFileNameSafeForBaseDir } from '../../util/safe-path-utils';
+import { resolveLocalePath } from '../../util/safe-path-utils';
 
 const logger = loggerFactory('growi:routes:apiv3:user-activation');
 
@@ -233,42 +233,12 @@ export const completeRegistrationAction = (crowi: Crowi) => {
             if (isMailerSetup) {
               const admins = await User.findAdmins();
               const appTitle = appService.getAppTitle();
-              const SUPPORTED_LOCALES = ['en_US', 'ja_JP', 'zh_CN'];
-              let locale = configManager.getConfig('app:globalLang');
-
-              if (!SUPPORTED_LOCALES.includes(locale)) {
-                logger.warn(
-                  `Invalid or untrusted locale detected: '${locale}'. Falling back to 'en_US' for safety.`,
-                );
-                locale = 'en_US';
-              }
-
-              try {
-                assertFileNameSafeForBaseDir(locale, crowi.localeDir);
-              } catch (_err) {
-                logger.error(
-                  `Path traversal attempt detected in locale: '${locale}'. Fallback to 'en_US'.`,
-                );
-                locale = 'en_US';
-              }
-
-              const templatePath = path.join(
+              const locale = configManager.getConfig('app:globalLang');
+              const template = resolveLocalePath(
+                locale,
                 crowi.localeDir,
-                `${locale}/admin/userWaitingActivation.ejs`,
+                'admin/userWaitingActivation.ejs',
               );
-              const normalizedTemplatePath = path.resolve(templatePath);
-              const baseDir = path.resolve(crowi.localeDir);
-
-              if (!normalizedTemplatePath.startsWith(baseDir)) {
-                logger.error(
-                  `Security Alert: Path traversal attempted! Resolved path: ${normalizedTemplatePath}`,
-                );
-                throw new Error(
-                  'Path traversal detected! Blocking template access.',
-                );
-              }
-
-              const template = templatePath;
               const url = growiInfoService.getSiteUrl();
 
               sendEmailToAllAdmins(
@@ -345,38 +315,12 @@ async function makeRegistrationEmailToken(email, crowi: Crowi) {
     throw Error('mailService is not setup');
   }
 
-  const SUPPORTED_LOCALES = ['en_US', 'ja_JP', 'zh_CN'];
-  let locale = configManager.getConfig('app:globalLang');
-
-  if (!SUPPORTED_LOCALES.includes(locale)) {
-    logger.warn(
-      `Invalid or untrusted locale detected: '${locale}'. Falling back to 'en_US' for safety.`,
-    );
-    locale = 'en_US';
-  }
-
-  try {
-    assertFileNameSafeForBaseDir(locale, localeDir);
-  } catch (_err) {
-    logger.error(
-      `Path traversal attempt detected in locale: '${locale}'. Fallback to 'en_US'.`,
-    );
-    locale = 'en_US';
-  }
-
-  const templatePath = path.join(
+  const locale = configManager.getConfig('app:globalLang');
+  const templatePath = resolveLocalePath(
+    locale,
     localeDir,
-    `${locale}/notifications/userActivation.ejs`,
+    'notifications/userActivation.ejs',
   );
-  const normalizedTemplatePath = path.resolve(templatePath);
-  const baseDir = path.resolve(localeDir);
-
-  if (!normalizedTemplatePath.startsWith(baseDir)) {
-    logger.error(
-      `Security Alert: Path traversal attempted! Resolved path: ${normalizedTemplatePath}`,
-    );
-    throw new Error('Path traversal detected! Blocking template access.');
-  }
 
   const appUrl = growiInfoService.getSiteUrl();
 

+ 9 - 63
apps/app/src/server/routes/apiv3/users.js

@@ -1,4 +1,3 @@
-import nodePath from 'node:path';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
@@ -6,7 +5,6 @@ import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
 import express from 'express';
 import { body, query } from 'express-validator';
-import path from 'pathe';
 import { isEmail } from 'validator';
 
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
@@ -27,7 +25,7 @@ import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import { assertFileNameSafeForBaseDir } from '../../util/safe-path-utils';
+import { resolveLocalePath } from '../../util/safe-path-utils';
 
 const logger = loggerFactory('growi:routes:apiv3:users');
 
@@ -208,38 +206,12 @@ module.exports = (crowi) => {
   const sendEmailByUserList = async (userList) => {
     const { appService, mailService } = crowi;
     const appTitle = appService.getAppTitle();
-    const SUPPORTED_LOCALES = ['en_US', 'ja_JP', 'zh_CN'];
-    let locale = configManager.getConfig('app:globalLang');
-
-    if (!SUPPORTED_LOCALES.includes(locale)) {
-      logger.warn(
-        `Invalid or untrusted locale detected: '${locale}'. Falling back to 'en_US' for safety.`,
-      );
-      locale = 'en_US';
-    }
-
-    try {
-      assertFileNameSafeForBaseDir(locale, crowi.localeDir);
-    } catch (_err) {
-      logger.error(
-        `Path traversal attempt detected in locale: '${locale}'. Fallback to 'en_US'.`,
-      );
-      locale = 'en_US';
-    }
-
-    const templatePath = path.join(
+    const locale = configManager.getConfig('app:globalLang');
+    const templatePath = resolveLocalePath(
+      locale,
       crowi.localeDir,
-      `${locale}/admin/userInvitation.ejs`,
+      'admin/userInvitation.ejs',
     );
-    const normalizedTemplatePath = nodePath.resolve(templatePath);
-    const baseDir = nodePath.resolve(crowi.localeDir);
-
-    if (!normalizedTemplatePath.startsWith(baseDir)) {
-      logger.error(
-        `Security Alert: Path traversal attempted! Resolved path: ${normalizedTemplatePath}`,
-      );
-      throw new Error('Path traversal detected! Blocking template access.');
-    }
 
     const failedToSendEmailList = [];
 
@@ -273,38 +245,12 @@ module.exports = (crowi) => {
   const sendEmailByUser = async (user) => {
     const { appService, mailService } = crowi;
     const appTitle = appService.getAppTitle();
-    const SUPPORTED_LOCALES = ['en_US', 'ja_JP', 'zh_CN'];
-    let locale = configManager.getConfig('app:globalLang');
-
-    if (!SUPPORTED_LOCALES.includes(locale)) {
-      logger.warn(
-        `Invalid or untrusted locale detected: '${locale}'. Falling back to 'en_US' for safety.`,
-      );
-      locale = 'en_US';
-    }
-
-    try {
-      assertFileNameSafeForBaseDir(locale, crowi.localeDir);
-    } catch (_err) {
-      logger.error(
-        `Path traversal attempt detected in locale: '${locale}'. Fallback to 'en_US'.`,
-      );
-      locale = 'en_US';
-    }
-
-    const templatePath = path.join(
+    const locale = configManager.getConfig('app:globalLang');
+    const templatePath = resolveLocalePath(
+      locale,
       crowi.localeDir,
-      `${locale}/admin/userResetPassword.ejs`,
+      'admin/userResetPassword.ejs',
     );
-    const normalizedTemplatePath = nodePath.resolve(templatePath);
-    const baseDir = nodePath.resolve(crowi.localeDir);
-
-    if (!normalizedTemplatePath.startsWith(baseDir)) {
-      logger.error(
-        `Security Alert: Path traversal attempted! Resolved path: ${normalizedTemplatePath}`,
-      );
-      throw new Error('Path traversal detected! Blocking template access.');
-    }
 
     await mailService.send({
       to: user.email,

+ 5 - 32
apps/app/src/server/routes/login.js

@@ -4,7 +4,7 @@ import loggerFactory from '~/utils/logger';
 
 import { UserStatus } from '../models/user/conts';
 import { growiInfoService } from '../service/growi-info';
-import { assertFileNameSafeForBaseDir } from '../util/safe-path-utils';
+import { resolveLocalePath } from '../util/safe-path-utils';
 
 // disable all of linting
 // because this file is a deprecated legacy of Crowi
@@ -12,7 +12,6 @@ import { assertFileNameSafeForBaseDir } from '../util/safe-path-utils';
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi, app) => {
   const logger = loggerFactory('growi:routes:login');
-  const path = require('path');
   const { User } = crowi.models;
   const { appService, aclService, mailService, activityService } = crowi;
   const activityEvent = crowi.events.activity;
@@ -23,38 +22,12 @@ module.exports = (crowi, app) => {
     // send mails to all admin users (derived from crowi) -- 2020.06.18 Yuki Takei
     const admins = await User.findAdmins();
     const appTitle = appService.getAppTitle();
-    const SUPPORTED_LOCALES = ['en_US', 'ja_JP', 'zh_CN'];
-    let locale = configManager.getConfig('app:globalLang');
-
-    if (!SUPPORTED_LOCALES.includes(locale)) {
-      logger.warn(
-        `Invalid or untrusted locale detected: '${locale}'. Falling back to 'en_US' for safety.`,
-      );
-      locale = 'en_US';
-    }
-
-    try {
-      assertFileNameSafeForBaseDir(locale, crowi.localeDir);
-    } catch (_err) {
-      logger.error(
-        `Path traversal attempt detected in locale: '${locale}'. Fallback to 'en_US'.`,
-      );
-      locale = 'en_US';
-    }
-
-    const templatePath = path.join(
+    const locale = configManager.getConfig('app:globalLang');
+    const templatePath = resolveLocalePath(
+      locale,
       crowi.localeDir,
-      `${locale}/admin/userWaitingActivation.ejs`,
+      'admin/userWaitingActivation.ejs',
     );
-    const normalizedTemplatePath = path.resolve(templatePath);
-    const baseDir = path.resolve(crowi.localeDir);
-
-    if (!normalizedTemplatePath.startsWith(baseDir)) {
-      logger.error(
-        `Security Alert: Path traversal attempted! Resolved path: ${normalizedTemplatePath}`,
-      );
-      throw new Error('Path traversal detected! Blocking template access.');
-    }
 
     const promises = admins.map((admin) => {
       return mailService.send({

+ 15 - 36
apps/app/src/server/service/global-notification/global-notification-mail.ts

@@ -1,4 +1,3 @@
-import nodePath from 'node:path';
 import type { IUser } from '@growi/core/dist/interfaces';
 
 import type Crowi from '~/server/crowi';
@@ -12,7 +11,7 @@ import { configManager } from '~/server/service/config-manager';
 import { growiInfoService } from '~/server/service/growi-info';
 import loggerFactory from '~/utils/logger';
 
-import { assertFileNameSafeForBaseDir } from '../../util/safe-path-utils';
+import { resolveLocalePath } from '../../util/safe-path-utils';
 import type { GlobalNotificationEventVars } from './types';
 
 const _logger = loggerFactory('growi:service:GlobalNotificationMailService');
@@ -90,48 +89,28 @@ class GlobalNotificationMailService {
     triggeredBy: IUser,
     { comment, oldPath }: GlobalNotificationEventVars,
   ): MailOption {
-    let locale = configManager.getConfig('app:globalLang');
-
-    const SUPPORTED_LOCALES = ['en_US', 'ja_JP', 'zh_CN'];
-
-    if (!SUPPORTED_LOCALES.includes(locale)) {
-      _logger.warn(
-        `Invalid or untrusted locale detected in DB: '${locale}'. Falling back to 'en_US' for safety.`,
-      );
-      locale = 'en_US';
-    }
-
-    // validate for all events
     if (event == null || page == null || triggeredBy == null) {
       throw new Error(
-        `invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`,
+        `Invalid vars supplied to GlobalNotificationMailService.generateOption: event=${event}`,
       );
     }
-
-    try {
-      assertFileNameSafeForBaseDir(locale, this.crowi.localeDir);
-    } catch (err) {
-      _logger.error(
-        `Path traversal attempt detected in app:globalLang: '${locale}'. Fallback to 'en_US'.`,
-      );
-      locale = 'en_US';
+    const validEvents = Object.values(
+      GlobalNotificationSettingEvent,
+    ) as string[];
+    if (!validEvents.includes(event)) {
+      _logger.error(`Unknown global notification event: ${event}`);
+      throw new Error(`Unknown global notification event: ${event}`);
     }
 
-    const template = nodePath.join(
+    const castedEvent =
+      event as (typeof GlobalNotificationSettingEvent)[keyof typeof GlobalNotificationSettingEvent];
+    const locale = configManager.getConfig('app:globalLang');
+    const template = resolveLocalePath(
+      locale,
       this.crowi.localeDir,
-      `${locale}/notifications/${event}.ejs`,
+      `notifications/${castedEvent}.ejs`,
     );
 
-    const normalizedTemplatePath = nodePath.resolve(template);
-    const baseDir = nodePath.resolve(this.crowi.localeDir);
-
-    if (!normalizedTemplatePath.startsWith(baseDir)) {
-      _logger.error(
-        `Security Alert: Path traversal attempted! Resolved path: ${normalizedTemplatePath}`,
-      );
-      throw new Error('Path traversal detected! Blocking template access.');
-    }
-
     const path = page.path;
     const appTitle = this.crowi.appService.getAppTitle();
     const siteUrl = growiInfoService.getSiteUrl();
@@ -145,7 +124,7 @@ class GlobalNotificationMailService {
       username: triggeredBy.username,
     };
 
-    switch (event) {
+    switch (castedEvent) {
       case GlobalNotificationSettingEvent.PAGE_CREATE:
         subject = `#${event} - ${triggeredBy.username} created ${path} at URL: ${pageUrl}`;
         break;

+ 4 - 7
apps/app/src/server/service/installer.ts

@@ -8,7 +8,7 @@ import path from 'path';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../crowi';
-import { assertFileNameSafeForBaseDir } from '../util/safe-path-utils';
+import { SUPPORTED_LOCALES } from '../util/safe-path-utils';
 import { configManager } from './config-manager';
 
 const logger = loggerFactory('growi:service:installer');
@@ -20,10 +20,9 @@ export type AutoInstallOptions = {
   serverDate?: Date;
 };
 
-const SUPPORTED_LOCALES: string[] = ['en_US', 'ja_JP', 'zh_CN'];
-
-const getSafeLang = (lang: string): Lang => {
-  return (SUPPORTED_LOCALES.includes(lang) ? lang : 'en_US') as Lang;
+const getSafeLang = (lang: Lang): Lang => {
+  if (SUPPORTED_LOCALES.includes(lang)) return lang;
+  return 'en_US';
 };
 
 export class InstallerService {
@@ -77,7 +76,6 @@ export class InstallerService {
      *   1. avoid creating the same pages
      *   2. avoid difference for order in VRT
      */
-    assertFileNameSafeForBaseDir(safeLang, localeDir);
     await this.createPage(
       path.join(localeDir, safeLang, 'sandbox.md'),
       '/Sandbox',
@@ -173,7 +171,6 @@ export class InstallerService {
     const safeLang = getSafeLang(globalLang);
 
     await this.initDB(safeLang, options);
-    assertFileNameSafeForBaseDir(safeLang, this.crowi.localeDir);
     const User = mongoose.model<IUser, { createUser }>('User');
 
     // create portal page for '/' before creating admin user

+ 21 - 0
apps/app/src/server/util/safe-path-utils.ts

@@ -1,5 +1,7 @@
 import path from 'pathe';
 
+export const SUPPORTED_LOCALES = ['en_US', 'ja_JP', 'zh_CN'];
+
 /**
  * Validates that the given file path is within the base directory.
  * This prevents path traversal attacks where an attacker could use sequences
@@ -47,6 +49,25 @@ export function assertFileNameSafeForBaseDir(
   }
 }
 
+/**
+ * Resolves a locale-specific template path safely, preventing path traversal attacks.
+ * Falls back to 'en_US' if the locale is not in the supported list.
+ *
+ * @param locale - The locale string (e.g. 'en_US')
+ * @param baseDir - The base directory for locale files
+ * @param templateSubPath - The sub-path within the locale directory (e.g. 'notifications/event.ejs')
+ * @returns The template path
+ * @throws Error if path traversal is detected
+ */
+export function resolveLocalePath(
+  locale: string,
+  baseDir: string,
+  templateSubPath: string,
+): string {
+  const safeLocale = SUPPORTED_LOCALES.includes(locale) ? locale : 'en_US';
+  return path.join(baseDir, safeLocale, templateSubPath);
+}
+
 /**
  * Validates that joining baseDir with fileName results in a path within baseDir.
  * This is useful for validating user-provided file names before using them.