Explorar el Código

Merge pull request #8211 from weseek/imprv/certify-shared-file

imprv: Certify shared page attachment middleware
Yuki Takei hace 2 años
padre
commit
97cc9823b8
Se han modificado 23 ficheros con 603 adiciones y 113 borrados
  1. 12 6
      apps/app/src/pages/share/[[...path]].page.tsx
  2. 2 0
      apps/app/src/server/crowi/index.js
  3. 0 54
      apps/app/src/server/middlewares/certify-shared-file.js
  4. 145 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.spec.ts
  5. 49 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.ts
  6. 1 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/index.ts
  7. 4 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/interfaces.ts
  8. 28 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/retrieve-valid-share-link.ts
  9. 25 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-attachment.ts
  10. 1 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/index.ts
  11. 59 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.spec.ts
  12. 22 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.ts
  13. 131 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.spec.ts
  14. 69 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.ts
  15. 1 1
      apps/app/src/server/middlewares/certify-shared-page.js
  16. 0 1
      apps/app/src/server/models/index.js
  17. 0 39
      apps/app/src/server/models/share-link.js
  18. 46 0
      apps/app/src/server/models/share-link.ts
  19. 1 2
      apps/app/src/server/routes/apiv3/security-settings/index.js
  20. 2 5
      apps/app/src/server/routes/apiv3/share-links.js
  21. 2 2
      apps/app/src/server/routes/index.js
  22. 1 1
      apps/app/src/server/service/page.ts
  23. 2 2
      apps/app/src/server/util/mongoose-utils.ts

+ 12 - 6
apps/app/src/pages/share/[[...path]].page.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect } from 'react';
 
-import type { IUserHasId, IPagePopulatedToShowRevision } from '@growi/core';
+import { type IUserHasId, type IPagePopulatedToShowRevision, getIdForRef } from '@growi/core';
 import type {
   GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -18,6 +18,7 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IShareLinkHasId } from '~/interfaces/share-link';
 import type { PageDocument } from '~/server/models/page';
