diff --git a/cspell.json b/cspell.json index 0e3483dc..92b7024c 100644 --- a/cspell.json +++ b/cspell.json @@ -47,11 +47,12 @@ "proxying", "rawbody", "restream", + "selfsigned", "snyk", "streamify", - "trivago", "tseslint", "typicode", + "unstub", "vhosted", "websockets", "xfwd" diff --git a/package.json b/package.json index 2cb834c1..83620ac0 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "open": "11.0.0", "oxfmt": "0.46.0", "pkg-pr-new": "0.0.66", + "selfsigned": "5.5.0", "supertest": "7.2.2", "typescript": "6.0.3", "typescript-eslint": "8.59.0", diff --git a/test/e2e/http2-server.spec.ts b/test/e2e/http2-server.spec.ts new file mode 100644 index 00000000..2076cda5 --- /dev/null +++ b/test/e2e/http2-server.spec.ts @@ -0,0 +1,157 @@ +import { createSecureServer, type Http2SecureServer } from 'node:http2'; + +import type { Mockttp } from 'mockttp'; +import { generate } from 'selfsigned'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createApp, createProxyMiddleware } from './test-kit.js'; + +describe('E2E http2', () => { + // Self-signed certs are used in this test; allow local TLS connections for this spec only. + beforeEach(() => vi.stubEnv('NODE_TLS_REJECT_UNAUTHORIZED', '0')); + afterEach(() => vi.unstubAllEnvs()); + + describe('E2E http2 to http1', () => { + let mockTargetServer: Mockttp; + let http2ProxyServer: Http2SecureServer | undefined; + let proxyServerCertPem: string; + + async function closeServer(server: Http2SecureServer) { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } + + beforeEach(async () => { + const cert = await generate([{ name: 'commonName', value: 'localhost' }]); + proxyServerCertPem = cert.cert; + + // Keep this dynamic to avoid a Vitest module-load ordering issue that breaks cert generation. + const { getLocal } = await import('mockttp'); + mockTargetServer = getLocal(); + await mockTargetServer.start(); + + const app = createApp( + createProxyMiddleware({ + target: mockTargetServer.url, + pathFilter: '/api', + }), + ); + + http2ProxyServer = createSecureServer( + { + key: cert.private, + cert: cert.cert, + // Express + supertest use the HTTP/1.1 request/response API in this e2e flow. + allowHTTP1: true, + }, + (req, res) => app(req as never, res as never), + ); + }); + + afterEach(async () => { + await mockTargetServer.stop(); + + const server = http2ProxyServer; + if (!server?.listening) { + return; + } + + await closeServer(server); + }); + + it('proxies requests through a secure HTTP/2 server to a mocked target', async () => { + await mockTargetServer.forGet('/api/hello').thenReply(200, 'hello from mocked target'); + + expect(http2ProxyServer).toBeDefined(); + const response = await request(http2ProxyServer!) + .get('/api/hello') + .ca(proxyServerCertPem) + .expect(200); + + expect(response.text).toBe('hello from mocked target'); + }); + }); + + describe('E2E http2 to http2', () => { + let mockTargetServer: Mockttp; + let http2ProxyServer: Http2SecureServer | undefined; + let proxyServerCertPem: string; + + async function closeServer(server: Http2SecureServer) { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } + + beforeEach(async () => { + const cert = await generate([{ name: 'commonName', value: 'localhost' }]); + proxyServerCertPem = cert.cert; + + // Keep this dynamic to avoid a Vitest module-load ordering issue that breaks cert generation. + const { getLocal } = await import('mockttp'); + mockTargetServer = getLocal({ http2: true }); + await mockTargetServer.start(); + + const app = createApp( + createProxyMiddleware({ + target: mockTargetServer.url, + pathFilter: '/api', + }), + ); + + http2ProxyServer = createSecureServer( + { + key: cert.private, + cert: cert.cert, + // Express + supertest use the HTTP/1.1 request/response API in this e2e flow. + allowHTTP1: true, + }, + (req, res) => app(req as never, res as never), + ); + }); + + afterEach(async () => { + await mockTargetServer.stop(); + + const server = http2ProxyServer; + if (!server?.listening) { + return; + } + + await closeServer(server); + }); + + it('proxies requests through a secure HTTP/2 server to an HTTP/2 mocked target', async () => { + await mockTargetServer.forGet('/api/hello').thenReply(200, 'hello from http2 target'); + + expect(http2ProxyServer).toBeDefined(); + const response = await request(http2ProxyServer!) + .get('/api/hello') + .ca(proxyServerCertPem) + .expect(200); + + expect(response.text).toBe('hello from http2 target'); + }); + + it('handles multiple concurrent HTTP/2 requests (multiplexing)', async () => { + await mockTargetServer.forGet('/api/request1').thenReply(200, 'response 1'); + await mockTargetServer.forGet('/api/request2').thenReply(200, 'response 2'); + await mockTargetServer.forGet('/api/request3').thenReply(200, 'response 3'); + + expect(http2ProxyServer).toBeDefined(); + + // Send multiple concurrent requests to test HTTP/2 multiplexing + const [response1, response2, response3] = await Promise.all([ + request(http2ProxyServer!).get('/api/request1').expect(200), + request(http2ProxyServer!).get('/api/request2').expect(200), + request(http2ProxyServer!).get('/api/request3').expect(200), + ]); + + expect(response1.text).toBe('response 1'); + expect(response2.text).toBe('response 2'); + expect(response3.text).toBe('response 3'); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 57293d40..65f350d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -504,6 +504,11 @@ dependencies: "@tybys/wasm-util" "^0.10.1" +"@noble/hashes@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.4.0.tgz#45814aa329f30e4fe0ba49426f49dfccdd066426" + integrity sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg== + "@noble/hashes@^1.1.5": version "1.8.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" @@ -762,6 +767,17 @@ asn1js "^3.0.6" tslib "^2.8.1" +"@peculiar/asn1-cms@^2.6.0", "@peculiar/asn1-cms@^2.7.0": + version "2.7.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz#8e0eb656f4fc85f7c621dd442fa2d298faa84984" + integrity sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ== + dependencies: + "@peculiar/asn1-schema" "^2.7.0" + "@peculiar/asn1-x509" "^2.7.0" + "@peculiar/asn1-x509-attr" "^2.7.0" + asn1js "^3.0.6" + tslib "^2.8.1" + "@peculiar/asn1-csr@^2.5.0": version "2.5.0" resolved "https://registry.yarnpkg.com/@peculiar/asn1-csr/-/asn1-csr-2.5.0.tgz#4dd7534bd7d7db5bbbbde4d00d4836bf7e818d1c" @@ -772,6 +788,16 @@ asn1js "^3.0.6" tslib "^2.8.1" +"@peculiar/asn1-csr@^2.6.0": + version "2.7.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz#1a03ac03f7571ea981f5d8377c6f4510c5d43411" + integrity sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA== + dependencies: + "@peculiar/asn1-schema" "^2.7.0" + "@peculiar/asn1-x509" "^2.7.0" + asn1js "^3.0.6" + tslib "^2.8.1" + "@peculiar/asn1-ecc@^2.5.0": version "2.5.0" resolved "https://registry.yarnpkg.com/@peculiar/asn1-ecc/-/asn1-ecc-2.5.0.tgz#3bbeaa3443567055be112b4c7e9d5562951242cf" @@ -782,6 +808,16 @@ asn1js "^3.0.6" tslib "^2.8.1" +"@peculiar/asn1-ecc@^2.6.0": + version "2.7.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz#c35b57859812ecd0c2ae7b2144855e8208c2cfee" + integrity sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw== + dependencies: + "@peculiar/asn1-schema" "^2.7.0" + "@peculiar/asn1-x509" "^2.7.0" + asn1js "^3.0.6" + tslib "^2.8.1" + "@peculiar/asn1-pfx@^2.5.0": version "2.5.0" resolved "https://registry.yarnpkg.com/@peculiar/asn1-pfx/-/asn1-pfx-2.5.0.tgz#22d12e676c063dfc6244278fe18eb75c2c121880" @@ -794,6 +830,18 @@ asn1js "^3.0.6" tslib "^2.8.1" +"@peculiar/asn1-pfx@^2.7.0": + version "2.7.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz#d00766b13ff49785684a604248e6aadd184b291f" + integrity sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA== + dependencies: + "@peculiar/asn1-cms" "^2.7.0" + "@peculiar/asn1-pkcs8" "^2.7.0" + "@peculiar/asn1-rsa" "^2.7.0" + "@peculiar/asn1-schema" "^2.7.0" + asn1js "^3.0.6" + tslib "^2.8.1" + "@peculiar/asn1-pkcs8@^2.5.0": version "2.5.0" resolved "https://registry.yarnpkg.com/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.5.0.tgz#1939643773e928a4802813b595e324a05b453709" @@ -804,6 +852,16 @@ asn1js "^3.0.6" tslib "^2.8.1" +"@peculiar/asn1-pkcs8@^2.7.0": + version "2.7.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz#5ee602d8a9a3e0a3f09f7b008ff644a657378692" + integrity sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw== + dependencies: + "@peculiar/asn1-schema" "^2.7.0" + "@peculiar/asn1-x509" "^2.7.0" + asn1js "^3.0.6" + tslib "^2.8.1" + "@peculiar/asn1-pkcs9@^2.5.0": version "2.5.0" resolved "https://registry.yarnpkg.com/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.5.0.tgz#8c5b873a721bb92b4fe758da9de1ead63165106d" @@ -818,6 +876,20 @@ asn1js "^3.0.6" tslib "^2.8.1" +"@peculiar/asn1-pkcs9@^2.6.0": + version "2.7.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz#23b4eae41c2feb8df258aa69c502a70092026b0a" + integrity sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew== + dependencies: + "@peculiar/asn1-cms" "^2.7.0" + "@peculiar/asn1-pfx" "^2.7.0" + "@peculiar/asn1-pkcs8" "^2.7.0" + "@peculiar/asn1-schema" "^2.7.0" + "@peculiar/asn1-x509" "^2.7.0" + "@peculiar/asn1-x509-attr" "^2.7.0" + asn1js "^3.0.6" + tslib "^2.8.1" + "@peculiar/asn1-rsa@^2.5.0": version "2.5.0" resolved "https://registry.yarnpkg.com/@peculiar/asn1-rsa/-/asn1-rsa-2.5.0.tgz#7283756ec596ccfbef23ff0e7eda0c37133ebed8" @@ -828,6 +900,16 @@ asn1js "^3.0.6" tslib "^2.8.1" +"@peculiar/asn1-rsa@^2.6.0", "@peculiar/asn1-rsa@^2.7.0": + version "2.7.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz#6dc5c78c643264dd5a251a66f1dd9a38fcbba385" + integrity sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ== + dependencies: + "@peculiar/asn1-schema" "^2.7.0" + "@peculiar/asn1-x509" "^2.7.0" + asn1js "^3.0.6" + tslib "^2.8.1" + "@peculiar/asn1-schema@^2.3.15", "@peculiar/asn1-schema@^2.5.0": version "2.5.0" resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.5.0.tgz#4e58d7c3087c4259cebf5363e092f85b9cbf0ca1" @@ -837,6 +919,15 @@ pvtsutils "^1.3.6" tslib "^2.8.1" +"@peculiar/asn1-schema@^2.6.0", "@peculiar/asn1-schema@^2.7.0": + version "2.7.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz#f2dcb25995ce7cac8687ba1039f043e5eff43820" + integrity sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg== + dependencies: + "@peculiar/utils" "^2.0.2" + asn1js "^3.0.6" + tslib "^2.8.1" + "@peculiar/asn1-x509-attr@^2.5.0": version "2.5.0" resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.5.0.tgz#d413597dfe097620a00780e9e2ae851b06f32aed" @@ -847,6 +938,16 @@ asn1js "^3.0.6" tslib "^2.8.1" +"@peculiar/asn1-x509-attr@^2.7.0": + version "2.7.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz#5ef2a10d3a78d4763b848a2cb56db4bb6b1e082a" + integrity sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w== + dependencies: + "@peculiar/asn1-schema" "^2.7.0" + "@peculiar/asn1-x509" "^2.7.0" + asn1js "^3.0.6" + tslib "^2.8.1" + "@peculiar/asn1-x509@^2.3.15", "@peculiar/asn1-x509@^2.5.0": version "2.5.0" resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.5.0.tgz#305f9cd534f4b6a723d27fc59363f382debf5500" @@ -857,6 +958,23 @@ pvtsutils "^1.3.6" tslib "^2.8.1" +"@peculiar/asn1-x509@^2.6.0", "@peculiar/asn1-x509@^2.7.0": + version "2.7.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz#84793efb7819dbc9526fd6b0a4ccd86f90464b96" + integrity sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g== + dependencies: + "@peculiar/asn1-schema" "^2.7.0" + "@peculiar/utils" "^2.0.2" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/utils@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@peculiar/utils/-/utils-2.0.3.tgz#a27ca4c4b73652e110f19a7d16d664f458a5528e" + integrity sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ== + dependencies: + tslib "^2.8.1" + "@peculiar/x509@^1.12.3": version "1.14.0" resolved "https://registry.yarnpkg.com/@peculiar/x509/-/x509-1.14.0.tgz#4b1abdf7ca5e46f2cb303fba608ef0507762e84a" @@ -874,6 +992,23 @@ tslib "^2.8.1" tsyringe "^4.10.0" +"@peculiar/x509@^1.14.2": + version "1.14.3" + resolved "https://registry.yarnpkg.com/@peculiar/x509/-/x509-1.14.3.tgz#2c44c2b89474346afec38a0c2803ec4fb8ce959e" + integrity sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA== + dependencies: + "@peculiar/asn1-cms" "^2.6.0" + "@peculiar/asn1-csr" "^2.6.0" + "@peculiar/asn1-ecc" "^2.6.0" + "@peculiar/asn1-pkcs9" "^2.6.0" + "@peculiar/asn1-rsa" "^2.6.0" + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + pvtsutils "^1.3.6" + reflect-metadata "^0.2.2" + tslib "^2.8.1" + tsyringe "^4.10.0" + "@rolldown/binding-android-arm64@1.0.0-rc.15": version "1.0.0-rc.15" resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz#ca20574c469ade7b941f90c9af5e83e7c67f06b7" @@ -1583,6 +1718,11 @@ bytes@^3.1.2, bytes@~3.1.2: resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== +bytestreamjs@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/bytestreamjs/-/bytestreamjs-2.0.1.tgz#a32947c7ce389a6fa11a09a9a563d0a45889535e" + integrity sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ== + cacheable-lookup@^6.0.0: version "6.1.0" resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-6.1.0.tgz#0330a543471c61faa4e9035db583aad753b36385" @@ -3454,6 +3594,18 @@ pkg-types@^1.1.1, pkg-types@^1.3.0: mlly "^1.7.4" pathe "^2.0.1" +pkijs@^3.3.3: + version "3.4.0" + resolved "https://registry.yarnpkg.com/pkijs/-/pkijs-3.4.0.tgz#d9164def30ff6d97be2d88966d5e36192499ca9c" + integrity sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw== + dependencies: + "@noble/hashes" "1.4.0" + asn1js "^3.0.6" + bytestreamjs "^2.0.1" + pvtsutils "^1.3.6" + pvutils "^1.1.3" + tslib "^2.8.1" + postcss@^8.5.8: version "8.5.8" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.8.tgz#6230ecc8fb02e7a0f6982e53990937857e13f399" @@ -3684,6 +3836,14 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +selfsigned@5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-5.5.0.tgz#4c9ab7c7c9f35f18fb6a9882c253eb0e6bd6557b" + integrity sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew== + dependencies: + "@peculiar/x509" "^1.14.2" + pkijs "^3.3.3" + semver@^7.5.2: version "7.7.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"