Skip to content
Open
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@hmcts/opal-frontend-common-node",
"type": "module",
"version": "0.0.28",
"version": "0.0.29",
"license": "MIT",
"description": "Common nodejs library components for opal",
"engines": {
Expand Down
50 changes: 50 additions & 0 deletions src/proxy/middlewares/digest-verify.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import crypto from 'node:crypto';
import express from 'express';
import type { NextFunction, Request, RequestHandler, Response } from 'express';

/**
* Capture the exact bytes for JSON payloads (extend to more media types if needed).
* @returns Express middleware that exposes the raw request body buffer.
*/
export const rawJson = (): RequestHandler => express.raw({ type: ['application/json', 'application/*+json'] });

/**
* Extract the base64 digest token from a Content-Digest header.
* @param value Header value that may contain the digest.
*/
function parseSha256Base64(value: string | undefined): string | null {
if (!value) return null;
const match = /sha-256=:(.+?):/i.exec(value);
return match ? match[1] : null;
}

/**
* Strictly verifies Content-Digest (sha-256) over the raw request body.
* - Skips GET/HEAD and empty-body requests.
* - Responds with 400 on missing, invalid, or mismatched digest.
* @param req Express request carrying the raw buffer.
* @param res Express response, used to emit 400 failures.
* @param next Express next handler.
*/
export function verifyContentDigest(req: Request, res: Response, next: NextFunction): void {
if (req.method === 'GET' || req.method === 'HEAD') return next();

const body = req.body;
const buffer = Buffer.isBuffer(body) ? body : null;
if (!buffer || buffer.length === 0) return next();

const header = req.header('Content-Digest');
const expected = parseSha256Base64(header);
if (!expected) {
res.status(400).send('Missing or invalid Content-Digest');
return;
}

const actual = crypto.createHash('sha256').update(buffer).digest('base64');
if (actual !== expected) {
res.status(400).send('Content-Digest verification failed');
return;
}

next();
}
39 changes: 36 additions & 3 deletions src/proxy/opal-api-proxy/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
import { createProxyMiddleware } from 'http-proxy-middleware';
import { Router } from 'express';
import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware';
import { Logger } from '@hmcts/nodejs-logging';

const logger = Logger.getLogger('opalApiProxy');
import { rawJson, verifyContentDigest } from '../middlewares/digest-verify.middleware.js';
import { verifyResponseDigest } from '../utils/response-digest.js';

/**
* Creates an Express router that validates request digests and proxies requests to the Opal API.
* @param opalApiTarget Upstream Opal API base URL.
* @returns Configured Express router ready to mount in the host app.
*/
const opalApiProxy = (opalApiTarget: string, logEnabled: boolean) => {
return createProxyMiddleware({
const router = Router();

router.use(rawJson());
router.use(verifyContentDigest);

const handleProxyResponse = responseInterceptor(async (responseBuffer, proxyRes, _req, res) =>
verifyResponseDigest(responseBuffer, proxyRes, res),
);

const proxy = createProxyMiddleware({
target: opalApiTarget,
changeOrigin: true,
logger: console,
selfHandleResponse: true,
on: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
proxyReq: (proxyReq, req: any) => {
Expand All @@ -22,9 +39,25 @@ const opalApiProxy = (opalApiTarget: string, logEnabled: boolean) => {
if (logEnabled) {
logger.info(`client ip: ${requestIp}`);
}
proxyReq.setHeader('Want-Content-Digest', 'sha-256');

const body = req.body;
const buffer = Buffer.isBuffer(body) ? body : null;
if (buffer && buffer.length > 0) {
proxyReq.setHeader('Content-Length', buffer.length.toString());
proxyReq.write(buffer);
proxyReq.end();
}
},
proxyRes: (proxyRes, req, res) => {
void handleProxyResponse(proxyRes, req, res);
},
},
});

router.use(proxy);

return router;
};

export default opalApiProxy;
75 changes: 75 additions & 0 deletions src/proxy/utils/response-digest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import crypto from 'node:crypto';
import type { IncomingMessage, ServerResponse } from 'node:http';

type HeaderValue = string | string[] | undefined | null;

function firstHeaderValue(value: HeaderValue): string | undefined {
if (Array.isArray(value)) {
return value.length > 0 ? value[0] : undefined;
}
return value ?? undefined;
}

/**
* Extract base64 from a 'sha-256=:<base64>:' style header.
*/
export function parseSha256Base64(value: HeaderValue): string | null {
const raw = firstHeaderValue(value);
if (!raw) return null;
const match = /sha-256=:(.+?):/i.exec(raw);
return match ? match[1] : null;
}

/**
* Compute base64 SHA-256 over the given bytes.
*/
export function sha256Base64(buf: Buffer): string {
return crypto.createHash('sha256').update(buf).digest('base64');
}

/**
* Check whether a Content-Type should be verified.
* Covers JSON and text content types for v1.
*/
export function isVerifiableContentType(value: HeaderValue): boolean {
const raw = firstHeaderValue(value)?.toLowerCase();
if (!raw) return false;
const [type] = raw.split(';', 1);
if (!type) return false;
if (type.startsWith('text/')) return true;
if (type === 'application/json') return true;
return type.startsWith('application/') && type.endsWith('+json');
}

function failWithBadGateway(res: ServerResponse, message: string): Buffer {
res.statusCode = 502;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.removeHeader('Content-Digest');
return Buffer.from(message, 'utf8');
}

/**
* Verifies upstream Content-Digest header against the decoded response buffer.
* Returns the buffer unchanged on success, or a 502 response payload on failure.
*/
export function verifyResponseDigest(responseBuffer: Buffer, proxyRes: IncomingMessage, res: ServerResponse): Buffer {
const digestHeader = proxyRes.headers['content-digest'];
const contentTypeHeader = proxyRes.headers['content-type'];

if (!digestHeader || !isVerifiableContentType(contentTypeHeader)) {
return responseBuffer;
}

const expected = parseSha256Base64(digestHeader);
if (!expected) {
return failWithBadGateway(res, 'Upstream Content-Digest header malformed');
}

const actual = sha256Base64(responseBuffer);
if (actual !== expected) {
return failWithBadGateway(res, 'Upstream Content-Digest verification failed');
}

return responseBuffer;
}
1 change: 0 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noImplicitOverride": true,
"target": "ES2022",
Expand Down
Loading