snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
This commit is contained in:
278
packages/server-fetch/CHANGELOG.md
Normal file
278
packages/server-fetch/CHANGELOG.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# @rocket.chat/server-fetch
|
||||
|
||||
## 0.2.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [4947601bbf042cd1b2385f8f5dda438e608faea7, b89a8d411ef65f6931a5fd1cd057740bc00cd9ba, 5deefe2766132f13118f38dc42cf1c9895208961, 8984df841e1ecc46198e073982adabc1f069c352, a9f3e6cd3f31eac17bec626287fcf815d802e9b9, 742009a09148e33d141f50691cdec0f7e9818535]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.5.0
|
||||
- @rocket.chat/tools@0.4.0
|
||||
</details>
|
||||
|
||||
## 0.2.4-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [4947601bbf042cd1b2385f8f5dda438e608faea7, b89a8d411ef65f6931a5fd1cd057740bc00cd9ba, 5deefe2766132f13118f38dc42cf1c9895208961, 8984df841e1ecc46198e073982adabc1f069c352, a9f3e6cd3f31eac17bec626287fcf815d802e9b9, 742009a09148e33d141f50691cdec0f7e9818535]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.5.0-rc.0
|
||||
- @rocket.chat/tools@0.4.0-rc.0
|
||||
</details>
|
||||
|
||||
## 0.2.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [c7aff48a40a9a78924cbf27fd38930c536ee11e5, 5f92f9a27dca70d506d919351612bd32dc04241a, 13b4a7b2dc203959b77b3b0c5f154d3e34fe2058, 4b34bd62f2ac8d51efd2f48caea7092e87f30ce7, ffe1b646226eeeda5a4d4697c831e568ec1eec64, 3cd7db677a72521439b564dca7a4ca6d6c3a1c07, 719e3db9734708e812ceb33483ffaa2e064b4d59, 1cc7bbdef9330899a8207d3d55130f48321d68f1]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.4.0
|
||||
</details>
|
||||
|
||||
## 0.2.3-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [c7aff48a40a9a78924cbf27fd38930c536ee11e5, 5f92f9a27dca70d506d919351612bd32dc04241a, 13b4a7b2dc203959b77b3b0c5f154d3e34fe2058, 4b34bd62f2ac8d51efd2f48caea7092e87f30ce7, ffe1b646226eeeda5a4d4697c831e568ec1eec64, 3cd7db677a72521439b564dca7a4ca6d6c3a1c07, 719e3db9734708e812ceb33483ffaa2e064b4d59, 1cc7bbdef9330899a8207d3d55130f48321d68f1]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.4.0-rc.0
|
||||
</details>
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [73e12e1707baea845395e0582892f65456598672, 7380c44c751eff9ee624d80bf26370411ffed78b]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.3.1
|
||||
</details>
|
||||
|
||||
## 0.2.2-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [73e12e1707baea845395e0582892f65456598672, 7380c44c751eff9ee624d80bf26370411ffed78b]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.3.1-rc.0
|
||||
</details>
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [f7d47dd3517ec14ca2ec5c3c95fcdf9e1e2fb8b0, b6b04aadfcc8558f888b334e37c46a77e5816237, 4704bf81ca370f120af32185a7c55407a26f8514, 12897e25d0dc25b7373f5264d38f38a5a7444257, e45585b70a3a7b75434c88e4b2ea9af0a0764a76]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.3.0
|
||||
</details>
|
||||
|
||||
## 0.2.1-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [f7d47dd3517ec14ca2ec5c3c95fcdf9e1e2fb8b0, ae9f740d6af20557eac61b4af902c868b4132b49, b6b04aadfcc8558f888b334e37c46a77e5816237, 4704bf81ca370f120af32185a7c55407a26f8514, 12897e25d0dc25b7373f5264d38f38a5a7444257, e45585b70a3a7b75434c88e4b2ea9af0a0764a76]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.3.0-rc.0
|
||||
</details>
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#40096](https://github.com/RocketChat/Rocket.Chat/pull/40096)) Introduces redaction of potentially sensitive data when logging request URLs
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [21cd54f87de5837fe6c999a44bd15be34b9fe905, 278b84f78360e53792a2e5d7620615039a0e15e9, 12c44d2db65af9f90c741da621164f5738fc19f5, 9713af36f5c0d673f2d2093015f322341706bab0, f3fa3ee2f2e53b777de9abd466f1b76a1ec1b96c, 95a82f72dd45fc51d54bb1beed295315facf9109, f4dfb8ddc2049692371aeb084110b5768151b5df]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.2.0
|
||||
- @rocket.chat/tools@0.3.0
|
||||
</details>
|
||||
|
||||
## 0.2.0-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/models@2.2.0-rc.2
|
||||
</details>
|
||||
|
||||
## 0.2.0-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/models@2.2.0-rc.1
|
||||
</details>
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/models@2.1.4
|
||||
</details>
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/models@2.1.3
|
||||
</details>
|
||||
|
||||
## 0.2.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#40096](https://github.com/RocketChat/Rocket.Chat/pull/40096)) Introduces redaction of potentially sensitive data when logging request URLs
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [21cd54f87de5837fe6c999a44bd15be34b9fe905, 278b84f78360e53792a2e5d7620615039a0e15e9, 12c44d2db65af9f90c741da621164f5738fc19f5, 9713af36f5c0d673f2d2093015f322341706bab0, f3fa3ee2f2e53b777de9abd466f1b76a1ec1b96c, 95a82f72dd45fc51d54bb1beed295315facf9109, f4dfb8ddc2049692371aeb084110b5768151b5df]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.2.0-rc.0
|
||||
- @rocket.chat/tools@0.3.0-rc.0
|
||||
</details>
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
|
||||
|
||||
- <details><summary>Updated dependencies [602b20a8c570b895eb296ecfe39c9b7fcb12fabd, e2068892bf1ffc88b15ab71ad743cf84e5d31ed5, cd2fc208d351032c0b729755af4886665dca08b6, 539659af22bc19880eda047dfc0b152472ccb65c, eae3fb3136bd0b48294c050a71b0a36d05ca02b0]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.1.2
|
||||
- @rocket.chat/logger@1.0.1
|
||||
- @rocket.chat/tools@0.2.5
|
||||
</details>
|
||||
|
||||
## 0.1.2-rc.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/models@2.1.2-rc.4
|
||||
</details>
|
||||
|
||||
## 0.1.2-rc.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/models@2.1.2-rc.3
|
||||
</details>
|
||||
|
||||
## 0.1.2-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/models@2.1.2-rc.2
|
||||
</details>
|
||||
|
||||
## 0.1.2-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/models@2.1.2-rc.1
|
||||
</details>
|
||||
|
||||
## 0.1.2-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
|
||||
|
||||
- <details><summary>Updated dependencies [602b20a8c570b895eb296ecfe39c9b7fcb12fabd, e2068892bf1ffc88b15ab71ad743cf84e5d31ed5, cd2fc208d351032c0b729755af4886665dca08b6, 539659af22bc19880eda047dfc0b152472ccb65c, eae3fb3136bd0b48294c050a71b0a36d05ca02b0]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.1.2-rc.0
|
||||
- @rocket.chat/logger@1.0.1-rc.0
|
||||
- @rocket.chat/tools@0.2.5-rc.0
|
||||
</details>
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/models@2.1.1
|
||||
</details>
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#38044](https://github.com/RocketChat/Rocket.Chat/pull/38044)) Adds configurable SSRF validation for HTTP calls made from server
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [d3758a7d57ab602745369ef9d2ccdbf9271cf305, e57f15845e4df048dd2f08f11aa08215780a2c34, 3b003e6b69c11b280d55bcc8db2f3e4ae7a4a573, 508b4a17d76dc1cd7d3a55bdba826216f51432e2, 123aebec2caa74b17d2b5dcbd2a2db2e687cf3ac]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.1.0
|
||||
</details>
|
||||
|
||||
## 0.1.0-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/models@2.1.0-rc.2
|
||||
</details>
|
||||
|
||||
## 0.1.0-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/models@2.1.0-rc.1
|
||||
</details>
|
||||
|
||||
## 0.1.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#38044](https://github.com/RocketChat/Rocket.Chat/pull/38044)) Adds configurable SSRF validation for HTTP calls made from server
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [d3758a7d57ab602745369ef9d2ccdbf9271cf305, e57f15845e4df048dd2f08f11aa08215780a2c34, 3b003e6b69c11b280d55bcc8db2f3e4ae7a4a573, 508b4a17d76dc1cd7d3a55bdba826216f51432e2, 123aebec2caa74b17d2b5dcbd2a2db2e687cf3ac]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.1.0-rc.0
|
||||
</details>
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#31138](https://github.com/RocketChat/Rocket.Chat/pull/31138)) feat(uikit): Move `@rocket.chat/ui-kit` package to the main monorepo
|
||||
|
||||
## 0.0.3-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b223cbde14: feat(uikit): Move `@rocket.chat/ui-kit` package to the main monorepo
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 35363420f0: Fixed an issue where the payload of an HTTP request made by an app wouldn't be correctly encoded in some cases
|
||||
|
||||
## 0.0.2-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 35363420f0: Fixed an issue where the payload of an HTTP request made by an app wouldn't be correctly encoded in some cases
|
||||
7
packages/server-fetch/jest.config.ts
Normal file
7
packages/server-fetch/jest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import server from '@rocket.chat/jest-presets/server';
|
||||
import type { Config } from 'jest';
|
||||
|
||||
export default {
|
||||
preset: server.preset,
|
||||
testMatch: ['<rootDir>/tests/**/*.spec.(ts|js|mjs)'],
|
||||
} satisfies Config;
|
||||
39
packages/server-fetch/package.json
Normal file
39
packages/server-fetch/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@rocket.chat/server-fetch",
|
||||
"version": "0.2.4",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"typings": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
||||
"dev": "tsc --watch --preserveWatchOutput",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"testunit": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rocket.chat/logger": "workspace:^",
|
||||
"@rocket.chat/models": "workspace:^",
|
||||
"@rocket.chat/tools": "workspace:^",
|
||||
"@types/proxy-from-env": "^1.0.4",
|
||||
"abort-controller": "^3.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"node-fetch": "2.7.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node-fetch": "~2.6.13",
|
||||
"eslint": "~9.39.5",
|
||||
"jest": "^29.7.0",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../package.json"
|
||||
}
|
||||
}
|
||||
146
packages/server-fetch/src/checkForSsrf.ts
Normal file
146
packages/server-fetch/src/checkForSsrf.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { isTruthy } from '@rocket.chat/tools';
|
||||
|
||||
import {
|
||||
allowlistedIpResolved,
|
||||
isIpInAnyRange,
|
||||
isIpValid,
|
||||
isValidDomain,
|
||||
normalizeAllowlistEntry,
|
||||
normalizeHostForAllowlistMatch,
|
||||
nslookup,
|
||||
parseIpv4WithPort,
|
||||
} from './helpers';
|
||||
|
||||
export const parseSsrfAllowlist = (value: string): string[] => {
|
||||
if (!value.trim()) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.split(/[\n,]/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(isTruthy);
|
||||
};
|
||||
|
||||
function getEffectiveAllowlist(allowListRaw: string[]): string[] {
|
||||
return allowListRaw.map(normalizeAllowlistEntry).filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
/** Normalize allowList (string or string[]) to a single effective list. Parses raw string in this single place. */
|
||||
function toEffectiveAllowlist(allowList?: string | string[]): string[] {
|
||||
if (allowList === undefined || allowList === null) {
|
||||
return [];
|
||||
}
|
||||
if (typeof allowList === 'string') {
|
||||
return getEffectiveAllowlist(parseSsrfAllowlist(allowList));
|
||||
}
|
||||
return getEffectiveAllowlist(allowList);
|
||||
}
|
||||
|
||||
function isInAllowlist(hostOrIp: string, port: string | undefined, allowlist: string[]): boolean {
|
||||
if (allowlist.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeHostForAllowlistMatch(hostOrIp);
|
||||
const withPort = port ? `${normalized}:${port}` : normalized;
|
||||
return allowlist.some((entry) => entry === normalized || entry === withPort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the URL is allowed by SSRF rules.
|
||||
* @param input - URL or host to check
|
||||
* @param allowList - Optional raw string (newline/comma-separated) or array of allowed hosts/IPs/CIDRs. Parsed inside.
|
||||
*/
|
||||
export const checkForSsrf = async (input: string, allowList?: string | string[]): Promise<boolean> => {
|
||||
const result = await checkForSsrfWithIp(input, allowList);
|
||||
return result.allowed;
|
||||
};
|
||||
|
||||
/**
|
||||
* SSRF check with resolved IP for pinning. allowList is optional; string is parsed in this single place.
|
||||
*/
|
||||
export const checkForSsrfWithIp = async (
|
||||
input: string,
|
||||
allowList?: string | string[],
|
||||
): Promise<{ allowed: false } | { allowed: true; resolvedIp: string }> => {
|
||||
const effectiveAllowlist = toEffectiveAllowlist(allowList);
|
||||
let ipOrDomain: string;
|
||||
let port: string | undefined;
|
||||
let wasUrlParsed = false;
|
||||
|
||||
try {
|
||||
const url = new URL(input);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return { allowed: false };
|
||||
}
|
||||
ipOrDomain = url.hostname;
|
||||
port = url.port || undefined;
|
||||
wasUrlParsed = true;
|
||||
} catch {
|
||||
const parsed = parseIpv4WithPort(input);
|
||||
if (parsed) {
|
||||
ipOrDomain = parsed.ip;
|
||||
port = parsed.port;
|
||||
} else {
|
||||
ipOrDomain = input;
|
||||
}
|
||||
}
|
||||
|
||||
if (ipOrDomain.startsWith('[') && ipOrDomain.endsWith(']')) {
|
||||
ipOrDomain = ipOrDomain.slice(1, -1);
|
||||
}
|
||||
|
||||
const parsed = parseIpv4WithPort(ipOrDomain);
|
||||
if (parsed) {
|
||||
ipOrDomain = parsed.ip;
|
||||
if (parsed.port && !port) {
|
||||
port = parsed.port;
|
||||
}
|
||||
}
|
||||
|
||||
const ipValid = isIpValid(ipOrDomain);
|
||||
const domainValid = isValidDomain(ipOrDomain);
|
||||
|
||||
if (!ipValid && !domainValid) {
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
if (isInAllowlist(ipOrDomain, port, effectiveAllowlist)) {
|
||||
if (ipValid) {
|
||||
return { allowed: true, resolvedIp: allowlistedIpResolved(ipOrDomain, port, wasUrlParsed) };
|
||||
}
|
||||
|
||||
if (domainValid) {
|
||||
try {
|
||||
const resolvedIp = await nslookup(ipOrDomain);
|
||||
const resolvedIpWithPort = !wasUrlParsed && port ? `${resolvedIp}:${port}` : resolvedIp;
|
||||
return { allowed: true, resolvedIp: resolvedIpWithPort };
|
||||
} catch {
|
||||
return { allowed: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ipValid && isIpInAnyRange(ipOrDomain)) {
|
||||
return { allowed: false };
|
||||
}
|
||||
if (domainValid && /metadata\.google\.internal/i.test(ipOrDomain)) {
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
if (!domainValid) {
|
||||
const ipWithPort = !wasUrlParsed && port ? `${ipOrDomain}:${port}` : ipOrDomain;
|
||||
return { allowed: true, resolvedIp: ipWithPort };
|
||||
}
|
||||
|
||||
try {
|
||||
const resolvedIp = await nslookup(ipOrDomain);
|
||||
if (isIpInAnyRange(resolvedIp)) {
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
const resolvedIpWithPort = !wasUrlParsed && port ? `${resolvedIp}:${port}` : resolvedIp;
|
||||
return { allowed: true, resolvedIp: resolvedIpWithPort };
|
||||
} catch {
|
||||
return { allowed: false };
|
||||
}
|
||||
};
|
||||
45
packages/server-fetch/src/constants.ts
Normal file
45
packages/server-fetch/src/constants.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const ipv4Ranges = [
|
||||
'0.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'100.64.0.0/10',
|
||||
'127.0.0.0/8',
|
||||
'169.254.0.0/16',
|
||||
'172.16.0.0/12',
|
||||
'192.0.0.0/24',
|
||||
'192.0.2.0/24',
|
||||
'192.88.99.0/24',
|
||||
'192.168.0.0/16',
|
||||
'198.18.0.0/15',
|
||||
'198.51.100.0/24',
|
||||
'203.0.113.0/24',
|
||||
'224.0.0.0/4',
|
||||
'240.0.0.0/4',
|
||||
'255.255.255.255',
|
||||
'100.100.100.200/32',
|
||||
];
|
||||
|
||||
export const ipv6Ranges = [
|
||||
'::/128',
|
||||
'::1/128',
|
||||
'::ffff:0:0/96',
|
||||
'3fff::/20',
|
||||
'5f00::/16',
|
||||
'64:ff9b::/96',
|
||||
'64:ff9b:1::/48',
|
||||
'100::/64',
|
||||
'2001::/32',
|
||||
'2001:20::/28',
|
||||
'2001:db8::/32',
|
||||
'2002::/16',
|
||||
'fc00::/7',
|
||||
'fe80::/10',
|
||||
'ff00::/8',
|
||||
];
|
||||
|
||||
export const domainPattern = /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[A-Za-z]{2,63}$/;
|
||||
|
||||
export const ipv4WithPortPattern = /^(\d+\.\d+\.\d+\.\d+)(?::(\d+))?$/;
|
||||
|
||||
export const MAX_REDIRECTS = 5;
|
||||
|
||||
export const redirectStatus = new Set([301, 302, 303, 307, 308]);
|
||||
163
packages/server-fetch/src/helpers.ts
Normal file
163
packages/server-fetch/src/helpers.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { lookup } from 'node:dns';
|
||||
import net from 'node:net';
|
||||
|
||||
import { domainPattern, ipv4Ranges, ipv4WithPortPattern, ipv6Ranges } from './constants';
|
||||
|
||||
export const isValidDomain = (domain: string) => domainPattern.test(domain);
|
||||
|
||||
export const unwrapBrackets = (address: string) => (address.startsWith('[') && address.endsWith(']') ? address.slice(1, -1) : address);
|
||||
|
||||
export const isIpValid = (ip: string): boolean => net.isIP(unwrapBrackets(ip)) !== 0;
|
||||
|
||||
export const isIpInCidrRange = (ip: string, cidr: string): boolean => {
|
||||
const [network, prefixStr] = cidr.split('/');
|
||||
const ipUnwrapped = unwrapBrackets(ip);
|
||||
if (net.isIPv4(ipUnwrapped)) {
|
||||
const prefix = prefixStr ? parseInt(prefixStr, 10) : 32;
|
||||
if (!network || !net.isIPv4(network)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const toNum = (ipString: string) =>
|
||||
ipString.split('.').reduce((accumulated, octet) => (accumulated << 8) + parseInt(octet, 10), 0) >>> 0;
|
||||
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
||||
return (toNum(ipUnwrapped) & mask) === (toNum(network) & mask);
|
||||
}
|
||||
if (net.isIPv6(ipUnwrapped)) {
|
||||
const prefix = prefixStr ? parseInt(prefixStr, 10) : 128;
|
||||
if (!network || !net.isIPv6(unwrapBrackets(network))) {
|
||||
return false;
|
||||
}
|
||||
const toBigInt = (address: string): bigint => {
|
||||
const parts = unwrapBrackets(address).split(':');
|
||||
const nonEmptyParts = parts.filter((part) => part.length > 0);
|
||||
const fill = 8 - nonEmptyParts.reduce((count, part) => count + (part.includes('.') ? 2 : 1), 0);
|
||||
const groups: string[] = [];
|
||||
let filled = false;
|
||||
for (const part of parts) {
|
||||
if (part === '') {
|
||||
if (!filled) {
|
||||
for (let index = 0; index < fill; index++) groups.push('0');
|
||||
filled = true;
|
||||
}
|
||||
} else if (part.includes('.')) {
|
||||
if (!net.isIPv4(part)) {
|
||||
return 0n;
|
||||
}
|
||||
const num = part.split('.').reduce((accumulated, octet) => (accumulated << 8) + parseInt(octet, 10), 0) >>> 0;
|
||||
groups.push((num >>> 16).toString(16), (num & 0xffff).toString(16));
|
||||
} else {
|
||||
groups.push(part);
|
||||
}
|
||||
}
|
||||
return groups.reduce((value, hexGroup) => (value << 16n) + BigInt(parseInt(hexGroup, 16)), 0n);
|
||||
};
|
||||
const mask = prefix === 0 ? 0n : ((1n << BigInt(prefix)) - 1n) << (128n - BigInt(prefix));
|
||||
return (toBigInt(ip) & mask) === (toBigInt(network) & mask);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isIpInAnyRange = (ip: string): boolean => {
|
||||
const ranges = ip.includes(':') ? ipv6Ranges : ipv4Ranges;
|
||||
return ranges.some((range) => isIpInCidrRange(ip, range));
|
||||
};
|
||||
|
||||
export const normalizeAllowlistEntry = (entry: string): string => {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
if (isValidDomain(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
if (trimmed.startsWith('[')) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.includes(':') && net.isIPv6(trimmed)) {
|
||||
return `[${trimmed}]`;
|
||||
}
|
||||
return trimmed.toLowerCase();
|
||||
};
|
||||
|
||||
export const normalizeHostForAllowlistMatch = (hostOrIp: string): string => {
|
||||
if (hostOrIp.startsWith('[')) {
|
||||
return hostOrIp;
|
||||
}
|
||||
if (hostOrIp.includes(':') && net.isIPv6(hostOrIp)) {
|
||||
return `[${hostOrIp}]`;
|
||||
}
|
||||
return hostOrIp.toLowerCase();
|
||||
};
|
||||
|
||||
export const parseIpv4WithPort = (input: string): { ip: string; port?: string } | null => {
|
||||
const match = input.match(ipv4WithPortPattern);
|
||||
if (match) {
|
||||
return { ip: match[1]!, port: match[2] };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const allowlistedIpResolved = (ipOrDomain: string, port: string | undefined, wasUrlParsed: boolean): string => {
|
||||
if (wasUrlParsed || !port) {
|
||||
return ipOrDomain;
|
||||
}
|
||||
if (ipOrDomain.includes(':')) {
|
||||
return `[${ipOrDomain}]:${port}`;
|
||||
}
|
||||
return `${ipOrDomain}:${port}`;
|
||||
};
|
||||
|
||||
export function nslookup(hostname: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
lookup(hostname, (err, address) => {
|
||||
if (err) reject(err);
|
||||
else resolve(address);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function checkDirectIp(ip: string): boolean {
|
||||
return /^(\d+\.\d+\.\d+\.\d+|\[?[0-9a-fA-F:]+]?)$/.test(ip);
|
||||
}
|
||||
|
||||
export function extractHostname(urlString: string): string | null {
|
||||
try {
|
||||
const { hostname } = new URL(urlString);
|
||||
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
||||
return hostname.slice(1, -1);
|
||||
}
|
||||
return hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPinnedUrl(originalUrl: string, resolvedIp: string): string {
|
||||
try {
|
||||
const url = new URL(originalUrl);
|
||||
|
||||
let ipAddress: string;
|
||||
|
||||
const ipv4WithPortMatch = resolvedIp.match(/^(\d+\.\d+\.\d+\.\d+)(?::(\d+))$/);
|
||||
if (ipv4WithPortMatch) {
|
||||
ipAddress = ipv4WithPortMatch[1];
|
||||
} else if (resolvedIp.includes(':') && !resolvedIp.includes('.')) {
|
||||
const ipv6WithPortMatch = resolvedIp.match(/^(\[[0-9a-fA-F:]+\])(?::(\d+))$/);
|
||||
if (ipv6WithPortMatch) {
|
||||
ipAddress = ipv6WithPortMatch[1];
|
||||
} else {
|
||||
ipAddress = resolvedIp.startsWith('[') && resolvedIp.endsWith(']') ? resolvedIp : `[${resolvedIp}]`;
|
||||
}
|
||||
} else {
|
||||
// IPv4 without port
|
||||
ipAddress = resolvedIp;
|
||||
}
|
||||
|
||||
url.hostname = ipAddress;
|
||||
|
||||
return url.toString();
|
||||
} catch {
|
||||
return originalUrl;
|
||||
}
|
||||
}
|
||||
198
packages/server-fetch/src/index.ts
Normal file
198
packages/server-fetch/src/index.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { censorUrl } from '@rocket.chat/tools';
|
||||
import { AbortController } from 'abort-controller';
|
||||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import fetch, { FetchError, Response } from 'node-fetch';
|
||||
import { getProxyForUrl } from 'proxy-from-env';
|
||||
|
||||
import { checkForSsrfWithIp, parseSsrfAllowlist } from './checkForSsrf';
|
||||
import { MAX_REDIRECTS, redirectStatus } from './constants';
|
||||
import { buildPinnedUrl, checkDirectIp, extractHostname } from './helpers';
|
||||
import { parseRequestOptions } from './parsers';
|
||||
import type { ExtendedFetchOptions } from './types';
|
||||
|
||||
const logger = new Logger('ExternalRequest');
|
||||
|
||||
function getFetchAgent<U extends string>(
|
||||
url: U,
|
||||
allowSelfSignedCerts?: boolean,
|
||||
originalHostname?: string,
|
||||
): http.Agent | https.Agent | null | HttpsProxyAgent<U> | HttpProxyAgent<U> {
|
||||
const isHttps = url.startsWith('https');
|
||||
|
||||
const proxy = getProxyForUrl(url);
|
||||
if (proxy) {
|
||||
const AgentFn = isHttps ? HttpsProxyAgent : HttpProxyAgent;
|
||||
return new AgentFn(proxy);
|
||||
}
|
||||
|
||||
if (!isHttps) {
|
||||
return new http.Agent();
|
||||
}
|
||||
|
||||
const agentOptions: https.AgentOptions = {};
|
||||
|
||||
if (originalHostname) {
|
||||
agentOptions.servername = originalHostname;
|
||||
agentOptions.rejectUnauthorized = true;
|
||||
} else if (allowSelfSignedCerts) {
|
||||
// For self-signed certificates, do not disable certificate validation.
|
||||
// Instead, fall back to the default HTTPS agent behavior (full validation).
|
||||
return null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new https.Agent(agentOptions);
|
||||
}
|
||||
|
||||
async function getFetchAgentWithValidation<U extends string>(
|
||||
url: U,
|
||||
allowSelfSignedCerts?: boolean,
|
||||
ignoreSsrfValidation?: boolean,
|
||||
allowList?: string | string[],
|
||||
): Promise<{
|
||||
agent: http.Agent | https.Agent | null | HttpsProxyAgent<U> | HttpProxyAgent<U>;
|
||||
pinnedUrl: string;
|
||||
originalHostname?: string;
|
||||
resolvedIp?: string;
|
||||
}> {
|
||||
let pinnedUrl: string = url;
|
||||
let originalHostname: string | undefined;
|
||||
let resolvedIp: string | undefined;
|
||||
|
||||
if (!ignoreSsrfValidation) {
|
||||
const ssrfResult = await checkForSsrfWithIp(url, allowList);
|
||||
if (!ssrfResult.allowed) {
|
||||
logger.error({ msg: 'SSRF validation failed for URL', url: censorUrl(url) });
|
||||
throw new Error('error-ssrf-validation-failed');
|
||||
}
|
||||
|
||||
resolvedIp = ssrfResult.resolvedIp;
|
||||
const extractedHostname = extractHostname(url);
|
||||
|
||||
if (extractedHostname) {
|
||||
originalHostname = extractedHostname;
|
||||
|
||||
if (!checkDirectIp(extractedHostname)) {
|
||||
pinnedUrl = buildPinnedUrl(url, resolvedIp);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.debug({ msg: 'Request not using SSRF validation', url: censorUrl(pinnedUrl) });
|
||||
}
|
||||
|
||||
return { agent: getFetchAgent(pinnedUrl, allowSelfSignedCerts, originalHostname), pinnedUrl, originalHostname, resolvedIp };
|
||||
}
|
||||
|
||||
function getTimeout(timeout?: number) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout ?? 20000);
|
||||
|
||||
return { controller, timeoutId };
|
||||
}
|
||||
|
||||
function followRedirect(response: fetch.Response, redirectCount = 0) {
|
||||
const location = response.headers.get('location');
|
||||
|
||||
if (!location) {
|
||||
logger.error({ msg: 'Malformed redirect response', status: response.status });
|
||||
throw new Error('redirect-response-location-header-missing');
|
||||
}
|
||||
|
||||
if (redirectCount === MAX_REDIRECTS) {
|
||||
logger.error({ msg: 'Redirect count reached the maximum amount of redirects allowed', MAX_REDIRECTS, status: response.status });
|
||||
throw new Error('error-too-many-redirects');
|
||||
}
|
||||
|
||||
logger.debug({ msg: 'Following redirect', redirectCount, location: censorUrl(location), status: response.status });
|
||||
return location;
|
||||
}
|
||||
|
||||
export async function serverFetch(input: string, options?: ExtendedFetchOptions, allowSelfSignedCerts?: boolean): Promise<Response> {
|
||||
let currentUrl = input;
|
||||
const { controller, timeoutId } = getTimeout(options?.timeout);
|
||||
|
||||
try {
|
||||
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) {
|
||||
const { agent, pinnedUrl, originalHostname, resolvedIp } = await getFetchAgentWithValidation(
|
||||
currentUrl,
|
||||
allowSelfSignedCerts,
|
||||
options?.ignoreSsrfValidation,
|
||||
options?.allowList,
|
||||
);
|
||||
|
||||
const params = new URLSearchParams(options?.params);
|
||||
const url = new URL(pinnedUrl);
|
||||
|
||||
if (redirectCount === 0 && params.toString()) {
|
||||
params.forEach((value, key) => {
|
||||
if (value) {
|
||||
url.searchParams.append(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const parsedOptions = parseRequestOptions(options) || {};
|
||||
const existingHeaders = parsedOptions.headers || {};
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (existingHeaders && typeof existingHeaders === 'object') {
|
||||
if (existingHeaders instanceof Headers) {
|
||||
existingHeaders.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
} else if (Array.isArray(existingHeaders)) {
|
||||
existingHeaders.forEach(([key, value]) => {
|
||||
headers[key] = Array.isArray(value) ? value.join(', ') : value;
|
||||
});
|
||||
} else {
|
||||
Object.assign(headers, existingHeaders);
|
||||
}
|
||||
}
|
||||
|
||||
if (originalHostname && resolvedIp) {
|
||||
if (!checkDirectIp(originalHostname)) {
|
||||
try {
|
||||
const originalUrl = new URL(currentUrl);
|
||||
headers.Host = originalUrl.port ? `${originalHostname}:${originalUrl.port}` : originalHostname;
|
||||
} catch {
|
||||
headers.Host = originalHostname;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
// @ts-expect-error - This complained when types were moved to file :/
|
||||
signal: controller.signal,
|
||||
...parsedOptions,
|
||||
redirect: 'manual',
|
||||
headers,
|
||||
...(agent ? { agent } : {}),
|
||||
});
|
||||
|
||||
if (!redirectStatus.has(response.status)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
currentUrl = new URL(followRedirect(response, redirectCount), currentUrl).toString();
|
||||
|
||||
// https://github.com/node-fetch/node-fetch/issues/1673 - body not consumed == open socket
|
||||
response.body.resume();
|
||||
}
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('error-processing-request');
|
||||
}
|
||||
|
||||
export { FetchError, Response };
|
||||
export type { ExtendedFetchOptions };
|
||||
export { parseSsrfAllowlist };
|
||||
48
packages/server-fetch/src/parsers.ts
Normal file
48
packages/server-fetch/src/parsers.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ExtendedFetchOptions, FetchOptions, OriginalFetchOptions } from './types';
|
||||
|
||||
const jsonParser = (options: ExtendedFetchOptions) => {
|
||||
if (!options) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof options.body === 'object' && !Buffer.isBuffer(options.body)) {
|
||||
options.body = JSON.stringify(options.body);
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
'Content-Type': 'application/json', // force content type to be json
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// Body is not JSON, do nothing
|
||||
}
|
||||
|
||||
return options as FetchOptions;
|
||||
};
|
||||
|
||||
const urlencodedParser = (options: ExtendedFetchOptions) => {
|
||||
return options as FetchOptions;
|
||||
};
|
||||
|
||||
const getParser = (contentTypeHeader?: string): ((options: ExtendedFetchOptions) => FetchOptions) => {
|
||||
switch (contentTypeHeader) {
|
||||
case 'application/json':
|
||||
return jsonParser;
|
||||
case 'application/x-www-form-urlencoded':
|
||||
return urlencodedParser;
|
||||
default:
|
||||
return jsonParser;
|
||||
}
|
||||
};
|
||||
|
||||
export function parseRequestOptions(options?: ExtendedFetchOptions): OriginalFetchOptions {
|
||||
if (!options) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const headers = (options.headers as { [k: string]: string }) ?? {};
|
||||
|
||||
const contentTypeHeader = headers['Content-Type'] || headers['content-type'];
|
||||
|
||||
return getParser(contentTypeHeader?.toLowerCase())(options);
|
||||
}
|
||||
26
packages/server-fetch/src/types.ts
Normal file
26
packages/server-fetch/src/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { BodyInit } from 'node-fetch';
|
||||
|
||||
export type OriginalFetchOptions = Parameters<typeof fetch>[1];
|
||||
export type FetchOptions = NonNullable<Parameters<typeof fetch>[1]>;
|
||||
export type FetchOptionsWithoutBody = Omit<FetchOptions, 'body'>;
|
||||
|
||||
type BaseExtendedOptions = FetchOptionsWithoutBody & {
|
||||
compress?: boolean;
|
||||
follow?: number;
|
||||
size?: number;
|
||||
timeout?: number;
|
||||
params?: Record<string, any>;
|
||||
body?: BodyInit | Record<string, any>;
|
||||
};
|
||||
|
||||
type SsrfValidationIgnored = {
|
||||
ignoreSsrfValidation: true;
|
||||
allowList?: never;
|
||||
};
|
||||
|
||||
type SsrfValidationEnforced = {
|
||||
ignoreSsrfValidation: false;
|
||||
allowList: string | string[];
|
||||
};
|
||||
|
||||
export type ExtendedFetchOptions = BaseExtendedOptions & (SsrfValidationIgnored | SsrfValidationEnforced);
|
||||
553
packages/server-fetch/tests/checkForSsrf.spec.ts
Normal file
553
packages/server-fetch/tests/checkForSsrf.spec.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
import * as ssrfModule from '../src/checkForSsrf';
|
||||
import {
|
||||
allowlistedIpResolved,
|
||||
buildPinnedUrl,
|
||||
checkDirectIp,
|
||||
extractHostname,
|
||||
isIpInCidrRange,
|
||||
isIpInAnyRange,
|
||||
isIpValid,
|
||||
isValidDomain,
|
||||
normalizeAllowlistEntry,
|
||||
normalizeHostForAllowlistMatch,
|
||||
parseIpv4WithPort,
|
||||
unwrapBrackets,
|
||||
nslookup,
|
||||
} from '../src/helpers';
|
||||
|
||||
// Mock the nslookup function
|
||||
jest.mock('../src/helpers', () => ({
|
||||
...jest.requireActual('../src/helpers'),
|
||||
nslookup: jest.fn(),
|
||||
}));
|
||||
|
||||
const nslookupMock = nslookup as jest.MockedFunction<typeof nslookup>;
|
||||
|
||||
describe('checkForSsrf', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns false if URL does not start with http:// or https://', async () => {
|
||||
expect(await ssrfModule.checkForSsrf('ftp://example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if domain is invalid', async () => {
|
||||
expect(await ssrfModule.checkForSsrf('https://invalid_domain')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if IPv4 is invalid', async () => {
|
||||
expect(await ssrfModule.checkForSsrf('https://127.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if IPv4 is restricted', async () => {
|
||||
expect(await ssrfModule.checkForSsrf('http://127.0.0.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if domain is metadata.google.internal', async () => {
|
||||
expect(await ssrfModule.checkForSsrf('http://metadata.google.internal')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if DNS resolves to restricted IPv4', async () => {
|
||||
nslookupMock.mockResolvedValue('127.0.0.1');
|
||||
expect(await ssrfModule.checkForSsrf('http://example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true if DNS resolves to public IPv4', async () => {
|
||||
nslookupMock.mockResolvedValue('216.58.214.174');
|
||||
expect(await ssrfModule.checkForSsrf('http://example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true if valid public IPv4 address', async () => {
|
||||
expect(await ssrfModule.checkForSsrf('http://216.58.214.174')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true if valid URL resolves to public IPv4', async () => {
|
||||
nslookupMock.mockResolvedValue('216.58.214.174');
|
||||
const url = 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/15/Cat_August_2010-4.jpg/2560px-Cat_August_2010-4.jpg';
|
||||
expect(await ssrfModule.checkForSsrf(url)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for valid public IPv6 addresses', async () => {
|
||||
const publicIp = '2a00:1450:4007:806::200e';
|
||||
nslookupMock.mockResolvedValue(publicIp);
|
||||
expect(await ssrfModule.checkForSsrf('http://example.com')).toBe(true);
|
||||
expect(await ssrfModule.checkForSsrf(`[${publicIp}]`)).toBe(true);
|
||||
expect(await ssrfModule.checkForSsrf(publicIp)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false if DNS resolves to restricted IPv6', async () => {
|
||||
nslookupMock.mockResolvedValue('::1');
|
||||
expect(await ssrfModule.checkForSsrf('http://example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true if DNS resolves to public IPv6', async () => {
|
||||
const publicIp = '2a00:1450:4007:806::200e';
|
||||
nslookupMock.mockResolvedValue(publicIp);
|
||||
expect(await ssrfModule.checkForSsrf('https://[2a00:1450:4007:806::200e]')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for invalid IPv6 format', async () => {
|
||||
expect(await ssrfModule.checkForSsrf('http://[gggg::1]')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if DNS resolution fails', async () => {
|
||||
nslookupMock.mockRejectedValue(new Error('DNS fail'));
|
||||
expect(await ssrfModule.checkForSsrf('http://example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles bracketed and unbracketed IPv6 correctly', async () => {
|
||||
const ip = '2606:4700:4700::1111';
|
||||
expect(await ssrfModule.checkForSsrf(`[${ip}]`)).toBe(true);
|
||||
expect(await ssrfModule.checkForSsrf(ip)).toBe(true);
|
||||
});
|
||||
|
||||
describe('restricted IPv6 addresses', () => {
|
||||
test.each(['::1', 'fe80::1', 'fc00::1234', 'ff02::1'])('returns false for restricted IPv6 address %s', async (ip) => {
|
||||
expect(await ssrfModule.checkForSsrf(`[${ip}]`)).toBe(false);
|
||||
expect(await ssrfModule.checkForSsrf(ip)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowlist (options.allowList per call)', () => {
|
||||
it('allows allowlisted private IPv4 when allowList contains it', async () => {
|
||||
expect(await ssrfModule.checkForSsrf('http://127.0.0.1', ['127.0.0.1'])).toBe(true);
|
||||
});
|
||||
|
||||
it('allows allowlisted private IPv4 with port', async () => {
|
||||
expect(await ssrfModule.checkForSsrf('http://192.168.1.1:8080', ['192.168.1.1:8080'])).toBe(true);
|
||||
});
|
||||
|
||||
it('allows allowlisted private IPv6', async () => {
|
||||
expect(await ssrfModule.checkForSsrf('http://[::1]', ['[::1]'])).toBe(true);
|
||||
});
|
||||
|
||||
it('allows allowlisted domain that resolves to private IP', async () => {
|
||||
nslookupMock.mockResolvedValue('10.0.0.1');
|
||||
expect(await ssrfModule.checkForSsrf('http://internal.corp', ['internal.corp'])).toBe(true);
|
||||
});
|
||||
|
||||
it('denies when allowList is empty or not provided', async () => {
|
||||
expect(await ssrfModule.checkForSsrf('http://127.0.0.1', ['127.0.0.1'])).toBe(true);
|
||||
expect(await ssrfModule.checkForSsrf('http://127.0.0.1', [])).toBe(false);
|
||||
expect(await ssrfModule.checkForSsrf('http://127.0.0.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts multiple allowlist entries per call', async () => {
|
||||
const allowList = ['127.0.0.1', '192.168.1.1', '10.0.0.1'];
|
||||
expect(await ssrfModule.checkForSsrf('http://127.0.0.1', allowList)).toBe(true);
|
||||
expect(await ssrfModule.checkForSsrf('http://192.168.1.1', allowList)).toBe(true);
|
||||
expect(await ssrfModule.checkForSsrf('http://10.0.0.1', allowList)).toBe(true);
|
||||
});
|
||||
|
||||
it('denies when allowList is undefined', async () => {
|
||||
expect(await ssrfModule.checkForSsrf('http://127.0.0.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('parses raw string (newline/comma-separated) in a single place', async () => {
|
||||
const rawSetting = '127.0.0.1\n192.168.1.1, 10.0.0.1';
|
||||
expect(await ssrfModule.checkForSsrf('http://127.0.0.1', rawSetting)).toBe(true);
|
||||
expect(await ssrfModule.checkForSsrf('http://192.168.1.1', rawSetting)).toBe(true);
|
||||
expect(await ssrfModule.checkForSsrf('http://10.0.0.1', rawSetting)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSsrfAllowlist', () => {
|
||||
it('returns empty array for empty string', () => {
|
||||
expect(ssrfModule.parseSsrfAllowlist('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for whitespace-only string', () => {
|
||||
expect(ssrfModule.parseSsrfAllowlist(' \n\t ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('splits by newlines', () => {
|
||||
expect(ssrfModule.parseSsrfAllowlist('a\nb\nc')).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('splits by commas', () => {
|
||||
expect(ssrfModule.parseSsrfAllowlist('a,b,c')).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('splits by mixed newlines and commas', () => {
|
||||
expect(ssrfModule.parseSsrfAllowlist('a\nb,c\nd')).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
it('trims each entry', () => {
|
||||
expect(ssrfModule.parseSsrfAllowlist(' a , b \n c ')).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('filters out empty entries', () => {
|
||||
expect(ssrfModule.parseSsrfAllowlist('a,,b\n\nc')).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('returns single entry for non-empty string without separators', () => {
|
||||
expect(ssrfModule.parseSsrfAllowlist('127.0.0.1')).toEqual(['127.0.0.1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForSsrfWithIp', () => {
|
||||
it('returns allowed: false and no resolvedIp when URL is blocked', async () => {
|
||||
const result = await ssrfModule.checkForSsrfWithIp('http://127.0.0.1');
|
||||
expect(result).toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it('returns allowed: true with resolvedIp for public URL', async () => {
|
||||
const result = await ssrfModule.checkForSsrfWithIp('http://216.58.214.174');
|
||||
expect(result).toEqual({ allowed: true, resolvedIp: '216.58.214.174' });
|
||||
});
|
||||
|
||||
it('returns allowed: true with resolvedIp for allowlisted private IP', async () => {
|
||||
const result = await ssrfModule.checkForSsrfWithIp('http://127.0.0.1', ['127.0.0.1']);
|
||||
expect(result).toEqual({ allowed: true, resolvedIp: '127.0.0.1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForSsrfHelpers', () => {
|
||||
describe('isValidDomain', () => {
|
||||
it('returns true for valid domain', () => {
|
||||
expect(isValidDomain('example.com')).toBe(true);
|
||||
expect(isValidDomain('sub.example.com')).toBe(true);
|
||||
});
|
||||
it('returns false for invalid domain', () => {
|
||||
expect(isValidDomain('')).toBe(false);
|
||||
expect(isValidDomain('invalid_domain')).toBe(false);
|
||||
expect(isValidDomain('a')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unwrapBrackets', () => {
|
||||
it('removes brackets from IPv6 address', () => {
|
||||
expect(unwrapBrackets('[::1]')).toBe('::1');
|
||||
});
|
||||
it('returns unchanged string when no brackets', () => {
|
||||
expect(unwrapBrackets('::1')).toBe('::1');
|
||||
expect(unwrapBrackets('example.com')).toBe('example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isIpValid', () => {
|
||||
it('returns true for valid IPv4 and IPv6', () => {
|
||||
expect(isIpValid('127.0.0.1')).toBe(true);
|
||||
expect(isIpValid('::1')).toBe(true);
|
||||
expect(isIpValid('[::1]')).toBe(true);
|
||||
});
|
||||
it('returns false for invalid IP', () => {
|
||||
expect(isIpValid('')).toBe(false);
|
||||
expect(isIpValid('256.1.1.1')).toBe(false);
|
||||
expect(isIpValid('not-an-ip')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isIpInCidrRange', () => {
|
||||
describe('IPv4', () => {
|
||||
it('returns true when IPv4 is in CIDR range', () => {
|
||||
expect(isIpInCidrRange('192.168.1.1', '192.168.0.0/16')).toBe(true);
|
||||
expect(isIpInCidrRange('10.0.0.1', '10.0.0.0/8')).toBe(true);
|
||||
});
|
||||
it('returns false when IPv4 is not in CIDR range', () => {
|
||||
expect(isIpInCidrRange('192.168.1.1', '10.0.0.0/8')).toBe(false);
|
||||
expect(isIpInCidrRange('8.8.8.8', '127.0.0.0/8')).toBe(false);
|
||||
});
|
||||
it('returns true for /32 exact match', () => {
|
||||
expect(isIpInCidrRange('192.168.1.1', '192.168.1.1/32')).toBe(true);
|
||||
expect(isIpInCidrRange('192.168.1.2', '192.168.1.1/32')).toBe(false);
|
||||
});
|
||||
it('treats missing prefix as /32', () => {
|
||||
expect(isIpInCidrRange('10.0.0.1', '10.0.0.1')).toBe(true);
|
||||
expect(isIpInCidrRange('10.0.0.2', '10.0.0.1')).toBe(false);
|
||||
});
|
||||
it('returns true for first and last IP in range (boundary)', () => {
|
||||
expect(isIpInCidrRange('127.0.0.0', '127.0.0.0/8')).toBe(true);
|
||||
expect(isIpInCidrRange('127.255.255.255', '127.0.0.0/8')).toBe(true);
|
||||
expect(isIpInCidrRange('128.0.0.0', '127.0.0.0/8')).toBe(false);
|
||||
});
|
||||
it('returns false when network is invalid or missing', () => {
|
||||
expect(isIpInCidrRange('192.168.1.1', '/16')).toBe(false);
|
||||
});
|
||||
it('returns false when IP version does not match CIDR (IPv4 IP with IPv6-style cidr)', () => {
|
||||
// 192.168.1.1 is IPv4; if cidr looks like IPv6 we still parse as IPv4 first - actually the cidr is split by / so "::1/128" gives network "::1" which is not valid IPv4, so net.isIP(network) !== 4 and we return false
|
||||
expect(isIpInCidrRange('192.168.1.1', '::1/128')).toBe(false);
|
||||
});
|
||||
});
|
||||
describe('IPv6', () => {
|
||||
it('returns true when IPv6 is in CIDR range', () => {
|
||||
expect(isIpInCidrRange('::1', '::1/128')).toBe(true);
|
||||
expect(isIpInCidrRange('fe80::1', 'fe80::/10')).toBe(true);
|
||||
});
|
||||
it('returns false when IPv6 is not in CIDR range', () => {
|
||||
expect(isIpInCidrRange('2a00:1450:4007:806::200e', 'fc00::/7')).toBe(false);
|
||||
expect(isIpInCidrRange('::2', '::1/128')).toBe(false);
|
||||
});
|
||||
it('accepts bracketed IPv6', () => {
|
||||
expect(isIpInCidrRange('[::1]', '::1/128')).toBe(true);
|
||||
expect(isIpInCidrRange('[fe80::1]', 'fe80::/10')).toBe(true);
|
||||
});
|
||||
it('treats missing prefix as /128', () => {
|
||||
expect(isIpInCidrRange('::1', '::1')).toBe(true);
|
||||
expect(isIpInCidrRange('::2', '::1')).toBe(false);
|
||||
});
|
||||
it('returns true for range boundaries', () => {
|
||||
expect(isIpInCidrRange('fe80::', 'fe80::/10')).toBe(true);
|
||||
expect(isIpInCidrRange('febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff', 'fe80::/10')).toBe(true);
|
||||
expect(isIpInCidrRange('fec0::1', 'fe80::/10')).toBe(false);
|
||||
});
|
||||
it('returns false when IP version does not match (IPv6 IP with IPv4 cidr)', () => {
|
||||
expect(isIpInCidrRange('::1', '127.0.0.0/8')).toBe(false);
|
||||
});
|
||||
});
|
||||
it('returns false for invalid IP', () => {
|
||||
expect(isIpInCidrRange('not-an-ip', '192.168.0.0/16')).toBe(false);
|
||||
expect(isIpInCidrRange('256.1.1.1', '192.168.0.0/16')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isIpInAnyRange', () => {
|
||||
it('returns true for blocked IPv4 ranges', () => {
|
||||
expect(isIpInAnyRange('127.0.0.1')).toBe(true);
|
||||
expect(isIpInAnyRange('10.0.0.1')).toBe(true);
|
||||
expect(isIpInAnyRange('192.168.1.1')).toBe(true);
|
||||
});
|
||||
it('returns true for blocked IPv6 ranges', () => {
|
||||
expect(isIpInAnyRange('::1')).toBe(true);
|
||||
expect(isIpInAnyRange('fe80::1')).toBe(true);
|
||||
});
|
||||
it('returns false for public IPs', () => {
|
||||
expect(isIpInAnyRange('216.58.214.174')).toBe(false);
|
||||
expect(isIpInAnyRange('2a00:1450:4007:806::200e')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeAllowlistEntry', () => {
|
||||
it('lowercases domain', () => {
|
||||
expect(normalizeAllowlistEntry('Example.COM')).toBe('example.com');
|
||||
});
|
||||
it('keeps IPv6 with brackets as-is', () => {
|
||||
expect(normalizeAllowlistEntry('[::1]')).toBe('[::1]');
|
||||
});
|
||||
it('wraps IPv6 without brackets', () => {
|
||||
expect(normalizeAllowlistEntry('::1')).toBe('[::1]');
|
||||
});
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(normalizeAllowlistEntry('')).toBe('');
|
||||
expect(normalizeAllowlistEntry(' ')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeHostForAllowlistMatch', () => {
|
||||
it('keeps bracketed IPv6 as-is', () => {
|
||||
expect(normalizeHostForAllowlistMatch('[::1]')).toBe('[::1]');
|
||||
});
|
||||
it('wraps IPv6 without brackets', () => {
|
||||
expect(normalizeHostForAllowlistMatch('::1')).toBe('[::1]');
|
||||
});
|
||||
it('lowercases hostname', () => {
|
||||
expect(normalizeHostForAllowlistMatch('Example.COM')).toBe('example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseIpv4WithPort', () => {
|
||||
it('parses IPv4 with port', () => {
|
||||
expect(parseIpv4WithPort('192.168.1.1:8080')).toEqual({ ip: '192.168.1.1', port: '8080' });
|
||||
});
|
||||
it('parses IPv4 without port', () => {
|
||||
expect(parseIpv4WithPort('192.168.1.1')).toEqual({ ip: '192.168.1.1' });
|
||||
});
|
||||
it('returns null for invalid input', () => {
|
||||
expect(parseIpv4WithPort('not-an-ip')).toBeNull();
|
||||
expect(parseIpv4WithPort('')).toBeNull();
|
||||
expect(parseIpv4WithPort('1.2.3')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowlistedIpResolved', () => {
|
||||
it('returns ipOrDomain when wasUrlParsed or no port', () => {
|
||||
expect(allowlistedIpResolved('127.0.0.1', undefined, true)).toBe('127.0.0.1');
|
||||
expect(allowlistedIpResolved('127.0.0.1', undefined, false)).toBe('127.0.0.1');
|
||||
});
|
||||
it('appends port for IPv4 when not URL parsed and port present', () => {
|
||||
expect(allowlistedIpResolved('127.0.0.1', '8080', false)).toBe('127.0.0.1:8080');
|
||||
});
|
||||
it('formats IPv6 with port in brackets', () => {
|
||||
expect(allowlistedIpResolved('::1', '443', false)).toBe('[::1]:443');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isIpInCidrRange (IPv4-mapped IPv6)', () => {
|
||||
it('parses IPv4-mapped address correctly so prefix > 96 works', () => {
|
||||
// ::ffff:192.168.1.1 should be in ::ffff:192.168.0.0/112 but not in ::ffff:10.0.0.0/112
|
||||
expect(isIpInCidrRange('::ffff:192.168.1.1', '::ffff:192.168.0.0/112')).toBe(true);
|
||||
expect(isIpInCidrRange('::ffff:192.168.1.1', '::ffff:10.0.0.0/112')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkDirectIp', () => {
|
||||
it('returns true for IPv4 addresses', () => {
|
||||
expect(checkDirectIp('192.168.1.1')).toBe(true);
|
||||
expect(checkDirectIp('127.0.0.1')).toBe(true);
|
||||
expect(checkDirectIp('10.0.0.1')).toBe(true);
|
||||
expect(checkDirectIp('255.255.255.255')).toBe(true);
|
||||
expect(checkDirectIp('8.8.8.8')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for IPv6 addresses with brackets', () => {
|
||||
expect(checkDirectIp('[2001:db8::1]')).toBe(true);
|
||||
expect(checkDirectIp('[::1]')).toBe(true);
|
||||
expect(checkDirectIp('[fe80::1]')).toBe(true);
|
||||
expect(checkDirectIp('[2a00:1450:4007:806::200e]')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for IPv6 addresses without brackets', () => {
|
||||
expect(checkDirectIp('2001:db8::1')).toBe(true);
|
||||
expect(checkDirectIp('::1')).toBe(true);
|
||||
expect(checkDirectIp('fe80::1')).toBe(true);
|
||||
expect(checkDirectIp('2a00:1450:4007:806::200e')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for domain names', () => {
|
||||
expect(checkDirectIp('example.com')).toBe(false);
|
||||
expect(checkDirectIp('sub.example.com')).toBe(false);
|
||||
expect(checkDirectIp('google.com')).toBe(false);
|
||||
expect(checkDirectIp('localhost')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for invalid input', () => {
|
||||
expect(checkDirectIp('')).toBe(false);
|
||||
expect(checkDirectIp('not-an-ip')).toBe(false);
|
||||
expect(checkDirectIp('http://example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for patterns that look like IPs (even if invalid)', () => {
|
||||
// checkDirectIp uses a simple regex pattern match, not validation
|
||||
// It's designed to detect if something looks like an IP for DNS pinning decisions
|
||||
// So things that look like IPs but are invalid should still return true here, and then later validation will catch them as invalid
|
||||
expect(checkDirectIp('256.1.1.1')).toBe(true);
|
||||
expect(checkDirectIp('999.999.999.999')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for IPv4 with port', () => {
|
||||
expect(checkDirectIp('192.168.1.1:8080')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for IPv6 with port', () => {
|
||||
expect(checkDirectIp('[::1]:8080')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractHostname', () => {
|
||||
it('extracts hostname from http URL', () => {
|
||||
expect(extractHostname('http://example.com')).toBe('example.com');
|
||||
expect(extractHostname('http://example.com/')).toBe('example.com');
|
||||
expect(extractHostname('http://example.com/path')).toBe('example.com');
|
||||
});
|
||||
|
||||
it('extracts hostname from https URL', () => {
|
||||
expect(extractHostname('https://example.com')).toBe('example.com');
|
||||
expect(extractHostname('https://sub.example.com')).toBe('sub.example.com');
|
||||
});
|
||||
|
||||
it('extracts hostname with port', () => {
|
||||
expect(extractHostname('http://example.com:8080')).toBe('example.com');
|
||||
expect(extractHostname('https://example.com:443')).toBe('example.com');
|
||||
});
|
||||
|
||||
it('extracts IPv4 address', () => {
|
||||
expect(extractHostname('http://192.168.1.1')).toBe('192.168.1.1');
|
||||
expect(extractHostname('http://127.0.0.1:8080')).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('extracts and unwraps bracketed IPv6 addresses', () => {
|
||||
expect(extractHostname('http://[::1]')).toBe('::1');
|
||||
expect(extractHostname('http://[2001:db8::1]')).toBe('2001:db8::1');
|
||||
expect(extractHostname('https://[fe80::1]:8080')).toBe('fe80::1');
|
||||
});
|
||||
|
||||
it('extracts hostname from URL with path and query', () => {
|
||||
expect(extractHostname('https://example.com/path/to/resource?query=value')).toBe('example.com');
|
||||
expect(extractHostname('http://example.com:3000/api/users?id=123')).toBe('example.com');
|
||||
});
|
||||
|
||||
it('extracts hostname from URL with fragment', () => {
|
||||
expect(extractHostname('https://example.com/page#section')).toBe('example.com');
|
||||
});
|
||||
|
||||
it('returns null for invalid URLs', () => {
|
||||
expect(extractHostname('not-a-url')).toBeNull();
|
||||
expect(extractHostname('example.com')).toBeNull();
|
||||
expect(extractHostname('')).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts hostname from complex URLs', () => {
|
||||
expect(extractHostname('https://user:pass@example.com:8080/path')).toBe('example.com');
|
||||
expect(extractHostname('http://subdomain.example.co.uk/path')).toBe('subdomain.example.co.uk');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPinnedUrl', () => {
|
||||
it('replaces hostname with IPv4 address', () => {
|
||||
expect(buildPinnedUrl('http://example.com/path', '93.184.216.34')).toBe('http://93.184.216.34/path');
|
||||
expect(buildPinnedUrl('https://example.com:443/api', '93.184.216.34')).toBe('https://93.184.216.34/api');
|
||||
});
|
||||
|
||||
it('replaces hostname with IPv4 address and strips port from resolvedIp', () => {
|
||||
// When resolvedIp has port like "93.184.216.34:8080", extract just the IP
|
||||
expect(buildPinnedUrl('http://example.com/path', '93.184.216.34:8080')).toBe('http://93.184.216.34/path');
|
||||
});
|
||||
|
||||
it('replaces hostname with bracketed IPv6 address', () => {
|
||||
expect(buildPinnedUrl('http://example.com/path', '2001:db8::1')).toBe('http://[2001:db8::1]/path');
|
||||
expect(buildPinnedUrl('https://example.com:443/api', 'fe80::1')).toBe('https://[fe80::1]/api');
|
||||
});
|
||||
|
||||
it('replaces hostname with already-bracketed IPv6 address', () => {
|
||||
expect(buildPinnedUrl('http://example.com/path', '[2001:db8::1]')).toBe('http://[2001:db8::1]/path');
|
||||
expect(buildPinnedUrl('https://example.com/api', '[::1]')).toBe('https://[::1]/api');
|
||||
});
|
||||
|
||||
it('replaces hostname with IPv6 address and strips port from resolvedIp', () => {
|
||||
// When resolvedIp has port like "[2001:db8::1]:8080", extract just the bracketed IP
|
||||
expect(buildPinnedUrl('http://example.com/path', '[2001:db8::1]:8080')).toBe('http://[2001:db8::1]/path');
|
||||
expect(buildPinnedUrl('https://example.com/api', '[::1]:443')).toBe('https://[::1]/api');
|
||||
});
|
||||
|
||||
it('preserves original URL path, query, and fragment', () => {
|
||||
expect(buildPinnedUrl('http://example.com/path/to/resource?query=value#section', '93.184.216.34')).toBe(
|
||||
'http://93.184.216.34/path/to/resource?query=value#section',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves original URL port', () => {
|
||||
expect(buildPinnedUrl('http://example.com:8080/path', '93.184.216.34')).toBe('http://93.184.216.34:8080/path');
|
||||
expect(buildPinnedUrl('https://example.com:9443/api', '2001:db8::1')).toBe('https://[2001:db8::1]:9443/api');
|
||||
});
|
||||
|
||||
it('preserves original URL protocol', () => {
|
||||
expect(buildPinnedUrl('http://example.com/path', '93.184.216.34')).toBe('http://93.184.216.34/path');
|
||||
expect(buildPinnedUrl('https://example.com/path', '93.184.216.34')).toBe('https://93.184.216.34/path');
|
||||
});
|
||||
|
||||
it('handles authentication in URL', () => {
|
||||
expect(buildPinnedUrl('http://user:pass@example.com/path', '93.184.216.34')).toBe('http://user:pass@93.184.216.34/path');
|
||||
});
|
||||
|
||||
it('returns original URL when URL parsing fails', () => {
|
||||
expect(buildPinnedUrl('not-a-url', '93.184.216.34')).toBe('not-a-url');
|
||||
expect(buildPinnedUrl('', '93.184.216.34')).toBe('');
|
||||
});
|
||||
|
||||
it('returns original URL for IPv4-mapped IPv6 addresses without brackets', () => {
|
||||
// ::ffff:192.168.1.1 contains both dots and colons
|
||||
// The function's condition `includes(':') && !includes('.')` is false, so it falls through to else
|
||||
// It tries to set the hostname to the raw IPv6 string without brackets, which fails
|
||||
// The try-catch returns the original URL
|
||||
expect(buildPinnedUrl('http://example.com/path', '::ffff:192.168.1.1')).toBe('http://example.com/path');
|
||||
});
|
||||
|
||||
it('handles IPv4-mapped IPv6 addresses with brackets correctly', () => {
|
||||
// If the IPv4-mapped IPv6 is already bracketed, the URL API converts it to canonical form
|
||||
// 192.168.1.1 = 0xc0a80101 = c0a8:0101 in hex
|
||||
expect(buildPinnedUrl('http://example.com/path', '[::ffff:192.168.1.1]')).toBe('http://[::ffff:c0a8:101]/path');
|
||||
});
|
||||
});
|
||||
12
packages/server-fetch/tsconfig.build.json
Normal file
12
packages/server-fetch/tsconfig.build.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
"exclude": ["**/*.spec.ts", "**/*.test.ts", "./tests/**/*"]
|
||||
}
|
||||
9
packages/server-fetch/tsconfig.json
Normal file
9
packages/server-fetch/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/server.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
},
|
||||
"include": ["./src/**/*", "./tests/**/*"],
|
||||
}
|
||||
Reference in New Issue
Block a user