From db270fd9cfc1193b7e2c9b768b23a1fb0844f1d3 Mon Sep 17 00:00:00 2001 From: Hafiz Mohsin Ayoob Date: Mon, 6 Jan 2025 22:08:11 +0500 Subject: [PATCH 1/4] chore: protected proxy by origins and signature --- src/api.ts | 42 ++++++-------- src/pages/api/proxy/[...path].ts | 95 ++++++++++++++++++++++++-------- src/utils/auth/api.ts | 30 ++++------ src/utils/auth/signature.ts | 24 ++++---- src/utils/headers.ts | 42 ++++++++++++++ 5 files changed, 156 insertions(+), 77 deletions(-) create mode 100644 src/utils/headers.ts diff --git a/src/api.ts b/src/api.ts index f70a27c84c..d7f16e87b3 100644 --- a/src/api.ts +++ b/src/api.ts @@ -30,8 +30,7 @@ import { makeByRangeVersesUrl, makeWordByWordTranslationsUrl, } from '@/utils/apiPaths'; -import generateSignature from '@/utils/auth/signature'; -import { isStaticBuild } from '@/utils/build'; +import { getAdditionalHeaders } from '@/utils/headers'; import { SearchRequest, AdvancedCopyRequest, PagesLookUpRequest } from 'types/ApiRequests'; import { TranslationsResponse, @@ -62,10 +61,6 @@ export const SEARCH_FETCH_OPTIONS = { export const OFFLINE_ERROR = 'OFFLINE'; -export const X_AUTH_SIGNATURE = 'x-auth-signature'; -export const X_TIMESTAMP = 'x-timestamp'; -export const X_INTERNAL_CLIENT = 'x-internal-client'; - export const fetcher = async function fetcher( input: RequestInfo, init: RequestInit = {}, @@ -74,26 +69,23 @@ export const fetcher = async function fetcher( if (typeof window !== 'undefined' && !window.navigator.onLine) { throw new Error(OFFLINE_ERROR); } - - let reqInit = init; - if (isStaticBuild) { - const req: NextApiRequest = { - url: typeof input === 'string' ? input : input.url, - method: init.method || 'GET', - body: init.body, - headers: init.headers, - query: {}, - } as NextApiRequest; - - const { signature, timestamp } = generateSignature(req, req.url); - const headers = { + const req: NextApiRequest = { + url: typeof input === 'string' ? input : input.url, + method: init.method || 'GET', + body: init.body, + headers: init.headers, + query: {}, + } as NextApiRequest; + + const additionalHeaders = getAdditionalHeaders(req); + + const reqInit = { + ...init, + headers: { ...init.headers, - [X_AUTH_SIGNATURE]: signature, - [X_TIMESTAMP]: timestamp, - [X_INTERNAL_CLIENT]: process.env.INTERNAL_CLIENT_ID, - }; - reqInit = { ...init, headers }; - } + ...additionalHeaders, + }, + }; const res = await fetch(input, reqInit); if (!res.ok || res.status === 500 || res.status === 404) { diff --git a/src/pages/api/proxy/[...path].ts b/src/pages/api/proxy/[...path].ts index 7143fb8e02..26c885e18f 100644 --- a/src/pages/api/proxy/[...path].ts +++ b/src/pages/api/proxy/[...path].ts @@ -3,23 +3,90 @@ import { EventEmitter } from 'events'; import { createProxyMiddleware, fixRequestBody } from 'http-proxy-middleware'; import { NextApiRequest, NextApiResponse } from 'next'; -import { X_AUTH_SIGNATURE, X_INTERNAL_CLIENT, X_TIMESTAMP } from '@/api'; import generateSignature from '@/utils/auth/signature'; +import { + X_AUTH_SIGNATURE, + X_INTERNAL_CLIENT, + X_TIMESTAMP, + X_PROXY_SIGNATURE, + X_PROXY_TIMESTAMP, +} from '@/utils/headers'; -// Define error messages in a constant object const ERROR_MESSAGES = { PROXY_ERROR: 'Proxy error', PROXY_HANDLER_ERROR: 'Proxy handler error', + FORBIDDEN: 'Forbidden', }; +const ALLOWED_DOMAINS = (process.env.ALLOWED_ORIGINS || '') + .split(',') + .map((domain) => domain.trim()); + // This line increases the default maximum number of event listeners for the EventEmitter to a better number like 20. // It is necessary to prevent memory leak warnings when multiple listeners are added, // which can occur in a proxy setup like this where multiple requests are handled concurrently. EventEmitter.defaultMaxListeners = Number(process.env.PROXY_DEFAULT_MAX_LISTENERS) || 100; -// This file sets up a proxy middleware for API requests. It is needed to forward requests from the frontend -// to the backend server, allowing for features like cookie handling and request body fixing, which are essential -// for maintaining session state and ensuring correct request formatting while in a cross domain env. +const isOriginAllowed = (origin: string | undefined): boolean => { + if (!origin) return false; + const url = new URL(origin); + const { hostname } = url; + return ALLOWED_DOMAINS.some((domain) => hostname.endsWith(domain)); +}; + +const handleProxyReq = (proxyReq, req, res) => { + const origin = req.headers.origin || req.headers.referer || ''; + if (origin) { + if (!isOriginAllowed(origin)) { + res.status(403).send({ error: ERROR_MESSAGES.FORBIDDEN }); + return; + } + } else if (!verifySignature(req, res)) { + return; + } + + attachCookies(proxyReq, req); + attachSignatureHeaders(proxyReq, req); + fixRequestBody(proxyReq, req); +}; + +const verifySignature = (req, res) => { + const protocol = req.headers['x-forwarded-proto'] || 'http'; + const requestUrl = `${protocol}://${req.headers.host}/api/proxy${req.url}`; + const timestampHeader = req.headers[X_PROXY_TIMESTAMP] as string; + const { signature } = generateSignature( + req, + requestUrl, + process.env.PROXY_SIGNATURE_TOKEN as string, + timestampHeader, + ); + + if (req.headers[X_PROXY_SIGNATURE] !== signature) { + res.status(403).send({ error: ERROR_MESSAGES.FORBIDDEN }); + return false; + } + return true; +}; + +const attachCookies = (proxyReq, req) => { + if (req.headers.cookie) { + proxyReq.setHeader('Cookie', req.headers.cookie); + } +}; + +const attachSignatureHeaders = (proxyReq, req) => { + const requestUrl = `${process.env.API_GATEWAY_URL}${req.url}`; + const { signature, timestamp } = generateSignature( + req, + requestUrl, + process.env.SIGNATURE_TOKEN as string, + ); + + proxyReq.setHeader(X_AUTH_SIGNATURE, signature); + proxyReq.setHeader(X_TIMESTAMP, timestamp); + proxyReq.setHeader(X_INTERNAL_CLIENT, process.env.INTERNAL_CLIENT_ID); +}; + const apiProxy = createProxyMiddleware({ target: process.env.API_GATEWAY_URL, changeOrigin: true, @@ -28,23 +95,7 @@ const apiProxy = createProxyMiddleware({ logger: console, on: { - proxyReq: (proxyReq, req) => { - // Attach cookies from the request to the proxy request - if (req.headers.cookie) { - proxyReq.setHeader('Cookie', req.headers.cookie); - } - - // Generate and attach signature headers - const requestUrl = `${process.env.API_GATEWAY_URL}${req.url}`; - const { signature, timestamp } = generateSignature(req, requestUrl); - - proxyReq.setHeader(X_AUTH_SIGNATURE, signature); - proxyReq.setHeader(X_TIMESTAMP, timestamp); - proxyReq.setHeader(X_INTERNAL_CLIENT, process.env.INTERNAL_CLIENT_ID); - - // Fix the request body if bodyParser is involved - fixRequestBody(proxyReq, req); - }, + proxyReq: handleProxyReq, proxyRes: (proxyRes, req, res) => { // Set cookies from the proxy response to the original response diff --git a/src/utils/auth/api.ts b/src/utils/auth/api.ts index 582479bdf3..a7c0749a97 100644 --- a/src/utils/auth/api.ts +++ b/src/utils/auth/api.ts @@ -7,11 +7,10 @@ import { getTimezone } from '../datetime'; import { prepareGenerateMediaFileRequestData } from '../media/utils'; import { BANNED_USER_ERROR_ID } from './constants'; -import generateSignature from './signature'; import BookmarkByCollectionIdQueryParams from './types/BookmarkByCollectionIdQueryParams'; import GetAllNotesQueryParams from './types/Note/GetAllNotesQueryParams'; -import { fetcher, X_AUTH_SIGNATURE, X_INTERNAL_CLIENT, X_TIMESTAMP } from '@/api'; +import { fetcher } from '@/api'; import { FilterActivityDaysParams, QuranActivityDay, @@ -77,7 +76,7 @@ import { makeGetMediaFileProgressUrl, makeGetMonthlyMediaFilesCountUrl, } from '@/utils/auth/apiPaths'; -import { isStaticBuild } from '@/utils/build'; +import { getAdditionalHeaders } from '@/utils/headers'; import CompleteAnnouncementRequest from 'types/auth/CompleteAnnouncementRequest'; import { GetBookmarkCollectionsIdResponse } from 'types/auth/GetBookmarksByCollectionId'; import PreferenceGroup from 'types/auth/PreferenceGroup'; @@ -459,23 +458,14 @@ export const withCredentialsFetcher = async ( init?: RequestInit, ): Promise => { try { - let additionalHeaders = {}; - if (isStaticBuild) { - const req: NextApiRequest = { - url: typeof input === 'string' ? input : input.url, - method: init.method || 'GET', - body: init.body, - headers: init.headers, - query: {}, - } as NextApiRequest; - - const { signature, timestamp } = generateSignature(req, req.url); - additionalHeaders = { - [X_AUTH_SIGNATURE]: signature, - [X_TIMESTAMP]: timestamp, - [X_INTERNAL_CLIENT]: process.env.INTERNAL_CLIENT_ID, - }; - } + const request: NextApiRequest = { + url: typeof input === 'string' ? input : input.url, + method: init?.method || 'GET', + body: init?.body, + headers: init?.headers, + query: {}, + } as NextApiRequest; + const additionalHeaders = getAdditionalHeaders(request); const data = await fetcher(input, { ...init, credentials: 'include', diff --git a/src/utils/auth/signature.ts b/src/utils/auth/signature.ts index 02dfcd2315..057c96b16b 100644 --- a/src/utils/auth/signature.ts +++ b/src/utils/auth/signature.ts @@ -25,21 +25,25 @@ const recursiveSortedObjectToString = (params: any): string => { /** * Generates a signature for the given request. * - * @param {NextApiRequest} req - The request object. - * @returns {{ signature: string; timestamp: string }} - The generated signature and timestamp. - */ -/** - * Generates a signature for the given request. + * This function creates a signature using the request details, a specified URL, and a signature token. + * It supports requests with bodies for methods like POST, PUT, PATCH, and DELETE. * - * @param {NextApiRequest} req - The request object. - * @returns {{ signature: string; timestamp: string }} - The generated signature and timestamp. + * @param {NextApiRequest} req - The request object containing details of the HTTP request. + * @param {string} url - The URL for which the signature is being generated. + * @param {string} signatureToken - The token used to sign the request. + * @param {string} [timestamp] - An optional timestamp to use for the signature. If not provided, the current time is used. + * @returns {{ signature: string; timestamp: string }} - An object containing the generated signature and the timestamp used. */ -// Start of Selection const generateSignature = ( req: NextApiRequest, url: string, + signatureToken: string, + timestamp?: string, ): { signature: string; timestamp: string } => { - const currentTimestamp = new Date().getTime().toString(); + let currentTimestamp = timestamp; + if (!timestamp) { + currentTimestamp = new Date().getTime().toString(); + } let params = {}; try { @@ -53,7 +57,7 @@ const generateSignature = ( } const rawString = `${url}.${currentTimestamp}${recursiveSortedObjectToString(params)}`; - const signature = CryptoJS.HmacSHA512(rawString, process.env.SIGNATURE_TOKEN); + const signature = CryptoJS.HmacSHA512(rawString, signatureToken); const encodedSignature = CryptoJS.enc.Base64.stringify(signature); return { signature: encodedSignature, timestamp: currentTimestamp }; diff --git a/src/utils/headers.ts b/src/utils/headers.ts new file mode 100644 index 0000000000..e77d98ef5f --- /dev/null +++ b/src/utils/headers.ts @@ -0,0 +1,42 @@ +import { NextApiRequest } from 'next'; + +import generateSignature from './auth/signature'; +import { isStaticBuild } from './build'; + +export const X_AUTH_SIGNATURE = 'x-auth-signature'; +export const X_TIMESTAMP = 'x-timestamp'; +export const X_PROXY_SIGNATURE = 'x-proxy-signature'; +export const X_PROXY_TIMESTAMP = 'x-proxy-timestamp'; +export const X_INTERNAL_CLIENT = 'x-internal-client'; + +export const getAdditionalHeaders = (req: NextApiRequest) => { + let additionalHeaders = {}; + + if (isStaticBuild) { + const { signature, timestamp } = generateSignature( + req, + req.url, + process.env.SIGNATURE_TOKEN as string, + ); + additionalHeaders = { + [X_AUTH_SIGNATURE]: signature, + [X_TIMESTAMP]: timestamp, + [X_INTERNAL_CLIENT]: process.env.INTERNAL_CLIENT_ID, + }; + } + + if (typeof window === 'undefined') { + const { signature: proxySignature, timestamp: proxyTimestamp } = generateSignature( + req, + req.url, + process.env.PROXY_SIGNATURE_TOKEN as string, + ); + additionalHeaders = { + ...additionalHeaders, + [X_PROXY_SIGNATURE]: proxySignature, + [X_PROXY_TIMESTAMP]: proxyTimestamp, + }; + } + + return additionalHeaders; +}; From 1c6ebc89b81e2ff8648ec340c0e90cffe5683958 Mon Sep 17 00:00:00 2001 From: Hafiz Mohsin Ayoob Date: Mon, 6 Jan 2025 22:12:46 +0500 Subject: [PATCH 2/4] env vars fix --- .env.example | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 05de88231a..6951c7087c 100644 --- a/.env.example +++ b/.env.example @@ -17,4 +17,7 @@ NEXT_PUBLIC_CLIENT_SENTRY_ENABLED=true NODE_TLS_REJECT_UNAUTHORIZED=0 #set this only when SSL is self signed SIGNATURE_TOKEN=1234 -INTERNAL_CLIENT_ID=QDC_WEB \ No newline at end of file +INTERNAL_CLIENT_ID=QDC_WEB + +ALLOWED_ORIGINS=quran.com,test.quran.com +PROXY_SIGNATURE_TOKEN=123456 \ No newline at end of file From fffd6088de5e9d98a7527aca1a2dc531f001884e Mon Sep 17 00:00:00 2001 From: Hafiz Mohsin Ayoob Date: Wed, 8 Jan 2025 18:41:22 +0500 Subject: [PATCH 3/4] fix: checking of origin with exact match --- src/pages/api/proxy/[...path].ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/api/proxy/[...path].ts b/src/pages/api/proxy/[...path].ts index 26c885e18f..f9c0b8d1db 100644 --- a/src/pages/api/proxy/[...path].ts +++ b/src/pages/api/proxy/[...path].ts @@ -31,7 +31,7 @@ const isOriginAllowed = (origin: string | undefined): boolean => { if (!origin) return false; const url = new URL(origin); const { hostname } = url; - return ALLOWED_DOMAINS.some((domain) => hostname.endsWith(domain)); + return ALLOWED_DOMAINS.includes(hostname); }; const handleProxyReq = (proxyReq, req, res) => { From 10d59aa19ae9099184c81718286c46a7a18e52c9 Mon Sep 17 00:00:00 2001 From: Hafiz Mohsin Ayoob Date: Wed, 15 Jan 2025 15:46:29 +0500 Subject: [PATCH 4/4] fix: fixed api call in auth to use secure proxy --- src/api.ts | 2 ++ src/pages/auth.tsx | 17 +++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/api.ts b/src/api.ts index d7f16e87b3..4ad9b675cc 100644 --- a/src/api.ts +++ b/src/api.ts @@ -64,6 +64,7 @@ export const OFFLINE_ERROR = 'OFFLINE'; export const fetcher = async function fetcher( input: RequestInfo, init: RequestInit = {}, + fullResponse: boolean = false, ): Promise { // if the user is not online when making the API call if (typeof window !== 'undefined' && !window.navigator.onLine) { @@ -88,6 +89,7 @@ export const fetcher = async function fetcher( }; const res = await fetch(input, reqInit); + if (fullResponse) return res as unknown as T; if (!res.ok || res.status === 500 || res.status === 404) { throw res; } diff --git a/src/pages/auth.tsx b/src/pages/auth.tsx index 36ef58c798..2e95b45093 100644 --- a/src/pages/auth.tsx +++ b/src/pages/auth.tsx @@ -4,6 +4,7 @@ import { GetServerSideProps, GetServerSidePropsContext, GetServerSidePropsResult import { useRouter } from 'next/router'; import useTranslation from 'next-translate/useTranslation'; +import { fetcher } from '@/api'; import { ToastStatus, useToast } from '@/dls/Toast/Toast'; import AuthError from '@/types/AuthError'; import { makeRedirectTokenUrl } from '@/utils/auth/apiPaths'; @@ -83,13 +84,17 @@ const handleTokenRedirection = async ( * @returns {Promise} - A promise that resolves to the response from the fetch request. */ const fetchToken = async (token: string, context: GetServerSidePropsContext): Promise => { - return fetch(makeRedirectTokenUrl(token), { - method: 'GET', - headers: { - cookie: context.req.headers.cookie || '', + return fetcher( + makeRedirectTokenUrl(token), + { + method: 'GET', + headers: { + cookie: context.req.headers.cookie || '', + }, + credentials: 'include', }, - credentials: 'include', - }); + true, + ); }; /**