+import ShareLink from '~/server/models/share-link';
 import {
   useCurrentUser, useRendererConfig, useIsSearchPage, useCurrentPathname,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useIsContainerFluid, useIsEnabledMarp,
@@ -233,18 +234,23 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   const props: Props = result.props as Props;
 
   try {
-    const ShareLinkModel = crowi.model('ShareLink');
-    const shareLink = await ShareLinkModel.findOne({ _id: params.linkId }).populate('relatedPage');
+    const shareLink = await ShareLink.findOne({ _id: params.linkId }).populate('relatedPage');
     if (shareLink == null) {
       props.isNotFound = true;
     }
     else {
       props.isNotFound = false;
-      const ssrMaxRevisionBodyLength = crowi.configManager.getConfig('crowi', 'app:ssrMaxRevisionBodyLength');
-      props.skipSSR = await skipSSR(shareLink.relatedPage, ssrMaxRevisionBodyLength);
-      props.shareLinkRelatedPage = await shareLink.relatedPage.populateDataToShowRevision(props.skipSSR); // shouldExcludeBody = skipSSR
       props.isExpired = shareLink.isExpired();
       props.shareLink = shareLink.toObject();
+
+      // retrieve Page
+      const Page = crowi.model('Page');
+      const relatedPage = await Page.findOne({ _id: getIdForRef(shareLink.relatedPage) });
+      // determine whether skip SSR
+      const ssrMaxRevisionBodyLength = crowi.configManager.getConfig('crowi', 'app:ssrMaxRevisionBodyLength');
+      props.skipSSR = await skipSSR(relatedPage, ssrMaxRevisionBodyLength);
+      // populate
+      props.shareLinkRelatedPage = await relatedPage.populateDataToShowRevision(props.skipSSR); // shouldExcludeBody = skipSSR
     }
   }
   catch (err) {

+ 2 - 0
apps/app/src/server/crowi/index.js

@@ -20,6 +20,7 @@ import { projectRoot } from '~/utils/project-dir-utils';
 import UserEvent from '../events/user';
 import Activity from '../models/activity';
 import PageRedirect from '../models/page-redirect';
+import ShareLink from '../models/share-link';
 import Tag from '../models/tag';
 import UserGroup from '../models/user-group';
 import { aclService as aclServiceSingletonInstance } from '../service/acl';
@@ -303,6 +304,7 @@ Crowi.prototype.setupModels = async function() {
   allModels.Tag = Tag;
   allModels.UserGroup = UserGroup;
   allModels.PageRedirect = PageRedirect;
+  allModels.ShareLink = ShareLink;
 
   Object.keys(allModels).forEach((key) => {
     return this.model(key, models[key](this));

+ 0 - 54
apps/app/src/server/middlewares/certify-shared-file.js

@@ -1,54 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-const url = require('url');
-
-const logger = loggerFactory('growi:middleware:certify-shared-fire');
-
-module.exports = (crowi) => {
-
-  return async(req, res, next) => {
-    const { referer } = req.headers;
-
-    // Attachments cannot be viewed by clients who do not send referer.
-    // https://github.com/weseek/growi/issues/2819
-    if (referer == null) {
-      return next();
-    }
-
-    const { path } = url.parse(referer);
-
-    if (!path.startsWith('/share/')) {
-      return next();
-    }
-
-    const fileId = req.params.id || null;
-
-    const Attachment = crowi.model('Attachment');
-    const ShareLink = crowi.model('ShareLink');
-
-    const attachment = await Attachment.findOne({ _id: fileId });
-
-    if (attachment == null) {
-      return next();
-    }
-
-    const shareLinks = await ShareLink.find({ relatedPage: attachment.page });
-
-    // If sharelinks don't exist, skip it
-    if (shareLinks.length === 0) {
-      return next();
-    }
-
-    // Is there a valid share link
-    shareLinks.map((sharelink) => {
-      if (!sharelink.isExpired()) {
-        logger.debug('Confirmed target file belong to a share page');
-        req.isSharedPage = true;
-      }
-      return;
-    });
-
-    next();
-  };
-
-};

+ 145 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.spec.ts

@@ -0,0 +1,145 @@
+import type { Response } from 'express';
+import { mock } from 'vitest-mock-extended';
+
+import { ShareLinkDocument } from '~/server/models/share-link';
+
+import { certifySharedPageAttachmentMiddleware, type RequestToAllowShareLink } from './certify-shared-page-attachment';
+import { ValidReferer } from './interfaces';
+
+const mocks = vi.hoisted(() => {
+  return {
+    validateRefererMock: vi.fn(),
+    retrieveValidShareLinkByRefererMock: vi.fn(),
+    validateAttachmentMock: vi.fn(),
+  };
+});
+
+vi.mock('./validate-referer', () => ({ validateReferer: mocks.validateRefererMock }));
+vi.mock('./retrieve-valid-share-link', () => ({ retrieveValidShareLinkByReferer: mocks.retrieveValidShareLinkByRefererMock }));
+vi.mock('./validate-attachment', () => ({ validateAttachment: mocks.validateAttachmentMock }));
+
+
+describe('certifySharedPageAttachmentMiddleware', () => {
+
+  const res = mock<Response>();
+  const next = vi.fn();
+
+  describe('should called next() without req.isSharedPage set', () => {
+
+    it('when the fileId param is null', async() => {
+      // setup
+      const req = mock<RequestToAllowShareLink>();
+      req.params = {}; // id: undefined
+      req.headers = {};
+
+      // when
+      await certifySharedPageAttachmentMiddleware(req, res, next);
+
+      // then
+      expect(mocks.validateRefererMock).not.toHaveBeenCalled();
+      expect(req.isSharedPage === true).toBeFalsy();
+      expect(next).toHaveBeenCalledOnce();
+    });
+
+    it('when validateReferer returns null', async() => {
+      // setup
+      const req = mock<RequestToAllowShareLink>();
+      req.params = { id: 'file id string' };
+      req.headers = { referer: 'referer string' };
+
+      // when
+      await certifySharedPageAttachmentMiddleware(req, res, next);
+
+      // then
+      expect(mocks.validateRefererMock).toHaveBeenCalledOnce();
+      expect(mocks.validateRefererMock).toHaveBeenCalledWith('referer string');
+      expect(req.isSharedPage === true).toBeFalsy();
+      expect(next).toHaveBeenCalledOnce();
+    });
+
+    it('when retrieveValidShareLinkByReferer returns null', async() => {
+      // setup
+      const req = mock<RequestToAllowShareLink>();
+      req.params = { id: 'file id string' };
+      req.headers = { referer: 'referer string' };
+
+      const validReferer: ValidReferer = {
+        referer: 'referer string',
+        shareLinkId: 'ffffffffffffffffffffffff',
+      };
+      mocks.validateRefererMock.mockImplementation(() => validReferer);
+
+      mocks.retrieveValidShareLinkByRefererMock.mockResolvedValue(null);
+
+      // when
+      await certifySharedPageAttachmentMiddleware(req, res, next);
+
+      // then
+      expect(mocks.validateRefererMock).toHaveBeenCalledOnce();
+      expect(mocks.validateRefererMock).toHaveBeenCalledWith('referer string');
+      expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledOnce();
+      expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledWith(validReferer);
+      expect(req.isSharedPage === true).toBeFalsy();
+      expect(next).toHaveBeenCalledOnce();
+    });
+
+    it('when validateAttachment returns false', async() => {
+      // setup
+      const req = mock<RequestToAllowShareLink>();
+      req.params = { id: 'file id string' };
+      req.headers = { referer: 'referer string' };
+
+      const validReferer = vi.fn();
+      mocks.validateRefererMock.mockImplementation(() => validReferer);
+
+      const shareLinkMock = mock<ShareLinkDocument>();
+      mocks.retrieveValidShareLinkByRefererMock.mockResolvedValue(shareLinkMock);
+
+      mocks.validateAttachmentMock.mockResolvedValue(false);
+
+      // when
+      await certifySharedPageAttachmentMiddleware(req, res, next);
+
+      // then
+      expect(mocks.validateRefererMock).toHaveBeenCalledOnce();
+      expect(mocks.validateRefererMock).toHaveBeenCalledWith('referer string');
+      expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledOnce();
+      expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledWith(validReferer);
+      expect(mocks.validateAttachmentMock).toHaveBeenCalledOnce();
+      expect(mocks.validateAttachmentMock).toHaveBeenCalledWith('file id string', shareLinkMock);
+      expect(req.isSharedPage === true).toBeFalsy();
+      expect(next).toHaveBeenCalledOnce();
+    });
+
+  });
+
+  it('should set req.isSharedPage true', async() => {
+    // setup
+    const req = mock<RequestToAllowShareLink>();
+    req.params = { id: 'file id string' };
+    req.headers = { referer: 'referer string' };
+
+    const validReferer = vi.fn();
+    mocks.validateRefererMock.mockImplementation(() => validReferer);
+
+    const shareLinkMock = mock<ShareLinkDocument>();
+    mocks.retrieveValidShareLinkByRefererMock.mockResolvedValue(shareLinkMock);
+
+    mocks.validateAttachmentMock.mockResolvedValue(true);
+
+    // when
+    await certifySharedPageAttachmentMiddleware(req, res, next);
+
+    // then
+    expect(mocks.validateRefererMock).toHaveBeenCalledOnce();
+    expect(mocks.validateRefererMock).toHaveBeenCalledWith('referer string');
+    expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledOnce();
+    expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledWith(validReferer);
+    expect(mocks.validateAttachmentMock).toHaveBeenCalledOnce();
+    expect(mocks.validateAttachmentMock).toHaveBeenCalledWith('file id string', shareLinkMock);
+
+    expect(req.isSharedPage === true).toBeTruthy();
+
+    expect(next).toHaveBeenCalledOnce();
+  });
+});

+ 49 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.ts

@@ -0,0 +1,49 @@
+import type { NextFunction, Request, Response } from 'express';
+
+import loggerFactory from '~/utils/logger';
+
+import { retrieveValidShareLinkByReferer } from './retrieve-valid-share-link';
+import { validateAttachment } from './validate-attachment';
+import { validateReferer } from './validate-referer';
+
+
+const logger = loggerFactory('growi:middleware:certify-shared-page-attachment');
+
+
+export interface RequestToAllowShareLink extends Request {
+  isSharedPage?: boolean,
+}
+
+export const certifySharedPageAttachmentMiddleware = async(req: RequestToAllowShareLink, res: Response, next: NextFunction): Promise<void> => {
+
+  const fileId: string | undefined = req.params.id;
+  const { referer } = req.headers;
+
+  if (fileId == null) {
+    logger.error('The param fileId is required. Please confirm to usage of this middleware.');
+    return next();
+  }
+
+  const validReferer = validateReferer(referer);
+  if (!validReferer) {
+    logger.info('invalid referer.');
+    return next();
+  }
+
+  logger.info('referer is valid.');
+
+  const shareLink = await retrieveValidShareLinkByReferer(validReferer);
+  if (shareLink == null) {
+    logger.info(`No valid ShareLink document found by the referer (${validReferer.referer}})`);
+    return next();
+  }
+
+  if (!(await validateAttachment(fileId, shareLink))) {
+    logger.info(`No valid ShareLink document found by the fileId (${fileId}) and referer (${validReferer.referer}})`);
+    return next();
+  }
+
+  req.isSharedPage = true;
+  next();
+
+};

+ 1 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/index.ts

@@ -0,0 +1 @@
+export * from './certify-shared-page-attachment';

+ 4 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/interfaces.ts

@@ -0,0 +1,4 @@
+export type ValidReferer = {
+  referer: string,
+  shareLinkId: string,
+};

+ 28 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/retrieve-valid-share-link.ts

@@ -0,0 +1,28 @@
+import type { ShareLinkDocument, ShareLinkModel } from '~/server/models/share-link';
+import { getModelSafely } from '~/server/util/mongoose-utils';
+import loggerFactory from '~/utils/logger';
+
+import type { ValidReferer } from './interfaces';
+
+
+const logger = loggerFactory('growi:middleware:certify-shared-page-attachment:retrieve-valid-share-link');
+
+
+export const retrieveValidShareLinkByReferer = async(referer: ValidReferer): Promise<ShareLinkDocument | null> => {
+  const ShareLink = getModelSafely<ShareLinkDocument, ShareLinkModel>('ShareLink');
+  if (ShareLink == null) {
+    logger.warn('Could not get ShareLink model. next() will be called without processing anything.');
+    return null;
+  }
+
+  const shareLinkId = referer;
+  const shareLink = await ShareLink.findOne({
+    id: shareLinkId,
+  });
+  if (shareLink == null || shareLink.isExpired()) {
+    logger.info(`ShareLink ('${shareLinkId}') is not found or has already expired.`);
+    return null;
+  }
+
+  return shareLink;
+};

+ 25 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-attachment.ts

@@ -0,0 +1,25 @@
+import { getIdForRef, type IAttachment } from '@growi/core';
+
+import { ShareLinkDocument } from '~/server/models/share-link';
+import { getModelSafely } from '~/server/util/mongoose-utils';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:middleware:certify-shared-page-attachment:validate-attachment');
+
+
+export const validateAttachment = async(fileId: string, shareLink: ShareLinkDocument): Promise<boolean> => {
+  const Attachment = getModelSafely<IAttachment>('Attachment');
+  if (Attachment == null) {
+    logger.warn('Could not get Attachment model. next() will be called without processing anything.');
+    return false;
+  }
+
+  const relatedPageId = getIdForRef(shareLink.relatedPage);
+  const result = await Attachment.exists({
+    _id: fileId,
+    page: relatedPageId,
+  });
+
+  return result != null;
+};

+ 1 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/index.ts

@@ -0,0 +1 @@
+export * from './validate-referer';

+ 59 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.spec.ts

@@ -0,0 +1,59 @@
+import { retrieveSiteUrl } from './retrieve-site-url';
+
+const mocks = vi.hoisted(() => {
+  return {
+    configManagerMock: {
+      getConfig: vi.fn(),
+    },
+  };
+});
+
+vi.mock('~/server/service/config-manager', () => {
+  return { configManager: mocks.configManagerMock };
+});
+
+
+describe('retrieveSiteUrl', () => {
+
+  describe('returns null', () => {
+
+    it('when the siteUrl is not set', () => {
+      // setup
+      mocks.configManagerMock.getConfig.mockImplementation(() => {
+        return null;
+      });
+
+      // when
+      const result = retrieveSiteUrl();
+
+      // then
+      expect(result).toBeNull();
+    });
+
+    it('when the siteUrl is invalid', () => {
+      // setup
+      mocks.configManagerMock.getConfig.mockImplementation(() => {
+        return 'invalid siteUrl string';
+      });
+
+      // when
+      const result = retrieveSiteUrl();
+
+      // then
+      expect(result).toBeNull();
+    });
+  });
+
+  it('returns a URL instance', () => {
+    // setup
+    const siteUrl = 'https://example.com';
+    mocks.configManagerMock.getConfig.mockImplementation(() => siteUrl);
+
+    // when
+    const result = retrieveSiteUrl();
+
+    // then
+    expect(result).toEqual(new URL(siteUrl));
+  });
+
+});

+ 22 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.ts

@@ -0,0 +1,22 @@
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:middlewares:certify-shared-file:validate-referer:retrieve-site-url');
+
+
+export const retrieveSiteUrl = (): URL | null => {
+  const siteUrlString = configManager.getConfig('crowi', 'app:siteUrl');
+  if (siteUrlString == null) {
+    logger.warn("Verification referer does not work because 'Site URL' is NOT set. All of attachments in share link page is invisible.");
+    return null;
+  }
+
+  try {
+    return new URL(siteUrlString);
+  }
+  catch (err) {
+    logger.error(`Parsing 'app:siteUrl' ('${siteUrlString}') has failed.`);
+    return null;
+  }
+};

+ 131 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.spec.ts

@@ -0,0 +1,131 @@
+import { objectIdUtils } from '@growi/core/dist/utils';
+
+import { validateReferer } from './validate-referer';
+
+const mocks = vi.hoisted(() => {
+  return {
+    retrieveSiteUrlMock: vi.fn(),
+  };
+});
+
+vi.mock('./retrieve-site-url', () => ({ retrieveSiteUrl: mocks.retrieveSiteUrlMock }));
+
+
+describe('validateReferer', () => {
+
+  const isValidObjectIdSpy = vi.spyOn(objectIdUtils, 'isValidObjectId');
+
+  beforeEach(() => {
+    isValidObjectIdSpy.mockClear();
+  });
+
+  describe('refurns false', () => {
+
+    it('when the referer argument is undefined', () => {
+      // setup
+
+      // when
+      const result = validateReferer(undefined);
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).not.toHaveBeenCalled();
+      expect(isValidObjectIdSpy).not.toHaveBeenCalled();
+    });
+
+    it('when the referer is invalid', () => {
+      // when
+      const result = validateReferer('invalid URL');
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).not.toHaveBeenCalledOnce();
+      expect(isValidObjectIdSpy).not.toHaveBeenCalled();
+    });
+
+    it('when the siteUrl returns null', () => {
+      // setup
+      mocks.retrieveSiteUrlMock.mockImplementation(() => {
+        return null;
+      });
+
+      // when
+      const refererString = 'https://example.org/share/xxxxx';
+      const result = validateReferer(refererString);
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).toHaveBeenCalledOnce();
+      expect(isValidObjectIdSpy).not.toHaveBeenCalled();
+    });
+
+    it('when the hostname of the referer does not match with siteUrl', () => {
+      // setup
+      mocks.retrieveSiteUrlMock.mockImplementation(() => {
+        return new URL('https://example.com');
+      });
+
+      // when
+      const refererString = 'https://example.org/share/xxxxx';
+      const result = validateReferer(refererString);
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).toHaveBeenCalledOnce();
+      expect(isValidObjectIdSpy).not.toHaveBeenCalled();
+    });
+
+    it('when the port of the referer does not match with siteUrl', () => {
+      // setup
+      mocks.retrieveSiteUrlMock.mockImplementation(() => {
+        return new URL('https://example.com');
+      });
+
+      // when
+      const refererString = 'https://example.com:8080/share/xxxxx';
+      const result = validateReferer(refererString);
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).toHaveBeenCalledOnce();
+      expect(isValidObjectIdSpy).not.toHaveBeenCalled();
+    });
+
+    it('when the shareLinkId is invalid', () => {
+      // setup
+      mocks.retrieveSiteUrlMock.mockImplementation(() => {
+        return new URL('https://example.com');
+      });
+
+      // when
+      const refererString = 'https://example.com/share/FFFFFFFFFFFFFFFFFFFFFFFF';
+      const result = validateReferer(refererString);
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).toHaveBeenCalledOnce();
+      expect(isValidObjectIdSpy).toHaveBeenCalledOnce();
+    });
+
+  });
+
+  it('returns ValidReferer instance', () => {
+    // setup
+    mocks.retrieveSiteUrlMock.mockImplementation(() => {
+      return new URL('https://example.com');
+    });
+
+
+    // when
+    const shareLinkId = '65436ba09ae6983bd608b89c';
+    const refererString = `https://example.com/share/${shareLinkId}`;
+    const result = validateReferer(refererString);
+
+    // then
+    expect(result).toStrictEqual({
+      referer: refererString,
+      shareLinkId,
+    });
+  });
+
+});

