From 63759e7c2140e648753cebffc9fef52c6ad3c94f Mon Sep 17 00:00:00 2001 From: Mayur Deshmukh Date: Wed, 3 Jun 2026 15:16:40 +0530 Subject: [PATCH] feat(api-gateway): add user blacklist feature Assisted-By: Cursor Signed-off-by: Mayur Deshmukh --- packages/api-gateway-service/.env.example | 3 + packages/api-gateway-service/.gitignore | 3 + packages/api-gateway-service/README.md | 25 +++ .../api-gateway-service/blacklist.example.txt | 3 + packages/api-gateway-service/jest.config.js | 3 +- packages/api-gateway-service/package.json | 2 +- packages/api-gateway-service/service.ts | 154 +++++++++++------- .../src/blacklist/blacklist.ts | 28 ++++ .../src/blacklist/extractOwnerClaims.spec.ts | 46 ++++++ .../src/blacklist/extractOwnerClaims.ts | 13 ++ .../src/blacklist/extractUserClaims.spec.ts | 49 ++++++ .../src/blacklist/extractUserClaims.ts | 24 +++ .../src/blacklist/isUserBlacklisted.ts | 23 +++ .../src/blacklist/loadBlacklistFromFile.ts | 19 +++ .../src/blacklist/parseBlacklistFile.spec.ts | 50 ++++++ .../src/blacklist/parseBlacklistFile.ts | 15 ++ .../src/blacklist/types.ts | 8 + .../api-gateway-service/src/verify-token.ts | 21 ++- .../api-gateway-service/webpack.common.js | 2 + 19 files changed, 432 insertions(+), 59 deletions(-) create mode 100644 packages/api-gateway-service/blacklist.example.txt create mode 100644 packages/api-gateway-service/src/blacklist/blacklist.ts create mode 100644 packages/api-gateway-service/src/blacklist/extractOwnerClaims.spec.ts create mode 100644 packages/api-gateway-service/src/blacklist/extractOwnerClaims.ts create mode 100644 packages/api-gateway-service/src/blacklist/extractUserClaims.spec.ts create mode 100644 packages/api-gateway-service/src/blacklist/extractUserClaims.ts create mode 100644 packages/api-gateway-service/src/blacklist/isUserBlacklisted.ts create mode 100644 packages/api-gateway-service/src/blacklist/loadBlacklistFromFile.ts create mode 100644 packages/api-gateway-service/src/blacklist/parseBlacklistFile.spec.ts create mode 100644 packages/api-gateway-service/src/blacklist/parseBlacklistFile.ts create mode 100644 packages/api-gateway-service/src/blacklist/types.ts diff --git a/packages/api-gateway-service/.env.example b/packages/api-gateway-service/.env.example index 473cc9b47..e3b4f9b05 100644 --- a/packages/api-gateway-service/.env.example +++ b/packages/api-gateway-service/.env.example @@ -11,3 +11,6 @@ CONFIG_PATH= ## Keycloak Public Key for JWT Token verification KEYCLOAK_PUBKEY= + +## User blacklist (optional, loaded once at startup) +BLACKLIST_FILE_PATH=./blacklist.txt diff --git a/packages/api-gateway-service/.gitignore b/packages/api-gateway-service/.gitignore index d58a064b5..576610ad5 100644 --- a/packages/api-gateway-service/.gitignore +++ b/packages/api-gateway-service/.gitignore @@ -5,5 +5,8 @@ node_modules # ignore dist dist +# Local blacklist copy +blacklist.txt + # Logs npm-debug.log* diff --git a/packages/api-gateway-service/README.md b/packages/api-gateway-service/README.md index a8ceae5b4..6f745abb3 100644 --- a/packages/api-gateway-service/README.md +++ b/packages/api-gateway-service/README.md @@ -14,6 +14,31 @@ API Gateway handles all the tasks involved in accepting and processing up to hun *Note:* Before starting the gateway, also make sure the microservices in this project are configured properly. +## User blacklist + +When `BLACKLIST_FILE_PATH` is set, the gateway loads a text file of blocked user identifiers once at startup. Restart the gateway to pick up file changes. + +```env +BLACKLIST_FILE_PATH=./blacklist.txt +``` + +See [`blacklist.example.txt`](blacklist.example.txt). One **uid** or **email** per line; empty lines and `#` comments are ignored. + +Regenerate from Compass: + +```bash +node scripts/generate-blacklist-from-compass-output.mjs +``` + +Matching uses the **token owner** identity (not `rhatUUID`): + +- **JWT:** Keycloak `uid` and `email` (or `mail`) from the access token +- **API key:** owning user's `uid` and `mail` from User Group when `ownerType` is `User` + +Downstream forwarding still uses `rhatUUID` in Apollo context / `X-OP-User-ID` for JWTs. Group-owned API keys are not evaluated against the blacklist. + +OpenShift: [openshift/README.md](openshift/README.md). + ## Running Tests ```bash diff --git a/packages/api-gateway-service/blacklist.example.txt b/packages/api-gateway-service/blacklist.example.txt new file mode 100644 index 000000000..fc7179e78 --- /dev/null +++ b/packages/api-gateway-service/blacklist.example.txt @@ -0,0 +1,3 @@ +# One identifier per line (uid or email) +jdoe +blocked.user@redhat.com diff --git a/packages/api-gateway-service/jest.config.js b/packages/api-gateway-service/jest.config.js index b01f04ab2..a8cca7b7a 100644 --- a/packages/api-gateway-service/jest.config.js +++ b/packages/api-gateway-service/jest.config.js @@ -16,7 +16,8 @@ module.exports = { }, "collectCoverage": true, "testMatch": [ - "**/src/e2e/*.spec.(ts|tsx|js)" + "**/src/e2e/*.spec.(ts|tsx|js)", + "**/src/blacklist/*.spec.(ts|tsx|js)" ], "testEnvironment": "node" } diff --git a/packages/api-gateway-service/package.json b/packages/api-gateway-service/package.json index 857afdd71..7913a557f 100644 --- a/packages/api-gateway-service/package.json +++ b/packages/api-gateway-service/package.json @@ -24,7 +24,7 @@ "dev": "webpack --watch --config webpack.dev.js", "build": "webpack --config webpack.prod.js", "build:dev": "webpack --config webpack.dev.js", - "test": "echo \"Error: no test specified\"" + "test": "jest" }, "author": { "name": "Rigin Oommen", diff --git a/packages/api-gateway-service/service.ts b/packages/api-gateway-service/service.ts index 68c2cc44a..7af659f76 100644 --- a/packages/api-gateway-service/service.ts +++ b/packages/api-gateway-service/service.ts @@ -6,7 +6,7 @@ if ( process.env.NODE_ENV === 'test' ) { dotenv.config(); } -import { ApolloServer, AuthenticationError } from 'apollo-server-express'; +import { ApolloServer, AuthenticationError, ForbiddenError } from 'apollo-server-express'; import express from 'express'; import http from 'http'; import cors from 'cors'; @@ -15,6 +15,14 @@ import { stitchedSchemas } from './src/stitch-schema'; import { verifyAPIKey, verifyJwtToken } from './src/verify-token'; import path from 'path'; import helmet from 'helmet'; +import { + getBlacklistIndex, + initBlacklist, + isBlacklistEnabled, +} from './src/blacklist/blacklist'; +import { extractTokenOwnerClaims } from './src/blacklist/extractUserClaims'; +import { extractOwnerClaims } from './src/blacklist/extractOwnerClaims'; +import { isUserBlacklisted } from './src/blacklist/isUserBlacklisted'; /* Setting base url and port for the server */ const baseUrl = process.env.BASE_URL ?? '/'; @@ -38,6 +46,15 @@ app.use( helmet( { /* include cors middleware */ app.use( cors() ); +function assertNotBlacklisted( claims: { uid?: string; email?: string } ): void { + if ( !isBlacklistEnabled() ) { + return; + } + if ( isUserBlacklisted( getBlacklistIndex(), claims ) ) { + throw new ForbiddenError( 'Access denied' ); + } +} + const context = ({ req, connection }: any) => { const authorizationHeader = req?.headers?.authorization || connection?.context?.Authorization; @@ -52,71 +69,96 @@ const context = ({ req, connection }: any) => { const token = authorizationHeader.split( ' ' )[ 1 ]; if ( uuidValidate( token ) ) { - return verifyAPIKey(token) - .then((res) => ({ uid: res._id, roles: res.roles, scopes: res.scopes, token })) - .catch((err) => { - throw new AuthenticationError(err.message); - }); - } else { - return verifyJwtToken( token, ( err: any, payload: any ) => { - if ( err ) { + return verifyAPIKey( token ) + .then( ( res ) => { + if ( res.ownerType === 'User' && res.owner ) { + assertNotBlacklisted( extractOwnerClaims( res.owner ) ); + } + return { uid: res._id, roles: res.roles, scopes: res.scopes, token }; + } ) + .catch( ( err ) => { + if ( err instanceof ForbiddenError ) { + throw err; + } throw new AuthenticationError( err.message ); + } ); + } + + return new Promise( ( resolve, reject ) => { + verifyJwtToken( token, ( err: any, payload: any ) => { + if ( err ) { + reject( new AuthenticationError( err.message ) ); + return; + } + try { + assertNotBlacklisted( extractTokenOwnerClaims( payload ) ); + resolve( { + uid: payload.rhatUUID, + roles: payload.role, + scope: payload.scope?.split( ' ' ), + token, + } ); + } catch ( blacklistErr ) { + reject( blacklistErr ); } - return { uid: payload.rhatUUID, roles: payload.role, scope: payload.scope?.split(' '), token }; } ); - } + } ); }; -stitchedSchemas() - .then( schema => { - /* Defining the Apollo Server */ - const apollo = new ApolloServer( { - subscriptions: { - path: subsciptionsBaseUrl, +/* Creating the server based on the environment */ +const server = http.createServer( app ); + +async function startGateway(): Promise { + await initBlacklist(); + + const schema = await stitchedSchemas(); + + const apollo = new ApolloServer( { + subscriptions: { + path: subsciptionsBaseUrl, + }, + schema, + context, + introspection: true, + tracing: process.env.NODE_ENV !== 'production', + playground: { + title: 'API Gateway', + settings: { + 'request.credentials': 'include' }, - schema, - context, - introspection: true, - tracing: process.env.NODE_ENV !== 'production', - playground: { - title: 'API Gateway', - settings: { - 'request.credentials': 'include' - }, - headers: { - Authorization: `Bearer `, /* lgtm [js/hardcoded-credentials] */ - }, + headers: { + Authorization: `Bearer `, /* lgtm [js/hardcoded-credentials] */ }, - plugins: [ - { - requestDidStart: ( requestContext ) => { - if ( requestContext.request.http?.headers.has( 'x-apollo-tracing' ) ) { - return; - } - console.log( new Date().toISOString(), `- Incoming ${ requestContext.request.http?.method } request from: ${ requestContext.request.http?.headers.get( 'origin' ) || 'unknown' }`, `- via ${ requestContext.request.http?.headers.get( 'user-agent' ) }` ); + }, + plugins: [ + { + requestDidStart: ( requestContext ) => { + if ( requestContext.request.http?.headers.has( 'x-apollo-tracing' ) ) { + return; } + console.log( new Date().toISOString(), `- Incoming ${ requestContext.request.http?.method } request from: ${ requestContext.request.http?.headers.get( 'origin' ) || 'unknown' }`, `- via ${ requestContext.request.http?.headers.get( 'user-agent' ) }` ); } - ], - formatError: error => ( { - message: error.message, - locations: error.locations, - path: error.path, - ...error.extensions, - } ), - } ); - - /* Applying apollo middleware to express server */ - apollo.applyMiddleware( { app, path: baseUrl } ); - apollo.installSubscriptionHandlers( server ); - } ) - .catch( err => { - console.error( err ); - throw err; + } + ], + formatError: error => ( { + message: error.message, + locations: error.locations, + path: error.path, + ...error.extensions, + } ), } ); -/* Creating the server based on the environment */ -const server = http.createServer( app ); + apollo.applyMiddleware( { app, path: baseUrl } ); + apollo.installSubscriptionHandlers( server ); -export default server.listen( port, () => { - console.log( `Gateway Running on ${ process.env.NODE_ENV } environment at port ${ port }` ); + server.listen( port, () => { + console.log( `Gateway Running on ${ process.env.NODE_ENV } environment at port ${ port }` ); + } ); +} + +startGateway().catch( err => { + console.error( err ); + process.exit( 1 ); } ); + +export default server; diff --git a/packages/api-gateway-service/src/blacklist/blacklist.ts b/packages/api-gateway-service/src/blacklist/blacklist.ts new file mode 100644 index 000000000..5db7ab80b --- /dev/null +++ b/packages/api-gateway-service/src/blacklist/blacklist.ts @@ -0,0 +1,28 @@ +import { loadBlacklistFromFile } from './loadBlacklistFromFile'; +import { BlacklistIndex } from './types'; + +const emptyIndex = (): BlacklistIndex => ({ entries: new Set() }); + +let blacklistIndex: BlacklistIndex = emptyIndex(); + +function getBlacklistFilePath(): string | undefined { + const path = process.env.BLACKLIST_FILE_PATH?.trim(); + return path || undefined; +} + +export function isBlacklistEnabled(): boolean { + return Boolean(getBlacklistFilePath()); +} + +export function getBlacklistIndex(): BlacklistIndex { + return blacklistIndex; +} + +export async function initBlacklist(): Promise { + const filePath = getBlacklistFilePath(); + if (!filePath) { + blacklistIndex = emptyIndex(); + return; + } + blacklistIndex = await loadBlacklistFromFile(filePath); +} diff --git a/packages/api-gateway-service/src/blacklist/extractOwnerClaims.spec.ts b/packages/api-gateway-service/src/blacklist/extractOwnerClaims.spec.ts new file mode 100644 index 000000000..e28a9ffec --- /dev/null +++ b/packages/api-gateway-service/src/blacklist/extractOwnerClaims.spec.ts @@ -0,0 +1,46 @@ +import { extractOwnerClaims } from './extractOwnerClaims'; +import { parseBlacklistFile } from './parseBlacklistFile'; +import { isUserBlacklisted } from './isUserBlacklisted'; + +describe('extractOwnerClaims', () => { + it('maps uid and mail to UserClaims', () => { + expect( + extractOwnerClaims({ uid: 'jdoe', mail: 'jdoe@redhat.com' }) + ).toEqual({ + uid: 'jdoe', + email: 'jdoe@redhat.com', + }); + }); + + it('omits missing fields', () => { + expect(extractOwnerClaims({})).toEqual({ + uid: undefined, + email: undefined, + }); + }); +}); + +describe('API key owner blacklist', () => { + const index = parseBlacklistFile('jdoe\nblocked@redhat.com'); + + it('blocks User owner when uid is listed', () => { + const claims = extractOwnerClaims({ uid: 'jdoe', mail: 'jdoe@redhat.com' }); + expect(isUserBlacklisted(index, claims)).toBe(true); + }); + + it('blocks User owner when mail is listed', () => { + const claims = extractOwnerClaims({ + uid: 'other', + mail: 'blocked@redhat.com', + }); + expect(isUserBlacklisted(index, claims)).toBe(true); + }); + + it('allows User owner when neither field matches', () => { + const claims = extractOwnerClaims({ + uid: 'allowed', + mail: 'allowed@redhat.com', + }); + expect(isUserBlacklisted(index, claims)).toBe(false); + }); +}); diff --git a/packages/api-gateway-service/src/blacklist/extractOwnerClaims.ts b/packages/api-gateway-service/src/blacklist/extractOwnerClaims.ts new file mode 100644 index 000000000..ef49533a9 --- /dev/null +++ b/packages/api-gateway-service/src/blacklist/extractOwnerClaims.ts @@ -0,0 +1,13 @@ +import { UserClaims } from './types'; + +export type ApiKeyOwnerUser = { + uid?: string; + mail?: string; +}; + +export function extractOwnerClaims(owner: ApiKeyOwnerUser): UserClaims { + return { + uid: typeof owner.uid === 'string' ? owner.uid : undefined, + email: typeof owner.mail === 'string' ? owner.mail : undefined, + }; +} diff --git a/packages/api-gateway-service/src/blacklist/extractUserClaims.spec.ts b/packages/api-gateway-service/src/blacklist/extractUserClaims.spec.ts new file mode 100644 index 000000000..7419c3549 --- /dev/null +++ b/packages/api-gateway-service/src/blacklist/extractUserClaims.spec.ts @@ -0,0 +1,49 @@ +import { extractTokenOwnerClaims } from './extractUserClaims'; +import { parseBlacklistFile } from './parseBlacklistFile'; +import { isUserBlacklisted } from './isUserBlacklisted'; + +describe('extractTokenOwnerClaims', () => { + it('uses uid and email from JWT payload', () => { + expect( + extractTokenOwnerClaims({ + uid: 'jdoe', + email: 'jdoe@redhat.com', + rhatUUID: 'uuid-should-not-be-used', + }) + ).toEqual({ + uid: 'jdoe', + email: 'jdoe@redhat.com', + }); + }); + + it('falls back to mail when email claim is absent', () => { + expect( + extractTokenOwnerClaims({ + uid: 'jdoe', + mail: 'jdoe@redhat.com', + rhatUUID: 'uuid-123', + }) + ).toEqual({ + uid: 'jdoe', + email: 'jdoe@redhat.com', + }); + }); + + it('does not blacklist when only rhatUUID matches a listed uuid-like entry', () => { + const index = parseBlacklistFile('uuid-123'); + const claims = extractTokenOwnerClaims({ + rhatUUID: 'uuid-123', + uid: 'allowed-user', + }); + expect(isUserBlacklisted(index, claims)).toBe(false); + }); + + it('blacklists token owner by uid when listed', () => { + const index = parseBlacklistFile('jdoe'); + const claims = extractTokenOwnerClaims({ + uid: 'jdoe', + rhatUUID: 'other-uuid', + }); + expect(isUserBlacklisted(index, claims)).toBe(true); + }); +}); diff --git a/packages/api-gateway-service/src/blacklist/extractUserClaims.ts b/packages/api-gateway-service/src/blacklist/extractUserClaims.ts new file mode 100644 index 000000000..b688ec6f4 --- /dev/null +++ b/packages/api-gateway-service/src/blacklist/extractUserClaims.ts @@ -0,0 +1,24 @@ +// Identity for blacklist is the JWT token owner (uid / email only, never rhatUUID). + +import { UserClaims } from './types'; + +/** Keycloak uid of the human token owner. */ +export function extractTokenOwnerClaims( + payload: Record, +): UserClaims { + const uid = typeof payload.uid === 'string' ? payload.uid : undefined; + const email = + typeof payload.email === 'string' + ? payload.email + : typeof payload.mail === 'string' + ? payload.mail + : undefined; + return { uid, email }; +} + +/** @deprecated Use extractTokenOwnerClaims */ +export function extractUserClaims( + payload: Record, +): UserClaims { + return extractTokenOwnerClaims(payload); +} diff --git a/packages/api-gateway-service/src/blacklist/isUserBlacklisted.ts b/packages/api-gateway-service/src/blacklist/isUserBlacklisted.ts new file mode 100644 index 000000000..8ee2ddc1e --- /dev/null +++ b/packages/api-gateway-service/src/blacklist/isUserBlacklisted.ts @@ -0,0 +1,23 @@ +import { BlacklistIndex, UserClaims } from './types'; + +export function isUserBlacklisted( + index: BlacklistIndex, + user: UserClaims, +): boolean { + const { entries } = index; + if (entries.size === 0) { + return false; + } + + if (user.uid && entries.has(user.uid)) { + console.info('user is blacklisted by uid', user); + return true; + } + + if (user.email && entries.has(user.email.toLowerCase())) { + console.info('user is blacklisted by email', user); + return true; + } + + return false; +} diff --git a/packages/api-gateway-service/src/blacklist/loadBlacklistFromFile.ts b/packages/api-gateway-service/src/blacklist/loadBlacklistFromFile.ts new file mode 100644 index 000000000..d915bd346 --- /dev/null +++ b/packages/api-gateway-service/src/blacklist/loadBlacklistFromFile.ts @@ -0,0 +1,19 @@ +import { promises as fs } from 'fs'; +import { parseBlacklistFile } from './parseBlacklistFile'; +import { BlacklistIndex } from './types'; + +const emptyIndex = (): BlacklistIndex => ({ entries: new Set() }); + +export async function loadBlacklistFromFile( + path: string +): Promise { + try { + const content = await fs.readFile(path, 'utf8'); + const index = parseBlacklistFile(content); + console.info(`blacklist loaded: ${index.entries.size} entries`); + return index; + } catch (err) { + console.error('failed to load blacklist file', { path, err }); + return emptyIndex(); + } +} diff --git a/packages/api-gateway-service/src/blacklist/parseBlacklistFile.spec.ts b/packages/api-gateway-service/src/blacklist/parseBlacklistFile.spec.ts new file mode 100644 index 000000000..a15f7e1c5 --- /dev/null +++ b/packages/api-gateway-service/src/blacklist/parseBlacklistFile.spec.ts @@ -0,0 +1,50 @@ +import { parseBlacklistFile } from './parseBlacklistFile'; +import { isUserBlacklisted } from './isUserBlacklisted'; + +describe('parseBlacklistFile', () => { + it('ignores empty lines and comments', () => { + const index = parseBlacklistFile(` +# comment +jdoe + +blocked@example.com + `); + expect(index.entries.size).toBe(2); + expect(index.entries.has('jdoe')).toBe(true); + expect(index.entries.has('blocked@example.com')).toBe(true); + }); + + it('keeps values containing colons as literal entries', () => { + const index = parseBlacklistFile('user:name@example.com'); + expect(index.entries.has('user:name@example.com')).toBe(true); + }); +}); + +describe('isUserBlacklisted', () => { + const index = parseBlacklistFile('jdoe\nblocked@example.com\nuuid-123'); + + it('matches uid', () => { + expect(isUserBlacklisted(index, { uid: 'jdoe' })).toBe(true); + }); + + it('matches email case-insensitively', () => { + expect(isUserBlacklisted(index, { email: 'Blocked@Example.com' })).toBe( + true + ); + }); + + it('returns false when no field matches', () => { + expect( + isUserBlacklisted(index, { + uid: 'other', + email: 'other@example.com', + }) + ).toBe(false); + }); + + it('returns false for empty blacklist', () => { + expect(isUserBlacklisted({ entries: new Set() }, { uid: 'jdoe' })).toBe( + false + ); + }); +}); diff --git a/packages/api-gateway-service/src/blacklist/parseBlacklistFile.ts b/packages/api-gateway-service/src/blacklist/parseBlacklistFile.ts new file mode 100644 index 000000000..a3cd9f3c2 --- /dev/null +++ b/packages/api-gateway-service/src/blacklist/parseBlacklistFile.ts @@ -0,0 +1,15 @@ +import { BlacklistIndex } from './types'; + +export function parseBlacklistFile(content: string): BlacklistIndex { + const entries = new Set(); + + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith('#')) { + continue; + } + entries.add(trimmed); + } + + return { entries }; +} diff --git a/packages/api-gateway-service/src/blacklist/types.ts b/packages/api-gateway-service/src/blacklist/types.ts new file mode 100644 index 000000000..5499fe7a3 --- /dev/null +++ b/packages/api-gateway-service/src/blacklist/types.ts @@ -0,0 +1,8 @@ +export type BlacklistIndex = { + entries: Set; +}; + +export type UserClaims = { + uid?: string; + email?: string; +}; diff --git a/packages/api-gateway-service/src/verify-token.ts b/packages/api-gateway-service/src/verify-token.ts index 0f53e2eec..b87f463ce 100644 --- a/packages/api-gateway-service/src/verify-token.ts +++ b/packages/api-gateway-service/src/verify-token.ts @@ -37,10 +37,22 @@ export function verifyJwtToken ( token: string, callback: any ) { return JWT.verify( token, getPublicKey(), callback ); } +export type ValidatedApiKey = { + _id: string; + ownerType?: string; + owner?: { + uid?: string; + mail?: string; + }; + access?: Array<{ role: string; microservice: string }>; + roles?: string[]; + scopes?: string[]; +}; + /** * Verifies the API Key */ -export function verifyAPIKey ( accessToken: string ) { +export function verifyAPIKey ( accessToken: string ): Promise { const userGroupAPI = microservices.find( service => service.name === 'User Group' ); if ( !userGroupAPI ) { throw new Error( 'API Key Config error. User Group not configured properly.' ); @@ -56,6 +68,13 @@ export function verifyAPIKey ( accessToken: string ) { query ValidateAPIKey($accessToken: String!) { apiKey: validateAPIKey(accessToken: $accessToken) { _id + ownerType + owner { + ... on UserType { + uid + mail + } + } access { role microservice diff --git a/packages/api-gateway-service/webpack.common.js b/packages/api-gateway-service/webpack.common.js index d56645f5d..ccba2c3d5 100644 --- a/packages/api-gateway-service/webpack.common.js +++ b/packages/api-gateway-service/webpack.common.js @@ -26,6 +26,8 @@ module.exports = { output: { filename: 'bundle.js', path: path.resolve( __dirname, 'dist' ), + // MD4 is unavailable under OpenSSL 3 (Node 17+) + hashFunction: 'sha256', }, target: 'node', externals: [ nodeExternals() ],