Przeglądaj źródła

Merge pull request #10998 from growilabs/fix/170342-path-traversal

fix: Path traversal
mergify[bot] 17 godzin temu
rodzic
commit
a828eb6d75

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

@@ -14,6 +14,7 @@ import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import httpErrorHandler from '../../middlewares/http-error-handler';
+import { resolveLocalePath } from '../../util/safe-path-utils';
 import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
 
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword');
@@ -85,6 +86,7 @@ module.exports = (crowi) => {
 
   const checkPassportStrategyMiddleware =
     checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
+  const ALLOWED_TEMPLATE_NAMES = ['passwordReset', 'passwordResetSuccessful'];
 
   async function sendPasswordResetEmail(
     templateFileName,
@@ -93,13 +95,19 @@ module.exports = (crowi) => {
     url,
     expiredAt,
   ) {
+    if (!ALLOWED_TEMPLATE_NAMES.includes(templateFileName)) {
+      throw new Error(`Invalid template name: ${templateFileName}`);
+    }
+    const templatePath = resolveLocalePath(
+      locale,
+      crowi.localeDir,
+      `notifications/${templateFileName}.ejs`,
+    );
+
     return mailService.send({
       to: email,
       subject: '[GROWI] Password Reset',
-      template: join(
-        crowi.localeDir,
-        `${locale}/notifications/${templateFileName}.ejs`,
-      ),
+      template: templatePath,
       vars: {
         appTitle: appService.getAppTitle(),
         email,

+ 12 - 6
apps/app/src/server/routes/apiv3/user-activation.ts

@@ -15,6 +15,8 @@ import { growiInfoService } from '~/server/service/growi-info';
 import { getTranslation } from '~/server/service/i18next';
 import loggerFactory from '~/utils/logger';
 
+import { resolveLocalePath } from '../../util/safe-path-utils';
+
 const logger = loggerFactory('growi:routes:apiv3:user-activation');
 
 const PASSOWRD_MINIMUM_NUMBER = 8;
@@ -233,9 +235,10 @@ export const completeRegistrationAction = (crowi: Crowi) => {
               const admins = await User.findAdmins();
               const appTitle = appService.getAppTitle();
               const locale = configManager.getConfig('app:globalLang');
-              const template = path.join(
+              const template = resolveLocalePath(
+                locale,
                 crowi.localeDir,
-                `${locale}/admin/userWaitingActivation.ejs`,
+                'admin/userWaitingActivation.ejs',
               );
               const url = growiInfoService.getSiteUrl();
 
@@ -314,6 +317,12 @@ async function makeRegistrationEmailToken(email, crowi: Crowi) {
   }
 
   const locale = configManager.getConfig('app:globalLang');
+  const templatePath = resolveLocalePath(
+    locale,
+    localeDir,
+    'notifications/userActivation.ejs',
+  );
+
   const appUrl = growiInfoService.getSiteUrl();
 
   const userRegistrationOrder =
@@ -330,10 +339,7 @@ async function makeRegistrationEmailToken(email, crowi: Crowi) {
   return mailService.send({
     to: email,
     subject: '[GROWI] User Activation',
-    template: path.join(
-      localeDir,
-      `${locale}/notifications/userActivation.ejs`,
-    ),
+    template: templatePath,
     vars: {
       appTitle: appService.getAppTitle(),
       email,

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

@@ -4,7 +4,6 @@ import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 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';
@@ -25,6 +24,7 @@ import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { resolveLocalePath } from '../../util/safe-path-utils';
 
 const logger = loggerFactory('growi:routes:apiv3:users');
 
@@ -206,6 +206,12 @@ module.exports = (crowi) => {
     const { appService, mailService } = crowi;
     const appTitle = appService.getAppTitle();
     const locale = configManager.getConfig('app:globalLang');
+    const templatePath = resolveLocalePath(
+      locale,
+      crowi.localeDir,
+      'admin/userInvitation.ejs',
+    );
+
     const failedToSendEmailList = [];
 
     for (const user of userList) {
@@ -214,10 +220,7 @@ module.exports = (crowi) => {
         await mailService.send({
           to: user.email,
           subject: `Invitation to ${appTitle}`,
-          template: path.join(
-            crowi.localeDir,
-            `${locale}/admin/userInvitation.ejs`,
-          ),
+          template: templatePath,
           vars: {
             email: user.email,
             password: user.password,
@@ -242,14 +245,16 @@ module.exports = (crowi) => {
     const { appService, mailService } = crowi;
     const appTitle = appService.getAppTitle();
     const locale = configManager.getConfig('app:globalLang');
+    const templatePath = resolveLocalePath(
+      locale,
+      crowi.localeDir,
+      'admin/userResetPassword.ejs',
+    );
 
     await mailService.send({
       to: user.email,
       subject: `New password for ${appTitle}`,
-      template: path.join(
-        crowi.localeDir,
-        `${locale}/admin/userResetPassword.ejs`,
-      ),
+      template: templatePath,
       vars: {
         email: user.email,
         password: user.password,

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

@@ -4,6 +4,7 @@ import loggerFactory from '~/utils/logger';
 
 import { UserStatus } from '../models/user/conts';
 import { growiInfoService } from '../service/growi-info';
+import { resolveLocalePath } from '../util/safe-path-utils';
 
 // disable all of linting
 // because this file is a deprecated legacy of Crowi
@@ -11,7 +12,6 @@ import { growiInfoService } from '../service/growi-info';
 /** @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,15 +23,17 @@ module.exports = (crowi, app) => {
     const admins = await User.findAdmins();
     const appTitle = appService.getAppTitle();
     const locale = configManager.getConfig('app:globalLang');
+    const templatePath = resolveLocalePath(
+      locale,
+      crowi.localeDir,
+      'admin/userWaitingActivation.ejs',
+    );
 
     const promises = admins.map((admin) => {
       return mailService.send({
         to: admin.email,
         subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
-        template: path.join(
-          crowi.localeDir,
-          `${locale}/admin/userWaitingActivation.ejs`,
-        ),
+        template: templatePath,
         vars: {
           adminUser: admin,
           createdUser: userData,

+ 16 - 7
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,6 +11,7 @@ import { configManager } from '~/server/service/config-manager';
 import { growiInfoService } from '~/server/service/growi-info';
 import loggerFactory from '~/utils/logger';
 
+import { resolveLocalePath } from '../../util/safe-path-utils';
 import type { GlobalNotificationEventVars } from './types';
 
 const _logger = loggerFactory('growi:service:GlobalNotificationMailService');
@@ -89,17 +89,26 @@ class GlobalNotificationMailService {
     triggeredBy: IUser,
     { comment, oldPath }: GlobalNotificationEventVars,
   ): MailOption {
-    const locale = configManager.getConfig('app:globalLang');
-    // 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}`,
       );
     }
+    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 path = page.path;
@@ -115,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;

+ 31 - 11
apps/app/src/server/service/installer.ts

@@ -8,6 +8,7 @@ import path from 'path';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../crowi';
+import { SUPPORTED_LOCALES } from '../util/safe-path-utils';
 import { configManager } from './config-manager';
 
 const logger = loggerFactory('growi:service:installer');
@@ -19,6 +20,11 @@ export type AutoInstallOptions = {
   serverDate?: Date;
 };
 
+const getSafeLang = (lang: Lang): Lang => {
+  if (SUPPORTED_LOCALES.includes(lang)) return lang;
+  return 'en_US';
+};
+
 export class InstallerService {
   crowi: Crowi;
 
@@ -44,7 +50,12 @@ export class InstallerService {
     const { pageService } = this.crowi;
 
     try {
-      const markdown = fs.readFileSync(filePath);
+      const normalizedPath = path.resolve(filePath);
+      const baseDir = path.resolve(this.crowi.localeDir);
+      if (!normalizedPath.startsWith(baseDir)) {
+        throw new Error(`Path traversal detected: ${normalizedPath}`);
+      }
+      const markdown = fs.readFileSync(normalizedPath);
       return pageService.forceCreateBySystem(pagePath, markdown.toString(), {});
     } catch (err) {
       logger.error(`Failed to create ${pagePath}`, err);
@@ -56,27 +67,33 @@ export class InstallerService {
     initialPagesCreatedAt?: Date,
   ): Promise<any> {
     const { localeDir } = this.crowi;
+
+    const safeLang = getSafeLang(lang);
+
     // create /Sandbox/*
     /*
      * Keep in this order to
      *   1. avoid creating the same pages
      *   2. avoid difference for order in VRT
      */
-    await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox');
     await this.createPage(
-      path.join(localeDir, lang, 'sandbox-markdown.md'),
+      path.join(localeDir, safeLang, 'sandbox.md'),
+      '/Sandbox',
+    );
+    await this.createPage(
+      path.join(localeDir, safeLang, 'sandbox-markdown.md'),
       '/Sandbox/Markdown',
     );
     await this.createPage(
-      path.join(localeDir, lang, 'sandbox-bootstrap5.md'),
+      path.join(localeDir, safeLang, 'sandbox-bootstrap5.md'),
       '/Sandbox/Bootstrap5',
     );
     await this.createPage(
-      path.join(localeDir, lang, 'sandbox-diagrams.md'),
+      path.join(localeDir, safeLang, 'sandbox-diagrams.md'),
       '/Sandbox/Diagrams',
     );
     await this.createPage(
-      path.join(localeDir, lang, 'sandbox-math.md'),
+      path.join(localeDir, safeLang, 'sandbox-math.md'),
       '/Sandbox/Math',
     );
 
@@ -123,11 +140,13 @@ export class InstallerService {
     globalLang: Lang,
     options?: AutoInstallOptions,
   ): Promise<void> {
+    const safeLang = getSafeLang(globalLang);
+
     await configManager.updateConfigs(
       {
         'app:installed': true,
         'app:isV5Compatible': true,
-        'app:globalLang': globalLang,
+        'app:globalLang': safeLang,
       },
       { skipPubsub: true },
     );
@@ -149,14 +168,15 @@ export class InstallerService {
     globalLang: Lang,
     options?: AutoInstallOptions,
   ): Promise<IUser> {
-    await this.initDB(globalLang, options);
+    const safeLang = getSafeLang(globalLang);
 
+    await this.initDB(safeLang, options);
     const User = mongoose.model<IUser, { createUser }>('User');
 
     // create portal page for '/' before creating admin user
     try {
       await this.createPage(
-        path.join(this.crowi.localeDir, globalLang, 'welcome.md'),
+        path.join(this.crowi.localeDir, safeLang, 'welcome.md'),
         '/',
       );
     } catch (err) {
@@ -172,12 +192,12 @@ export class InstallerService {
         username,
         email,
         password,
-        globalLang,
+        safeLang,
       );
       await (adminUser as any).asyncGrantAdmin();
 
       // create initial pages
-      await this.createInitialPages(globalLang, options?.serverDate);
+      await this.createInitialPages(safeLang, options?.serverDate);
 
       return adminUser;
     } catch (err) {

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

@@ -1,4 +1,6 @@
+import { AllLang } from '@growi/core';
 import path from 'pathe';
+export { AllLang as SUPPORTED_LOCALES };
 
 /**
  * Validates that the given file path is within the base directory.
@@ -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 = (AllLang as string[]).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.