+ 69 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.ts

@@ -0,0 +1,69 @@
+import { objectIdUtils } from '@growi/core/dist/utils';
+
+import loggerFactory from '~/utils/logger';
+
+import { ValidReferer } from '../interfaces';
+
+import { retrieveSiteUrl } from './retrieve-site-url';
+
+
+const logger = loggerFactory('growi:middlewares:certify-shared-file:validate-referer');
+
+
+export const validateReferer = (referer: string | undefined): ValidReferer | false => {
+  // not null
+  if (referer == null) {
+    logger.info('The referer string is undefined');
+    return false;
+  }
+
+  let refererUrl: URL;
+  try {
+    refererUrl = new URL(referer);
+  }
+  catch (err) {
+    logger.info(`Parsing referer ('${referer}') has failed`);
+    return false;
+  }
+
+  // siteUrl
+  const siteUrl = retrieveSiteUrl();
+  if (siteUrl == null) {
+    logger.info('The siteUrl is null.');
+    return false;
+  }
+
+  // validate hostname and port
+  if (refererUrl.hostname !== siteUrl.hostname || refererUrl.port !== siteUrl.port) {
+    logger.warn('The hostname or port mismatched.', {
+      refererUrl: {
+        hostname: refererUrl.hostname,
+        port: refererUrl.port,
+      },
+      siteUrl: {
+        hostname: siteUrl.hostname,
+        port: siteUrl.port,
+      },
+    });
+    return false;
+  }
+
+  // validate pathname
+  // https://regex101.com/r/M5Bp6E/1
+  const match = refererUrl.pathname.match(/^\/share\/(?<shareLinkId>[a-f0-9]{24})$/i);
+  if (match == null || match.groups?.shareLinkId == null) {
+    logger.warn(`The pathname ('${refererUrl.pathname}') is invalid.`, match);
+    return false;
+  }
+
+  // validate shareLinkId is an correct ObjectId
+  if (!objectIdUtils.isValidObjectId(match.groups.shareLinkId)) {
+    logger.warn(`The shareLinkId ('${match.groups.shareLinkId}') is invalid as an ObjectId.`);
+    return false;
+  }
+
+  return {
+    referer,
+    shareLinkId: match.groups.shareLinkId,
+  };
+};

