Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Protects next.js proxy from unauthorized usage #2273

Open
wants to merge 4 commits into
base: testing
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
INTERNAL_CLIENT_ID=QDC_WEB

ALLOWED_ORIGINS=quran.com,test.quran.com
PROXY_SIGNATURE_TOKEN=123456
42 changes: 17 additions & 25 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<T>(
input: RequestInfo,
init: RequestInit = {},
Expand All @@ -74,26 +69,23 @@ export const fetcher = async function fetcher<T>(
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) {
Expand Down
95 changes: 73 additions & 22 deletions src/pages/api/proxy/[...path].ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
osamasayed marked this conversation as resolved.
Show resolved Hide resolved
};

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<NextApiRequest, NextApiResponse>({
target: process.env.API_GATEWAY_URL,
changeOrigin: true,
Expand All @@ -28,23 +95,7 @@ const apiProxy = createProxyMiddleware<NextApiRequest, NextApiResponse>({
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
Expand Down
30 changes: 10 additions & 20 deletions src/utils/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -459,23 +458,14 @@ export const withCredentialsFetcher = async <T>(
init?: RequestInit,
): Promise<T> => {
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<T>(input, {
...init,
credentials: 'include',
Expand Down
24 changes: 14 additions & 10 deletions src/utils/auth/signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 };
Expand Down
42 changes: 42 additions & 0 deletions src/utils/headers.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading