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 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": { 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 7dd5b84..0eef124 100644 --- a/src/proxy/opal-api-proxy/index.ts +++ b/src/proxy/opal-api-proxy/index.ts @@ -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) => { @@ -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; 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; +} diff --git a/tsconfig.json b/tsconfig.json index 6295d14..cb79655 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,6 @@ "compilerOptions": { "rootDir": "./src", "outDir": "./dist", - "rootDir": "./src", "strict": true, "noImplicitOverride": true, "target": "ES2022",