From fc07b4e9586ced3e72dafc5dc2648952fd10592b Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 27 Oct 2025 11:43:47 +0000 Subject: [PATCH 1/6] Add digest verification middleware and integrate with Opal API proxy --- .../middlewares/digest-verify.middleware.ts | 50 +++++++++++++++++++ src/proxy/opal-api-proxy/index.ts | 28 ++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 src/proxy/middlewares/digest-verify.middleware.ts diff --git a/src/proxy/middlewares/digest-verify.middleware.ts b/src/proxy/middlewares/digest-verify.middleware.ts new file mode 100644 index 0000000..b95d1d5 --- /dev/null +++ b/src/proxy/middlewares/digest-verify.middleware.ts @@ -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(); +} diff --git a/src/proxy/opal-api-proxy/index.ts b/src/proxy/opal-api-proxy/index.ts index ef0c257..479ce05 100644 --- a/src/proxy/opal-api-proxy/index.ts +++ b/src/proxy/opal-api-proxy/index.ts @@ -1,19 +1,43 @@ +import { Router } from 'express'; import { createProxyMiddleware } from 'http-proxy-middleware'; +import { rawJson, verifyContentDigest } from '../middlewares/digest-verify.middleware.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) => { - return createProxyMiddleware({ + const router = Router(); + + router.use(rawJson()); + router.use(verifyContentDigest); + + const proxy = createProxyMiddleware({ target: opalApiTarget, changeOrigin: true, - logger: console, on: { // eslint-disable-next-line @typescript-eslint/no-explicit-any proxyReq: (proxyReq, req: any) => { if (req.session.securityToken?.access_token) { proxyReq.setHeader('Authorization', `Bearer ${req.session.securityToken.access_token}`); } + 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(); + } }, }, }); + + router.use(proxy); + + return router; }; export default opalApiProxy; From 2b3c2c367a3e4f50b7013d71557027861c35d189 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 27 Oct 2025 17:04:02 +0000 Subject: [PATCH 2/6] WIP: Response check --- src/proxy/opal-api-proxy/index.ts | 11 ++++- src/proxy/utils/response-digest.ts | 75 ++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/proxy/utils/response-digest.ts diff --git a/src/proxy/opal-api-proxy/index.ts b/src/proxy/opal-api-proxy/index.ts index 479ce05..9cfd0a0 100644 --- a/src/proxy/opal-api-proxy/index.ts +++ b/src/proxy/opal-api-proxy/index.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; -import { createProxyMiddleware } from 'http-proxy-middleware'; +import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware'; 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. @@ -13,9 +14,14 @@ const opalApiProxy = (opalApiTarget: string) => { 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, + selfHandleResponse: true, on: { // eslint-disable-next-line @typescript-eslint/no-explicit-any proxyReq: (proxyReq, req: any) => { @@ -32,6 +38,9 @@ const opalApiProxy = (opalApiTarget: string) => { proxyReq.end(); } }, + proxyRes: (proxyRes, req, res) => { + void handleProxyResponse(proxyRes, req, res); + }, }, }); diff --git a/src/proxy/utils/response-digest.ts b/src/proxy/utils/response-digest.ts new file mode 100644 index 0000000..3898b68 --- /dev/null +++ b/src/proxy/utils/response-digest.ts @@ -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=::' 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; +} From d1e9abddc15b9f8c2471062ced27af8d8b434c45 Mon Sep 17 00:00:00 2001 From: Frankie Moran Date: Thu, 30 Oct 2025 14:54:01 +0000 Subject: [PATCH 3/6] Bump version number --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c531d02..50c6f82 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hmcts/opal-frontend-common-node", "type": "module", - "version": "0.0.16", + "version": "0.0.17", "license": "MIT", "description": "Common nodejs library components for opal", "main": "dist/index", From 53725158a93beb784bb78ddfdbd4b92d5329466d Mon Sep 17 00:00:00 2001 From: Frankie Moran Date: Wed, 28 Jan 2026 13:52:23 +0000 Subject: [PATCH 4/6] bump version number for release --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1944fac..f2056d6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hmcts/opal-frontend-common-node", "type": "module", - "version": "0.0.22", + "version": "0.0.23", "license": "MIT", "description": "Common nodejs library components for opal", "repository": { From a190a1707a5147701422d2f94cb1982070a3f2bc Mon Sep 17 00:00:00 2001 From: Frankie Moran Date: Fri, 30 Jan 2026 11:46:15 +0000 Subject: [PATCH 5/6] Move skills to `.codex` folder --- .../opal-frontend-common-node-repo-guidelines/SKILL.md | 0 .../opal-frontend-common-node-review-guidelines/SKILL.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {skills => .codex/skills}/opal-frontend-common-node/opal-frontend-common-node-repo-guidelines/SKILL.md (100%) rename {skills => .codex/skills}/opal-frontend-common-node/opal-frontend-common-node-review-guidelines/SKILL.md (100%) diff --git a/skills/opal-frontend-common-node/opal-frontend-common-node-repo-guidelines/SKILL.md b/.codex/skills/opal-frontend-common-node/opal-frontend-common-node-repo-guidelines/SKILL.md similarity index 100% rename from skills/opal-frontend-common-node/opal-frontend-common-node-repo-guidelines/SKILL.md rename to .codex/skills/opal-frontend-common-node/opal-frontend-common-node-repo-guidelines/SKILL.md diff --git a/skills/opal-frontend-common-node/opal-frontend-common-node-review-guidelines/SKILL.md b/.codex/skills/opal-frontend-common-node/opal-frontend-common-node-review-guidelines/SKILL.md similarity index 100% rename from skills/opal-frontend-common-node/opal-frontend-common-node-review-guidelines/SKILL.md rename to .codex/skills/opal-frontend-common-node/opal-frontend-common-node-review-guidelines/SKILL.md From df54be7e2e3e0da9eb2026b2d8c1c81fc64180da Mon Sep 17 00:00:00 2001 From: Frankie Moran Date: Wed, 1 Apr 2026 14:53:27 +0100 Subject: [PATCH 6/6] (bump): version number --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f0f55ec..f02d580 100644 --- a/package.json +++ b/package.json @@ -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": {