+ 1 - 1
apps/app/src/server/middlewares/certify-shared-page.js

@@ -1,3 +1,4 @@
+import ShareLink from '~/server/models/share-link';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:middleware:certify-shared-page');
@@ -11,7 +12,6 @@ module.exports = (crowi) => {
       return next();
     }
 
-    const ShareLink = crowi.model('ShareLink');
     const sharelink = await ShareLink.findOne({ _id: shareLinkId, relatedPage: pageId });
 
     // check sharelink enabled

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

@@ -14,6 +14,5 @@ module.exports = {
   GlobalNotificationSetting: require('./GlobalNotificationSetting'),
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
-  ShareLink: require('./share-link'),
   SlackAppIntegration: require('./slack-app-integration'),
 };

+ 0 - 39
apps/app/src/server/models/share-link.js

@@ -1,39 +0,0 @@
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-const uniqueValidator = require('mongoose-unique-validator');
-
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-/*
- * define schema
- */
-const schema = new mongoose.Schema({
-  relatedPage: {
-    type: ObjectId,
-    ref: 'Page',
-    required: true,
-    index: true,
-  },
-  expiredAt: { type: Date },
-  description: { type: String },
-}, {
-  timestamps: { createdAt: true, updatedAt: false },
-});
-schema.plugin(mongoosePaginate);
-schema.plugin(uniqueValidator);
-
-module.exports = function(crowi) {
-
-  schema.methods.isExpired = function() {
-    if (this.expiredAt == null) {
-      return false;
-    }
-    return this.expiredAt.getTime() < new Date().getTime();
-  };
-
-  const model = mongoose.model('ShareLink', schema);
-  return model;
-};

