diff --git a/backend/src/admin-app.ts b/backend/src/admin-app.ts index ff60879b3..84a1b69c8 100644 --- a/backend/src/admin-app.ts +++ b/backend/src/admin-app.ts @@ -3,7 +3,7 @@ import express, { NextFunction, Request, Response } from 'express'; import bodyParser from 'body-parser'; import promBundle from 'express-prom-bundle'; import { rateLimit } from 'express-rate-limit'; -import session from 'express-session'; +import session, { CookieOptions } from 'express-session'; import helmet from 'helmet'; import morgan from 'morgan'; import noCache from 'nocache'; @@ -24,15 +24,15 @@ import { logger } from './logger'; import prisma from './v1/prisma/prisma-client'; import adminAuthRouter from './v1/routes/admin-auth-routes'; import adminReportRoutes from './v1/routes/admin-report-routes'; -import adminUsersRoutes from './v1/routes/admin-users-routes'; import adminUserRouter from './v1/routes/admin-user-info-routes'; +import adminUsersRoutes from './v1/routes/admin-users-routes'; +import analyticRoutes from './v1/routes/analytic-routes'; +import announcementsRoutes from './v1/routes/announcement-routes'; import codeRouter from './v1/routes/code-routes'; import { router as configRouter } from './v1/routes/config-routes'; -import announcementsRoutes from './v1/routes/announcement-routes'; -import analyticRoutes from './v1/routes/analytic-routes'; -import resourcesRoutes from './v1/routes/resources-routes'; -import employerRoutes from './v1/routes/employer-routes'; import dashboardMetricsRouter from './v1/routes/dashboard'; +import employerRoutes from './v1/routes/employer-routes'; +import resourcesRoutes from './v1/routes/resources-routes'; import { adminAuth } from './v1/services/admin-auth-service'; import { utils } from './v1/services/utils-service'; @@ -77,10 +77,19 @@ adminApp.use( }), ); let proxy = true; -const cookie = { +const cookie: CookieOptions = { secure: true, httpOnly: true, - maxAge: 1800000, //30 minutes in ms. this is same as session time. DO NOT MODIFY, IF MODIFIED, MAKE SURE SAME AS SESSION TIME OUT VALUE. + //30 minutes in ms. this is same as session time. + //DO NOT MODIFY, IF MODIFIED, MAKE SURE SAME AS SESSION TIME OUT VALUE. + maxAge: 1800000, + //sameSite: 'strict' would be preferable, but it complicates the + //authentication code flow of the login process. The callback + //url is accessed by the identify provider, which is a third party. + //With a strict policy the the browser won't pass the cookie to the + //callback endpoint. 'lax' is a middle ground that will provide + //some protection against CSRF attacks. + sameSite: 'lax', }; if ('local' === config.get('environment')) { cookie.secure = false; diff --git a/backend/src/app.ts b/backend/src/app.ts index 6c3f5e4dd..c4a4cb5c4 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -3,7 +3,7 @@ import express, { NextFunction, Request, Response } from 'express'; import bodyParser from 'body-parser'; import promBundle from 'express-prom-bundle'; import { rateLimit } from 'express-rate-limit'; -import session from 'express-session'; +import session, { CookieOptions } from 'express-session'; import helmet from 'helmet'; import morgan from 'morgan'; import noCache from 'nocache'; @@ -16,14 +16,14 @@ import fileSessionStore from 'session-file-store'; import { config } from './config'; import { logger } from './logger'; import prisma from './v1/prisma/prisma-client'; +import announcementRouter from './v1/routes/announcement-routes'; import codeRouter from './v1/routes/code-routes'; import { router as configRouter } from './v1/routes/config-routes'; import { fileUploadRouter } from './v1/routes/file-upload-routes'; import authRouter from './v1/routes/public-auth-routes'; import { reportRouter } from './v1/routes/report-routes'; -import userRouter from './v1/routes/user-info-routes'; -import announcementRouter from './v1/routes/announcement-routes'; import resourcesRoutes from './v1/routes/resources-routes'; +import userRouter from './v1/routes/user-info-routes'; import { publicAuth } from './v1/services/public-auth-service'; import { utils } from './v1/services/utils-service'; @@ -94,10 +94,18 @@ app.use( }), ); let proxy = true; -const cookie = { +const cookie: CookieOptions = { secure: true, httpOnly: true, - maxAge: 1800000, //30 minutes in ms. this is same as session time. DO NOT MODIFY, IF MODIFIED, MAKE SURE SAME AS SESSION TIME OUT VALUE. + //maxAge: 30 minutes in ms. this is same as session time. + //DO NOT MODIFY, IF MODIFIED, MAKE SURE SAME AS SESSION TIME OUT VALUE. + maxAge: 1800000, + //sameSite: 'strict' would be preferable, but it complicates the + //authentication code flow of the login process (because the callback + //url is issued by the identify provider, which is a third party so the + //browser doesn't pass the cookie to the callback endpoint). + //'lax' is a middle ground that will + sameSite: 'lax', }; if ('local' === config.get('environment')) { cookie.secure = false; diff --git a/backend/src/v1/routes/file-upload-routes.spec.ts b/backend/src/v1/routes/file-upload-routes.spec.ts index b40a5cb1a..4767a11b5 100644 --- a/backend/src/v1/routes/file-upload-routes.spec.ts +++ b/backend/src/v1/routes/file-upload-routes.spec.ts @@ -1,9 +1,24 @@ +import bodyParser from 'body-parser'; import express, { Application } from 'express'; import request from 'supertest'; -import { fileUploadRouter } from './file-upload-routes'; import { SubmissionError } from '../services/file-upload-service'; +import { fileUploadRouter } from './file-upload-routes'; let app: Application; +const maliciousObject = { $ne: '1' }; +const validSubmissionBody = { + companyName: 'ABC Company', + companyAddress: '123 Main St', + naicsCode: '11', + employeeCountRangeId: '123434565467678', + startDate: '2024-01-01', + endDate: '2024-12-31', + reportingYear: 2024, + dataConstraints: '

Data constraints here

', + comments: null, + rows: [], +}; + // Mock the module, but only override handleSubmission const mockHandleSubmission = jest.fn().mockResolvedValue({}); jest.mock('../services/file-upload-service', () => { @@ -31,25 +46,46 @@ describe('file-upload', () => { beforeEach(() => { jest.clearAllMocks(); app = express(); + app.use(bodyParser.json()); app.use(fileUploadRouter); }); - describe('/', () => { - it('/ [POST] - should return 200', async () => { - await request(app).post('/').expect(200); - expect(mockStoreError).not.toHaveBeenCalled(); + describe('POST /', () => { + describe('when the request body is valid', () => { + it('should return 200', async () => { + await request(app).post('/').send(validSubmissionBody).expect(200); + expect(mockStoreError).not.toHaveBeenCalled(); + }); }); - it('/ [POST] - should return 400', async () => { - mockHandleSubmission.mockResolvedValue(new SubmissionError('test')); - await request(app).post('/').expect(400); - expect(mockStoreError).toHaveBeenCalled(); + describe('when the request body is empty', () => { + it('should return 400', async () => { + mockHandleSubmission.mockResolvedValue(new SubmissionError('test')); + await request(app).post('/').expect(400); + expect(mockStoreError).toHaveBeenCalled(); + }); }); - it('/ [POST] - should return 500', async () => { - mockHandleSubmission.mockImplementation(() => { - throw Error('test'); + [ + 'companyName', + 'companyAddress', + 'naicsCode', + 'employeeCountRangeId', + 'startDate', + 'endDate', + 'reportingYear', + 'dataConstraints', + 'comments', + 'rows', + ].forEach((fieldName) => { + describe(`when ${fieldName} is invalid`, () => { + it('should return 400', async () => { + const invalidSubmissionBody = { + ...validSubmissionBody, + }; + invalidSubmissionBody[fieldName] = maliciousObject; + await request(app).post('/').send(invalidSubmissionBody).expect(400); + expect(mockStoreError).toHaveBeenCalled(); + }); }); - await request(app).post('/').expect(500); - expect(mockStoreError).toHaveBeenCalled(); }); }); }); diff --git a/backend/src/v1/routes/file-upload-routes.ts b/backend/src/v1/routes/file-upload-routes.ts index 1b8042898..7e257ed57 100644 --- a/backend/src/v1/routes/file-upload-routes.ts +++ b/backend/src/v1/routes/file-upload-routes.ts @@ -1,16 +1,29 @@ import express, { Request, Response } from 'express'; +import { body, validationResult } from 'express-validator'; import { logger as log } from '../../logger'; +import { errorService } from '../services/error-service'; import { ISubmission, SubmissionError, fileUploadService, } from '../services/file-upload-service'; import { utils } from '../services/utils-service'; -import { errorService } from '../services/error-service'; const fileUploadRouter = express.Router(); fileUploadRouter.post( '/', + [ + body('companyName').exists().isString(), + body('companyAddress').exists().isString(), + body('naicsCode').exists().isString(), + body('employeeCountRangeId').exists().isString(), + body('startDate').exists().isString(), + body('endDate').exists().isString(), + body('reportingYear').exists().isNumeric(), + body('dataConstraints').optional({ nullable: true }).isString(), + body('comments').optional({ nullable: true }).isString(), + body('rows').exists().isArray(), + ], utils.asyncHandler( async (req: Request, res: Response) => { const session: any = req?.session; @@ -22,6 +35,11 @@ fileUploadRouter.post( const data: ISubmission = req.body; let error = null; try { + const preliminaryValidationErrors = validationResult(req); + if (!preliminaryValidationErrors.isEmpty()) { + error = new SubmissionError(preliminaryValidationErrors.array()); + return res.status(400).json(error); + } const result: any = await fileUploadService.handleSubmission( userInfo, data, diff --git a/backend/src/v1/routes/public-auth-routes.spec.ts b/backend/src/v1/routes/public-auth-routes.spec.ts index 43987c995..c795657d0 100644 --- a/backend/src/v1/routes/public-auth-routes.spec.ts +++ b/backend/src/v1/routes/public-auth-routes.spec.ts @@ -18,7 +18,7 @@ jest.mock('../services/public-auth-service', () => { const actualPublicAuth = jest.requireActual( '../services/public-auth-service', ); - const mockedPublicAuth = jest.genMockFromModule( + const mockedPublicAuth = jest.createMockFromModule( '../services/public-auth-service', ) as any; @@ -61,16 +61,6 @@ jest.mock('passport', () => ({ jest.fn((req, res, next) => mockAuthenticate(req, res, next)), })); -const mockExists = jest.fn(); -const mockValidationResult = jest.fn(); -jest.mock('express-validator', () => ({ - ...jest.requireActual('express-validator'), - body: (...args) => ({ - exists: () => jest.fn((req, res, next) => mockExists(req, res, next)), - }), - validationResult: jest.fn(() => mockValidationResult()), -})); - const mockLogout = jest.fn(); const mockRequest = { logout: jest.fn(mockLogout), @@ -79,6 +69,7 @@ const mockRequest = { }, user: { idToken: 'ey....', + jwtFrontend: '567ghi', }, }; @@ -264,7 +255,6 @@ describe('public-auth-routes', () => { describe('/refresh', () => { it('return 401 if token company details are missing', () => { - mockExists.mockImplementation((req, res, next) => next()); app.use((req: any, res, next) => { req.session = mockRequest.session; req.user = mockRequest.user; @@ -280,36 +270,31 @@ describe('public-auth-routes', () => { expect(res.body.error).toBe(MISSING_COMPANY_DETAILS_ERROR); }); }); - it('should handle validation errors', () => { - mockExists.mockImplementation((req, res, next) => next()); - mockValidationResult.mockImplementation(() => { - return { - isEmpty: () => false, - array: () => [1, 2], - }; - }); + it("if user isn't initialized, fail with 401", () => { + mockIsTokenExpired.mockReturnValue(false); + mockIsRenewable.mockReturnValue(true); app.use((req: any, res, next) => { req.session = { ...mockRequest.session, companyDetails: { id: 1 } }; req.user = mockRequest.user; req.logout = mockRequest.logout; + req.body = { refreshToken: mockRequest.user.jwtFrontend }; next(); }); app.use('/auth', router); return request(app) .post('/auth/refresh') - .expect(400) + .expect(401) .expect((res) => { - expect(res.body.errors).toEqual([1, 2]); + expect(res.body).toEqual({ + error: 'Unauthorized', + error_description: 'Not logged in', + }); }); }); it('should return unauthorized when jwt or refresh token is not found', () => { - mockExists.mockImplementation((req, res, next) => next()); - mockValidationResult.mockImplementation(() => { - return { - isEmpty: () => true, - }; - }); + mockIsTokenExpired.mockReturnValue(false); + mockIsRenewable.mockReturnValue(false); app.use((req: any, res, next) => { req.session = { ...mockRequest.session, companyDetails: { id: 1 } }; req.user = mockRequest.user; @@ -318,29 +303,33 @@ describe('public-auth-routes', () => { }); app.use('/auth', router); - return request(app) - .post('/auth/refresh') - .expect(401) - .expect((res) => { - expect(res.body).toEqual({ - error: 'Unauthorized', - error_description: 'Not logged in', - }); - }); + return request(app).post('/auth/refresh').expect(401); }); - it('should renew the token if renewable', async () => { + it('should return 401 when the refreshToken body param contains a non-string', async () => { const mockCorrelationId = 12; - const mockFrontendToken = 'jwt_value'; - mockExists.mockImplementation((req, res, next) => next()); - mockValidationResult.mockImplementation(() => { - return { - isEmpty: () => true, + mockIsTokenExpired.mockReturnValue(true); + mockIsRenewable.mockReturnValue(true); + + app.use((req: any, res, next) => { + req.session = { + ...mockRequest.session, + companyDetails: { id: 1 }, + correlationID: mockCorrelationId, }; + req.user = { ...mockRequest.user, jwt: 'jwt', refreshToken: 'jwt' }; + req.logout = mockRequest.logout; + req.body = { refreshToken: { $ne: '1' } }; + next(); }); + app.use('/auth', router); + await request(app).post('/auth/refresh').expect(401); + }); + it('should renew the token if renewable', async () => { + const mockCorrelationId = 12; + const mockFrontendToken = 'jwt_value'; mockIsTokenExpired.mockReturnValue(true); mockGenerateFrontendToken.mockReturnValue(mockFrontendToken); mockIsRenewable.mockReturnValue(true); - mockRenew.mockResolvedValue({ jwt: 1, refreshToken: 1 }); mockRenewBackendAndFrontendTokens.mockImplementation((req, res) => res.status(200).json({ jwtFrontend: mockFrontendToken, @@ -356,6 +345,7 @@ describe('public-auth-routes', () => { }; req.user = { ...mockRequest.user, jwt: 'jwt', refreshToken: 'jwt' }; req.logout = mockRequest.logout; + req.body = { refreshToken: mockRequest.user.jwtFrontend }; next(); }); app.use('/auth', router); @@ -371,12 +361,6 @@ describe('public-auth-routes', () => { }); it('should return unauthorized when it fails to renew tokens', () => { const mockCorrelationId = 12; - mockExists.mockImplementation((req, res, next) => next()); - mockValidationResult.mockImplementation(() => { - return { - isEmpty: () => true, - }; - }); mockIsTokenExpired.mockReturnValue(true); mockGenerateFrontendToken.mockReturnValue('jwt_value'); mockIsRenewable.mockReturnValue(true); @@ -399,13 +383,7 @@ describe('public-auth-routes', () => { return request(app).post('/auth/refresh').expect(401); }); - it('should return unauthorized if token is not expired', () => { - mockExists.mockImplementation((req, res, next) => next()); - mockValidationResult.mockImplementation(() => { - return { - isEmpty: () => true, - }; - }); + it('should return unauthorized if token is not renewable', () => { mockIsTokenExpired.mockReturnValue(true); mockIsRenewable.mockReturnValue(false); @@ -417,21 +395,17 @@ describe('public-auth-routes', () => { }; req.user = { ...mockRequest.user, jwt: 'jwt', refreshToken: 'jwt' }; req.logout = mockRequest.logout; + req.body = { refreshToken: mockRequest.user.jwtFrontend }; next(); }); app.use('/auth', router); return request(app).post('/auth/refresh').expect(401); }); - it('should return with current user tokens', () => { - mockExists.mockImplementation((req, res, next) => next()); - mockValidationResult.mockImplementation(() => { - return { - isEmpty: () => true, - }; - }); - mockIsTokenExpired.mockReturnValue(false); + it('should return 200 with current user token', () => { + mockIsTokenExpired.mockReturnValue(false); + mockIsRenewable.mockReturnValue(false); app.use((req: any, res, next) => { req.session = { ...mockRequest.session, @@ -440,21 +414,21 @@ describe('public-auth-routes', () => { }; req.user = { ...mockRequest.user, - jwtFrontend: 'jwt', jwt: 'jwt', refreshToken: 'jwt', }; req.logout = mockRequest.logout; + req.body = { refreshToken: mockRequest.user.jwtFrontend }; next(); }); app.use('/auth', router); - return request(app) .post('/auth/refresh') + .set('Accept', 'application/json') .expect(200) .expect((res) => { expect(res.body).toEqual({ - jwtFrontend: 'jwt', + jwtFrontend: mockRequest.user.jwtFrontend, correlationID: 12, }); }); @@ -463,7 +437,6 @@ describe('public-auth-routes', () => { describe('/token', () => { it('return 401 if token company details are missing', () => { mockRefreshJWT.mockImplementation((req, res, next) => next()); - mockExists.mockImplementation((req, res, next) => next()); app.use((req: any, res, next) => { req.session = mockRequest.session; req.user = mockRequest.user; @@ -481,7 +454,6 @@ describe('public-auth-routes', () => { }); it('return 200 if jwtFrontend and refreshToken are available', () => { mockRefreshJWT.mockImplementation((req, res, next) => next()); - mockExists.mockImplementation((req, res, next) => next()); app.use((req: any, res, next) => { req.session = { ...mockRequest.session, @@ -513,7 +485,6 @@ describe('public-auth-routes', () => { }); it('return 401 unauthorized', () => { mockRefreshJWT.mockImplementation((req, res, next) => next()); - mockExists.mockImplementation((req, res, next) => next()); app.use((req: any, res, next) => { req.session = { ...mockRequest.session, diff --git a/backend/src/v1/routes/public-auth-routes.ts b/backend/src/v1/routes/public-auth-routes.ts index 75802160d..9c60cd06e 100644 --- a/backend/src/v1/routes/public-auth-routes.ts +++ b/backend/src/v1/routes/public-auth-routes.ts @@ -108,7 +108,16 @@ router.get( //refreshes jwt on refresh if refreshToken is valid router.post( '/refresh', - [body('refreshToken').exists()], + [ + body('refreshToken').exists().isString(), + body('refreshToken').custom(async (refreshToken, { req }) => { + //the value of the 'refreshToken' attribute in the body must equal + //the value of the user's frontend token + if (!req?.user?.jwtFrontend || req?.user?.jwtFrontend != refreshToken) { + throw new Error('Invalid refresh token'); + } + }), + ], utils.asyncHandler(async (req: Request, res: Response) => { const user: any = req.user; const session: any = req.session; @@ -129,9 +138,7 @@ router.post( if (!errors.isEmpty()) { log.error(JSON.stringify(errors)); - return res.status(400).json({ - errors: errors.array(), - }); + return res.status(401).json(UnauthorizedRsp); } if (!user?.refreshToken || !user?.jwt) { log.error(MISSING_TOKENS_ERROR);