diff --git a/app.ts b/app.ts index ba36c024..30264dfb 100644 --- a/app.ts +++ b/app.ts @@ -32,10 +32,15 @@ registerViewEngine(app); // Security // Defaults https://www.npmjs.com/package/helmet#how-it-works +// ReSpec pill UI is embedded via iframe on any spec-hosting site, +// so we must not send frame-ancestors or X-Frame-Options. +const cspDirectives = helmet.contentSecurityPolicy.getDefaultDirectives(); +delete cspDirectives["frame-ancestors"]; + app.use( helmet({ - // Allow for UI inclusion as iframe in ReSpec pill. frameguard: false, + contentSecurityPolicy: { directives: cspDirectives }, }), ); diff --git a/package.json b/package.json index 5cd413a7..52b8525a 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", + "express-rate-limit": "^8.4.0", "helmet": "^8.1.0", "morgan": "^1.10.1", "nanoid": "^5.1.9", @@ -34,6 +35,11 @@ "update-data-sources": "node build/scripts/update-data-sources.js" }, "packageManager": "pnpm@9.8.0", + "pnpm": { + "overrides": { + "html-minifier": "npm:html-minifier-terser@^7.2.0" + } + }, "simple-git-hooks": { "post-merge": "pnpm i && pnpm build" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa1e8134..b487a19c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + html-minifier: npm:html-minifier-terser@^7.2.0 + importers: .: @@ -23,6 +26,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + express-rate-limit: + specifier: ^8.4.0 + version: 8.4.0(express@5.2.1) helmet: specifier: ^8.1.0 version: 8.1.0 @@ -75,6 +81,22 @@ packages: '@jasminejs/reporters@1.0.0': resolution: {integrity: sha512-rM3GG4vx2H1Gp5kYCTr9aKlOEJFd43pzpiMAiy5b1+FUc2ub4e6bS6yCi/WQNDzAa5MVp9++dwcoEtcIfoEnhA==} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -129,6 +151,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -160,16 +187,20 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - camel-case@3.0.0: - resolution: {integrity: sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==} + camel-case@4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - clean-css@4.2.4: - resolution: {integrity: sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==} - engines: {node: '>= 4.0'} + clean-css@5.3.3: + resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} + engines: {node: '>= 10.0'} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -231,6 +262,9 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -246,6 +280,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -265,6 +303,12 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + express-rate-limit@8.4.0: + resolution: {integrity: sha512-gDK8yiqKxrGta+3WtON59arrrw6GLmadA1qoFgYXzdcch8fmKDID2XqO8itsi3f1wufXYPT51387dN6cvVBS3Q==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -308,10 +352,6 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - he@1.2.0: - resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} - hasBin: true - helmet@8.1.0: resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} engines: {node: '>=18.0.0'} @@ -319,9 +359,9 @@ packages: html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} - html-minifier@4.0.0: - resolution: {integrity: sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==} - engines: {node: '>=6'} + html-minifier-terser@7.2.0: + resolution: {integrity: sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==} + engines: {node: ^14.13.1 || >=16.0.0} hasBin: true http-errors@2.0.1: @@ -335,6 +375,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -349,8 +393,8 @@ packages: resolution: {integrity: sha512-dvYt7bidcu0JvvSbiUnSDW7UQQiflUwDr6C+5wzoZ0J7RY9u+UcoSIzyhMPj6fnU/tC7KinJ5QrjwD2Y9p4T4w==} hasBin: true - lower-case@1.1.4: - resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==} + lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} lru-cache@11.3.5: resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} @@ -410,8 +454,8 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - no-case@2.3.2: - resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==} + no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} non-error@0.1.0: resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==} @@ -440,13 +484,16 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - param-case@2.1.1: - resolution: {integrity: sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==} + param-case@3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + pascal-case@3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -546,10 +593,18 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + terser@5.46.2: + resolution: {integrity: sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==} + engines: {node: '>=10'} + hasBin: true + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-fest@5.6.0: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} @@ -566,11 +621,6 @@ packages: ucontent@2.0.0: resolution: {integrity: sha512-yGV/qZpKW8KlWJea43V+pRvNNGuF2Ei9MTO5MwWF5yiQVenQlEDhkjedF91Szg8w0SdRSIzKEgMuaj4RweKqYg==} - uglify-js@3.17.4: - resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==} - engines: {node: '>=0.8.0'} - hasBin: true - uhyphen@0.1.0: resolution: {integrity: sha512-o0QVGuFg24FK765Qdd5kk0zU/U4dEsCtN/GSiwNI9i8xsSVtjIAOdTaVhLwZ1nrbWxFVMxNDDl+9fednsOMsBw==} @@ -587,9 +637,6 @@ packages: uparser@0.2.1: resolution: {integrity: sha512-hTwK8e+bAaER8gJBOysoFGjRPsX/xOthSPR/ieI9EgTf1rts9ey25tbDXIU0rDr/cGcLsvkCo1n8J/eccfAvvw==} - upper-case@1.1.3: - resolution: {integrity: sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==} - vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -601,6 +648,25 @@ snapshots: '@jasminejs/reporters@1.0.0': {} + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -670,6 +736,8 @@ snapshots: acorn@8.11.3: {} + acorn@8.16.0: {} + balanced-match@4.0.4: {} basic-auth@2.0.1: @@ -708,17 +776,19 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - camel-case@3.0.0: + camel-case@4.1.2: dependencies: - no-case: 2.3.2 - upper-case: 1.1.3 + pascal-case: 3.1.2 + tslib: 2.8.1 chalk@5.6.2: {} - clean-css@4.2.4: + clean-css@5.3.3: dependencies: source-map: 0.6.1 + commander@10.0.1: {} + commander@2.20.3: {} compressible@2.0.18: @@ -769,6 +839,11 @@ snapshots: depd@2.0.0: {} + dot-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + dotenv@17.4.2: {} dunder-proto@1.0.1: @@ -781,6 +856,8 @@ snapshots: encodeurl@2.0.0: {} + entities@4.5.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -793,6 +870,11 @@ snapshots: etag@1.8.1: {} + express-rate-limit@8.4.0(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + express@5.2.1: dependencies: accepts: 2.0.0 @@ -875,21 +957,19 @@ snapshots: dependencies: function-bind: 1.1.2 - he@1.2.0: {} - helmet@8.1.0: {} html-escaper@3.0.3: {} - html-minifier@4.0.0: + html-minifier-terser@7.2.0: dependencies: - camel-case: 3.0.0 - clean-css: 4.2.4 - commander: 2.20.3 - he: 1.2.0 - param-case: 2.1.1 + camel-case: 4.1.2 + clean-css: 5.3.3 + commander: 10.0.1 + entities: 4.5.0 + param-case: 3.0.4 relateurl: 0.2.7 - uglify-js: 3.17.4 + terser: 5.46.2 http-errors@2.0.1: dependencies: @@ -905,6 +985,8 @@ snapshots: inherits@2.0.4: {} + ip-address@10.1.0: {} + ipaddr.js@1.9.1: {} is-promise@4.0.0: {} @@ -917,7 +999,9 @@ snapshots: glob: 13.0.6 jasmine-core: 6.2.0 - lower-case@1.1.4: {} + lower-case@2.0.2: + dependencies: + tslib: 2.8.1 lru-cache@11.3.5: {} @@ -961,9 +1045,10 @@ snapshots: negotiator@1.0.0: {} - no-case@2.3.2: + no-case@3.0.4: dependencies: - lower-case: 1.1.4 + lower-case: 2.0.2 + tslib: 2.8.1 non-error@0.1.0: {} @@ -985,12 +1070,18 @@ snapshots: dependencies: wrappy: 1.0.2 - param-case@2.1.1: + param-case@3.0.4: dependencies: - no-case: 2.3.2 + dot-case: 3.0.4 + tslib: 2.8.1 parseurl@1.3.3: {} + pascal-case@3.1.2: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + path-scurry@2.0.2: dependencies: lru-cache: 11.3.5 @@ -1116,8 +1207,17 @@ snapshots: source-map: 0.6.1 source-map-support: 0.5.21 + terser@5.46.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.16.0 + commander: 2.20.3 + source-map-support: 0.5.21 + toidentifier@1.0.1: {} + tslib@2.8.1: {} + type-fest@5.6.0: dependencies: tagged-tag: 1.0.0 @@ -1134,14 +1234,12 @@ snapshots: dependencies: csso: 4.2.0 html-escaper: 3.0.3 - html-minifier: 4.0.0 + html-minifier: html-minifier-terser@7.2.0 terser: 4.8.1 uhyphen: 0.1.0 umap: 1.0.2 uparser: 0.2.1 - uglify-js@3.17.4: {} - uhyphen@0.1.0: {} umap@1.0.2: {} @@ -1152,8 +1250,6 @@ snapshots: uparser@0.2.1: {} - upper-case@1.1.3: {} - vary@1.1.2: {} wrappy@1.0.2: {} diff --git a/routes/github/lib/utils/rest.ts b/routes/github/lib/utils/rest.ts index 1774140f..b7b687a0 100644 --- a/routes/github/lib/utils/rest.ts +++ b/routes/github/lib/utils/rest.ts @@ -1,11 +1,18 @@ import { getToken, updateRateLimit, RateLimit } from "./tokens.js"; -const GITHUB_API_PREFIX = "https://api.github.com/"; +const GITHUB_API_ORIGIN = "https://api.github.com"; + +function assertGitHubAPIUrl(url: string) { + const parsed = URL.parse(url); + if (parsed?.protocol !== "https:" || parsed.origin !== GITHUB_API_ORIGIN) { + throw new Error( + `requestData: expected ${GITHUB_API_ORIGIN}, got ${parsed?.origin ?? "invalid URL"}`, + ); + } +} export async function* requestData(endpoint: string, pages = 30) { - if (!endpoint.startsWith(GITHUB_API_PREFIX)) { - throw new Error(`requestData: endpoint must start with ${GITHUB_API_PREFIX}`); - } + assertGitHubAPIUrl(endpoint); let url: string | null = endpoint; do { const token = getToken(); @@ -25,9 +32,7 @@ export async function* requestData(endpoint: string, pages = 30) { yield { url, result }; const next = nextPage(response.headers.get("link") || ""); - if (next !== null && !next.startsWith(GITHUB_API_PREFIX)) { - throw new Error(`requestData: pagination URL must start with ${GITHUB_API_PREFIX}`); - } + if (next !== null) assertGitHubAPIUrl(next); url = next; updateRateLimit(token, getRateLimit(response.headers)); } while (url !== null && --pages > 0); diff --git a/routes/respec/index.ts b/routes/respec/index.ts index 96488628..4d992d9e 100644 --- a/routes/respec/index.ts +++ b/routes/respec/index.ts @@ -2,6 +2,7 @@ import path from "node:path"; import express from "express"; import { env, ms } from "../../utils/misc.js"; +import rateLimit from "express-rate-limit"; import authGithubWebhook from "../../utils/auth-github-webhook.js"; import * as sizeRoute from "./size.js"; @@ -9,7 +10,9 @@ import buildUpdateRoute, { PKG_DIR } from "./builds/update.js"; const router = express.Router({ mergeParams: true }); -router.get("/size", sizeRoute.get); +const sizeRateLimit = rateLimit({ windowMs: ms("1m"), max: 10 }); + +router.get("/size", sizeRateLimit, sizeRoute.get); router.put( "/size", express.urlencoded({ extended: false, parameterLimit: 4, limit: "128b" }), diff --git a/tests/helpers/env.js b/tests/helpers/env.js new file mode 100644 index 00000000..26f7bf85 --- /dev/null +++ b/tests/helpers/env.js @@ -0,0 +1,6 @@ +import os from "node:os"; + +// Set required env vars only when not already configured, +// so local developer settings are preserved during test runs. +process.env.GH_TOKEN ||= "test-token"; +process.env.DATA_DIR ||= os.tmpdir(); diff --git a/tests/jasmine.json b/tests/jasmine.json index 236f3b57..20146f08 100644 --- a/tests/jasmine.json +++ b/tests/jasmine.json @@ -1,6 +1,7 @@ { "spec_dir": "tests", "spec_files": ["**/*.test.js"], + "helpers": ["helpers/env.js"], "jsLoader": "import", "stopSpecOnExpectationFailure": false, "random": true diff --git a/tests/routes/github/lib/utils/rest.test.js b/tests/routes/github/lib/utils/rest.test.js new file mode 100644 index 00000000..5a20bd96 --- /dev/null +++ b/tests/routes/github/lib/utils/rest.test.js @@ -0,0 +1,93 @@ +import { requestData } from "../../../../../build/routes/github/lib/utils/rest.js"; + +describe("routes/github/lib/utils/rest - requestData", () => { + it("throws for a non-GitHub API endpoint", async () => { + const gen = requestData("https://evil.example.com/steal"); + await expectAsync(gen.next()).toBeRejectedWithError( + /expected https:\/\/api\.github\.com/, + ); + }); + + it("throws for an http (non-https) GitHub URL", async () => { + const gen = requestData("http://api.github.com/repos/foo/bar"); + await expectAsync(gen.next()).toBeRejectedWithError( + /expected https:\/\/api\.github\.com/, + ); + }); + + it("throws for a GitHub URL that isn't the API subdomain", async () => { + const gen = requestData("https://github.com/speced/respec"); + await expectAsync(gen.next()).toBeRejectedWithError( + /expected https:\/\/api\.github\.com/, + ); + }); + + it("throws for an empty string endpoint", async () => { + const gen = requestData(""); + await expectAsync(gen.next()).toBeRejectedWithError( + /expected https:\/\/api\.github\.com/, + ); + }); + + it("throws for a URL with github.com as username (URL confusion)", async () => { + const gen = requestData("https://api.github.com@evil.com/path"); + await expectAsync(gen.next()).toBeRejectedWithError( + /expected https:\/\/api\.github\.com/, + ); + }); + + it("throws for a blob: URL with matching origin", async () => { + const gen = requestData("blob:https://api.github.com/some-uuid"); + await expectAsync(gen.next()).toBeRejectedWithError( + /expected https:\/\/api\.github\.com/, + ); + }); + + it("throws for a data: URL", async () => { + const gen = requestData("data:text/html,