+ 46 - 0
apps/app/src/server/models/share-link.ts

@@ -0,0 +1,46 @@
+import mongoose, { Schema } from 'mongoose';
+import type {
+  Document, Model,
+} from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+import uniqueValidator from 'mongoose-unique-validator';
+
+import type { IShareLink } from '~/interfaces/share-link';
+
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+
+export interface ShareLinkDocument extends IShareLink, Document {
+  isExpired: () => boolean,
+}
+
+export type ShareLinkModel = Model<ShareLinkDocument>;
+
+
+/*
+ * define schema
+ */
+const ObjectId = mongoose.Schema.Types.ObjectId;
+const schema = new Schema<ShareLinkDocument, ShareLinkModel>({
+  relatedPage: {
+    type: ObjectId,
+    ref: 'Page',
+    required: true,
+    index: true,
+  },
+  expiredAt: { type: Date },
+  description: { type: String },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
+});
+schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
+schema.methods.isExpired = function() {
+  if (this.expiredAt == null) {
+    return false;
+  }
+  return this.expiredAt.getTime() < new Date().getTime();
+};
+
+export default getOrCreateModel<ShareLinkDocument, ShareLinkModel>('ShareLink', schema);

+ 1 - 2
apps/app/src/server/routes/apiv3/security-settings/index.js

@@ -5,6 +5,7 @@ import { SupportedAction } from '~/interfaces/activity';
 import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import ShareLink from '~/server/models/share-link';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import { validateDeleteConfigs, prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
@@ -731,7 +732,6 @@ module.exports = (crowi) => {
    *                      description: suceed to get all share links
    */
   router.get('/all-share-links/', loginRequiredStrictly, adminRequired, async(req, res) => {
-    const ShareLink = crowi.model('ShareLink');
     const page = parseInt(req.query.page) || 1;
     const limit = 10;
     const linkQuery = {};
@@ -769,7 +769,6 @@ module.exports = (crowi) => {
    */
 
   router.delete('/all-share-links/', loginRequiredStrictly, adminRequired, async(req, res) => {
-    const ShareLink = crowi.model('ShareLink');
     try {
       const removedAct = await ShareLink.remove({});
       const removeTotal = await removedAct.n;

+ 2 - 5
apps/app/src/server/routes/apiv3/share-links.js

@@ -1,17 +1,17 @@
 // TODO remove this setting after implemented all
 /* eslint-disable no-unused-vars */
 import { ErrorV3 } from '@growi/core/dist/models';
+import express from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
+import ShareLink from '~/server/models/share-link';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:share-links');
 
-const express = require('express');
-
 const router = express.Router();
 
 const { body, query, param } = require('express-validator');
@@ -31,7 +31,6 @@ module.exports = (crowi) => {
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
-  const ShareLink = crowi.model('ShareLink');
   const Page = crowi.model('Page');
 
   const activityEvent = crowi.event('activity');
@@ -145,8 +144,6 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(msg, 'post-shareLink-failed'));
     }
 
-    const ShareLink = crowi.model('ShareLink');
-
     try {
       const postedShareLink = await ShareLink.create({ relatedPage, expiredAt, description });
 

+ 2 - 2
apps/app/src/server/routes/index.js

@@ -6,6 +6,7 @@ import { middlewareFactory as rateLimiterFactory } from '~/features/rate-limiter
 import { generateAddActivityMiddleware } from '../middlewares/add-activity';
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
 import { generateCertifyBrandLogoMiddleware } from '../middlewares/certify-brand-logo';
+import { certifySharedPageAttachmentMiddleware } from '../middlewares/certify-shared-page-attachment';
 import { excludeReadOnlyUser } from '../middlewares/exclude-read-only-user';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
@@ -33,7 +34,6 @@ module.exports = function(crowi, app) {
   const loginRequiredStrictly = require('../middlewares/login-required')(crowi);
   const loginRequired = require('../middlewares/login-required')(crowi, true);
   const adminRequired = require('../middlewares/admin-required')(crowi);
-  const certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
   const certifyBrandLogo = generateCertifyBrandLogoMiddleware(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
@@ -156,7 +156,7 @@ module.exports = function(crowi, app) {
 
   app.get('/me'                                   , loginRequiredStrictly, next.delegateToNext);
   app.get('/me/*'                                 , loginRequiredStrictly, next.delegateToNext);
-  app.get('/attachment/:id([0-9a-z]{24})' , certifySharedFile , loginRequired, attachment.api.get);
+  app.get('/attachment/:id([0-9a-z]{24})'         , certifySharedPageAttachmentMiddleware , loginRequired, attachment.api.get);
   app.get('/attachment/profile/:id([0-9a-z]{24})' , loginRequired, attachment.api.get);
   app.get('/attachment/:pageId/:fileName'       , loginRequired, attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
   app.get('/download/:id([0-9a-z]{24})'         , loginRequired, attachment.api.download);

+ 1 - 1
apps/app/src/server/service/page.ts

@@ -36,6 +36,7 @@ import { IOptionsForCreate, IOptionsForUpdate } from '../models/interfaces/page-
 import PageOperation, { PageOperationDocument } from '../models/page-operation';
 import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
+import ShareLink from '../models/share-link';
 import Subscription from '../models/subscription';
 import { V5ConversionError } from '../models/vo/v5-conversion-error';
 
@@ -1690,7 +1691,6 @@ class PageService {
     const Comment = this.crowi.model('Comment');
     const Page = this.crowi.model('Page');
     const PageTagRelation = this.crowi.model('PageTagRelation');
-    const ShareLink = this.crowi.model('ShareLink');
     const Revision = this.crowi.model('Revision');
     const Attachment = this.crowi.model('Attachment');
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;

+ 2 - 2
apps/app/src/server/util/mongoose-utils.ts

@@ -18,9 +18,9 @@ export const getMongoUri = (): string => {
     || ((env.NODE_ENV === 'test') ? 'mongodb://mongo/growi_test' : 'mongodb://mongo/growi');
 };
 
-export const getModelSafely = <T>(modelName: string): Model<T & Document> | null => {
+export const getModelSafely = <Interface, Method = Interface>(modelName: string): Method & Model<Interface & Document> | null => {
   if (mongoose.modelNames().includes(modelName)) {
-    return mongoose.model<T & Document>(modelName);
+    return mongoose.model<Interface & Document, Method & Model<Interface & Document>>(modelName);
   }
   return null;
 };