From bf57c65b9237f4cef84eaa1aa2122ddf8bbc2b21 Mon Sep 17 00:00:00 2001 From: Leah Restad Date: Mon, 4 May 2026 16:57:51 -0700 Subject: [PATCH] Initial commit --- .gitattributes | 7 + .github/ISSUE_TEMPLATE/bug_report_dotnet.yml | 80 + .github/ISSUE_TEMPLATE/bug_report_nodejs.yml | 80 + .github/ISSUE_TEMPLATE/feature_request.yml | 58 + .github/workflows/check-license-headers.yml | 84 + .github/workflows/codeql.yml | 100 + .github/workflows/jekyll-gh-pages.yml | 72 + .github/workflows/test-dotnet.yml | 44 + .github/workflows/test-nodejs.yml | 53 + .github/workflows/update-domains.yml | 54 + .github/workflows/update-ip-ranges.yml | 54 + .gitignore | 7 + CODE_OF_CONDUCT.md | 10 + CONTRIBUTING.md | 79 + LICENSE | 21 + README.md | 72 + SECURITY.md | 14 + SUPPORT.md | 14 + config/Domains.json | 74 + config/IPAddressRanges.json | 439 +++ docs/.gitignore | 7 + docs/404.html | 25 + docs/Gemfile | 37 + docs/_config.yml | 115 + docs/dotnet-api/antissrfhandler.md | 44 + docs/dotnet-api/antissrfpolicy/constructor.md | 51 + docs/dotnet-api/antissrfpolicy/index.md | 59 + .../methods/addallowedaddresses.md | 67 + .../methods/adddeniedaddresses.md | 70 + .../methods/adddeniedheaders.md | 81 + .../methods/addrequiredheaders.md | 84 + .../antissrfpolicy/methods/gethandler.md | 42 + .../antissrfpolicy/methods/index.md | 26 + .../antissrfpolicy/properties/addxffheader.md | 39 + .../properties/allowedaddresses.md | 27 + .../properties/allowplaintexthttp.md | 36 + .../properties/deniedaddresses.md | 30 + .../properties/deniedheaders.md | 27 + .../properties/denyallunspecifiedips.md | 35 + .../antissrfpolicy/properties/index.md | 23 + .../properties/requiredheaders.md | 27 + docs/dotnet-api/changelog.md | 18 + docs/dotnet-api/index.md | 34 + .../urivalidator/inazurekeyvaultdomain.md | 99 + .../urivalidator/inazurestoragedomain.md | 106 + docs/dotnet-api/urivalidator/index.md | 39 + docs/dotnet-api/urivalidator/indomain.md | 155 + docs/faq.md | 64 + docs/getting-started.md | 114 + docs/index.md | 67 + docs/ipaddressranges.md | 127 + docs/nodejs-api/antissrfpolicy/constructor.md | 47 + docs/nodejs-api/antissrfpolicy/index.md | 60 + .../methods/addallowedaddresses.md | 75 + .../methods/adddeniedaddresses.md | 78 + .../methods/adddeniedheaders.md | 77 + .../methods/addrequiredheaders.md | 78 + .../antissrfpolicy/methods/gethttpagent.md | 71 + .../antissrfpolicy/methods/gethttpsagent.md | 67 + .../antissrfpolicy/methods/index.md | 27 + .../antissrfpolicy/properties/addxffheader.md | 37 + .../properties/allowedaddresses.md | 27 + .../properties/allowplaintexthttp.md | 36 + .../properties/deniedaddresses.md | 30 + .../properties/deniedheaders.md | 27 + .../properties/denyallunspecifiedips.md | 33 + .../antissrfpolicy/properties/index.md | 23 + .../properties/requiredheaders.md | 27 + docs/nodejs-api/changelog.md | 18 + docs/nodejs-api/index.md | 33 + docs/nodejs-api/samples/axios.md | 121 + docs/nodejs-api/samples/follow-redirects.md | 74 + docs/nodejs-api/samples/index.md | 14 + docs/nodejs-api/samples/node-fetch.md | 64 + .../urivalidator/inazurekeyvaultdomain.md | 64 + .../urivalidator/inazurestoragedomain.md | 71 + docs/nodejs-api/urivalidator/index.md | 35 + docs/nodejs-api/urivalidator/indomain.md | 94 + docs/support.md | 23 + dotnet/Directory.Packages.props | 18 + .../FunctionalTests/AntiSSRFHandlerTests.cs | 623 ++++ .../AntiSSRFPolicy.AddXFFHeaderTests.cs | 72 + .../AntiSSRFPolicy.AddressTests.cs | 773 ++++ .../AntiSSRFPolicy.HeaderTests.cs | 214 ++ .../AntiSSRFPolicy.SchemeTests.cs | 119 + .../InAzureKeyVaultDomainTests.cs | 177 + .../InAzureStorageDomainTests.cs | 223 ++ dotnet/FunctionalTests/InDomainTests.cs | 232 ++ ...t.Security.AntiSSRF.FunctionalTests.csproj | 30 + dotnet/Microsoft.Security.AntiSSRF.sln | 62 + dotnet/UnitTests/CIDRBlockTests.cs | 263 ++ ...crosoft.Security.AntiSSRF.UnitTests.csproj | 23 + dotnet/src/AntiSSRFException.cs | 13 + dotnet/src/AntiSSRFHandler.cs | 252 ++ dotnet/src/AntiSSRFPolicy.cs | 354 ++ dotnet/src/Helpers/CIDRBlock.cs | 165 + dotnet/src/Helpers/Domains.cs | 58 + dotnet/src/Helpers/InnerHandler.NetCore.cs | 45 + .../src/Helpers/InnerHandler.NetStandard.cs | 78 + dotnet/src/Helpers/RedirectHandler.cs | 165 + dotnet/src/IPAddressRanges.cs | 48 + dotnet/src/Microsoft.Security.AntiSSRF.csproj | 84 + dotnet/src/README.md | 57 + dotnet/src/URIValidator.cs | 219 ++ dotnet/src/microsoft.png | Bin 0 -> 393 bytes nodejs/.prettierignore | 11 + nodejs/.prettierrc | 9 + nodejs/README.md | 57 + nodejs/eslint.config.mjs | 20 + nodejs/package-lock.json | 3117 +++++++++++++++++ nodejs/package.json | 38 + nodejs/src/AntiSSRFError.ts | 4 + nodejs/src/AntiSSRFPolicy.ts | 346 ++ nodejs/src/Helpers/AntiSSRFDnsLookup.ts | 107 + nodejs/src/Helpers/AntiSSRFHttpAgent.ts | 111 + nodejs/src/Helpers/AntiSSRFHttpsAgent.ts | 112 + nodejs/src/Helpers/CIDRBlock.ts | 134 + nodejs/src/Helpers/Domains.ts | 51 + nodejs/src/IPAddressRanges.ts | 44 + nodejs/src/URIValidator.ts | 150 + nodejs/src/index.ts | 7 + .../FunctionalTests/AxiosDefaults.test.ts | 231 ++ .../FunctionalTests/AxiosInstance.test.ts | 243 ++ .../FunctionalTests/FollowRedirects.test.ts | 116 + .../tests/FunctionalTests/HttpAgent.test.ts | 466 +++ .../tests/FunctionalTests/HttpsAgent.test.ts | 722 ++++ .../tests/FunctionalTests/NodeFetch.test.ts | 359 ++ .../tests/PrePublishTests/PrePublish.test.js | 121 + .../tests/UnitTests/AntiSSRFDnsLookup.test.ts | 577 +++ .../AntiSSRFPolicy.AddXFFHeader.test.ts | 50 + .../UnitTests/AntiSSRFPolicy.Address.test.ts | 821 +++++ .../UnitTests/AntiSSRFPolicy.Header.test.ts | 233 ++ .../UnitTests/AntiSSRFPolicy.Scheme.test.ts | 90 + nodejs/tests/UnitTests/CIDRBlock.test.ts | 326 ++ .../UnitTests/InAzureKeyVaultDomain.test.ts | 232 ++ .../UnitTests/InAzureStorageDomain.test.ts | 299 ++ nodejs/tests/UnitTests/InDomain.test.ts | 383 ++ nodejs/tsconfig.json | 14 + scripts/build-domains-dotnet.sh | 85 + scripts/build-domains-nodejs.sh | 78 + scripts/build-ip-ranges-dotnet.sh | 73 + scripts/build-ip-ranges-nodejs.sh | 60 + 142 files changed, 18886 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug_report_dotnet.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report_nodejs.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/workflows/check-license-headers.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/jekyll-gh-pages.yml create mode 100644 .github/workflows/test-dotnet.yml create mode 100644 .github/workflows/test-nodejs.yml create mode 100644 .github/workflows/update-domains.yml create mode 100644 .github/workflows/update-ip-ranges.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 SUPPORT.md create mode 100644 config/Domains.json create mode 100644 config/IPAddressRanges.json create mode 100644 docs/.gitignore create mode 100644 docs/404.html create mode 100644 docs/Gemfile create mode 100644 docs/_config.yml create mode 100644 docs/dotnet-api/antissrfhandler.md create mode 100644 docs/dotnet-api/antissrfpolicy/constructor.md create mode 100644 docs/dotnet-api/antissrfpolicy/index.md create mode 100644 docs/dotnet-api/antissrfpolicy/methods/addallowedaddresses.md create mode 100644 docs/dotnet-api/antissrfpolicy/methods/adddeniedaddresses.md create mode 100644 docs/dotnet-api/antissrfpolicy/methods/adddeniedheaders.md create mode 100644 docs/dotnet-api/antissrfpolicy/methods/addrequiredheaders.md create mode 100644 docs/dotnet-api/antissrfpolicy/methods/gethandler.md create mode 100644 docs/dotnet-api/antissrfpolicy/methods/index.md create mode 100644 docs/dotnet-api/antissrfpolicy/properties/addxffheader.md create mode 100644 docs/dotnet-api/antissrfpolicy/properties/allowedaddresses.md create mode 100644 docs/dotnet-api/antissrfpolicy/properties/allowplaintexthttp.md create mode 100644 docs/dotnet-api/antissrfpolicy/properties/deniedaddresses.md create mode 100644 docs/dotnet-api/antissrfpolicy/properties/deniedheaders.md create mode 100644 docs/dotnet-api/antissrfpolicy/properties/denyallunspecifiedips.md create mode 100644 docs/dotnet-api/antissrfpolicy/properties/index.md create mode 100644 docs/dotnet-api/antissrfpolicy/properties/requiredheaders.md create mode 100644 docs/dotnet-api/changelog.md create mode 100644 docs/dotnet-api/index.md create mode 100644 docs/dotnet-api/urivalidator/inazurekeyvaultdomain.md create mode 100644 docs/dotnet-api/urivalidator/inazurestoragedomain.md create mode 100644 docs/dotnet-api/urivalidator/index.md create mode 100644 docs/dotnet-api/urivalidator/indomain.md create mode 100644 docs/faq.md create mode 100644 docs/getting-started.md create mode 100644 docs/index.md create mode 100644 docs/ipaddressranges.md create mode 100644 docs/nodejs-api/antissrfpolicy/constructor.md create mode 100644 docs/nodejs-api/antissrfpolicy/index.md create mode 100644 docs/nodejs-api/antissrfpolicy/methods/addallowedaddresses.md create mode 100644 docs/nodejs-api/antissrfpolicy/methods/adddeniedaddresses.md create mode 100755 docs/nodejs-api/antissrfpolicy/methods/adddeniedheaders.md create mode 100644 docs/nodejs-api/antissrfpolicy/methods/addrequiredheaders.md create mode 100644 docs/nodejs-api/antissrfpolicy/methods/gethttpagent.md create mode 100644 docs/nodejs-api/antissrfpolicy/methods/gethttpsagent.md create mode 100644 docs/nodejs-api/antissrfpolicy/methods/index.md create mode 100644 docs/nodejs-api/antissrfpolicy/properties/addxffheader.md create mode 100644 docs/nodejs-api/antissrfpolicy/properties/allowedaddresses.md create mode 100755 docs/nodejs-api/antissrfpolicy/properties/allowplaintexthttp.md create mode 100644 docs/nodejs-api/antissrfpolicy/properties/deniedaddresses.md create mode 100644 docs/nodejs-api/antissrfpolicy/properties/deniedheaders.md create mode 100644 docs/nodejs-api/antissrfpolicy/properties/denyallunspecifiedips.md create mode 100644 docs/nodejs-api/antissrfpolicy/properties/index.md create mode 100644 docs/nodejs-api/antissrfpolicy/properties/requiredheaders.md create mode 100644 docs/nodejs-api/changelog.md create mode 100755 docs/nodejs-api/index.md create mode 100644 docs/nodejs-api/samples/axios.md create mode 100644 docs/nodejs-api/samples/follow-redirects.md create mode 100644 docs/nodejs-api/samples/index.md create mode 100644 docs/nodejs-api/samples/node-fetch.md create mode 100755 docs/nodejs-api/urivalidator/inazurekeyvaultdomain.md create mode 100755 docs/nodejs-api/urivalidator/inazurestoragedomain.md create mode 100755 docs/nodejs-api/urivalidator/index.md create mode 100755 docs/nodejs-api/urivalidator/indomain.md create mode 100644 docs/support.md create mode 100644 dotnet/Directory.Packages.props create mode 100644 dotnet/FunctionalTests/AntiSSRFHandlerTests.cs create mode 100644 dotnet/FunctionalTests/AntiSSRFPolicy.AddXFFHeaderTests.cs create mode 100644 dotnet/FunctionalTests/AntiSSRFPolicy.AddressTests.cs create mode 100755 dotnet/FunctionalTests/AntiSSRFPolicy.HeaderTests.cs create mode 100644 dotnet/FunctionalTests/AntiSSRFPolicy.SchemeTests.cs create mode 100644 dotnet/FunctionalTests/InAzureKeyVaultDomainTests.cs create mode 100644 dotnet/FunctionalTests/InAzureStorageDomainTests.cs create mode 100644 dotnet/FunctionalTests/InDomainTests.cs create mode 100644 dotnet/FunctionalTests/Microsoft.Security.AntiSSRF.FunctionalTests.csproj create mode 100644 dotnet/Microsoft.Security.AntiSSRF.sln create mode 100644 dotnet/UnitTests/CIDRBlockTests.cs create mode 100644 dotnet/UnitTests/Microsoft.Security.AntiSSRF.UnitTests.csproj create mode 100644 dotnet/src/AntiSSRFException.cs create mode 100644 dotnet/src/AntiSSRFHandler.cs create mode 100644 dotnet/src/AntiSSRFPolicy.cs create mode 100644 dotnet/src/Helpers/CIDRBlock.cs create mode 100644 dotnet/src/Helpers/Domains.cs create mode 100644 dotnet/src/Helpers/InnerHandler.NetCore.cs create mode 100644 dotnet/src/Helpers/InnerHandler.NetStandard.cs create mode 100644 dotnet/src/Helpers/RedirectHandler.cs create mode 100644 dotnet/src/IPAddressRanges.cs create mode 100644 dotnet/src/Microsoft.Security.AntiSSRF.csproj create mode 100644 dotnet/src/README.md create mode 100644 dotnet/src/URIValidator.cs create mode 100644 dotnet/src/microsoft.png create mode 100644 nodejs/.prettierignore create mode 100644 nodejs/.prettierrc create mode 100644 nodejs/README.md create mode 100644 nodejs/eslint.config.mjs create mode 100644 nodejs/package-lock.json create mode 100644 nodejs/package.json create mode 100644 nodejs/src/AntiSSRFError.ts create mode 100644 nodejs/src/AntiSSRFPolicy.ts create mode 100644 nodejs/src/Helpers/AntiSSRFDnsLookup.ts create mode 100644 nodejs/src/Helpers/AntiSSRFHttpAgent.ts create mode 100644 nodejs/src/Helpers/AntiSSRFHttpsAgent.ts create mode 100644 nodejs/src/Helpers/CIDRBlock.ts create mode 100644 nodejs/src/Helpers/Domains.ts create mode 100644 nodejs/src/IPAddressRanges.ts create mode 100644 nodejs/src/URIValidator.ts create mode 100644 nodejs/src/index.ts create mode 100644 nodejs/tests/FunctionalTests/AxiosDefaults.test.ts create mode 100644 nodejs/tests/FunctionalTests/AxiosInstance.test.ts create mode 100644 nodejs/tests/FunctionalTests/FollowRedirects.test.ts create mode 100644 nodejs/tests/FunctionalTests/HttpAgent.test.ts create mode 100644 nodejs/tests/FunctionalTests/HttpsAgent.test.ts create mode 100644 nodejs/tests/FunctionalTests/NodeFetch.test.ts create mode 100644 nodejs/tests/PrePublishTests/PrePublish.test.js create mode 100644 nodejs/tests/UnitTests/AntiSSRFDnsLookup.test.ts create mode 100644 nodejs/tests/UnitTests/AntiSSRFPolicy.AddXFFHeader.test.ts create mode 100644 nodejs/tests/UnitTests/AntiSSRFPolicy.Address.test.ts create mode 100644 nodejs/tests/UnitTests/AntiSSRFPolicy.Header.test.ts create mode 100644 nodejs/tests/UnitTests/AntiSSRFPolicy.Scheme.test.ts create mode 100644 nodejs/tests/UnitTests/CIDRBlock.test.ts create mode 100644 nodejs/tests/UnitTests/InAzureKeyVaultDomain.test.ts create mode 100644 nodejs/tests/UnitTests/InAzureStorageDomain.test.ts create mode 100644 nodejs/tests/UnitTests/InDomain.test.ts create mode 100644 nodejs/tsconfig.json create mode 100755 scripts/build-domains-dotnet.sh create mode 100755 scripts/build-domains-nodejs.sh create mode 100755 scripts/build-ip-ranges-dotnet.sh create mode 100755 scripts/build-ip-ranges-nodejs.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f6317a2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Normalize text files and keep repository line endings as LF +* text=auto eol=lf + +# Keep Windows-specific scripts as CRLF +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf diff --git a/.github/ISSUE_TEMPLATE/bug_report_dotnet.yml b/.github/ISSUE_TEMPLATE/bug_report_dotnet.yml new file mode 100644 index 0000000..b5d2ed8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report_dotnet.yml @@ -0,0 +1,80 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +name: Bug Report - .NET +description: File a bug report for the .NET version of the AntiSSRF library +title: "[Bug][.NET]: " +labels: ["bug", "dotnet"] +type: bug +body: + + - type: textarea + attributes: + label: Bug Behavior + description: Describe the current behavior you are experiencing. Include any error messages, unexpected responses, or other issues. + validations: + required: false + + - type: textarea + attributes: + label: Expected Behavior + description: Describe what you expected to happen instead of the current behavior. + validations: + required: false + + - type: textarea + attributes: + label: Steps To Reproduce + description: Provide clear steps to reproduce this issue, including code samples if applicable. + placeholder: | + 1. Create policy: `var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest);` + 2. Get handler: `using var handler = policy.GetHandler();` + 3. Create HttpClient: `using var client = new HttpClient(handler);` + 4. Make request: `var response = await client.GetAsync("https://example.com");` + 5. Observe unexpected behavior: [describe what happens] + validations: + required: false + + - type: input + attributes: + label: Version + description: Specify the version of the AntiSSRF .NET library where you encountered this issue. + placeholder: "e.g., 1.0.0" + validations: + required: true + + - type: input + attributes: + label: Operating System + description: Specify your operating system and version. + placeholder: "e.g., Ubuntu 22.04, Windows 11, macOS 15.0" + validations: + required: true + + - type: input + attributes: + label: .NET Version + description: Specify the .NET version you are using. + placeholder: "e.g., 8.0.100" + validations: + required: true + + - type: textarea + attributes: + label: Additional Context + description: | + Provide any additional context, links to documentation, stack traces, or other relevant information that may help us understand and resolve this issue. + validations: + required: false + + - type: checkboxes + attributes: + label: Verification + description: Please confirm before submitting your bug report. + options: + - label: I have searched existing issues and confirmed this bug has not already been reported + required: true + + - type: markdown + attributes: + value: "Thank you for helping us improve the AntiSSRF library!" diff --git a/.github/ISSUE_TEMPLATE/bug_report_nodejs.yml b/.github/ISSUE_TEMPLATE/bug_report_nodejs.yml new file mode 100644 index 0000000..3a9dc37 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report_nodejs.yml @@ -0,0 +1,80 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +name: Bug Report - Node.js +description: File a bug report for the Node.js version of the AntiSSRF library +title: "[Bug][Node.js]: <title>" +labels: ["bug", "nodejs"] +type: bug +body: + + - type: textarea + attributes: + label: Bug Behavior + description: Describe the current behavior you are experiencing. Include any error messages, unexpected responses, or other issues. + validations: + required: false + + - type: textarea + attributes: + label: Expected Behavior + description: Describe what you expected to happen instead of the current behavior. + validations: + required: false + + - type: textarea + attributes: + label: Steps To Reproduce + description: Provide clear steps to reproduce this issue, including code samples if applicable. + placeholder: | + 1. Import the library: `import { AntiSSRFPolicy, PolicyConfigOptions } from "@microsoft/antissrf";` + 2. Create policy: `const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest);` + 3. Get agent: `const agent = policy.getHttpsAgent();` + 4. Make request: `https.get("https://example.com", { agent }, (res) => { ... });` + 5. Observe unexpected behavior: [describe what happens] + validations: + required: false + + - type: input + attributes: + label: Version + description: Specify the version of the AntiSSRF Node.js library where you encountered this issue. + placeholder: "e.g., 1.0.0" + validations: + required: true + + - type: input + attributes: + label: Operating System + description: Specify your operating system and version. + placeholder: "e.g., Ubuntu 22.04, Windows 11, macOS 15.0" + validations: + required: true + + - type: input + attributes: + label: Node.js Version + description: Specify the Node.js version you are using. + placeholder: "e.g., 22.1.0" + validations: + required: true + + - type: textarea + attributes: + label: Additional Context + description: | + Provide any additional context, links to documentation, stack traces, or other relevant information that may help us understand and resolve this issue. + validations: + required: false + + - type: checkboxes + attributes: + label: Verification + description: Please confirm before submitting your bug report. + options: + - label: I have searched existing issues and confirmed this bug has not already been reported + required: true + + - type: markdown + attributes: + value: "Thank you for helping us improve the AntiSSRF library!" diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..4f3e2c9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +name: Feature Request +description: Request a new feature or enhancement for the AntiSSRF library +title: "[Feature]: <title>" +labels: ["enhancement"] +type: feature +body: + + - type: textarea + attributes: + label: Feature Description + description: Describe the feature you would like to see added to AntiSSRF. + validations: + required: true + + - type: textarea + attributes: + label: Problem Statement + description: Describe the problem this feature would solve or the use case it would enable. + validations: + required: true + + - type: textarea + attributes: + label: Proposed Solution + description: Describe how you envision this feature working. Include any API design ideas or implementation approaches. + validations: + required: false + + - type: textarea + attributes: + label: Alternative Solutions + description: Describe any alternative solutions or workarounds you have considered. + validations: + required: false + + - type: textarea + attributes: + label: Additional Context + description: Provide any additional context, examples, links to documentation, or other relevant information that supports this feature request. + validations: + required: false + + - type: checkboxes + attributes: + label: Applicable Libraries + description: Select all that apply for which AntiSSRF library or libraries this feature request applies to. + options: + - label: .NET Library + - label: Node.js Library + validations: + required: false + + - type: markdown + attributes: + value: "Thank you for helping us improve the AntiSSRF library!" \ No newline at end of file diff --git a/.github/workflows/check-license-headers.yml b/.github/workflows/check-license-headers.yml new file mode 100644 index 0000000..befda88 --- /dev/null +++ b/.github/workflows/check-license-headers.yml @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +name: Check License Headers + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + check-license: + runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Check license headers + run: | + echo "Checking for required license headers..." + + # Define the required headers for different file types + HEADER_HASH="# Copyright (c) Microsoft Corporation." + HEADER_SLASH="// Copyright (c) Microsoft Corporation." + + # Find files that should have headers + files_to_check=$(find . -type f \( \ + -name "*.cs" -o \ + -name "*.ts" -o \ + -name "*.js" -o \ + -name "*.sh" -o \ + -name "*.yml" -o \ + -name "*.yaml" \ + \) \ + -not -path "./.git/*" \ + -not -path "./node_modules/*" \ + -not -path "./**/bin/*" \ + -not -path "./**/obj/*" \ + -not -path "./docs/*") + + missing_headers=() + + for file in $files_to_check; do + echo "Checking: $file" + + # Check based on file extension + case "$file" in + *.sh|*.yml|*.yaml) + if ! head -5 "$file" | grep -q "# Copyright (c) Microsoft Corporation."; then + missing_headers+=("$file") + fi + ;; + *.cs|*.ts|*.js) + if ! head -5 "$file" | grep -q "// Copyright (c) Microsoft Corporation."; then + missing_headers+=("$file") + fi + ;; + esac + done + + # Report results + if [ ${#missing_headers[@]} -eq 0 ]; then + echo "✅ All files have required license headers" + else + echo "❌ The following files are missing license headers:" + printf '%s\n' "${missing_headers[@]}" + echo "" + echo "Please add the appropriate header to each file:" + echo "For .sh/.yml/.yaml files:" + echo " # Copyright (c) Microsoft Corporation." + echo " # Licensed under the MIT License." + echo "" + echo "For .cs/.ts/.js files:" + echo " // Copyright (c) Microsoft Corporation." + echo " // Licensed under the MIT License." + exit 1 + fi \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..b298fe8 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,100 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +name: "CodeQL Advanced" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '18 16 * * 4' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: csharp + build-mode: none + - language: javascript-typescript + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config: | + paths-ignore: + - 'dotnet/FunctionalTests/**' + - 'dotnet/UnitTests/**' + - 'nodejs/tests/**' + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # - name: Run manual build steps + # if: matrix.build-mode == 'manual' + # shell: bash + # run: | + # echo 'If you are using a "manual" build mode for one or more of the' \ + # 'languages you are analyzing, replace this with the commands to build' \ + # 'your code, for example:' + # echo ' make bootstrap' + # echo ' make release' + # exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/jekyll-gh-pages.yml b/.github/workflows/jekyll-gh-pages.yml new file mode 100644 index 0000000..2c6747a --- /dev/null +++ b/.github/workflows/jekyll-gh-pages.yml @@ -0,0 +1,72 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +name: Deploy Jekyll with GitHub Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["main"] + paths: + - 'docs/**' + - ".github/workflows/jekyll-gh-pages.yml" + # Runs on PRs to validate build + pull_request: + paths: + - 'docs/**' + - ".github/workflows/jekyll-gh-pages.yml" + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Build job + build: + runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4.1' + bundler-cache: true + working-directory: ./docs + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Build with Jekyll + run: | + cd docs + bundle exec jekyll build --destination ./_site + env: + JEKYLL_ENV: production + - name: Upload artifact + if: github.event_name == 'push' + uses: actions/upload-pages-artifact@v3 + with: + path: ./docs/_site + + # Deployment job + deploy: + if: github.event_name == 'push' + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/test-dotnet.yml b/.github/workflows/test-dotnet.yml new file mode 100644 index 0000000..fcacfce --- /dev/null +++ b/.github/workflows/test-dotnet.yml @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +name: Run .NET Tests + +on: + pull_request: + branches: + - main + paths: + - "dotnet/**" + - ".github/workflows/test-dotnet.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + framework: [net8.0, net48] + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 8.0.x + + - name: Restore solution + run: dotnet restore dotnet/Microsoft.Security.AntiSSRF.sln + + - name: Run UnitTests (${{ matrix.framework }}) + run: dotnet test dotnet/UnitTests/Microsoft.Security.AntiSSRF.UnitTests.csproj --configuration Release --framework ${{ matrix.framework }} --no-restore + + - name: Run FunctionalTests (${{ matrix.framework }}) + run: dotnet test dotnet/FunctionalTests/Microsoft.Security.AntiSSRF.FunctionalTests.csproj --configuration Release --framework ${{ matrix.framework }} --no-restore diff --git a/.github/workflows/test-nodejs.yml b/.github/workflows/test-nodejs.yml new file mode 100644 index 0000000..cc22d84 --- /dev/null +++ b/.github/workflows/test-nodejs.yml @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +name: Run Node.js Tests + +on: + pull_request: + branches: + - main + paths: + - "nodejs/**" + - ".github/workflows/test-nodejs.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + + strategy: + fail-fast: false + matrix: + node-version: [20.x, 22.x, 24.x, 25.x] + + steps: + - uses: actions/checkout@v5 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + cache-dependency-path: "nodejs/package-lock.json" + + - name: Install dependencies + working-directory: nodejs + run: npm ci + + - name: Run unit tests + working-directory: nodejs + run: npm run test:unit + + - name: Run functional tests + working-directory: nodejs + run: npm run test:functional + + - name: Run pre-publish tests + working-directory: nodejs + run: npm run build && npm pack && npm run test:prepublish diff --git a/.github/workflows/update-domains.yml b/.github/workflows/update-domains.yml new file mode 100644 index 0000000..e07058e --- /dev/null +++ b/.github/workflows/update-domains.yml @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +name: Verify Azure Service Domains + +on: + pull_request: + branches: + - main + paths: + - 'config/Domains.json' + - 'nodejs/src/Helpers/Domains.ts' + - 'dotnet/src/Helpers/Domains.cs' + workflow_dispatch: # Allow manual triggering + +permissions: + contents: read + +jobs: + verify-domains: + runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Install jq + run: sudo apt-get update && sudo apt-get install -y jq + + - name: Make scripts executable + run: chmod +x scripts/*.sh + + - name: Build Node.js/TypeScript version + run: ./scripts/build-domains-nodejs.sh + + - name: Build C# version + run: ./scripts/build-domains-dotnet.sh + + - name: Verify generated files are up-to-date + run: | + if ! git diff --exit-code nodejs/src/Helpers/Domains.ts dotnet/src/Helpers/Domains.cs; then + echo "❌ Generated domain files are out of sync!" + echo "The following files need to be regenerated:" + git diff --name-only nodejs/src/Helpers/Domains.ts dotnet/src/Helpers/Domains.cs + echo "" + echo "Please run the following commands locally and commit the results:" + echo " ./scripts/build-domains-nodejs.sh" + echo " ./scripts/build-domains-dotnet.sh" + exit 1 + else + echo "✅ All generated files are up-to-date" + fi \ No newline at end of file diff --git a/.github/workflows/update-ip-ranges.yml b/.github/workflows/update-ip-ranges.yml new file mode 100644 index 0000000..08f148e --- /dev/null +++ b/.github/workflows/update-ip-ranges.yml @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +name: Verify IP Address Ranges + +on: + pull_request: + branches: + - main + paths: + - 'config/IPAddressRanges.json' + - 'nodejs/src/IPAddressRanges.ts' + - 'dotnet/src/IPAddressRanges.cs' + workflow_dispatch: # Allow manual triggering + +permissions: + contents: read + +jobs: + verify-ranges: + runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Install jq + run: sudo apt-get update && sudo apt-get install -y jq + + - name: Make scripts executable + run: chmod +x scripts/*.sh + + - name: Build Node.js/TypeScript version + run: ./scripts/build-ip-ranges-nodejs.sh + + - name: Build C# version + run: ./scripts/build-ip-ranges-dotnet.sh + + - name: Verify generated files are up-to-date + run: | + if ! git diff --exit-code nodejs/src/IPAddressRanges.ts dotnet/src/IPAddressRanges.cs; then + echo "❌ Generated IP address range files are out of sync!" + echo "The following files need to be regenerated:" + git diff --name-only nodejs/src/IPAddressRanges.ts dotnet/src/IPAddressRanges.cs + echo "" + echo "Please run the following commands locally and commit the results:" + echo " ./scripts/build-ip-ranges-nodejs.sh" + echo " ./scripts/build-ip-ranges-dotnet.sh" + exit 1 + else + echo "✅ All generated files are up-to-date" + fi diff --git a/.gitignore b/.gitignore index ce89292..96f1315 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,9 @@ bld/ # Visual Studio 2017 auto generated files Generated\ Files/ +# C# Dev Kit cache files +*.lscache + # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* @@ -416,3 +419,7 @@ FodyWeavers.xsd *.msix *.msm *.msp + +.DS_Store +*.tgz +temp-lib/ \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..686e5e7 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,10 @@ +# Microsoft Open Source Code of Conduct + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). + +Resources: + +- [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) +- [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) +- Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns +- Employees can reach out at [aka.ms/opensource/moderation-support](https://aka.ms/opensource/moderation-support) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c51c239 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,79 @@ +# Contributing to AntiSSRF + +Thank you for your interest in contributing to Microsoft AntiSSRF! This guide will help you get started with contributing to our security library. + +## Development Setup + +### AntiSSRF .NET Library + +**Prerequisites:** +- .NET 8.0 SDK +- Make sure you are in the `/dotnet` folder + +**Useful Commands:** +- Restore dependencies: `dotnet restore` +- Build solution: `dotnet build` +- Run all tests: `dotnet test` + +### AntiSSRF Node.js Library + +**Prerequisites:** +- Node.js 20.0 or later +- Make sure you are in the `/nodejs` folder + +**Useful Commands:** +- Install dependencies: `npm install` +- Build TypeScript: `npm run build` +- Run all tests: `npm test` +- Lint code: `npm run lint` +- Format code: `npm run format` + +### Documentation + +**Prerequisites:** +- Jekyll (for GitHub Pages) +- Ruby 3.4 or later +- Bundler (`gem install bundler`) +- Make sure you are in the `/docs` folder + +**Useful Commands:** +- Install dependencies: `bundle install` +- Test locally: `bundle exec jekyll serve` + +## Submitting Issues + +We welcome bug reports and feature requests! Please use our issue templates to provide the information we need: + +### Bug Reports +- Report in our [GitHub Issues](https://github.com/microsoft/AntiSSRF/issues) +- Please search existing issues first to avoid duplicates +- Provide clear reproduction steps and expected behavior +- Include relevant version information and environment details + +### Feature Requests +- Report in our [GitHub Issues](https://github.com/microsoft/AntiSSRF/issues) +- Clearly describe the problem your feature would solve +- Explain your proposed solution and any alternatives considered +- Specify which library (or both) the feature applies to + +## Contributing Code + +We welcome pull requests for bug fixes, features, and documentation improvements: + +### Before Submitting +- Verify that all existing tests pass without failures +- Add appropriate test coverage for your changes +- Ensure there are no breaking changes (in rare cases where unavoidable, discuss with maintainers in an issue first) +- Documentation updates are optional but very appreciated + +### When Discussion is Required + +Before submitting a PR, you must create an issue and discuss with maintainers if your changes involve: + +- Breaking changes - Any modification that could break existing functionality. All other possibilities must be considered before introducing a breaking change. +- New API additions - Adding new public methods, classes, or interfaces. +- Configuration file changes - Modifications to IP address ranges or domains configuration files. These changes will almost always be rejected unless there is an exceptional reason. + +### Pull Request Process +- Be detailed in your description and reproduction steps +- Reference any related issues, bugs, or feature requests diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9e841e7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc1eecc --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Microsoft AntiSSRF + +The Microsoft AntiSSRF library is a security-developed, exhaustively-tested secure code library that provides robust URL validation to mitigate the risk of Server-Side Request Forgery (SSRF) vulnerabilities. It is an easy-to-use drop-in library with minimal adoption effort for developers, available for both .NET and Node.js applications. + +## What is Server-Side Request Forgery (SSRF)? + +Server-Side Request Forgery (also known as SSRF) is a critical web security vulnerability in which an attacker can manipulate the server-side application to make network requests to an arbitrary endpoint. Through this vulnerability, the attacker manipulates the target web server to connect to internal, sensitive networks or exfiltrate sensitive data to an untrusted endpoint on the Internet. + +SSRF can lead (but is not limited) to: +- Exposure of internal services +- Leakage of sensitive data +- Service disruption +- Remote code execution + +### What is "Untrusted" Input? + +**All incoming HTTP requests are untrusted.** Any data originating from outside your service's immediate trust boundary must be treated as potentially malicious. This includes: + +- User-provided URLs, filenames, or identifiers +- Data from external APIs, webhooks, or partner services +- Configuration values, metadata, or file contents that users can influence +- Requests from your own service's backend applications or other components within the same environment (query parameters, headers, form fields, etc.) + +Even data that doesn't initially appear to be a URL should be treated as one. For example, a workspace name or resource identifier that gets concatenated into a URL. All untrusted input used in URL construction MUST be validated. + +## How the Microsoft AntiSSRF Library Helps + +A common scenario in many online services is handling requests from customers containing customer-supplied strings that are, or are used to construct a URL. These strings are often not validated properly, leading to vulnerabilities such as Server-Side Request Forgery which can result in token theft. + +AntiSSRF helps mitigate these risks by: +- Automatically validating URLs and network connections and rejecting/refusing unsafe input +- Providing an agent that ensures HTTP requests cannot reach internal or sensitive IP addresses + +## Getting Started + +### C# (.NET Framework and .NET Core) + +- **NuGet Package**: [Microsoft.Security.AntiSSRF](https://www.nuget.org/packages/Microsoft.Security.AntiSSRF/) +- **Documentation**: [AntiSSRF .NET API Documentation](https://microsoft.github.io/AntiSSRF/dotnet-api/) +- **Quick Start**: [Getting Started Guide](https://microsoft.github.io/AntiSSRF/getting-started) +- **Library README**: [.NET README](dotnet/README.md) + +### JavaScript/TypeScript (Node.js) + +- **npm Package**: [@microsoft/antissrf](https://www.npmjs.com/package/@microsoft/antissrf) +- **Documentation**: [AntiSSRF Node.js API Documentation](https://microsoft.github.io/AntiSSRF/nodejs-api/) +- **Quick Start**: [Getting Started Guide](https://microsoft.github.io/AntiSSRF/getting-started) +- **Library README**: [Node.js README](nodejs/README.md) + +## Contributing + +We welcome contributions! Please see our contribution resources: + +- **Contributing Guide**: [CONTRIBUTING.md](CONTRIBUTING.md) +- **Report Issues**: [GitHub Issues](https://github.com/Microsoft/AntiSSRF/issues) +- **License**: [LICENSE](LICENSE) +- **Security Policy**: [SECURITY.md](SECURITY.md) +- **Code of Conduct**: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) +- **Support**: [SUPPORT.md](SUPPORT.md) + +## More Resources + +### Learning About SSRF + +- **AntiSSRF Documentation**: [Microsoft AntiSSRF Documentation](https://microsoft.github.io/AntiSSRF/) +- **OWASP SSRF Guide**: [Server-Side Request Forgery Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html) +- **PortSwigger Web Security Academy**: [Server-side request forgery (SSRF)](https://portswigger.net/web-security/ssrf) +- **CWE-918**: [Server-Side Request Forgery (SSRF)](https://cwe.mitre.org/data/definitions/918.html) + +### Testing Tools + +- **Dusseldorf**: [Dynamic SSRF Testing Tool](https://github.com/Microsoft/Dusseldorf) - Microsoft's open-source tool for dynamic SSRF testing and validation \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e751608 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +<!-- BEGIN MICROSOFT SECURITY.MD V1.0.0 BLOCK --> + +## Security + +Microsoft takes the security of our software products and services seriously, which +includes all source code repositories in our GitHub organizations. + +**Please do not report security vulnerabilities through public GitHub issues.** + +For security reporting information, locations, contact information, and policies, +please review the latest guidance for Microsoft repositories at +[https://aka.ms/SECURITY.md](https://aka.ms/SECURITY.md). + +<!-- END MICROSOFT SECURITY.MD BLOCK --> \ No newline at end of file diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..a51f299 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,14 @@ +# Support + +This project uses GitHub Issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new issue. + +For help and questions about using this project, please: + +- **Check the documentation**: Visit our [documentation site](https://microsoft.github.io/AntiSSRF/) for comprehensive guides and API references +- **Search existing issues**: Browse [GitHub Issues](https://github.com/Microsoft/AntiSSRF/issues) to see if your question has already been answered +- **File a new issue**: Create a [new issue](https://github.com/Microsoft/AntiSSRF/issues/new/choose) using our issue templates for bug reports or feature requests +- **Contact us directly**: Email us at **antissrf-oss@microsoft.com** for questions about usage, integration, or other inquiries + +## Microsoft Support Policy + +Support for Microsoft AntiSSRF is limited to the resources listed above. This is an open source project maintained by Microsoft, and support is provided on a best-effort basis through the community channels listed above. diff --git a/config/Domains.json b/config/Domains.json new file mode 100644 index 0000000..68199aa --- /dev/null +++ b/config/Domains.json @@ -0,0 +1,74 @@ +{ + "azureKeyVault": { + "domains": [ + "vault.azure.net", + "managedhsm.azure.net", + "vault.azure.cn", + "managedhsm.azure.cn", + "vault.usgovcloudapi.net", + "managedhsm.usgovcloudapi.net" + ], + "_sources": { + "keyVault": { + "title": "Azure Key Vault keys, secrets, and certificates overview", + "url": "https://learn.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates", + "last_updated": "2026-04-09" + } + } + }, + "azureStorage": { + "domains": [ + "blob.core.windows.net", + "web.core.windows.net", + "dfs.core.windows.net", + "file.core.windows.net", + "queue.core.windows.net", + "table.core.windows.net", + "blob.storage.azure.net", + "web.storage.azure.net", + "dfs.storage.azure.net", + "file.storage.azure.net", + "queue.storage.azure.net", + "table.storage.azure.net", + "blob.core.usgovcloudapi.net", + "web.core.usgovcloudapi.net", + "dfs.core.usgovcloudapi.net", + "file.core.usgovcloudapi.net", + "queue.core.usgovcloudapi.net", + "table.core.usgovcloudapi.net", + "blob.core.chinacloudapi.cn", + "web.core.chinacloudapi.cn", + "dfs.core.chinacloudapi.cn", + "file.core.chinacloudapi.cn", + "queue.core.chinacloudapi.cn", + "table.core.chinacloudapi.cn" + ], + "_sources": { + "storageAccounts": { + "title": "Overview of storage accounts", + "url": "https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview", + "last_updated": "2026-02-03" + }, + "privateEndpoints": { + "title": "Use private endpoints for Azure Storage", + "url": "https://learn.microsoft.com/en-us/azure/storage/common/storage-private-endpoints", + "last_updated": "2025-07-16" + }, + "staticWebsite": { + "title": "Static website hosting in Azure Storage", + "url": "https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blob-static-website", + "last_updated": "2025-04-14" + }, + "azureGovernment": { + "title": "Develop with Storage API on Azure Government", + "url": "https://learn.microsoft.com/en-us/azure/azure-government/documentation-government-get-started-connect-to-storage", + "last_updated": "2024-10-16" + }, + "azureChina": { + "title": "Overview of storage accounts", + "url": "https://docs.azure.cn/en-us/storage/common/storage-account-overview", + "last_updated": "2026-02-26" + } + } + } +} \ No newline at end of file diff --git a/config/IPAddressRanges.json b/config/IPAddressRanges.json new file mode 100644 index 0000000..8ef68eb --- /dev/null +++ b/config/IPAddressRanges.json @@ -0,0 +1,439 @@ +{ + "_sources": { + "ianaIpv4": { + "title": "IANA IPv4 Special-Purpose Address Registry", + "url": "https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml", + "last_updated": "2025-10-09" + }, + "ianaIpv6": { + "title": "IANA IPv6 Special-Purpose Address Registry", + "url": "https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml", + "last_updated": "2025-10-09" + }, + "ianaIpv4Multicast": { + "title": "IANA IPv4 Multicast Address Space", + "url": "https://www.iana.org/assignments/multicast-addresses/multicast-addresses.xhtml", + "last_updated": "2026-01-27" + }, + "ianaIpv6Multicast": { + "title": "IANA IPv6 Multicast Address Space", + "url": "https://www.iana.org/assignments/ipv6-multicast-addresses/ipv6-multicast-addresses.xhtml", + "last_updated": "2026-02-02" + }, + "imds": { + "title": "Microsoft Learn Azure Instance Metadata Service", + "url": "https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service?tabs=windows", + "last_updated": "NA" + }, + "wireserver": { + "title": "Microsoft Learn Azure IP Address 168.63.129.16 Overview", + "url": "https://learn.microsoft.com/en-us/azure/virtual-network/what-is-ip-address-168-63-129-16?tabs=windows", + "last_updated": "NA" + }, + "siteLocal": { + "title": "Microsoft Learn IPv6 Link-local and Site-local Addresses", + "url": "https://learn.microsoft.com/en-us/windows/win32/winsock/link-local-and-site-local-addresses-2", + "last_updated": "NA" + } + }, + "amt": { + "purpose": "AMT", + "cidr": [ + "192.52.193.0/24", + "2001:3::/32" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv4", + "ianaIpv6" + ] + }, + "as112": { + "purpose": "AS112", + "cidr": [ + "192.31.196.0/24", + "192.175.48.0/24", + "2001:4:112::/48", + "2620:4f:8000::/48" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv4", + "ianaIpv6" + ] + }, + "benchmarking": { + "purpose": "Benchmarking", + "cidr": [ + "198.18.0.0/15", + "2001:2::/48" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv4", + "ianaIpv6" + ] + }, + "deprecated": { + "purpose": "Deprecated", + "cidr": [ + "192.88.99.0/24", + "2001:10::/28" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv4", + "ianaIpv6" + ] + }, + "detsPrefix": { + "purpose": "Drone Remote ID Protocol Entity Tags (DETs) Prefix", + "cidr": [ + "2001:30::/28" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv6" + ] + }, + "discardOnly": { + "purpose": "Discard-Only Address Block", + "cidr": [ + "100::/64" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv6" + ] + }, + "dnsSdAnycast": { + "purpose": "DNS-SD Service Registration Protocol Anycast", + "cidr": [ + "2001:1::3/128" + ], + "standaloneVariable": false, + "_sources": [ + "ianaIpv6" + ] + }, + "documentation": { + "purpose": "Documentation", + "cidr": [ + "192.0.2.0/24", + "198.51.100.0/24", + "203.0.113.0/24", + "2001:db8::/32", + "3fff::/20" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv4", + "ianaIpv6" + ] + }, + "dummy": { + "purpose": "Dummy address", + "cidr": [ + "192.0.0.8/32", + "100:0:0:1::/64" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv4", + "ianaIpv6" + ] + }, + "ietfProtocol": { + "purpose": "IETF Protocol Assignments", + "cidr": [ + "192.0.0.0/24", + "2001::/23" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv4", + "ianaIpv6" + ] + }, + "imds": { + "purpose": "Instance Metadata Service", + "cidr": [ + "169.254.169.254/32" + ], + "standaloneVariable": true, + "_sources": [ + "imds" + ] + }, + "ipv4Ipv6Translat": { + "purpose": "IPv4-IPv6 Translated", + "cidr": [ + "64:ff9b::/96", + "64:ff9b:1::/48" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv6" + ] + }, + "ipv4ServiceContinuity": { + "purpose": "IPv4 Service Continuity Prefix", + "cidr": [ + "192.0.0.0/29" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv4" + ] + }, + "broadcast": { + "purpose": "Limited Broadcast", + "cidr": [ + "255.255.255.255/32" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv4" + ] + }, + "linkLocal": { + "purpose": "Link Local", + "cidr": [ + "169.254.0.0/16", + "fe80::/10" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv4", + "ianaIpv6", + "siteLocal" + ] + }, + "loopback": { + "purpose": "Loopback", + "cidr": [ + "127.0.0.0/8", + "::1/128" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv4", + "ianaIpv6" + ] + }, + "multicast": { + "purpose": "Multicast", + "cidr": [ + "224.0.0.0/4", + "ff00::/8" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv4Multicast", + "ianaIpv6Multicast" + ] + }, + "nat64Dns64Discovery": { + "purpose": "NAT64/DNS64 Discovery", + "cidr": [ + "192.0.0.170/32", + "192.0.0.171/32" + ], + "standaloneVariable": false, + "_sources": [ + "ianaIpv4" + ] + }, + "orchidv2": { + "purpose": "ORCHIDv2", + "cidr": [ + "2001:20::/28" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv6" + ] + }, + "pcpAnycast": { + "purpose": "Port Control Protocol Anycast", + "cidr": [ + "192.0.0.9/32", + "2001:1::1/128" + ], + "standaloneVariable": false, + "_sources": [ + "ianaIpv4", + "ianaIpv6" + ] + }, + "privateUse": { + "purpose": "Private-Use", + "cidr": [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv4" + ] + }, + "reserved": { + "purpose": "Reserved", + "cidr": [ + "240.0.0.0/4" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv4" + ] + }, + "sharedAddressSpace": { + "purpose": "Shared Address Space", + "cidr": [ + "100.64.0.0/10" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv4" + ] + }, + "siteLocal": { + "purpose": "Site Local", + "cidr": [ + "fec0::/10" + ], + "standaloneVariable": true, + "_sources": [ + "siteLocal" + ] + }, + "six4aaRelayAnycast": { + "purpose": "6a44-relay Anycast Address", + "cidr": [ + "192.88.99.2/32" + ], + "standaloneVariable": false, + "_sources": [ + "ianaIpv4" + ] + }, + "sixto4": { + "purpose": "6to4", + "cidr": [ + "2002::/16" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv6" + ] + }, + "srv6Sid": { + "purpose": "Segment Routing (SRv6) SIDs", + "cidr": [ + "5f00::/16" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv6" + ] + }, + "teredo": { + "purpose": "TEREDO", + "cidr": [ + "2001::/32" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv6" + ] + }, + "turnAnycast": { + "purpose": "Traversal Using Relays around NAT Anycast", + "cidr": [ + "192.0.0.10/32", + "2001:1::2/128" + ], + "standaloneVariable": false, + "_sources": [ + "ianaIpv4", + "ianaIpv6" + ] + }, + "uniqueLocal": { + "purpose": "Unique Local Unicast", + "cidr": [ + "fc00::/7" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv6" + ] + }, + "unspecified": { + "purpose": "This host on this network; unspecified address", + "cidr": [ + "0.0.0.0/8", + "::/128" + ], + "standaloneVariable": true, + "_sources": [ + "ianaIpv4", + "ianaIpv6" + ] + }, + "wireserver": { + "purpose": "Azure WireServer", + "cidr": [ + "168.63.129.16/32" + ], + "standaloneVariable": true, + "_sources": [ + "wireserver" + ] + }, + "recommendedV1": { + "purpose": "Current list of recommended special-purpose addresses", + "cidr": [ + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "168.63.129.16/32", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.31.196.0/24", + "192.52.193.0/24", + "192.88.99.0/24", + "192.168.0.0/16", + "192.175.48.0/24", + "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", + "::1/128", + "::/128", + "64:ff9b::/96", + "64:ff9b:1::/48", + "100::/64", + "100:0:0:1::/64", + "2001::/23", + "2001:db8::/32", + "2002::/16", + "2620:4f:8000::/48", + "3fff::/20", + "5f00::/16", + "fc00::/7", + "fe80::/10", + "fec0::/10", + "ff00::/8" + ], + "standaloneVariable": true, + "_notes": [ + "Skipping the following CIDR ranges that are already included in other CIDR ranges: 169.254.169.254/32, 192.0.0.0/29, 192.0.0.8/32, 192.0.0.9/32, 192.0.0.10/32, 192.0.0.170/32, 192.0.0.171/32, 192.88.99.2/32, 255.255.255.255/32, 2001::/32, 2001:1::1/128, 2001:1::2/128, 2001:1::3/128, 2001:2::/48, 2001:3::/32, 2001:4:112::/48, 2001:10::/28, 2001:20::/28, 2001:30::/28" + ] + } +} \ No newline at end of file diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..4794b48 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,7 @@ +_site +.sass-cache +.jekyll-cache +.jekyll-metadata +vendor + +Gemfile.lock \ No newline at end of file diff --git a/docs/404.html b/docs/404.html new file mode 100644 index 0000000..3a16ab5 --- /dev/null +++ b/docs/404.html @@ -0,0 +1,25 @@ +--- +permalink: /404.html +layout: page +--- + +<style type="text/css" media="screen"> + .container { + margin: 10px auto; + max-width: 600px; + text-align: center; + } + h1 { + margin: 30px 0; + font-size: 4em; + line-height: 1; + letter-spacing: -1px; + } +</style> + +<div class="container"> + <h1>404</h1> + + <p><strong>Page not found :(</strong></p> + <p>The requested page could not be found.</p> +</div> diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 0000000..bb4d76f --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,37 @@ +source "https://rubygems.org" +# Hello! This is where you manage which Jekyll version is used to run. +# When you want to use a different version, change it below, save the +# file and run `bundle install`. Run Jekyll with `bundle exec`, like so: +# +# bundle exec jekyll serve +# +# This will help ensure the proper Jekyll version is running. +# Happy Jekylling! +gem "jekyll", "~> 4.3.3" +# Using Just the Docs theme for documentation +gem "just-the-docs", "0.10.0" +# Required for Ruby 3.4+ compatibility +gem "csv", "~> 3.0" +gem "base64", "~> 0.1" +# If you want to use GitHub Pages, remove the "gem "jekyll"" above and +# uncomment the line below. To upgrade, run `bundle update github-pages`. +# gem "github-pages", "~> 232", group: :jekyll_plugins +# If you have any plugins, put them here! +group :jekyll_plugins do + gem "jekyll-feed", "~> 0.12" + gem "jekyll-sitemap", "~> 1.4" +end + +# Windows and JRuby does not include zoneinfo files, so bundle the tzinfo-data gem +# and associated library. +platforms :mingw, :x64_mingw, :mswin, :jruby do + gem "tzinfo", ">= 1", "< 3" + gem "tzinfo-data" +end + +# Performance-booster for watching directories on Windows +gem "wdm", "~> 0.1", :platforms => [:mingw, :x64_mingw, :mswin] + +# Lock `http_parser.rb` gem to `v0.6.x` on JRuby builds since newer versions of the gem +# do not have a Java counterpart. +gem "http_parser.rb", "~> 0.6.0", :platforms => [:jruby] diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..7eae72d --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,115 @@ +# Welcome to Jekyll! +# +# This config file is meant for settings that affect your whole blog, values +# which you are expected to set up once and rarely edit after that. If you find +# yourself editing this file very often, consider using Jekyll's data files +# feature for the data you need to update frequently. +# +# For technical reasons, this file is *NOT* reloaded automatically when you use +# 'bundle exec jekyll serve'. If you change this file, please restart the server process. +# +# If you need help with YAML syntax, here are some quick references for you: +# https://learn-the-web.algonquindesign.ca/topics/markdown-yaml-cheat-sheet/#yaml +# https://learnxinyminutes.com/docs/yaml/ +# +# Site settings +# These are used to personalize your new site. If you look in the HTML files, +# you will see them accessed via {{ site.title }}, {{ site.email }}, and so on. +# You can create any custom variable you would like, and they will be accessible +# in the templates via {{ site.myvariable }}. + +title: AntiSSRF Documentation +description: >- # this means to ignore newlines until "baseurl:" + Documentation for AntiSSRF - A security library that helps .NET and Node.js developers + protect their applications from Server-Side Request Forgery (SSRF) attacks. +baseurl: "" # the subpath of your site, e.g. /blog +url: "" # the base hostname & protocol for your site, e.g. http://example.com + +# Repository info for GitHub integration +gh_edit_repository: "https://github.com/Microsoft/AntiSSRF" +gh_edit_branch: "main" +gh_edit_source: docs +gh_edit_view_mode: "tree" + +# Build settings +theme: just-the-docs +remote_theme: just-the-docs/just-the-docs@v0.10.0 + +plugins: + - jekyll-feed + - jekyll-sitemap + +# Just the Docs configuration +color_scheme: light +search_enabled: true +search: + heading_level: 2 + previews: 3 + preview_words_before: 5 + preview_words_after: 10 + tokenizer_separator: /[\s/]+/ + rel_url: true + button: false + +# Navigation structure +nav_sort: case_insensitive +nav_external_links: + - title: GitHub Repository + url: https://github.com/Microsoft/AntiSSRF + hide_icon: false + +# Callout configuration +callouts: + highlight: + color: yellow + important: + title: Important + color: blue + new: + title: New + color: green + note: + title: Note + color: purple + warning: + title: Warning + color: red + +# Footer content +footer_content: "Copyright © 2026 Microsoft Corporation. Distributed under the <a href=\"https://github.com/Microsoft/AntiSSRF/tree/main/LICENSE\">MIT license.</a>" + +# Google Analytics (optional) +# ga_tracking: UA-XXXXXXXX-X +# ga_tracking_anonymize_ip: true + +# Aux links for the upper right navigation +aux_links: + "AntiSSRF on GitHub": + - "//github.com/Microsoft/AntiSSRF" + +# Makes Aux links open in a new tab +aux_links_new_tab: false + +# Exclude from processing. +# The following items will not be processed, by default. +# Any item listed under the `exclude:` key here will be automatically added to +# the internal "default list". +# +# Excluded items can be processed by explicitly listing the directories or +# their entries' file path in the `include:` list. +# +# Silence Dart Sass deprecation warnings from the just-the-docs theme +sass: + quiet_deps: true + silence_deprecations: + - import + - global-builtin + - color-functions + +exclude: + - .sass-cache/ + - .jekyll-cache/ + - gemfiles/ + - Gemfile + - Gemfile.lock + - _posts/ diff --git a/docs/dotnet-api/antissrfhandler.md b/docs/dotnet-api/antissrfhandler.md new file mode 100644 index 0000000..376b72e --- /dev/null +++ b/docs/dotnet-api/antissrfhandler.md @@ -0,0 +1,44 @@ +--- +layout: default +title: AntiSSRFHandler +parent: .NET API Reference +nav_order: 2 +description: "HttpMessageHandler that enforces AntiSSRF policies" +--- + +# AntiSSRFHandler + +`AntiSSRFHandler` is the type of the object returned from `AntiSSRFPolicy.GetHandler`. The `AntiSSRFHandler` wraps `HttpClientHandler` or `SocketsHttpHandler` and implements `HttpMessageHandler` while exposing some properties on the inner handler. This handler performs DNS resolution validation, scheme enforcement, header checks, and redirect following according to the configured policy. + +### .NET Core + +In .NET Core, the inner handler is a `SocketsHttpHandler`. The exposed properties below can be used exactly as they are used in the original `SocketsHttpHandler` type. Please see [SocketsHttpHandler](https://docs.microsoft.com/en-us/dotnet/api/system.net.http.socketshandler) for details. + +```csharp +- public bool AllowAutoRedirect { get; set; } +- public int MaxAutomaticRedirections { get; set; } +- public CookieContainer CookieContainer { get; set; } +- public ICredentials? Credentials { get; set; } +- public int MaxConnectionsPerServer { get; set; } +- public int MaxResponseHeadersLength { get; set; } +- public bool UseCookies { get; set; } +- public TimeSpan ConnectTimeout { get; set; } +- public TimeSpan ResponseDrainTimeout { get; set; } +- public TimeSpan PooledConnectionLifetime { get; set; } +- public SslClientAuthenticationOptions SslOptions { get; set; } +``` + +### .NET Standard 2.0 / .NET Framework + +In .NET Standard 2.0 / .NET Framework, the inner handler is an `HttpClientHandler`. The exposed properties below can be used exactly as they are used in the original `HttpClientHandler` type. Please see [HttpClientHandler](https://docs.microsoft.com/en-us/dotnet/api/system.net.http.httpclienthandler) for details. + +```csharp +- public bool AllowAutoRedirect { get; set; } +- public int MaxAutomaticRedirections { get; set; } +- public ICredentials? Credentials { get; set; } +- public int MaxConnectionsPerServer { get; set; } +- public int MaxResponseHeadersLength { get; set; } +- public bool CheckCertificateRevocationList { get; set; } +- public Func<HttpRequestMessage, X509Certificate2?, X509Chain?, SslPolicyErrors, bool>? ServerCertificateCustomValidationCallback { get; set; } +- public SslProtocols SslProtocols { get; set; } +``` \ No newline at end of file diff --git a/docs/dotnet-api/antissrfpolicy/constructor.md b/docs/dotnet-api/antissrfpolicy/constructor.md new file mode 100644 index 0000000..87ac853 --- /dev/null +++ b/docs/dotnet-api/antissrfpolicy/constructor.md @@ -0,0 +1,51 @@ +--- +layout: default +title: Constructor +parent: AntiSSRFPolicy +grand_parent: .NET API Reference +nav_order: 1 +description: "AntiSSRFPolicy constructor documentation" +--- + +# AntiSSRFPolicy Constructor + +## Definition + +Creates a new [AntiSSRFPolicy](../) instance with a predefined security configuration. + +```cs +AntiSSRFPolicy(config: PolicyConfigOptions) +``` + +### Parameters + +`config`: `PolicyConfigOptions` + +Choose from the following predefined policy configurations: + +### PolicyConfigOptions + +| Option | Use Case | Behavior | +| --- | --- | --- | +| **InternalOnly** | Making requests to internal, non-public addresses only **OR** restricting requests to specific allowed addresses | Blocks all IP addresses by default. Only allows addresses explicitly added via [AddAllowedAddresses](../methods/addallowedaddresses). | +| **ExternalOnlyV1** | Making requests to external APIs while blocking internal access | Blocks internal and special-purpose IP addresses per [IPAddressRanges.recommendedV1](../../ipaddressranges#recommendedrangesv1). Automatically adds `X-Forwarded-For` header to requests. | +| **ExternalOnlyLatest** | Currently the same as `ExternalOnlyV1` with automatic security updates | Always stays up to date with the latest `ExternalOnly` version, independent of semantic versioning. | +| **None** | Custom policy configuration | No restrictions applied. Requires manual configuration via policy methods. | + +{: .important } +> `PolicyConfigOptions.ExternalOnlyLatest` does NOT follow semantic versioning. It will always stay up-to-date with the latest recommended addresses with no code changes required by the user. + +## Security Notes + +To prevent SSRF vulnerabilities, it is a best practice to ensure that your code can make requests to external endpoints or to internal endpoints, but never both. + +If your service needs to make requests to external endpoints, you need to make sure that it can’t be abused to access internal resources. In this case, we recommend using `PolicyConfigOptions.ExternalOnlyLatest`, which takes care of blocking internal and special-purpose addresses automatically. + +If your service needs to make requests to backend services, you must prevent data exfiltration by ensuring it cannot be used to send data to external endpoints. In this case, we recommend using `PolicyConfigOptions.InternalOnly` to block all unspecified addresses, then use `AddAllowedAddresses(...)` with the specific internal IP addresses that your service might need to access. + +For more about the `X-Forwarded-For` header, see [addXFFHeader](../properties/addxffheader). + +## Immutability After Handler Creation + +{: .note } +> Once `GetHandler()` is called, the policy becomes immutable. Any attempt to change properties or call customization methods will throw an `AntiSSRFException`. diff --git a/docs/dotnet-api/antissrfpolicy/index.md b/docs/dotnet-api/antissrfpolicy/index.md new file mode 100644 index 0000000..952218f --- /dev/null +++ b/docs/dotnet-api/antissrfpolicy/index.md @@ -0,0 +1,59 @@ +--- +layout: default +title: AntiSSRFPolicy +parent: .NET API Reference +description: "Main policy configuration class for SSRF protection" +nav_order: 1 +has_children: true +has_toc: false +--- + +# AntiSSRFPolicy Class + +## Use Case + +Use this class whenever you are accessing a URL that can belong to **any domain** or **some untrusted domain**. + +This use case addresses two distinct security scenarios. For requests to external endpoints, the policy enforces that IP addresses are not internal or special-use addresses, preventing URLs from being abused to gain access to internal resources. For requests to backend resources, the policy blocks all IP addresses except for specific ranges that you expect to see, ensuring that URLs cannot be used to exfiltrate data to unauthorized destinations. + +{: .note } +> * If you instead expect the URL to be an **Azure Key Vault endpoint**, see [URIValidator.InAzureKeyVaultDomain](../urivalidator/inazurekeyvaultdomain). +> * If you instead expect the URL to be an **Azure Storage endpoint**, see [URIValidator.InAzureStorageDomain](../urivalidator/inazurestoragedomain). +> * If you instead expect the domain to be a **specific, trusted domain**, see [URIValidator.InDomain](../urivalidator/indomain). + +## Definition + +The `AntiSSRFPolicy` allows you to customize security requirements for headers, IP addresses, and protocols. You can configure the policy using built-in settings or define your own custom rules. The policy then provides an `HttpMessageHandler` that automatically enforces these security requirements on all outgoing requests. + +## Constructors + +| Constructor | Description | +| --- | --- | +| [AntiSSRFPolicy(PolicyConfigOptions)](constructor) | Initializes a new instance of the `AntiSSRFPolicy` class with the specified initial configuration. | + +## Properties + +| Property | Description | +| --- | --- | +| [AddXFFHeader](properties/addxffheader) | Determines whether to automatically add the `X-Forwarded-For` header to outgoing requests that don't already include it. | +| [AllowedAddresses](properties/allowedaddresses) | List of IP networks that are explicitly allowed by the policy. | +| [AllowPlainTextHttp](properties/allowplaintexthttp) | Determines whether HTTPS is required or HTTP is allowed. | +| [DeniedAddresses](properties/deniedaddresses) | List of IP networks that are explicitly blocked by the policy. | +| [DeniedHeaders](properties/deniedheaders) | List of headers that are forbidden from being included in outgoing requests. | +| [DenyAllUnspecifiedIPs](properties/denyallunspecifiedips) | Determines whether all IP addresses should be blocked by default or only `deniedAddresses` should be blocked. | +| [RequiredHeaders](properties/requiredheaders) | List of headers that are required to be present in outgoing requests. | + +## Policy Customization Methods + +| Method | Description | +| --- | --- | +| [AddAllowedAddresses(string[])](methods/addallowedaddresses) | Adds IP networks to be explicitly allowed by the policy. | +| [AddDeniedAddresses(string[])](methods/adddeniedaddresses) | Adds IP networks to be explicitly blocked by the policy. | +| [AddDeniedHeaders(string[])](methods/adddeniedheaders) | Adds headers to be explicitly blocked by the policy. | +| [AddRequiredHeaders(string[])](methods/addrequiredheaders) | Adds headers to be explicitly required by the policy. | + +## Policy Use Method + +| Method | Description | +| --- | --- | +| [GetHandler()](methods/gethandler) | Creates and returns a new `AntiSSRFHandler` that will enforce the policy on all outgoing requests. | \ No newline at end of file diff --git a/docs/dotnet-api/antissrfpolicy/methods/addallowedaddresses.md b/docs/dotnet-api/antissrfpolicy/methods/addallowedaddresses.md new file mode 100644 index 0000000..52b9eff --- /dev/null +++ b/docs/dotnet-api/antissrfpolicy/methods/addallowedaddresses.md @@ -0,0 +1,67 @@ +--- +layout: default +title: AddAllowedAddresses +parent: Methods +grand_parent: AntiSSRFPolicy +ancestor: .NET API Reference +description: "AddAllowedAddresses method documentation" +--- + +# AntiSSRFPolicy.AddAllowedAddresses Method + +## Definition + +Adds IP networks to be explicitly allowed by the policy. + +```csharp +public void AddAllowedAddresses(string[] networks) +``` + +{: .note } +> `AllowedAddresses` takes precedence over `DeniedAddresses`. If an IP address matches both, it will be considered allowed by the policy. + +### Parameters + +`networks`: `string[]` + +The list of IP networks to be explicitly allowed by the policy. + +### Exceptions + +`ArgumentNullException` +* The `networks` parameter is `null` or contains `null` values. + +`FormatException` +* A network string is not in valid CIDR format. + +`AntiSSRFException` +* Attempted to edit the policy after it has been used to create a handler via `GetHandler()`. + +## Examples + +```csharp +using Microsoft.Security.AntiSSRF; +using System; +using System.Net.Http; +using System.Threading.Tasks; + +// Customize the policy +var policy = new AntiSSRFPolicy(PolicyConfigOptions.None); +policy.DenyAllUnspecifiedIPs = true; +policy.AddAllowedAddresses(new[] { "1.2.3.4" }); + +// Create HttpClient with the policy handler +using var httpClient = new HttpClient(policy.GetHandler()); + +try +{ + // If the untrusted hostname directs to 1.2.3.4, + // the request will succeed here + var response = await httpClient.GetAsync("https://<some_untrusted_hostname>/public/data"); +} +catch (AntiSSRFException ex) +{ + // If untrusted hostname directs to anything besides 1.2.3.4, + // the request will fail here with an AntiSSRFException +} +``` diff --git a/docs/dotnet-api/antissrfpolicy/methods/adddeniedaddresses.md b/docs/dotnet-api/antissrfpolicy/methods/adddeniedaddresses.md new file mode 100644 index 0000000..55e44d7 --- /dev/null +++ b/docs/dotnet-api/antissrfpolicy/methods/adddeniedaddresses.md @@ -0,0 +1,70 @@ +--- +layout: default +title: AddDeniedAddresses +parent: Methods +grand_parent: AntiSSRFPolicy +ancestor: .NET API Reference +description: "AddDeniedAddresses method documentation" +--- + +# AntiSSRFPolicy.AddDeniedAddresses Method + +## Definition + +Adds IP networks to be explicitly blocked by the policy. + +```csharp +public void AddDeniedAddresses(string[] networks) +``` + +{: .note } +> `AllowedAddresses` takes precedence over `DeniedAddresses`. If an IP address matches both, it will be considered allowed by the policy. + +{: .note } +> `DenyAllUnspecifiedIPs` takes precedence over `DeniedAddresses`. If `DenyAllUnspecifiedIPs` is `true`, `DeniedAddresses` will not be considered when determining if an IP address is allowed or blocked by the policy. + +### Parameters + +`networks`: `string[]` + +The list of IP networks to be explicitly blocked by the policy. + +### Exceptions + +`ArgumentNullException` +* The `networks` parameter is `null` or contains `null` values. + +`FormatException` +* A network string is not in valid CIDR format. + +`AntiSSRFException` +* Attempted to edit the policy after it has been used to create a handler via `GetHandler()`. +* `DenyAllUnspecifiedIPs` is already set to `true`. + +## Examples + +```csharp +using Microsoft.Security.AntiSSRF; +using System; +using System.Net.Http; +using System.Threading.Tasks; + +// Customize the policy +var policy = new AntiSSRFPolicy(PolicyConfigOptions.None); +policy.AddDeniedAddresses(new[] { "1.2.3.4" }); + +// Create HttpClient with the policy handler +using var httpClient = new HttpClient(policy.GetHandler()); + +try +{ + // If the untrusted hostname directs to anything besides 1.2.3.4, + // the request will succeed here + var response = await httpClient.GetAsync("https://<some_untrusted_hostname>/public/data"); +} +catch (AntiSSRFException ex) +{ + // If untrusted hostname directs to 1.2.3.4, + // the request will fail here with an AntiSSRFException +} +``` \ No newline at end of file diff --git a/docs/dotnet-api/antissrfpolicy/methods/adddeniedheaders.md b/docs/dotnet-api/antissrfpolicy/methods/adddeniedheaders.md new file mode 100644 index 0000000..45077c3 --- /dev/null +++ b/docs/dotnet-api/antissrfpolicy/methods/adddeniedheaders.md @@ -0,0 +1,81 @@ +--- +layout: default +title: AddDeniedHeaders +parent: Methods +grand_parent: AntiSSRFPolicy +ancestor: .NET API Reference +description: "AddDeniedHeaders method documentation" +--- + +# AntiSSRFPolicy.AddDeniedHeaders Method + +## Definition + +Adds headers to be explicitly blocked by the policy. Requests that include a denied header will be blocked. + +```csharp +public void AddDeniedHeaders(string[] deniedHeaders) +``` + +{: .note } +> Both `RequiredHeaders` and `DeniedHeaders` are considered when validating a request. If any header is in both lists, the request will always be blocked. + +### Parameters + +`deniedHeaders`: `string[]` + +The list of headers for the policy to block. + +### Exceptions + +`ArgumentNullException` +* The `deniedHeaders` parameter is `null` or contains `null` values. + +`ArgumentException` +* A header name is empty or whitespace. + +`AntiSSRFException` +* Attempted to edit the policy after it has been used to create a handler via `GetHandler()`. + +## Examples + +```csharp +using Microsoft.Security.AntiSSRF; +using System; +using System.Net.Http; +using System.Threading.Tasks; + +// Customize the policy +var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); +policy.AddDeniedHeaders(new[] { "X-Real-IP", "X-Forwarded-Host" }); + +// Create HttpClient with the policy handler +using var httpClient = new HttpClient(policy.GetHandler()); + +try +{ + // This request will succeed (no denied headers) + httpClient.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0"); + httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); + + var response = await httpClient.GetAsync("https://<some_untrusted_hostname>/public/data"); +} +catch (AntiSSRFException ex) +{ + // Should not reach here +} + +try +{ + // This request will be blocked (contains denied header) + using var blockedClient = new HttpClient(policy.GetHandler()); + blockedClient.DefaultRequestHeaders.Add("X-Real-IP", "192.168.1.1"); // This header is denied + blockedClient.DefaultRequestHeaders.Add("Accept", "application/json"); + + var blockedResponse = await blockedClient.GetAsync("https://<some_untrusted_hostname>/admin/endpoint"); +} +catch (AntiSSRFException ex) +{ + Console.WriteLine($"Request blocked due to denied header: {ex.Message}"); +} +``` diff --git a/docs/dotnet-api/antissrfpolicy/methods/addrequiredheaders.md b/docs/dotnet-api/antissrfpolicy/methods/addrequiredheaders.md new file mode 100644 index 0000000..7c5e9af --- /dev/null +++ b/docs/dotnet-api/antissrfpolicy/methods/addrequiredheaders.md @@ -0,0 +1,84 @@ +--- +layout: default +title: AddRequiredHeaders +parent: Methods +grand_parent: AntiSSRFPolicy +ancestor: .NET API Reference +description: "AddRequiredHeaders method documentation" +--- + +# AntiSSRFPolicy.AddRequiredHeaders Method + +## Definition + +Adds headers to be explicitly required by the policy. Requests that are missing a required header will be blocked. + +```csharp +public void AddRequiredHeaders(string[] requiredHeaders) +``` + +{: .note } +> Both `RequiredHeaders` and `DeniedHeaders` are considered when validating a request. If any header is in both lists, the request will always be blocked. + +### Parameters + +`requiredHeaders`: `string[]` + +The list of headers for the policy to require. + +### Exceptions + +`ArgumentNullException` +* The `requiredHeaders` parameter is `null` or contains `null` values. + +`ArgumentException` +* A header name is empty or whitespace. + +`AntiSSRFException` +* Attempted to edit the policy after it has been used to create a handler via `GetHandler()`. + +## Examples + +```csharp +using Microsoft.Security.AntiSSRF; +using System; +using System.Net.Http; +using System.Threading.Tasks; + +// Customize the policy +var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); +policy.AddRequiredHeaders(new[] { "Authorization", "X-API-Key" }); + +// Create HttpClient with the policy handler +using var httpClient = new HttpClient(policy.GetHandler()); + +try +{ + // This request will succeed (all required headers present) + httpClient.DefaultRequestHeaders.Add("Authorization", "Bearer token123"); + httpClient.DefaultRequestHeaders.Add("X-API-Key", "key456"); + httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); + + var response = await httpClient.GetAsync("https://<some_untrusted_hostname>/secure/data"); +} +catch (AntiSSRFException ex) +{ + // Should not reach here +} + +try +{ + // This request will be blocked (missing required header) + using var blockedClient = new HttpClient(policy.GetHandler()); + blockedClient.DefaultRequestHeaders.Add("Authorization", "Bearer token123"); + blockedClient.DefaultRequestHeaders.Add("Accept", "application/json"); + // Missing X-API-Key header + + var blockedResponse = await blockedClient.GetAsync("https://<some_untrusted_hostname>/secure/admin"); + // This will not execute - request will be blocked +} +catch (AntiSSRFException ex) +{ + Console.WriteLine($"Request blocked due to missing required header: {ex.Message}"); +} +``` diff --git a/docs/dotnet-api/antissrfpolicy/methods/gethandler.md b/docs/dotnet-api/antissrfpolicy/methods/gethandler.md new file mode 100644 index 0000000..8144b8c --- /dev/null +++ b/docs/dotnet-api/antissrfpolicy/methods/gethandler.md @@ -0,0 +1,42 @@ +--- +layout: default +title: GetHandler +parent: Methods +grand_parent: AntiSSRFPolicy +ancestor: .NET API Reference +description: "GetHandler method documentation" +--- + +# AntiSSRFPolicy.GetHandler Method + +## Definition + +Builds an `AntiSSRFHandler` that will enforce the policy on all outgoing requests. + +```csharp +public AntiSSRFHandler GetHandler() +``` + +{: .note } +> After calling `GetHandler()`, the policy becomes immutable. Any attempt to change properties or call customization methods will throw an `AntiSSRFException`. + +### Returns + +`AntiSSRFHandler` + +A new `AntiSSRFHandler` instance that enforces this policy. + +### Example + +```csharp +using Microsoft.Security.AntiSSRF; +using System.Net.Http; + +// Customize the policy +var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); +policy.AddAllowedAddresses(new[] { "10.0.1.0/24" }); + +// Get the handler — policy is now locked +using var handler = policy.GetHandler(); +using var client = new HttpClient(handler); +``` diff --git a/docs/dotnet-api/antissrfpolicy/methods/index.md b/docs/dotnet-api/antissrfpolicy/methods/index.md new file mode 100644 index 0000000..ec454c3 --- /dev/null +++ b/docs/dotnet-api/antissrfpolicy/methods/index.md @@ -0,0 +1,26 @@ +--- +layout: default +title: Methods +parent: AntiSSRFPolicy +grand_parent: .NET API Reference +nav_order: 3 +description: "AntiSSRFPolicy methods documentation" +has_children: true +nav_fold: true +has_toc: false +--- + +## Policy Customization Methods + +| Method | Description | +| --- | --- | +| [AddAllowedAddresses(string[])](addallowedaddresses) | Adds IP networks to be explicitly allowed by the policy. | +| [AddDeniedAddresses(string[])](adddeniedaddresses) | Adds IP networks to be explicitly blocked by the policy. | +| [AddDeniedHeaders(string[])](adddeniedheaders) | Adds headers to be explicitly blocked by the policy. | +| [AddRequiredHeaders(string[])](addrequiredheaders) | Adds headers to be explicitly required by the policy. | + +## Policy Use Method + +| Method | Description | +| --- | --- | +| [GetHandler()](gethandler) | Creates and returns a new `AntiSSRFHandler` that will enforce the policy on all outgoing requests. | diff --git a/docs/dotnet-api/antissrfpolicy/properties/addxffheader.md b/docs/dotnet-api/antissrfpolicy/properties/addxffheader.md new file mode 100644 index 0000000..f54b6a6 --- /dev/null +++ b/docs/dotnet-api/antissrfpolicy/properties/addxffheader.md @@ -0,0 +1,39 @@ +--- +layout: default +title: AddXFFHeader +parent: Properties +grand_parent: AntiSSRFPolicy +ancestor: .NET API Reference +description: "AddXFFHeader property documentation" +--- + +# AntiSSRFPolicy.AddXFFHeader Property + +## Definition + +Determines whether to automatically add the `X-Forwarded-For` header to outgoing requests that don’t already include it. + +{: .important } +> The header is added with the dummy value `"true"`. If your end service requires this header to be a valid IP address, you will have to add the header manually. + +```csharp +public bool AddXFFHeader { get; set; } +``` + +### Property Value + +`bool` + +* `true` if the `X-Forwarded-For` header should be added to requests that don’t already include it. +* `false` if the `X-Forwarded-For` header should not be added. + +Default: `false` (unless using `ExternalOnlyV1` or `ExternalOnlyLatest`, which set it to `true`) + +### Exceptions + +`AntiSSRFException` +Thrown when attempting to change the property after the policy has been used to create a handler via `GetHandler()`. + +## Security Notes + +The `X-Forwarded-For` header can be an important defense-in-depth strategy against SSRF vulnerabilities. Some services, including IMDS, will drop all incoming requests with the `X-Forwarded-For` header present. By ensuring that the header is added to all outgoing requests, your service can be sure that it will never have an SSRF vulnerability that leaks data from IMDS. diff --git a/docs/dotnet-api/antissrfpolicy/properties/allowedaddresses.md b/docs/dotnet-api/antissrfpolicy/properties/allowedaddresses.md new file mode 100644 index 0000000..ee6813c --- /dev/null +++ b/docs/dotnet-api/antissrfpolicy/properties/allowedaddresses.md @@ -0,0 +1,27 @@ +--- +layout: default +title: AllowedAddresses +parent: Properties +grand_parent: AntiSSRFPolicy +ancestor: .NET API Reference +description: "AllowedAddresses property documentation" +--- + +# AntiSSRFPolicy.AllowedAddresses Property + +## Definition + +Gets a read-only view of the IP networks explicitly allowed by the policy. + +```csharp +public IReadOnlyList<string> AllowedAddresses { get; } +``` + +{: .note } +> `AllowedAddresses` takes precedence over `DeniedAddresses`. If an IP address matches both, it will be considered allowed by the policy. + +### Property Value + +`IReadOnlyList<string>` + +A read-only list of the allowed IP networks. diff --git a/docs/dotnet-api/antissrfpolicy/properties/allowplaintexthttp.md b/docs/dotnet-api/antissrfpolicy/properties/allowplaintexthttp.md new file mode 100644 index 0000000..b6dfccb --- /dev/null +++ b/docs/dotnet-api/antissrfpolicy/properties/allowplaintexthttp.md @@ -0,0 +1,36 @@ +--- +layout: default +title: AllowPlainTextHttp +parent: Properties +grand_parent: AntiSSRFPolicy +ancestor: .NET API Reference +description: "AllowPlainTextHttp property documentation" +--- + +# AntiSSRFPolicy.AllowPlainTextHttp Property + +{: .warning } +> Changing an `AntiSSRFPolicy` instance to allow plaintext HTTP means you will be able to send HTTP requests without the recommended TLS encryption. + +## Definition + +Determines whether HTTPS is required or HTTP is allowed. + +```csharp +public bool AllowPlainTextHttp { get; set; } +``` + +{: .note } +> With ALL configuration options, HTTP is disallowed unless `AllowPlainTextHttp` is explicitly set to `true`. + +### Property Value + +`bool` + +* `true` if HTTP should be allowed. +* `false` if HTTPS should be required. + +### Exceptions + +`AntiSSRFException` +Thrown when attempting to change the property after the policy has been used to create a handler via `GetHandler()`. diff --git a/docs/dotnet-api/antissrfpolicy/properties/deniedaddresses.md b/docs/dotnet-api/antissrfpolicy/properties/deniedaddresses.md new file mode 100644 index 0000000..32c186f --- /dev/null +++ b/docs/dotnet-api/antissrfpolicy/properties/deniedaddresses.md @@ -0,0 +1,30 @@ +--- +layout: default +title: DeniedAddresses +parent: Properties +grand_parent: AntiSSRFPolicy +ancestor: .NET API Reference +description: "DeniedAddresses property documentation" +--- + +# AntiSSRFPolicy.DeniedAddresses Property + +## Definition + +Gets a read-only view of IP networks explicitly blocked by the policy. + +```csharp +public IReadOnlyList<string> DeniedAddresses { get; } +``` + +{: .note } +> `AllowedAddresses` takes precedence over `DeniedAddresses`. If an IP address matches both, it will be considered allowed by the policy. + +{: .note } +> `DenyAllUnspecifiedIPs` takes precedence over `DeniedAddresses`. If `DenyAllUnspecifiedIPs` is `true`, `DeniedAddresses` will not be considered when determining if an IP address is allowed or blocked by the policy. + +### Property Value + +`IReadOnlyList<string>` + +A read-only list of the denied IP networks. diff --git a/docs/dotnet-api/antissrfpolicy/properties/deniedheaders.md b/docs/dotnet-api/antissrfpolicy/properties/deniedheaders.md new file mode 100644 index 0000000..27f0a43 --- /dev/null +++ b/docs/dotnet-api/antissrfpolicy/properties/deniedheaders.md @@ -0,0 +1,27 @@ +--- +layout: default +title: DeniedHeaders +parent: Properties +grand_parent: AntiSSRFPolicy +ancestor: .NET API Reference +description: "DeniedHeaders property documentation" +--- + +# AntiSSRFPolicy.DeniedHeaders Property + +## Definition + +Gets a read-only view of headers that are forbidden from being included in outgoing requests. + +```csharp +public IReadOnlyList<string> DeniedHeaders { get; } +``` + +{: .note } +> Both `RequiredHeaders` and `DeniedHeaders` are considered when validating a request. If any header is in both lists, the request will always be blocked. + +### Property Value + +`IReadOnlyList<string>` + +A read-only list of header names that are blocked by the policy. diff --git a/docs/dotnet-api/antissrfpolicy/properties/denyallunspecifiedips.md b/docs/dotnet-api/antissrfpolicy/properties/denyallunspecifiedips.md new file mode 100644 index 0000000..a0e4ddd --- /dev/null +++ b/docs/dotnet-api/antissrfpolicy/properties/denyallunspecifiedips.md @@ -0,0 +1,35 @@ +--- +layout: default +title: DenyAllUnspecifiedIPs +parent: Properties +grand_parent: AntiSSRFPolicy +ancestor: .NET API Reference +description: "DenyAllUnspecifiedIPs property documentation" +--- + +# AntiSSRFPolicy.DenyAllUnspecifiedIPs Property + +## Definition + +Determines whether all IP addresses should be blocked by default or only `DeniedAddresses` should be blocked. + +{: .note } +> To allow specific addresses, see [addAllowedAddresses](../methods/addallowedaddresses). + +```csharp +public bool DenyAllUnspecifiedIPs { get; set; } +``` + +### Property Value + +`bool` + +* `true` if all IP addresses NOT specified by `AddAllowedAddresses` should be blocked. +* `false` if only addresses in `DeniedAddresses` should be blocked. + +Default: `false` (unless using `InternalOnly`, which sets it to `true`) + +### Exceptions + +`AntiSSRFException` +Thrown when attempting to change the property after the policy has been used to create a handler via `GetHandler()`. diff --git a/docs/dotnet-api/antissrfpolicy/properties/index.md b/docs/dotnet-api/antissrfpolicy/properties/index.md new file mode 100644 index 0000000..3222f73 --- /dev/null +++ b/docs/dotnet-api/antissrfpolicy/properties/index.md @@ -0,0 +1,23 @@ +--- +layout: default +title: Properties +parent: AntiSSRFPolicy +grand_parent: .NET API Reference +nav_order: 2 +description: "AntiSSRFPolicy properties documentation" +has_children: true +nav_fold: true +has_toc: false +--- + +## AntiSSRFPolicy Properties + +| Property | Description | +| --- | --- | +| [AddXFFHeader](addxffheader) | Determines whether to automatically add the `X-Forwarded-For` header to outgoing requests that don't already include it. | +| [AllowedAddresses](allowedaddresses) | List of IP networks that are explicitly allowed by the policy. | +| [AllowPlainTextHttp](allowplaintexthttp) | Determines whether HTTPS is required or HTTP is allowed. | +| [DeniedAddresses](deniedaddresses) | List of IP networks that are explicitly blocked by the policy. | +| [DeniedHeaders](deniedheaders) | List of headers that are forbidden from being included in outgoing requests. | +| [DenyAllUnspecifiedIPs](denyallunspecifiedips) | Determines whether all IP addresses should be blocked by default or only `deniedAddresses` should be blocked. | +| [RequiredHeaders](requiredheaders) | List of headers that are required to be present in outgoing requests. | diff --git a/docs/dotnet-api/antissrfpolicy/properties/requiredheaders.md b/docs/dotnet-api/antissrfpolicy/properties/requiredheaders.md new file mode 100644 index 0000000..0fb5d3f --- /dev/null +++ b/docs/dotnet-api/antissrfpolicy/properties/requiredheaders.md @@ -0,0 +1,27 @@ +--- +layout: default +title: RequiredHeaders +parent: Properties +grand_parent: AntiSSRFPolicy +ancestor: .NET API Reference +description: "RequiredHeaders property documentation" +--- + +# AntiSSRFPolicy.RequiredHeaders Property + +## Definition + +Gets a read-only view of headers that are required to be present in outgoing requests. + +```csharp +public IReadOnlyList<string> RequiredHeaders { get; } +``` + +{: .note } +> Both `RequiredHeaders` and `DeniedHeaders` are considered when validating a request. If any header is in both lists, the request will always be blocked. + +### Property Value + +`IReadOnlyList<string>` + +A read-only list of header names that are required by the policy. diff --git a/docs/dotnet-api/changelog.md b/docs/dotnet-api/changelog.md new file mode 100644 index 0000000..f28b79a --- /dev/null +++ b/docs/dotnet-api/changelog.md @@ -0,0 +1,18 @@ +--- +layout: default +title: Changelog +parent: .NET API Reference +nav_order: 99 +description: "Release notes and version history for AntiSSRF .NET Library" +--- + +# Changelog + +All notable changes to the AntiSSRF .NET Library will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +Coming soon. \ No newline at end of file diff --git a/docs/dotnet-api/index.md b/docs/dotnet-api/index.md new file mode 100644 index 0000000..d694fae --- /dev/null +++ b/docs/dotnet-api/index.md @@ -0,0 +1,34 @@ +--- +layout: default +title: .NET API Reference +nav_order: 3 +description: "Complete API documentation for the AntiSSRF .NET Library" +has_children: true +has_toc: false +--- + +# API Documentation + +## AntiSSRF .NET Library + +The **AntiSSRF .NET Library** is a library for C# applications using .NET that provides robust URL validation and HTTP request protection to prevent SSRF vulnerabilities in code. It is designed as an easy, drop-in library with minimal impact on the engineering team, implemented both as an `HttpMessageHandler` for use with `HttpClient` and as a static URL validator, depending on use case. + +## Usage Instructions + +The AntiSSRF Library provides validation for different scenarios based on your trust requirements: + +| Use Case | Description | Documentation Link | +| --- | --- | --- | +| **General Case** | The untrusted URL can belong to **any domain** or an **untrusted domain**. | [AntiSSRFPolicy](antissrfpolicy) | +| **Azure Key Vault Domain** | The untrusted URL must be an **Azure Key Vault endpoint**. | [URIValidator.InAzureKeyVaultDomain](urivalidator/inazurekeyvaultdomain) | +| **Azure Storage Domain** | The untrusted URL must be an **Azure Storage endpoint**. | [URIValidator.InAzureStorageDomain](urivalidator/inazurestoragedomain) | +| **Allowlist of Trusted Domains** | The untrusted URL must belong to a **specific, trusted domain**. | [URIValidator.InDomain](urivalidator/indomain) | + +## Classes + +| Class | Description | +| --- | --- | +| [AntiSSRFPolicy](antissrfpolicy) | Represents a customizable security policy and provides an `AntiSSRFHandler` to ensure all outgoing `HttpClient` requests match the security policy. | +| [AntiSSRFHandler](antissrfhandler) | An `HttpMessageHandler` that enforces the `AntiSSRFPolicy` on all outgoing requests. | +| [IPAddressRanges](../ipaddressranges) | Provides predefined IP address ranges for use with AntiSSRF policies. | +| [URIValidator](urivalidator/) | Provides static methods for validating the hostname and protocol of URLs. | \ No newline at end of file diff --git a/docs/dotnet-api/urivalidator/inazurekeyvaultdomain.md b/docs/dotnet-api/urivalidator/inazurekeyvaultdomain.md new file mode 100644 index 0000000..d24e544 --- /dev/null +++ b/docs/dotnet-api/urivalidator/inazurekeyvaultdomain.md @@ -0,0 +1,99 @@ +--- +layout: default +title: InAzureKeyVaultDomain +parent: URIValidator +grand_parent: .NET API Reference +description: "Check if a URI belongs to Azure Key Vault domains" +--- + +# URIValidator.InAzureKeyVaultDomain Method + +## Use Case + +The code is making requests to a URL constructed using untrusted inputs, where an input is considered untrusted if it comes from *user input* or *other services*. + +**AND** + +The URL is expected to belong to an [**Azure Key Vault Domain**](#azure-key-vault-domain-names). + +{: .note } +> * If you instead expect the domain to be in **any domain** or **an untrusted domain**, see [AntiSSRFPolicy](../antissrfpolicy). +> * If you instead expect the URL to be an **Azure Storage endpoint**, see [InAzureStorageDomain](inazurestoragedomain). +> * If you instead expect the domain to be another **specific, trusted domain**, see [InDomain](indomain). + +## Definition + +Validates if a URL is an Azure Key Vault endpoint. + +## Overloads + +| Method | Description | +| --- | --- | +| [InAzureKeyVaultDomain(Uri)](#inazurekeyvaultdomainuri) | Validates if a URL is an Azure Key Vault endpoint. | +| [InAzureKeyVaultDomain(string)](#inazurekeyvaultdomainstring) | Validates if a URL is an Azure Key Vault endpoint. | + +## InAzureKeyVaultDomain(Uri) + +```csharp +public static bool InAzureKeyVaultDomain(Uri uri) +``` + +### Parameters + +`uri`: `Uri` + +The URI to be evaluated. + +### Returns + +`bool` + +* `true` if `uri` belongs to any of the listed Azure Key Vault domains. +* `false` if `uri` does not belong to any of the listed Azure Key Vault domains, the URI is not valid, or the protocol is not HTTP/S. + +## InAzureKeyVaultDomain(string) + +```csharp +public static bool InAzureKeyVaultDomain(string address) +``` + +### Parameters + +`address`: `string` + +The URI string to be evaluated. + +### Returns + +`bool` + +* `true` if `address` belongs to any of the listed Azure Key Vault domains. +* `false` if `address` does not belong to any of the listed Azure Key Vault domains, the string is not a valid URI, or the protocol is not HTTP/S. + +## Examples + +```csharp +using Microsoft.Security.AntiSSRF; +using System; + +URIValidator.InAzureKeyVaultDomain("https://myvault.vault.azure.net/secrets/api-key"); +// → true + +URIValidator.InAzureKeyVaultDomain("https://evil.com/secrets"); +// → false + +var uri = new Uri("https://myvault.vault.azure.net/secrets/api-key"); +URIValidator.InAzureKeyVaultDomain(uri); +// → true +``` + +## Azure Key Vault Domain Names + +`InAzureKeyVaultDomain` will evaluate whether the given parameter belongs to any of the following domains: + +* `vault.azure.net` +* `managedhsm.azure.net` +* `vault.azure.cn` +* `managedhsm.azure.cn` +* `vault.usgovcloudapi.net` +* `managedhsm.usgovcloudapi.net` diff --git a/docs/dotnet-api/urivalidator/inazurestoragedomain.md b/docs/dotnet-api/urivalidator/inazurestoragedomain.md new file mode 100644 index 0000000..77fc09e --- /dev/null +++ b/docs/dotnet-api/urivalidator/inazurestoragedomain.md @@ -0,0 +1,106 @@ +--- +layout: default +title: InAzureStorageDomain +parent: URIValidator +grand_parent: .NET API Reference +description: "Check if a URI belongs to Azure Storage domains" +--- + +# URIValidator.InAzureStorageDomain Method + +## Use Case + +The code is making requests to a URL constructed using untrusted inputs, where an input is considered untrusted if it comes from *user input* or *other services*. + +**AND** + +The URL is expected to belong to an [**Azure Storage Domain**](#azure-storage-domain-names). + +{: .note } +> * If you instead expect the domain to be in **any domain** or **an untrusted domain**, see [AntiSSRFPolicy](../antissrfpolicy). +> * If you instead expect the URL to be an **Azure Key Vault endpoint**, see [InAzureKeyVaultDomain](inazurekeyvaultdomain). +> * If you instead expect the domain to be another **specific, trusted domain**, see [InDomain](indomain). + +## Definition + +Validates if a URL is an Azure Storage endpoint. + +## Overloads + +| Method | Description | +| --- | --- | +| [InAzureStorageDomain(Uri)](#inazurestoragedomainuri) | Validates if a URL is an Azure Storage endpoint. | +| [InAzureStorageDomain(string)](#inazurestoragedomainstring) | Validates if a URL is an Azure Storage endpoint. | + +## InAzureStorageDomain(Uri) + +```csharp +public static bool InAzureStorageDomain(Uri uri) +``` + +### Parameters + +`uri`: `Uri` + +The URI to be evaluated. + +### Returns + +`bool` + +* `true` if `uri` belongs to any of the listed Azure Storage domains. +* `false` if `uri` does not belong to any of the listed Azure Storage domains, the URI is not valid, or the protocol is not HTTP/S. + +## InAzureStorageDomain(string) + +```csharp +public static bool InAzureStorageDomain(string address) +``` + +### Parameters + +`address`: `string` + +The URI string to be evaluated. + +### Returns + +`bool` + +* `true` if `address` belongs to any of the listed Azure Storage domains. +* `false` if `address` does not belong to any of the listed Azure Storage domains, the string is not a valid URI, or the protocol is not HTTP/S. + +## Examples + +```csharp +using Microsoft.Security.AntiSSRF; +using System; + +URIValidator.InAzureStorageDomain("https://mystorageaccount.blob.core.windows.net/container/file.txt"); +// → true + +URIValidator.InAzureStorageDomain("https://evil.com/data"); +// → false + +var uri = new Uri("https://mystorageaccount.blob.core.windows.net/container/file.txt"); +URIValidator.InAzureStorageDomain(uri); +// → true +``` + +## Azure Storage Domain Names + +`InAzureStorageDomain` will evaluate whether the given parameter belongs to any combination of the following domains and services: + +**Domains:** +- `core.windows.net` +- `storage.azure.net` +- `core.usgovcloudapi.net` +- `core.chinacloudapi.cn` + +**Services:** +- `blob` +- `web` +- `dfs` +- `file` +- `queue` +- `table` diff --git a/docs/dotnet-api/urivalidator/index.md b/docs/dotnet-api/urivalidator/index.md new file mode 100644 index 0000000..df7a196 --- /dev/null +++ b/docs/dotnet-api/urivalidator/index.md @@ -0,0 +1,39 @@ +--- +layout: default +title: URIValidator +parent: .NET API Reference +description: "URL validation class for SSRF protection" +nav_order: 3 +has_children: true +has_toc: false +--- + +# URIValidator Class + +## Use Case + +The code is making requests to a URL constructed using untrusted inputs, where an input is considered untrusted if it comes from *user input* or *other services*. + +**AND** + +The URL is expected to belong to a specific set of trusted domains, the [Azure Storage domains](inazurestoragedomain#azure-storage-domain-names), or the [Azure Key Vault domains](inazurekeyvaultdomain#azure-key-vault-domain-names). + +{: .note } +> If you instead expect the domain to be in **any domain** or **an untrusted domain**, see [AntiSSRFPolicy](../antissrfpolicy). + +## Definition + +Provides static methods for validating the hostname and protocol of URLs. + +## Methods + +| Method | Description | +| --- | --- | +| [InAzureKeyVaultDomain(Uri)](inazurekeyvaultdomain#inazurekeyvaultdomainuri) | Validates if a URL is an Azure Key Vault endpoint. | +| [InAzureKeyVaultDomain(string)](inazurekeyvaultdomain#inazurekeyvaultdomainstring) | Validates if a URL is an Azure Key Vault endpoint. | +| [InAzureStorageDomain(Uri)](inazurestoragedomain#inazurestoragedomainuri) | Validates if a URL is an Azure Storage endpoint. | +| [InAzureStorageDomain(string)](inazurestoragedomain#inazurestoragedomainstring) | Validates if a URL is an Azure Storage endpoint. | +| [InDomain(Uri, string)](indomain#indomainuri-string) | Validates if a URL belongs to a trusted domain. | +| [InDomain(string, string)](indomain#indomainstring-string) | Validates if a URL belongs to a trusted domain. | +| [InDomain(Uri, string[])](indomain#indomainuri-string-1) | Validates if a URL belongs to any of a list of trusted domains. | +| [InDomain(string, string[])](indomain#indomainstring-string-1) | Validates if a URL belongs to any of a list of trusted domains. | diff --git a/docs/dotnet-api/urivalidator/indomain.md b/docs/dotnet-api/urivalidator/indomain.md new file mode 100644 index 0000000..f7c3b38 --- /dev/null +++ b/docs/dotnet-api/urivalidator/indomain.md @@ -0,0 +1,155 @@ +--- +layout: default +title: InDomain +parent: URIValidator +grand_parent: .NET API Reference +nav_order: 1 +description: "Check if a URI belongs to specified domain(s)" +--- + +# URIValidator.InDomain Method + +## Use Case + +The code is making requests to a URL constructed using untrusted inputs, where an input is considered untrusted if it comes from *user input* or *other services*. + +**AND** + +The URL is expected to belong to a **specific set of trusted domains**. + +{: .note } +> * If you instead expect the domain to be in **any domain** or **an untrusted domain**, see [AntiSSRFPolicy](../antissrfpolicy). +> * If you instead expect the URL to be an **Azure Key Vault endpoint**, see [InAzureKeyVaultDomain](inazurekeyvaultdomain). +> * If you instead expect the URL to be an **Azure Storage endpoint**, see [InAzureStorageDomain](inazurestoragedomain). + +{: .important } +> If your untrusted URL needs to belong to a specific domain, but you do not fully control all subdomains of the domain, you can use BOTH `InDomain` AND `AntiSSRFPolicy` to be protected. If the untrusted URL belongs to a domain that cannot be fully trusted, at least `AntiSSRFPolicy` is required for full protection. + +## Definition + +Validates if a URL belongs to any of a list of trusted domains. + +## Overloads + +| Method | Description | +| --- | --- | +| [InDomain(Uri, string)](#indomainuri-string) | Validates if a URL belongs to a trusted domain. | +| [InDomain(string, string)](#indomainstring-string) | Validates if a URL belongs to a trusted domain. | +| [InDomain(Uri, string[])](#indomainuri-string-1) | Validates if a URL belongs to any of a list of trusted domains. | +| [InDomain(string, string[])](#indomainstring-string-1) | Validates if a URL belongs to any of a list of trusted domains. | + +## InDomain(Uri, string) + +```csharp +public static bool InDomain(Uri untrustedUri, string trustedDomain) +``` + +### Parameters + +`untrustedUri`: `Uri` + +The URI to be evaluated. + +`trustedDomain`: `string` + +The domain name that `untrustedUri` will be compared against. + +### Returns + +`bool` + +* `true` if `untrustedUri` belongs to `trustedDomain`. +* `false` if `untrustedUri` does not belong to `trustedDomain`, if `untrustedUri` is not a valid URI, if protocol is not HTTP/S or WS/S, or if either argument is `null`. + +## InDomain(string, string) + +```csharp +public static bool InDomain(string untrustedAddress, string trustedDomain) +``` + +### Parameters + +`untrustedAddress`: `string` + +The URI string to be evaluated. + +`trustedDomain`: `string` + +The domain name that `untrustedAddress` will be compared against. + +### Returns + +`bool` + +* `true` if `untrustedAddress` belongs to `trustedDomain`. +* `false` if `untrustedAddress` does not belong to `trustedDomain`, if `untrustedAddress` cannot be converted to a valid URI, if protocol is not HTTP/S or WS/S, or if either argument is `null`. + +## InDomain(Uri, string[]) + +```csharp +public static bool InDomain(Uri untrustedUri, string[] trustedDomains) +``` + +### Parameters + +`untrustedUri`: `Uri` + +The URI to be evaluated. + +`trustedDomains`: `string[]` + +The list of domain names that `untrustedUri` will be compared against. + +### Returns + +`bool` + +* `true` if `untrustedUri` belongs to any domain in `trustedDomains`. +* `false` if `untrustedUri` does not belong to any domain in `trustedDomains`, if `untrustedUri` is not a valid URI, if protocol is not HTTP/S or WS/S, or if either argument is `null`. + +## InDomain(string, string[]) + +```csharp +public static bool InDomain(string untrustedAddress, string[] trustedDomains) +``` + +### Parameters + +`untrustedAddress`: `string` + +The URI string to be evaluated. + +`trustedDomains`: `string[]` + +The list of domain names that `untrustedAddress` will be compared against. + +### Returns + +`bool` + +* `true` if `untrustedAddress` belongs to any domain in `trustedDomains`. +* `false` if `untrustedAddress` does not belong to any domain in `trustedDomains`, if `untrustedAddress` cannot be converted to a valid URI, if protocol is not HTTP/S or WS/S, or if either argument is `null`. + +## Examples + +```csharp +using Microsoft.Security.AntiSSRF; +using System; + +// Single domain validation +URIValidator.InDomain("https://api.mycompany.com/data", "mycompany.com"); +// → true + +// Multiple domain validation +URIValidator.InDomain("https://api.mycompany.com/data", new[] { "mycompany.com", "trusted.com" }); +// → true + +// Domain not in trusted list +URIValidator.InDomain("https://evil.com/secrets", "mycompany.com"); +// → false + +// Using Uri overload +var uri = new Uri("https://api.mycompany.com/data"); +URIValidator.InDomain(uri, "mycompany.com"); +// → true +``` diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..3ba6b64 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,64 @@ +--- +layout: default +title: FAQ +nav_order: 5 +description: "Frequently asked questions about AntiSSRF" +--- + +# Frequently Asked Questions + +## What IP addresses or subnets should my service block? + +It is important to separate HTTP clients intended for internal vs. external requests. + +Ideally, for internal requests or if the range of possible IP addresses is known, use the configuration `PolicyConfigOptions.InternalOnly` and ONLY allow the expected ranges. + +For external requests, your service should AT LEAST block the internal and special-use IP addresses, included in the configuration `PolicyConfigOptions.ExternalOnlyLatest`. + +## Does the library provide protections on redirects? + +The `AntiSSRFPolicy` and its handler/agent DO re-evaluate all policy requirements for every redirect. + +The `URIValidator` methods DO NOT apply protections on redirects. When using `InAzureKeyVaultDomain` or `InAzureStorageDomain`, this is okay, since they limit to only well-known, safe domains. When using `InDomain`, the lack of protections on redirects is part of the reason you must be sure that the `trustedDomains` argument truly only includes trusted domains. + +## Does the library provide protections against DNS-rebinding based attacks? + +The `AntiSSRFPolicy` and its handler/agent DO provide protections against DNS-rebinding based attacks. The IP address that is validated by the policy is EXACTLY the IP address used in the request, eliminating the potential TOCTOU problem. + +The `URIValidator` methods DO NOT provide any protections for DNS-based attacks at all. This is okay for `InAzureKeyVaultDomain` and `InAzureStorageDomain`, since they limit to only well-known, safe domains. When using `InDomain`, the lack of DNS-based protections is part of the reason you must be sure that the `trustedDomains` argument truly only includes trusted domains. + +## Does the AntiSSRF library support IPv6 functionality? + +The `AntiSSRFPolicy` and its handler/agent DO support IPv6 addresses in all formats described by [RFC 4291](https://www.rfc-editor.org/rfc/rfc4291). + +## My outgoing requests are being dropped at the destination because of an invalid X-Forwarded-For header. What should I do? + +By default, the external-only configurations of AntiSSRF automatically add an `X-Forwarded-For: "true"` header to outgoing requests that don't already have this header. This is an important security feature that helps protect against access to sensitive, internal endpoints like IMDS which safely drop all requests with the `X-Forwarded-For` header. + +However, some destination services may reject requests with this dummy value. If your destination service is rejecting requests because of the `X-Forwarded-For: "true"` header, you can disable this behavior: + +```csharp +var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); +policy.AddXFFHeader = false; // Disables automatic X-Forwarded-For header addition +``` + +**Important:** Only disable this feature if you are absolutely certain that: +* Your destination service drops ALL requests with the `X-Forwarded-For` header. For example, you are intentionally accessing IMDS while blocking all external IP addresses. +* **OR** another component in your service stack reliably adds the `X-Forwarded-For` header to all outgoing requests. That way, the policy does not need to add the invalid dummy value to include the header. + +For more information, see the [X-Forwarded-For security notes](../dotnet-api/antissrfpolicy/properties/addxffheader#security-notes) and `AddXFFHeader` property. + +## Should Microsoft services be onboarding to this library? + +If you are a Microsoft service with public code, YES! This is the Microsoft-recommended solution for mitigating SSRF vulnerabilities in Microsoft services. + +If you are a Microsoft internal service, we have a separate version of the library for you. Please search for the internal version of the library or reach out to us at [antissrf-oss@microsoft.com](mailto:antissrf-oss@microsoft.com) for help! + +## What languages and frameworks are supported? + +| Language | Documentation | Notes | +| --- | --- | --- | +| C# | [AntiSSRF .NET Library](dotnet-api/) | For web clients using `HttpClient` objects | +| JavaScript/TypeScript | [AntiSSRF Node.js Library](nodejs-api/) | For requests using NodeJS HTTP(S) Agents | + +Broader platform support is under development. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..9cf3312 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,114 @@ +--- +layout: default +title: Getting Started +nav_order: 2 +description: "Learn how to install and configure AntiSSRF in your application" +--- + +# Getting Started with Microsoft AntiSSRF + +The Microsoft AntiSSRF library helps protect your applications from Server-Side Request Forgery (SSRF) vulnerabilities by providing robust URL validation and secure HTTP client configurations. + +## Installation + +### .NET Framework and .NET Core (C#) + +Install the NuGet package: + +```bash +dotnet add package Microsoft.Security.AntiSSRF +``` + +### Node.js (JavaScript/TypeScript) + +Install the npm package: + +```bash +npm install @microsoft/antissrf +``` + +## Quick Start Examples + +### .NET Usage + +```csharp +using Microsoft.Security.AntiSSRF; +using System.Net.Http; + +// Create a policy for external-only requests +var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); + +// Get a secure HttpMessageHandler +using var handler = policy.GetHandler(); + +// Use with HttpClient +using var client = new HttpClient(handler); + +// Make secure requests - internal IPs will be blocked +var response = await client.GetAsync("https://api.example.com/data"); +``` + +### Node.js Usage + +```javascript +const { AntiSSRFPolicy, PolicyConfigOptions } = require('@microsoft/antissrf'); +const https = require('https'); + +// Create a policy for external-only requests +const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); + +// Get a secure HTTPS agent +const httpsAgent = policy.getHttpsAgent({ keepAlive: true }); + +// Use with standard Node.js requests +const options = { + hostname: 'api.example.com', + path: '/data', + agent: httpsAgent +}; + +https.get(options, (res) => { + // Handle response +}); +``` + +## How to Use + +The AntiSSRF library provides validation for different scenarios based on your trust requirements: + +| Use Case | Description | Documentation Link | +|----------|-------------|-------------------| +| **General Case** | The untrusted URL can belong to **any domain** or an **untrusted domain**. | [.NET](../dotnet-api/antissrfpolicy) \| [Node.js](../nodejs-api/antissrfpolicy) | +| **Azure Key Vault Domain** | The untrusted URL must be an **Azure Key Vault endpoint**. | [.NET](../dotnet-api/urivalidator/inazurekeyvaultdomain) \| [Node.js](../nodejs-api/urivalidator/inazurekeyvaultdomain) | +| **Azure Storage Domain** | The untrusted URL must be an **Azure Storage endpoint**. | [.NET](../dotnet-api/urivalidator/inazurestoragedomain/) \| [Node.js](../nodejs-api/urivalidator/inazurestoragedomain/) | +| **Allowlist of Trusted Domains** | The untrusted URL must belong to a **specific, trusted domain**. | [.NET](../dotnet-api/urivalidator/indomain/) \| [Node.js](../nodejs-api/urivalidator/indomain/) | + +## Best Practices + +1. **Use Separate Handlers for External vs. Internal Requests** + + Always create separate HTTP clients for external and internal requests so that you can use the strictest security policy possible on each. + This approach ensures that external API calls cannot accidentally reach internal services, and internal calls are restricted to only the networks you explicitly trust. When using the `AntiSSRFPolicy`, you can choose different built-in configuration options intended for each use-case. + +2. **Only Use `InDomain` for Owned and Trusted Domains** + + A domain should only be considered trusted if you fully control both the domain itself and all subdomains. You should trust the DNS responses for these domains and should be sure that no subdomain is configurable by a third party. + +3. **Add X-Forwarded-For Header whenever possible** + + The `X-Forwarded-For` header can be an important defense-in-depth strategy against SSRF vulnerabilities. Some services, including IMDS, will drop all incoming requests with the `X-Forwarded-For` present. By ensuring that the header is added to all outgoing requests, your service can be sure that it will never have an SSRF vulnerability that leaks data from IMDS. + +4. **Stay up-to-date** + + Keep the library updated to receive the latest security changes. Instead of using `PolicyConfigOptions.ExternalOnlyV1`, consider using `PolicyConfigOptions.ExternalOnlyLatest`. + +## Next Steps + +### Learn More +- 📖 **API Documentation**: [.NET API](../dotnet-api) \| [Node.js API](../nodejs-api) +- ❓ **Common Questions**: [FAQ](../faq) + +### Get Support + +- 🐛 **Report Issues**: [GitHub Issues](https://github.com/Microsoft/AntiSSRF/issues) +- 📧 **Contact**: [antissrf-oss@microsoft.com](mailto:antissrf-oss@microsoft.com) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..6ca0fb3 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,67 @@ +--- +layout: default +title: Home +nav_order: 1 +description: "Microsoft AntiSSRF Documentation - Protect your applications from SSRF attacks" +permalink: / +--- + +# Microsoft AntiSSRF Libraries + +## What is Server-Side Request Forgery (SSRF)? + +Server-Side Request Forgery (also known as SSRF) is a critical web security vulnerability in which an attacker can manipulate the server-side application to make network requests to an arbitrary endpoint. Through this vulnerability, the attacker manipulates the target web server to connect to internal, sensitive networks or exfiltrate sensitive data to an untrusted endpoint on the Internet. + +SSRF can lead (but is not limited) to: +- Exposure of internal services +- Leakage of sensitive data +- Service disruption +- Remote code execution + +### What is "Untrusted" Input? + +**All incoming HTTP requests are untrusted.** Any data originating from outside your service's immediate trust boundary must be treated as potentially malicious. This includes: + +- User-provided URLs, filenames, or identifiers +- Data from external APIs, webhooks, or partner services +- Configuration values, metadata, or file contents that users can influence +- Requests from your own service's backend applications or other components within the same environment (query parameters, headers, form fields, etc.) + +Even data that doesn't initially appear to be a URL should be treated as one. For example, a workspace name or resource identifier that gets concatenated into a URL. All untrusted input used in URL construction MUST be validated. + +## What is the Microsoft AntiSSRF Library? + +The Microsoft AntiSSRF Library is a security-developed, exhaustively-tested secure code library available for multiple platforms, that provides robust URL validation to mitigate the risk of SSRF vulnerabilities in code where it is integrated. It is an easy-to-use drop-in library with a minimal toil of adoption on developers. + +### How the Microsoft AntiSSRF Library Helps + +A common scenario in many online services is handling requests from customers containing customer-supplied strings that are, or are used to construct a URL. These strings are often not validated properly, leading to vulnerabilities such as Server-Side Request Forgery which can result in token theft. + +AntiSSRF helps mitigate these risks by: + +- Automatically validating URLs and network connections and rejecting/refusing unsafe input +- Providing an agent that ensures HTTP requests cannot reach internal or sensitive IP addresses + +## Supported Languages and Frameworks + +| Language | Documentation | Notes | +| --- | --- | --- | +| C# | [AntiSSRF .NET Library](dotnet-api/) | For web clients using `HttpClient` objects | +| JavaScript/TypeScript | [AntiSSRF Node.js Library](nodejs-api/) | For requests using NodeJS HTTP(S) Agents | + +{: .note } +> Broader platform support is under development. + +## Next Steps + +### Learn More + +- 🚀 **Getting Started**: [Installation and Quick Start Guide](getting-started) +- 📖 **API Documentation**: [.NET API](dotnet-api) \| [Node.js API](nodejs-api) +- ❓ **Common Questions**: [FAQ](faq) + +### Get Support + +- 🐛 **Report Issues**: [GitHub Issues](https://github.com/Microsoft/AntiSSRF/issues) +- 📧 **Contact**: [antissrf-oss@microsoft.com](mailto:antissrf-oss@microsoft.com) + diff --git a/docs/ipaddressranges.md b/docs/ipaddressranges.md new file mode 100644 index 0000000..fef2140 --- /dev/null +++ b/docs/ipaddressranges.md @@ -0,0 +1,127 @@ +--- +layout: default +title: IP Address Ranges +nav_order: 2 +description: "Predefined IP address ranges class" +--- + +# IPAddressRanges + +## Definition + +Provides predefined IP address ranges for internal and special-purpose addresses for use with AntiSSRF policies. + +This list is consistent and shared across all the languages and frameworks. + +## Example + +To block all address ranges except for the IPv4 Private-Use ranges: + +```cs +const policy = new AntiSSRFPolicy(PolicyConfigOptions.InternalOnly); +policy.addAllowedAddresses(IPAddressRanges.privateUse); +``` + +## Special-Purpose Ranges + +| Special Purpose | Variable Name | IP Address Ranges | +| --- | --- | --- | +| Automatic Multicast Tunneling | `IPAddressRanges.amt` | `192.52.193.0/24`, `2001:3::/32` | +| AS112 Service | `IPAddressRanges.as112` | `192.31.196.0/24`, `192.175.48.0/24`, `2001:4:112::/48`, `2620:4f:8000::/48` | +| Benchmarking | `IPAddressRanges.benchmarking` | `198.18.0.0/15`, `2001:2::/48` | +| Broadcast | `IPAddressRanges.broadcast` | `255.255.255.255/32` | +| Deprecated | `IPAddressRanges.deprecated` | `192.88.99.0/24`, `2001:10::/28` | +| Drone Remote ID Protocol Entity Tags (DETs) Prefix | `IPAddressRanges.detsPrefix` | `2001:30::/28` | +| Discard-Only | `IPAddressRanges.discardOnly` | `100::/64` | +| Documentation | `IPAddressRanges.documentation` | `192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`, `2001:db8::/32`, `3fff::/20` | +| Dummy | `IPAddressRanges.dummy` | `192.0.0.8/32`, `100:0:0:1::/64` | +| IETF Protocol Assignments | `IPAddressRanges.ietfProtocol` | `192.0.0.0/24`, `2001::/23` | +| IMDS | `IPAddressRanges.imds` | `169.254.169.254/32` | +| IPv4/IPv6 Translation | `IPAddressRanges.ipv4Ipv6Translat` | `64:ff9b::/96`, `64:ff9b:1::/48` | +| IPv4 Service Continuity | `IPAddressRanges.ipv4ServiceContinuity` | `192.0.0.0/29` | +| Link-Local | `IPAddressRanges.linkLocal` | `169.254.0.0/16`, `fe80::/10` | +| Loopback | `IPAddressRanges.loopback` | `127.0.0.0/8`, `::1/128` | +| Multicast | `IPAddressRanges.multicast` | `224.0.0.0/4`, `ff00::/8` | +| ORCHIDv2 | `IPAddressRanges.orchidv2` | `2001:20::/28` | +| Private-Use | `IPAddressRanges.privateUse` | `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` | +| Reserved | `IPAddressRanges.reserved` | `240.0.0.0/4` | +| Shared Address Space | `IPAddressRanges.sharedAddressSpace` | `100.64.0.0/10` | +| Site-Local | `IPAddressRanges.siteLocal` | `fec0::/10` | +| 6to4 | `IPAddressRanges.sixto4` | `2002::/16` | +| Segment Routing (SRv6) SIDs | `IPAddressRanges.srv6Sid` | `5f00::/16` | +| Teredo | `IPAddressRanges.teredo` | `2001::/32` | +| Unique-Local | `IPAddressRanges.uniqueLocal` | `fc00::/7` | +| Unspecified | `IPAddressRanges.unspecified` | `0.0.0.0/8`, `::/128` | +| Wireserver | `IPAddressRanges.wireserver` | `168.63.129.16/32` | +| Recommended V1 | `IPAddressRanges.recommendedV1` | See Recommended Ranges V1 | +| Recommended Latest | `IPAddressRanges.recommendedLatest` | See Recommended Latest | + +## IMDS (Instance Metadata Service) + +The `IPAddressRanges.imds` range (`169.254.169.254/32`) provides access to Azure's Instance Metadata Service, which exposes sensitive information about the virtual machine including access tokens, compute metadata, and network configuration. If not blocked, attackers could use SSRF vulnerabilities to retrieve Azure credentials and escalate privileges within the cloud environment. + +Please ensure you add the `X-Forwarded-For` header on all outgoing requests whenever possible, either manually or using `addXFFHeader`. Requests to IMDS that include the `X-Forwarded-For` header will be safely dropped by the service as a security measure. + +## Wireserver + +The `IPAddressRanges.wireserver` range (`168.63.129.16/32`) is Azure's internal communication endpoint used for VM provisioning, health monitoring, and DNS resolution. If not blocked, attackers could potentially interfere with Azure's internal services, access configuration data, or disrupt VM operations through SSRF attacks. + +## Recommended Ranges V1 + +The `IPAddressRanges.recommendedV1` contains the address ranges used by the constructor `AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1)`. This includes ALL internal and special-purpose addresses from above, simplified for efficiency when ranges overlap. + +**IPv4 Ranges:** +- `0.0.0.0/8` +- `10.0.0.0/8` +- `100.64.0.0/10` +- `127.0.0.0/8` +- `168.63.129.16/32` +- `169.254.0.0/16` +- `172.16.0.0/12` +- `192.0.0.0/24` +- `192.0.2.0/24` +- `192.31.196.0/24` +- `192.52.193.0/24` +- `192.88.99.0/24` +- `192.168.0.0/16` +- `192.175.48.0/24` +- `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` + +**IPv6 Ranges:** +- `::1/128` +- `::/128` +- `64:ff9b::/96` +- `64:ff9b:1::/48` +- `100::/64` +- `100:0:0:1::/64` +- `2001::/23` +- `2001:db8::/32` +- `2002::/16` +- `2620:4f:8000::/48` +- `3fff::/20` +- `5f00::/16` +- `fc00::/7` +- `fe80::/10` +- `fec0::/10` +- `ff00::/8` + +## Recommended Ranges Latest + +The `IPAddressRanges.recommendedLatest` contains the address ranges used by the constructor `AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest)`. + +{: .important } +> This policy operates independently of semantic versioning and ensures continuous alignment with current recommendations. + +## For More Details + +- [IANA IPv4 Special-Purpose Address Registry](https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml) +- [IANA IPv6 Special-Purpose Address Registry](https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml) +- [IANA IPv4 Multicast Address Space](https://www.iana.org/assignments/multicast-addresses/multicast-addresses.xhtml) +- [IANA IPv6 Multicast Address Space](https://www.iana.org/assignments/ipv6-multicast-addresses/ipv6-multicast-addresses.xhtml) +- [Microsoft Learn Azure Instance Metadata Service](https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service?tabs=windows) +- [Microsoft Learn Azure IP Address 168.63.129.16 Overview](https://learn.microsoft.com/en-us/azure/virtual-network/what-is-ip-address-168-63-129-16?tabs=windows) +- [Microsoft Learn IPv6 Link-local and Site-local Addresses](https://learn.microsoft.com/en-us/windows/win32/winsock/link-local-and-site-local-addresses-2) \ No newline at end of file diff --git a/docs/nodejs-api/antissrfpolicy/constructor.md b/docs/nodejs-api/antissrfpolicy/constructor.md new file mode 100644 index 0000000..5ed2ad6 --- /dev/null +++ b/docs/nodejs-api/antissrfpolicy/constructor.md @@ -0,0 +1,47 @@ +--- +layout: default +title: Constructor +parent: AntiSSRFPolicy +grand_parent: Node.js API Reference +nav_order: 1 +description: "AntiSSRFPolicy constructor documentation" +uid: constructor +--- + +# AntiSSRFPolicy Constructor + +## Definition + +Creates a new [AntiSSRFPolicy](../) instance with a predefined security configuration. + +```js +AntiSSRFPolicy(config: PolicyConfigOptions) +``` + +### Parameters + +`config`: `PolicyConfigOptions` + +Choose from the following predefined policy configurations: + +### PolicyConfigOptions + +| Option | Use Case | Behavior | +| --- | --- | --- | +| **InternalOnly** | Making requests to internal, non-public addresses only **OR** restricting requests to specific allowed addresses | Blocks all IP addresses by default. Only allows addresses explicitly added via [addAllowedAddresses](../methods/addallowedaddresses). | +| **ExternalOnlyV1** | Making requests to external APIs while blocking internal access | Blocks internal and special-purpose IP addresses per [IPAddressRanges.recommendedV1](../../ipaddressranges#recommendedrangesv1). Automatically adds `X-Forwarded-For` header to requests. | +| **ExternalOnlyLatest** | Currently the same as `ExternalOnlyV1` with automatic security updates | Always stays up to date with the latest `ExternalOnly` version, independent of semantic versioning. | +| **None** | Custom policy configuration | No restrictions applied. Requires manual configuration via policy methods. | + +{: .important } +> `PolicyConfigOptions.ExternalOnlyLatest` does NOT follow semantic versioning. It will always stay up-to-date with the latest recommended addresses with no code changes required by the user. + +## Security Notes + +To prevent SSRF vulnerabilities, it is a best practice to ensure that your code can make requests to external endpoints or to internal endpoints, but never both. + +If your service needs to make requests to external endpoints, you need to make sure that it cannot be abused to access internal resources. In this case, we recommend using `PolicyConfigOptions.ExternalOnlyLatest`, which takes care of blocking internal and special-purpose addresses automatically. + +If your service needs to make requests to backend services, you must prevent data exfiltration by ensuring it cannot be used to send data to external endpoints. In this case, we recommend using `PolicyConfigOptions.InternalOnly` to block all unspecified addresses, then use `addAllowedAddresses(...)` with the specific internal IP addresses that your service might need to access. + +For more about the `X-Forwarded-For` header, see [addXFFHeader](../properties/addxffheader). diff --git a/docs/nodejs-api/antissrfpolicy/index.md b/docs/nodejs-api/antissrfpolicy/index.md new file mode 100644 index 0000000..295d2ae --- /dev/null +++ b/docs/nodejs-api/antissrfpolicy/index.md @@ -0,0 +1,60 @@ +--- +layout: default +title: AntiSSRFPolicy +parent: Node.js API Reference +description: "Main policy configuration class for SSRF protection" +nav_order: 1 +has_children: true +has_toc: false +--- + +# AntiSSRFPolicy Class + +## Use Case + +Use this class whenever you are accessing a URL that can belong to **any domain** or **some untrusted domain**. + +This use case addresses two distinct security scenarios. For requests to external endpoints, the policy enforces that IP addresses are not internal or special-use addresses, preventing URLs from being abused to gain access to internal resources. For requests to backend resources, the policy blocks all IP addresses except for specific ranges that you expect to see, ensuring that URLs cannot be used to exfiltrate data to unauthorized destinations. + +{: .note } +> * If you instead expect the URL to be an **Azure Key Vault endpoint**, see [URIValidator.inAzureKeyVaultDomain](../urivalidator/inazurekeyvaultdomain). +> * If you instead expect the URL to be an **Azure Storage endpoint**, see [URIValidator.inAzureStorageDomain](../urivalidator/inazurestoragedomain). +> * If you instead expect the domain to be a **specific, trusted domain**, see [URIValidator.inDomain](../urivalidator/indomain). + +## Definition + +The `AntiSSRFPolicy` allows you to customize security requirements for headers, IP addresses, and protocols. You can configure the policy using built-in settings or define your own custom rules. The policy then provides Node.js HTTP(S) agents that automatically enforce these security requirements on all outgoing requests made using those agents. + +## Constructors + +| Constructor | Description | +| --- | --- | +| [AntiSSRFPolicy(PolicyConfigOptions)](constructor) | Initializes a new instance of the `AntiSSRFPolicy` class with the specified initial configuration. | + +## Properties + +| Property | Description | +| --- | --- | +| [addXFFHeader](properties/addxffheader) | Determines whether to automatically add the `X-Forwarded-For` header to outgoing requests that don't already include it. | +| [allowedAddresses](properties/allowedaddresses) | List of IP networks that are explicitly allowed by the policy. | +| [allowPlainTextHttp](properties/allowplaintexthttp) | Determines whether HTTPS is required or HTTP is allowed. | +| [deniedAddresses](properties/deniedaddresses) | List of IP networks that are explicitly blocked by the policy. | +| [deniedHeaders](properties/deniedheaders) | List of headers that are forbidden from being included in outgoing requests. | +| [denyAllUnspecifiedIPs](properties/denyallunspecifiedips) | Determines whether all IP addresses should be blocked by default or only `deniedAddresses` should be blocked. | +| [requiredHeaders](properties/requiredheaders) | List of headers that are required to be present in outgoing requests. | + +## Policy Customization Methods + +| Method | Description | +| --- | --- | +| [addAllowedAddresses(string[])](methods/addallowedaddresses) | Adds IP networks to be explicitly allowed by the policy. | +| [addDeniedAddresses(string[])](methods/adddeniedaddresses) | Adds IP networks to be explicitly blocked by the policy. | +| [addDeniedHeaders(string[])](methods/adddeniedheaders) | Adds headers to be explicitly blocked by the policy. | +| [addRequiredHeaders(string[])](methods/addrequiredheaders) | Adds headers to be explicitly required by the policy. | + +## Policy Use Methods + +| Method | Description | +| --- | --- | +| [getHttpAgent(any)](methods/gethttpagent) | Builds an `http.Agent` that will enforce the policy on all outgoing requests. | +| [getHttpsAgent(any)](methods/gethttpsagent) | Builds an `https.Agent` that will enforce the policy on all outgoing requests. | diff --git a/docs/nodejs-api/antissrfpolicy/methods/addallowedaddresses.md b/docs/nodejs-api/antissrfpolicy/methods/addallowedaddresses.md new file mode 100644 index 0000000..0e2bafa --- /dev/null +++ b/docs/nodejs-api/antissrfpolicy/methods/addallowedaddresses.md @@ -0,0 +1,75 @@ +--- +layout: default +title: addAllowedAddresses +parent: Methods +grand_parent: AntiSSRFPolicy +ancestor: Node.js API Reference +description: "addAllowedAddresses method documentation" +--- + +# AntiSSRFPolicy.addAllowedAddresses Method + +## Definition + +Adds IP networks to be explicitly allowed by the policy. + +```js +addAllowedAddresses(networks: string[]): void +``` + +{: .note } +> `allowedAddresses` takes precedence over `deniedAddresses`. If an IP address matches both, it will be considered allowed by the policy. + +### Parameters + +`networks`: `string[]` + +The list of IP networks to be explicitly allowed by the policy. + +Networks can be: +* IPv4 addresses in dotted-quad notation + * ex. `127.0.0.1` +* IPv6 addresses in expanded notation `x:x:x:x:x:x:x:x`, where the `x`s are one to four hexadecimal digits + * ex. `ABCD:EF01:2345:6789:ABCD:EF01:2345:6789` +* IPv6 addresses in compressed notation, where one group of consecutive 0s is represented with `::` + * ex. `ABCD::`, `::1`, `ABCD:EF01::2345:6789` +* IPv6 in mixed notation `x:x:x:x:x:x:d.d.d.d`, where the `x`s are hexadecimal values and the `d`s are decimal + * ex. `::FFFF:127.0.0.1` +* Any of the above addresses with a decimal prefix length `<ip-address>/<prefix-length>` + * ex. `192.0.2.0/24`, `2001:db8::/32` + +### Errors + +`AntiSSRFError` +* The `networks` argument is `null` or `undefined`. +* Some `network` in `networks` is not a valid format. + +## Examples + +```javascript +const { AntiSSRFPolicy, PolicyConfigOptions } = require('@microsoft/antissrf'); +const https = require('https'); + +// Customize the policy +const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); +policy.denyAllUnspecifiedIPs = true; +policy.addAllowedAddresses(["1.2.3.4"]); + +const options = { + hostname: '<some_untrusted_hostname>', + path: '/public/data', + agent: policy.getHttpsAgent() +}; + +const req = https.request(options, (res) => { + // If the untrusted hostname directs to 1.2.3.4, + // the request will succeed here +}); + +req.on('error', (err) => { + // If untrusted hostname directs to anything besides 1.2.3.4, + // the request will fail here with an AntiSSRFError +}); + +req.end(); +``` diff --git a/docs/nodejs-api/antissrfpolicy/methods/adddeniedaddresses.md b/docs/nodejs-api/antissrfpolicy/methods/adddeniedaddresses.md new file mode 100644 index 0000000..c69fe83 --- /dev/null +++ b/docs/nodejs-api/antissrfpolicy/methods/adddeniedaddresses.md @@ -0,0 +1,78 @@ +--- +layout: default +title: addDeniedAddresses +parent: Methods +grand_parent: AntiSSRFPolicy +ancestor: Node.js API Reference +description: "addDeniedAddresses method documentation" +--- + +# AntiSSRFPolicy.addDeniedAddresses Method + +## Definition + +Adds IP networks to be explicitly blocked by the policy. + +```js +addDeniedAddresses(networks: string[]): void +``` + +{: .note } +> `allowedAddresses` takes precedence over `deniedAddresses`. If an IP address matches both, it will be considered allowed by the policy. + +{: .note } +> `denyAllUnspecifiedIPs` takes precedence over `deniedAddresses`. If `denyAllUnspecifiedIPs` is `true`, `deniedAddresses` will not be considered when determining if an IP address is allowed or blocked by the policy. + +### Parameters + +`networks`: `string[]` + +The list of IP networks to be explicitly blocked by the policy. + +Networks can be: +* IPv4 addresses in dotted-quad notation + * ex. `127.0.0.1` +* IPv6 addresses in expanded notation `x:x:x:x:x:x:x:x`, where the `x`s are one to four hexadecimal digits + * ex. `ABCD:EF01:2345:6789:ABCD:EF01:2345:6789` +* IPv6 addresses in compressed notation, where one group of consecutive 0s is represented with `::` + * ex. `ABCD::`, `::1`, `ABCD:EF01::2345:6789` +* IPv6 in mixed notation `x:x:x:x:x:x:d.d.d.d`, where the `x`s are hexadecimal values and the `d`s are decimal + * ex. `::FFFF:127.0.0.1` +* Any of the above addresses with a decimal prefix length `<ip-address>/<prefix-length>` + * ex. `192.0.2.0/24`, `2001:db8::/32` + +### Errors + +`AntiSSRFError` +* The `networks` argument is `null` or `undefined`. +* Some `network` in `networks` is not a valid format. +* `denyAllUnspecifiedIPs` is already set to `true`. + +## Examples + +```js +const { AntiSSRFPolicy, PolicyConfigOptions } = require('@microsoft/antissrf'); +const https = require('https'); + +// Customize the policy +const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); +policy.addDeniedAddresses(["1.2.3.4"]); + +const options = { + hostname: '<some_untrusted_hostname>', + path: '/public/data', + agent: policy.getHttpsAgent() +}; + +const req = https.request(options, (res) => { + // If the untrusted hostname directs to anything besides 1.2.3.4, + // the request will succeed here +}); + +req.on('error', (err) => { + // If untrusted hostname directs to 1.2.3.4, + // the request will fail here with an AntiSSRFError +}); + +req.end(); +``` diff --git a/docs/nodejs-api/antissrfpolicy/methods/adddeniedheaders.md b/docs/nodejs-api/antissrfpolicy/methods/adddeniedheaders.md new file mode 100755 index 0000000..f669007 --- /dev/null +++ b/docs/nodejs-api/antissrfpolicy/methods/adddeniedheaders.md @@ -0,0 +1,77 @@ +--- +layout: default +title: addDeniedHeaders +parent: Methods +grand_parent: AntiSSRFPolicy +ancestor: Node.js API Reference +description: "addDeniedHeaders method documentation" +--- + +# AntiSSRFPolicy.addDeniedHeaders Method + +## Definition + +Adds headers to be explicitly blocked by the policy. Requests that include a denied header will be blocked. + +```js +addDeniedHeaders(headers: string[]): void +``` + +{: .note } +> Both `requiredHeaders` and `deniedHeaders` are considered when validating a request. If any header is in both lists, the request will always be blocked. + +### Parameters + +`headers`: `string[]` + +The list of headers for the policy to block. + +### Errors + +`AntiSSRFError` +* The `headers` argument is `null` or `undefined`. +* Some `header` in `headers` is `null`, `undefined`, or whitespace. + +## Examples + +```js +const { AntiSSRFPolicy, PolicyConfigOptions } = require('@microsoft/antissrf'); +const https = require('https'); + +// Customize the policy +const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); +policy.addDeniedHeaders(['X-Real-IP', 'X-Forwarded-Host']); +const agent = policy.getHttpsAgent(); + +// This request will succeed (no denied headers) +const options = { + hostname: '<some_untrusted_hostname>', + path: '/public/data', + headers: { + 'User-Agent': 'MyApp/1.0', + 'Accept': 'application/json' + }, + agent: agent +}; + +https.get(options, (res) => { + console.log('Request successful - no denied headers present'); +}); + +// This request will be blocked (contains denied header) +const blockedOptions = { + hostname: '<some_untrusted_hostname>', + path: '/admin/endpoint', + headers: { + 'X-Real-IP': '192.168.1.1', // This header is denied + 'Accept': 'application/json' + }, + agent: agent +}; + +https.get(blockedOptions, (res) => { + // This will not execute - request will be blocked +}).on('error', (err) => { + console.log('Request blocked due to denied header:', err.message); +}); +``` diff --git a/docs/nodejs-api/antissrfpolicy/methods/addrequiredheaders.md b/docs/nodejs-api/antissrfpolicy/methods/addrequiredheaders.md new file mode 100644 index 0000000..4cf8295 --- /dev/null +++ b/docs/nodejs-api/antissrfpolicy/methods/addrequiredheaders.md @@ -0,0 +1,78 @@ +--- +layout: default +title: addRequiredHeaders +parent: Methods +grand_parent: AntiSSRFPolicy +ancestor: Node.js API Reference +description: "addRequiredHeaders method documentation" +--- + +# AntiSSRFPolicy.addRequiredHeaders Method + +## Definition + +Adds headers to be explicitly required by the policy. Requests that are missing a required header will be blocked. + +```js +addRequiredHeaders(headers: string[]): void +``` + +{: .note } +> Both `requiredHeaders` and `deniedHeaders` are considered when validating a request. If any header is in both lists, the request will always be blocked. + +### Parameters + +`headers`: `string[]` + +The list of headers for the policy to require. + +### Errors + +`AntiSSRFError` +* The `headers` argument is `null` or `undefined`. +* Some `header` in `headers` is `null`, `undefined`, or whitespace. + +## Examples + +```js +const { AntiSSRFPolicy, PolicyConfigOptions } = require('@microsoft/antissrf'); +const https = require('https'); + +// Customize the policy +const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); +policy.addRequiredHeaders(['Authorization', 'X-API-Key']); +const agent = policy.getHttpsAgent(); + +// This request will succeed (all required headers present) +const options = { + hostname: '<some_untrusted_hostname>', + path: '/secure/data', + headers: { + 'Authorization': 'Bearer token123', + 'X-API-Key': 'key456', + 'Accept': 'application/json' + }, + agent: agent +}; + +https.get(options, (res) => { + console.log('Request successful - all required headers present'); +}); + +// This request will be blocked (missing required header) +const blockedOptions = { + hostname: '<some_untrusted_hostname>', + path: '/secure/admin', + headers: { + 'Authorization': 'Bearer token123', + 'Accept': 'application/json' + }, + agent: agent +}; + +https.get(blockedOptions, (res) => { + // This will not execute - request will be blocked +}).on('error', (err) => { + console.log('Request blocked due to missing required header:', err.message); +}); +``` diff --git a/docs/nodejs-api/antissrfpolicy/methods/gethttpagent.md b/docs/nodejs-api/antissrfpolicy/methods/gethttpagent.md new file mode 100644 index 0000000..79c5977 --- /dev/null +++ b/docs/nodejs-api/antissrfpolicy/methods/gethttpagent.md @@ -0,0 +1,71 @@ +--- +layout: default +title: getHttpAgent +parent: Methods +grand_parent: AntiSSRFPolicy +ancestor: Node.js API Reference +description: "getHttpAgent method documentation" +--- + +# AntiSSRFPolicy.getHttpAgent Method + +## Definition + +Builds an `http.Agent` that will enforce the policy on all outgoing requests. + +{: .warning } +> **HTTP is insecure**: HTTP requests send data in plaintext over the network. Using this HTTP agent when your policy does not allow plaintext connections will cause all requests to fail. For secure communications, use [`getHttpsAgent()`](gethttpsagent) instead. + +```js +getHttpAgent(options?: http.AgentOptions): http.Agent +``` + +### Parameters + +`options`: `http.AgentOptions` + +The optional `http.AgentOptions` to pass to the new agent. + +### Errors + +`AntiSSRFError` + +The function `lookup` is included in `options`. + +## Examples + +```js +const { AntiSSRFPolicy, PolicyConfigOptions } = require('@microsoft/antissrf'); +const http = require('http'); + +// Customize the policy +const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); +policy.allowPlainTextHttp = true; + +// Get HTTP agent with the configured policy +const httpAgent = policy.getHttpAgent(); + +const options = { + hostname: '<some_untrusted_hostname>', + port: 80, + path: '/public/data', + method: 'GET', + agent: httpAgent +}; + +const req = http.request(options, (res) => { + // If the untrusted hostname directs to an external address using HTTP, + // the request will succeed here +}); + +req.on('error', (err) => { + // If untrusted hostname directs to an internal or special-use address, + // the request will fail here with an AntiSSRF error +}); + +req.end(); +``` + +## Security Notes +* The agent utilizes the `lookup` function to apply the policy. Attempts to overwrite the `lookup` function will result in errors. +* While not explicitly blocked, any use of proxies, such as `proxyEnv` in `options` or in clients that use the agent, will bypass the protections provided by the AntiSSRF library. diff --git a/docs/nodejs-api/antissrfpolicy/methods/gethttpsagent.md b/docs/nodejs-api/antissrfpolicy/methods/gethttpsagent.md new file mode 100644 index 0000000..da31f7b --- /dev/null +++ b/docs/nodejs-api/antissrfpolicy/methods/gethttpsagent.md @@ -0,0 +1,67 @@ +--- +layout: default +title: getHttpsAgent +parent: Methods +grand_parent: AntiSSRFPolicy +ancestor: Node.js API Reference +description: "getHttpsAgent method documentation" +--- + +# AntiSSRFPolicy.getHttpsAgent Method + +## Definition + +Builds an `https.Agent` that will enforce the policy on all outgoing requests. + +```js +getHttpsAgent(options?: https.AgentOptions): https.Agent +``` + +### Parameters + +`options`: `https.AgentOptions` + +The optional `https.AgentOptions` to pass to the new agent. + +### Errors + +`AntiSSRFError` + +The function `lookup` is included in `options`. + +## Examples + +```js +const { AntiSSRFPolicy, PolicyConfigOptions } = require('@microsoft/antissrf'); +const https = require('https'); + +// Customize the policy +const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); + +// Get HTTPS agent with the configured policy +const httpsAgent = policy.getHttpsAgent(); + +const options = { + hostname: '<some_untrusted_hostname>', + port: 443, + path: '/public/data', + method: 'GET', + agent: httpsAgent +}; + +const req = https.request(options, (res) => { + // If the untrusted hostname directs to an external address using HTTPS, + // the request will succeed here +}); + +req.on('error', (err) => { + // If untrusted hostname directs to an internal or special-use address, + // the request will fail here with an AntiSSRF error +}); + +req.end(); +``` + +## Security Notes +* The agent utilizes the `lookup` function to apply the policy. Attempts to overwrite the `lookup` function will result in errors. +* While not explicitly blocked, any use of proxies, such as `proxyEnv` in `options` or in clients that use the agent, will bypass the protections provided by the AntiSSRF library. diff --git a/docs/nodejs-api/antissrfpolicy/methods/index.md b/docs/nodejs-api/antissrfpolicy/methods/index.md new file mode 100644 index 0000000..ca2f349 --- /dev/null +++ b/docs/nodejs-api/antissrfpolicy/methods/index.md @@ -0,0 +1,27 @@ +--- +layout: default +title: Methods +parent: AntiSSRFPolicy +grand_parent: Node.js API Reference +nav_order: 3 +description: "AntiSSRFPolicy methods documentation" +has_children: true +nav_fold: true +has_toc: false +--- + +## Policy Customization Methods + +| Method | Description | +| --- | --- | +| [addAllowedAddresses(string[])](addallowedaddresses) | Adds IP networks to be explicitly allowed by the policy. | +| [addDeniedAddresses(string[])](adddeniedaddresses) | Adds IP networks to be explicitly blocked by the policy. | +| [addDeniedHeaders(string[])](adddeniedheaders) | Adds headers to be explicitly blocked by the policy. | +| [addRequiredHeaders(string[])](addrequiredheaders) | Adds headers to be explicitly required by the policy. | + +## Policy Use Methods + +| Method | Description | +| --- | --- | +| [getHttpAgent(any)](gethttpagent) | Builds an `http.Agent` that will enforce the policy on all outgoing requests. | +| [getHttpsAgent(any)](gethttpsagent) | Builds an `https.Agent` that will enforce the policy on all outgoing requests. | diff --git a/docs/nodejs-api/antissrfpolicy/properties/addxffheader.md b/docs/nodejs-api/antissrfpolicy/properties/addxffheader.md new file mode 100644 index 0000000..1ef1f2d --- /dev/null +++ b/docs/nodejs-api/antissrfpolicy/properties/addxffheader.md @@ -0,0 +1,37 @@ +--- +layout: default +title: addXFFHeader +parent: Properties +grand_parent: AntiSSRFPolicy +ancestor: Node.js API Reference +description: "addXFFHeader property documentation" +--- + +# AntiSSRFPolicy.addXFFHeader Property + +## Definition + +Determines whether to automatically add the `X-Forwarded-For` header to outgoing requests that don’t already include it. + +{: .important } +> The header is added with the dummy value `"true"`. If your end service requires this header to be a valid IP address, you will have to add the header manually. + +```js +addXFFHeader: boolean { get; set; } +``` + +### Property Value + +`boolean` + +* `true` if the `X-Forwarded-For` header should be added to requests that don’t already include it. +* `false` if the `X-Forwarded-For` header should not be added. + +### Errors + +`AntiSSRFError` +The value passed cannot be `null` or `undefined`. + +## Security Notes + +The `X-Forwarded-For` header can be an important defense-in-depth strategy against SSRF vulnerabilities. Some services, including IMDS, will drop all incoming requests with the `X-Forwarded-For` header present. By ensuring that the header is added to all outgoing requests, your service can be sure that it will never have an SSRF vulnerability that leaks data from IMDS. diff --git a/docs/nodejs-api/antissrfpolicy/properties/allowedaddresses.md b/docs/nodejs-api/antissrfpolicy/properties/allowedaddresses.md new file mode 100644 index 0000000..bf4ab72 --- /dev/null +++ b/docs/nodejs-api/antissrfpolicy/properties/allowedaddresses.md @@ -0,0 +1,27 @@ +--- +layout: default +title: allowedAddresses +parent: Properties +grand_parent: AntiSSRFPolicy +ancestor: Node.js API Reference +description: "allowedAddresses property documentation" +--- + +# AntiSSRFPolicy.allowedAddresses Property + +## Definition + +The [`BlockList`](https://nodejs.org/api/net.html#class-netblocklist) of IP networks explicitly allowed by the policy. + +```js +allowedAddresses: ReadOnly<BlockList> { get; } +``` + +{: .note } +> `allowedAddresses` takes precedence over `deniedAddresses`. If an IP address matches both, it will be considered allowed by the policy. + +### Property Value + +`ReadOnly<BlockList>` + +The `ReadOnly` version of the `net.BlockList` storing the allowed IP networks. diff --git a/docs/nodejs-api/antissrfpolicy/properties/allowplaintexthttp.md b/docs/nodejs-api/antissrfpolicy/properties/allowplaintexthttp.md new file mode 100755 index 0000000..519b559 --- /dev/null +++ b/docs/nodejs-api/antissrfpolicy/properties/allowplaintexthttp.md @@ -0,0 +1,36 @@ +--- +layout: default +title: allowPlainTextHttp +parent: Properties +grand_parent: AntiSSRFPolicy +ancestor: Node.js API Reference +description: "allowPlainTextHttp property documentation" +--- + +# AntiSSRFPolicy.allowPlainTextHttp Property + +{: .warning } +> Changing an `AntiSSRFPolicy` instance to allow plaintext HTTP means you will be able to send HTTP requests without the recommended TLS encryption. + +## Definition + +Determines whether HTTPS is required or HTTP is allowed. + +```js +allowPlainTextHttp: boolean { get; set; } +``` + +{: .note } +> With ALL configuration options, HTTP is disallowed unless `allowPlainTextHttp` is explicitly set to `true`. + +### Property Value + +`boolean` + +* `true` if HTTP should be allowed. +* `false` if HTTPS should be required. + +### Errors + +`AntiSSRFError` +The value passed cannot be `null` or `undefined`. diff --git a/docs/nodejs-api/antissrfpolicy/properties/deniedaddresses.md b/docs/nodejs-api/antissrfpolicy/properties/deniedaddresses.md new file mode 100644 index 0000000..5445ebd --- /dev/null +++ b/docs/nodejs-api/antissrfpolicy/properties/deniedaddresses.md @@ -0,0 +1,30 @@ +--- +layout: default +title: deniedAddresses +parent: Properties +grand_parent: AntiSSRFPolicy +ancestor: Node.js API Reference +description: "deniedAddresses property documentation" +--- + +# AntiSSRFPolicy.deniedAddresses Property + +## Definition + +The [`BlockList`](https://nodejs.org/api/net.html#class-netblocklist) of IP networks explicitly blocked by the policy. + +```js +deniedAddresses: ReadOnly<BlockList> { get; } +``` + +{: .note } +> `allowedAddresses` takes precedence over `deniedAddresses`. If an IP address matches both, it will be considered allowed by the policy. + +{: .note } +> `denyAllUnspecifiedIPs` takes precedence over `deniedAddresses`. If `denyAllUnspecifiedIPs` is `true`, `deniedAddresses` will not be considered when determining if an IP address is allowed or blocked by the policy. + +### Property Value + +`ReadOnly<BlockList>` + +The `ReadOnly` version of the `net.BlockList` storing the denied IP networks. diff --git a/docs/nodejs-api/antissrfpolicy/properties/deniedheaders.md b/docs/nodejs-api/antissrfpolicy/properties/deniedheaders.md new file mode 100644 index 0000000..c9f011a --- /dev/null +++ b/docs/nodejs-api/antissrfpolicy/properties/deniedheaders.md @@ -0,0 +1,27 @@ +--- +layout: default +title: deniedHeaders +parent: Properties +grand_parent: AntiSSRFPolicy +ancestor: Node.js API Reference +description: "deniedHeaders property documentation" +--- + +# AntiSSRFPolicy.deniedHeaders Property + +## Definition + +A read-only array of headers that are forbidden from being included in outgoing requests. + +```js +deniedHeaders: readonly string[] { get; } +``` + +{: .note } +> Both `requiredHeaders` and `deniedHeaders` are considered when validating a request. If any header is in both lists, the request will always be blocked. + +### Property Value + +`readonly string[]` + +A read-only array of header names that are blocked by the policy. diff --git a/docs/nodejs-api/antissrfpolicy/properties/denyallunspecifiedips.md b/docs/nodejs-api/antissrfpolicy/properties/denyallunspecifiedips.md new file mode 100644 index 0000000..209d3d0 --- /dev/null +++ b/docs/nodejs-api/antissrfpolicy/properties/denyallunspecifiedips.md @@ -0,0 +1,33 @@ +--- +layout: default +title: denyAllUnspecifiedIPs +parent: Properties +grand_parent: AntiSSRFPolicy +ancestor: Node.js API Reference +description: "denyAllUnspecifiedIPs property documentation" +--- + +# AntiSSRFPolicy.denyAllUnspecifiedIPs Property + +## Definition + +Determines whether all IP addresses should be blocked by default or only `deniedAddresses` should be blocked. + +{: .note } +> To allow specific addresses, see [addAllowedAddresses](../methods/addallowedaddresses). + +```js +denyAllUnspecifiedIPs: boolean { get; set; } +``` + +### Property Value + +`boolean` + +* `true` if all IP addresses NOT specified by `addAllowedAddresses` should be blocked. +* `false` if only addresses in `deniedAddresses` should be blocked. + +### Errors + +`AntiSSRFError` +The value passed cannot be `null` or `undefined`. diff --git a/docs/nodejs-api/antissrfpolicy/properties/index.md b/docs/nodejs-api/antissrfpolicy/properties/index.md new file mode 100644 index 0000000..e6127c6 --- /dev/null +++ b/docs/nodejs-api/antissrfpolicy/properties/index.md @@ -0,0 +1,23 @@ +--- +layout: default +title: Properties +parent: AntiSSRFPolicy +grand_parent: Node.js API Reference +nav_order: 2 +description: "AntiSSRFPolicy properties documentation" +has_children: true +nav_fold: true +has_toc: false +--- + +# AntiSSRFPolicy Properties + +| Property | Description | +| --- | --- | +| [addXFFHeader](addxffheader) | Determines whether to automatically add the `X-Forwarded-For` header to outgoing requests that don't already include it. | +| [allowedAddresses](allowedaddresses) | List of IP networks that are explicitly allowed by the policy. | +| [allowPlainTextHttp](allowplaintexthttp) | Determines whether HTTPS is required or HTTP is allowed. | +| [deniedAddresses](deniedaddresses) | List of IP networks that are explicitly blocked by the policy. | +| [deniedHeaders](deniedheaders) | List of headers that are forbidden from being included in outgoing requests. | +| [denyAllUnspecifiedIPs](denyallunspecifiedips) | Determines whether all IP addresses should be blocked by default or only `deniedAddresses` should be blocked. | +| [requiredHeaders](requiredheaders) | List of headers that are required to be present in outgoing requests. | diff --git a/docs/nodejs-api/antissrfpolicy/properties/requiredheaders.md b/docs/nodejs-api/antissrfpolicy/properties/requiredheaders.md new file mode 100644 index 0000000..2e9e4de --- /dev/null +++ b/docs/nodejs-api/antissrfpolicy/properties/requiredheaders.md @@ -0,0 +1,27 @@ +--- +layout: default +title: requiredHeaders +parent: Properties +grand_parent: AntiSSRFPolicy +ancestor: Node.js API Reference +description: "requiredHeaders property documentation" +--- + +# AntiSSRFPolicy.requiredHeaders Property + +## Definition + +A read-only array of headers that are required to be present in outgoing requests. + +```js +requiredHeaders: readonly string[] { get; } +``` + +{: .note } +> Both `requiredHeaders` and `deniedHeaders` are considered when validating a request. If any header is in both lists, the request will always be blocked. + +### Property Value + +`readonly string[]` + +A read-only array of header names that are required by the policy. diff --git a/docs/nodejs-api/changelog.md b/docs/nodejs-api/changelog.md new file mode 100644 index 0000000..76c3d30 --- /dev/null +++ b/docs/nodejs-api/changelog.md @@ -0,0 +1,18 @@ +--- +layout: default +title: Changelog +parent: Node.js API Reference +nav_order: 98 +description: "Release notes and version history for AntiSSRF Node.js Library" +--- + +# Changelog + +All notable changes to the AntiSSRF Node.js Library will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +Coming soon. \ No newline at end of file diff --git a/docs/nodejs-api/index.md b/docs/nodejs-api/index.md new file mode 100755 index 0000000..059c8e9 --- /dev/null +++ b/docs/nodejs-api/index.md @@ -0,0 +1,33 @@ +--- +layout: default +title: Node.js API Reference +nav_order: 4 +description: "Complete API documentation for the AntiSSRF Node.js Library" +has_children: true +has_toc: false +--- + +# API Documentation + +## AntiSSRF Node.js Library + +The **AntiSSRF Node.js Library** is a library for JavaScript/TypeScript applications using Node.js that provides robust URL validation to prevent SSRF vulnerabilities in code. It is designed as an easy, drop-in library with a minimal impact on the engineering team, implemented both as [Node.js HTTP(S) agents](https://nodejs.org/api/http.html#class-httpagent) and a static URL validator, depending on use case. + +## Usage Instructions + +The AntiSSRF Library provides validation for different scenarios based on your trust requirements: + +| Use Case | Description | Documentation Link | +| --- | --- | --- | +| **General Case** | The untrusted URL can belong to **any domain** or an **untrusted domain**. | [AntiSSRFPolicy](antissrfpolicy) | +| **Azure Key Vault Domain** | The untrusted URL must be an **Azure Key Vault endpoint**. | [URIValidator.inAzureKeyVaultDomain](urivalidator/inazurekeyvaultdomain) | +| **Azure Storage Domain** | The untrusted URL must be an **Azure Storage endpoint**. | [URIValidator.inAzureStorageDomain](urivalidator/inazurestoragedomain) | +| **Allowlist of Trusted Domains** | The untrusted URL must belong to a **specific, trusted domain**. | [URIValidator.inDomain](urivalidator/indomain) | + +## Classes + +| Class | Description | +| --- | --- | +| [AntiSSRFPolicy](antissrfpolicy) | Represents a customizable security policy and provides HTTP(S) agents to ensure all outgoing requests match the security policy. | +| [IPAddressRanges](../ipaddressranges) | Provides predefined IP address ranges for use with AntiSSRF policies. | +| [URIValidator](urivalidator) | Provides static methods for validating the hostname and protocol of URLs. | diff --git a/docs/nodejs-api/samples/axios.md b/docs/nodejs-api/samples/axios.md new file mode 100644 index 0000000..3569618 --- /dev/null +++ b/docs/nodejs-api/samples/axios.md @@ -0,0 +1,121 @@ +--- +layout: default +title: Axios +parent: Samples +grand_parent: Node.js API Reference +description: "AntiSSRF agent with Axios" +--- + +# AntiSSRFPolicy with the Axios Library + +## Introduction + +[Axios](https://axios-http.com/docs/intro) is one of the most commonly used request libraries for JavaScript/TypeScript. It is very easy to use with the AntiSSRF Node.js Library, as shown in the examples below. + +{: .important } +> Be careful of options that can bypass AntiSSRF protections: +> * Changing the `adapter` option could lead to requests without a Node.js agent, and therefore without the `AntiSSRFPolicy` applied. +> * Like the AntiSSRF agents themselves, using a custom `lookup` function will bypass the IP address validations from `AntiSSRFPolicy`. +> * Using `proxy` with AntiSSRF will largely not work, since the `AntiSSRFPolicy` will not be used once control is passed to the proxy. + +## Axios with Axios Instance + +Axios allows you to create an Axios instance with a custom `config` including the `AntiSSRFPolicy` agents. As long as you don't overwrite the request `config` agents, the instance will enforce the policy. + +### Setup + +Set up the `AntiSSRFPolicy`, then create an Axios instance with the AntiSSRF agents from the policy. + +```js +import { AntiSSRFPolicy, PolicyConfigOptions } from '@microsoft/antissrf'; +import axios from "axios"; + +// Customize the policy +const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); + +// Use the policy with Axios +const secureClient = axios.create({ + httpAgent: policy.getHttpAgent(), + httpsAgent: policy.getHttpsAgent({ keepAlive: true }) +}); +``` + +### Use the Axios Instance for Requests + +Every request to an endpoint with untrusted input should use the `secureClient`. + +```js +secureClient.get( + "<some_https_url_constructed_with_untrusted_input>", + { + auth: { + username: 'janedoe', + password: 's00pers3cret' + } + }) + .then(function (res) { + /** + * Will get here if the untrusted URL does NOT direct the request to + * an internal or special-use IP address + */ + }) + .catch(function (err) { + /** + * Will get here if the untrusted URL directs the request to an + * internal or special-use IP address + */ + }); +``` + +## Axios with Request Agents + +Just like Node.js requests, Axios allows you to add agents to individual requests. + + +### Setup + +Set up the `AntiSSRFPolicy`, then get the AntiSSRF agents from the policy. + +```js +import { AntiSSRFPolicy, PolicyConfigOptions } from '@microsoft/antissrf'; +import axios from "axios"; + +// Customize the policy +const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); + +// Get the AntiSSRF agents +const httpAgent = policy.getHttpAgent(); +const httpsAgent = policy.getHttpsAgent({ keepAlive: true }); +``` + +### Use the AntiSSRF Agents for Requests + +Every request to an endpoint with untrusted input should include the AntiSSRF agents. + +```js +axios.get( + "<some_https_url_constructed_with_untrusted_input>", + { + httpAgent: httpAgent, + httpsAgent: httpsAgent, + auth: { + username: 'janedoe', + password: 's00pers3cret' + } + }) + .then(function (res) { + /** + * Will get here if the untrusted URL does NOT direct the request to + * an internal or special-use IP address + */ + }) + .catch(function (err) { + /** + * Will get here if the untrusted URL directs the request to an + * internal or special-use IP address + */ + }); +``` + +{: .note } +> If you want different examples or if you find any bug while using `AntiSSRFPolicy` with Axios, please let us know at [antissrf-oss@microsoft.com](mailto:antissrf-oss@microsoft.com). diff --git a/docs/nodejs-api/samples/follow-redirects.md b/docs/nodejs-api/samples/follow-redirects.md new file mode 100644 index 0000000..39e065c --- /dev/null +++ b/docs/nodejs-api/samples/follow-redirects.md @@ -0,0 +1,74 @@ +--- +layout: default +title: follow-redirects +parent: Samples +grand_parent: Node.js API Reference +description: "AntiSSRF agent with follow-redirects" +--- + +# AntiSSRFPolicy with the follow-redirects Library + +## Introduction + +The [follow-redirects library](https://github.com/follow-redirects/follow-redirects/blob/694d6b47a42bc8377e5ef1480394de451e16bd5b/README.md) is a commonly used request library to extend Node.js http(s) functionality with the ability to automatically follow redirects. The example below shows how you can use the follow-redirects library with the AntiSSRF Node.js library. + +{: .important } +> Be careful of options that can bypass AntiSSRF protections: +> * Changing the `wrap` option could lead to requests without a Node.js agent, and therefore without the `AntiSSRFPolicy` applied. +> * Like the AntiSSRF agents themselves, using a custom `lookup` function will bypass the IP address validations from `AntiSSRFPolicy`. + +## Example + +follow-redirects allows you to [make requests](https://github.com/follow-redirects/follow-redirects/blob/694d6b47a42bc8377e5ef1480394de451e16bd5b/README.md#per-request-options) either with the option `agent`, like in normal Node.js requests, or the option `agents`, with both `http` and `https` agents for use in redirects. + +### Setup + +Set up the `AntiSSRFPolicy`, then get the AntiSSRF agents from the policy. + +```js +import { AntiSSRFPolicy, PolicyConfigOptions } from '@microsoft/antissrf'; +import { http, https } from "follow-redirects"; + +// Customize the policy +const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); + +// Get the AntiSSRF agents +const agents = { + httpAgent: policy.getHttpAgent(), + httpsAgent: policy.getHttpsAgent({ keepAlive: true }) +} +``` + +### Use the AntiSSRF Agents for Requests + +Every request to an endpoint with untrusted input should include the AntiSSRF agents. + +```js +const httpsReq = https.get( + "<some_https_url_constructed_with_untrusted_input>", + { + agents: agents, + auth: { + username: 'janedoe', + password: 's00pers3cret' + } + }, + (res) => { + /** + * Will get here if the untrusted URL does NOT direct the request to + * an internal or special-purpose IP address + */ + }); + +httpsReq.on("error", (err) => { + /** + * Will get here if the untrusted URL directs the request to an internal + * or special-purpose IP address + */ +}); + +httpsReq.end(); +``` + +{: .note } +> If you want different examples or if you find any bug while using `AntiSSRFPolicy` with follow-redirects, please let us know at [antissrf-oss@microsoft.com](mailto:antissrf-oss@microsoft.com). diff --git a/docs/nodejs-api/samples/index.md b/docs/nodejs-api/samples/index.md new file mode 100644 index 0000000..69ee6ec --- /dev/null +++ b/docs/nodejs-api/samples/index.md @@ -0,0 +1,14 @@ +--- +layout: default +title: Samples +parent: Node.js API Reference +description: "Sample code for agent integration" +nav_order: 99 +has_children: true +has_toc: false +--- + +Sample code showing AntiSSRF integrating with common open-source request libraries: +* [Axios](axios) - Popular promise-based HTTP client for Node.js +* [follow-redirects](follow-redirects) - HTTP and HTTPS modules that follow redirects +* [node-fetch](node-fetch) - A light-weight module bringing Fetch API to Node.js \ No newline at end of file diff --git a/docs/nodejs-api/samples/node-fetch.md b/docs/nodejs-api/samples/node-fetch.md new file mode 100644 index 0000000..9f11189 --- /dev/null +++ b/docs/nodejs-api/samples/node-fetch.md @@ -0,0 +1,64 @@ +--- +layout: default +title: node-fetch +parent: Samples +grand_parent: Node.js API Reference +description: "AntiSSRF agent with node-fetch" +--- + +# AntiSSRFPolicy with the node-fetch Library + +## Introduction + +The [node-fetch library](https://github.com/node-fetch/node-fetch/tree/2.x#readme) is a commonly used request library to extend Node.js http(s) functionality with a `window.fetch` compatible API. The example below shows how you can use the node-fetch library with the AntiSSRF Node.js library. + +## Example + +node-fetch allows you to make requests with the option `agent`, which is either a Node.js HTTP/S agent or a function to return a Node.js HTTP/S agent. + +### Setup + +Set up the `AntiSSRFPolicy`, then get the AntiSSRF agents from the policy. + +```js +import { AntiSSRFPolicy, PolicyConfigOptions } from '@microsoft/antissrf'; +import fetch from "node-fetch"; + +// Customize the policy +const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); + +// Get the AntiSSRF agents +const httpAgent = policy.getHttpAgent(); +const httpsAgent = policy.getHttpsAgent({ keepAlive: true }); + +const agentFn = (_parsedURL: URL) => { + return _parsedURL.protocol === "https:" ? httpsAgent : httpAgent; +} +``` + +### Use the AntiSSRF Agents for Requests + +Every request to an endpoint with untrusted input should include the AntiSSRF agents. + +```js +fetch( + "<some_https_url_constructed_with_untrusted_input>", + { + agent: agentFn + }) + .then((res) => { + /** + * Will get here if the untrusted URL does NOT direct the request to + * an internal or special-purpose IP address + */ + }) + .catch((err) => { + /** + * Will get here if the untrusted URL directs the request to an internal + * or special-purpose IP address + */ + }); +``` + +{: .note } +> If you want different examples or if you find any bug while using `AntiSSRFPolicy` with node-fetch, please let us know at [antissrf-oss@microsoft.com](mailto:antissrf-oss@microsoft.com). diff --git a/docs/nodejs-api/urivalidator/inazurekeyvaultdomain.md b/docs/nodejs-api/urivalidator/inazurekeyvaultdomain.md new file mode 100755 index 0000000..6e1ee0f --- /dev/null +++ b/docs/nodejs-api/urivalidator/inazurekeyvaultdomain.md @@ -0,0 +1,64 @@ +--- +layout: default +title: inAzureKeyVaultDomain +parent: URIValidator +grand_parent: Node.js API Reference +description: "Check if a URI belongs to Azure Key Vault domains" +--- + +# URIValidator.inAzureKeyVaultDomain Method + +## Use Case + +The code is making requests to a URL constructed using untrusted inputs, where an input is considered untrusted if it comes from *user input* or *other services*. + +**AND** + +The URL is expected to belong to an [**Azure Key Vault Domain**](#azure-key-vault-domain-names). + +{: .note } +> * If you instead expect the domain to be in **any domain** or **an untrusted domain**, see [AntiSSRFPolicy](../antissrfpolicy). +> * If you instead expect the URL to be an **Azure Storage endpoint**, see [inAzureStorageDomain](inazurestoragedomain). +> * If you instead expect the domain to be another **specific, trusted domain**, see [inDomain](indomain). + +## Definition + +Validates if a URL is an Azure Key Vault endpoint. + +```js +inAzureKeyVaultDomain(url: URL | string): boolean +``` + +### Parameters + +`url`: `URL | string` + +The URL to be evaluated. + +### Returns + +* `true` if `url` belongs to any of the listed Azure Key Vault domains. +* `false` if `url` does not belong to any of the listed Azure Key Vault domains, the `url` is not a valid URL, or the protocol is not HTTP/S. + +## Examples + +```js +const { URIValidator } = require('@microsoft/antissrf'); + +URIValidator.inAzureKeyVaultDomain('https://myvault.vault.azure.net/secrets/api-key'); +// → true + +URIValidator.inAzureKeyVaultDomain('https://evil.com/secrets'); +// → false +``` + +## Azure Key Vault Domain Names + +`inAzureKeyVaultDomain` will evaluate whether the given parameter belongs to any of the following domains: + +* `vault.azure.net` +* `managedhsm.azure.net` +* `vault.azure.cn` +* `managedhsm.azure.cn` +* `vault.usgovcloudapi.net` +* `managedhsm.usgovcloudapi.net` diff --git a/docs/nodejs-api/urivalidator/inazurestoragedomain.md b/docs/nodejs-api/urivalidator/inazurestoragedomain.md new file mode 100755 index 0000000..92f4d57 --- /dev/null +++ b/docs/nodejs-api/urivalidator/inazurestoragedomain.md @@ -0,0 +1,71 @@ +--- +layout: default +title: inAzureStorageDomain +parent: URIValidator +grand_parent: Node.js API Reference +description: "Check if a URI belongs to Azure Storage domains" +--- + +# URIValidator.inAzureStorageDomain Method + +## Use Case + +The code is making requests to a URL constructed using untrusted inputs, where an input is considered untrusted if it comes from *user input* or *other services*. + +**AND** + +The URL is expected to belong to an [**Azure Storage Domain**](#azure-storage-domain-names). + +{: .note } +> * If you instead expect the domain to be in **any domain** or **an untrusted domain**, see [AntiSSRFPolicy](../antissrfpolicy). +> * If you instead expect the URL to be an **Azure Key Vault endpoint**, see [inAzureKeyVaultDomain](inazurekeyvaultdomain). +> * If you instead expect the domain to be another **specific, trusted domain**, see [inDomain](indomain). + +## Definition + +Validates if a URL is an Azure Storage endpoint. + +```js +inAzureStorageDomain(url: URL | string): boolean +``` + +### Parameters + +`url`: `URL | string` + +The URL to be evaluated. + +### Returns + +* `true` if `url` belongs to any of the listed Azure Storage domains. +* `false` if `url` does not belong to any of the listed Azure Storage domains, the `url` is not a valid URL, or the protocol is not HTTP/S. + +## Examples + +```js +const { URIValidator } = require('@microsoft/antissrf'); + +URIValidator.inAzureStorageDomain('https://mystorageaccount.blob.core.windows.net/container/file.txt'); +// → true + +URIValidator.inAzureStorageDomain('https://evil.com/data'); +// → false +``` + +## Azure Storage Domain Names + +`inAzureStorageDomain` will evaluate whether the given parameter belongs to any combination of the following domains and services: + +**Domains:** +- `core.windows.net` +- `storage.azure.net` +- `core.usgovcloudapi.net` +- `core.chinacloudapi.cn` + +**Services:** +- `blob` +- `web` +- `dfs` +- `file` +- `queue` +- `table` diff --git a/docs/nodejs-api/urivalidator/index.md b/docs/nodejs-api/urivalidator/index.md new file mode 100755 index 0000000..bfbaf8d --- /dev/null +++ b/docs/nodejs-api/urivalidator/index.md @@ -0,0 +1,35 @@ +--- +layout: default +title: URIValidator +parent: Node.js API Reference +description: "URL validation class for SSRF protection" +nav_order: 2 +has_children: true +has_toc: false +--- + +# URIValidator Class + +## Use Case + +The code is making requests to a URL constructed using untrusted inputs, where an input is considered untrusted if it comes from *user input* or *other services*. + +**AND** + +The URL is expected to belong to a specific set of trusted domains, the [Azure Storage domains](inazurestoragedomain#azure-storage-domain-names), or the [Azure Key Vault domains](inazurekeyvaultdomain#azure-key-vault-domain-names). + +{: .note } +> If you instead expect the domain to be in **any domain** or **an untrusted domain**, see [AntiSSRFPolicy](../antissrfpolicy/). + +## Definition + +Provides static methods for validating the hostname and protocol of URLs. + +## Methods + +| Method | Description | +| --- | --- | +| [inAzureKeyVaultDomain(URL \| string)](inazurekeyvaultdomain) | Validates if a URL is an Azure Key Vault endpoint. | +| [inAzureStorageDomain(URL \| string)](inazurestoragedomain) | Validates if a URL is an Azure Storage endpoint. | +| [inDomain(URL \| string, string)](indomain) | Validates if a URL belongs to a trusted domain. | +| [inDomain(URL \| string, string[])](indomain) | Validates if a URL belongs to any of a list of trusted domains. | diff --git a/docs/nodejs-api/urivalidator/indomain.md b/docs/nodejs-api/urivalidator/indomain.md new file mode 100755 index 0000000..5d12c7d --- /dev/null +++ b/docs/nodejs-api/urivalidator/indomain.md @@ -0,0 +1,94 @@ +--- +layout: default +title: inDomain +parent: URIValidator +grand_parent: Node.js API Reference +description: "Check if a URI belongs to specified domain(s)" +--- + +# URIValidator.inDomain Method + +## Use Case + +The code is making requests to a URL constructed using untrusted inputs, where an input is considered untrusted if it comes from *user input* or *other services*. + +**AND** + +The URL is expected to belong to a **specific set of trusted domains**. + +{: .note } +> * If you instead expect the domain to be in **any domain** or **an untrusted domain**, see [AntiSSRFPolicy](../antissrfpolicy). +> * If you instead expect the URL to be an **Azure Key Vault endpoint**, see [inAzureKeyVaultDomain](inazurekeyvaultdomain). +> * If you instead expect the URL to be an **Azure Storage endpoint**, see [inAzureStorageDomain](inazurestoragedomain). + +{: .important } +> If your untrusted URL needs to belong to a specific domain, but you do not fully control all subdomains of the domain, you can use BOTH `inDomain` AND `AntiSSRFPolicy` to be protected. If the untrusted URL belongs to a domain that cannot be fully trusted, at least `AntiSSRFPolicy` is required for full protection. + +## Definition + +Validates if a URL belongs to any of a list of trusted domains. + +## Overloads + +| Method | Description | +| --------------- | --------------- | +| [inDomain(URL \| string, string): boolean](#indomainurl-url--string-domain-boolean) | Validates if a URL belongs to a trusted domain. | +| [inDomain(URL \| string, string[]): boolean](#indomainurl-url--string-domains-string-boolean) | Validates if a URL belongs to any of a list of trusted domains. | + +## inDomain(URL | string, string): boolean + +```js +inDomain(untrustedUrl: URL | string, trustedDomain: string): boolean +``` + +### Parameters + +`untrustedUrl`: `URL | string` + +The URL to be evaluated. + +`trustedDomain`: `string` + +The domain name that `untrustedUrl` will be compared against. + +### Returns + +* `true` if `untrustedUrl` belongs to `trustedDomain`. +* `false` if `untrustedUrl` does not belong to `trustedDomain`, if `untrustedUrl` cannot be converted to a valid `URL`, if protocol is not HTTP/S or WS/S, or if either argument is invalid. + + +## inDomain(URL | string, string[]): boolean + +```js +inDomain(untrustedUrl: URL | string, trustedDomains: string[]): boolean +``` + +### Parameters + +`untrustedUrl`: `URL | string` + +The URL to be evaluated. + +`trustedDomains`: `string[]` + +The list of domain names that `untrustedUrl` will be compared against. + +### Returns + +* `true` if `untrustedUrl` belongs to any domain in `trustedDomains`. +* `false` if `untrustedUrl` does not belong to any domain in `trustedDomains`, if `untrustedUrl` cannot be converted to a valid `URL`, if protocol is not HTTP/S or WS/S, or if either argument is invalid. + +## Examples + +```javascript +const { URIValidator } = require('@microsoft/antissrf'); + +URIValidator.inDomain('https://api.mycompany.com/data', 'mycompany.com'); +// → true + +URIValidator.inDomain('https://api.mycompany.com/data', ['mycompany.com', 'trusted.com']); +// → true + +URIValidator.inDomain('https://evil.com/secrets', 'mycompany.com'); +// → false +``` diff --git a/docs/support.md b/docs/support.md new file mode 100644 index 0000000..c77f560 --- /dev/null +++ b/docs/support.md @@ -0,0 +1,23 @@ +--- +layout: default +title: Support +nav_order: 6 +description: "How to get help and support for Microsoft AntiSSRF" +--- + +# Support + +## How to file issues and get help + +This project uses GitHub Issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new Issue. + +For help and questions about using this project, please: + +- **Check the documentation**: Visit our [documentation site](https://microsoft.github.io/AntiSSRF/) for comprehensive guides and API references +- **Search existing issues**: Browse [GitHub Issues](https://github.com/Microsoft/AntiSSRF/issues) to see if your question has already been answered +- **File a new issue**: Create a [new issue](https://github.com/Microsoft/AntiSSRF/issues/new/choose) using our issue templates for bug reports or feature requests +- **Contact us directly**: Email us at **antissrf-oss@microsoft.com** for questions about usage, integration, or other inquiries + +## Microsoft Support Policy + +Support for Microsoft AntiSSRF is limited to the resources listed above. This is an open source project maintained by Microsoft, and support is provided on a best-effort basis through the community channels listed above. \ No newline at end of file diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props new file mode 100644 index 0000000..a6ff04b --- /dev/null +++ b/dotnet/Directory.Packages.props @@ -0,0 +1,18 @@ +<Project> + <PropertyGroup> + <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> + <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled> + </PropertyGroup> + <ItemGroup> + <PackageVersion Include="coverlet.collector" Version="[6.0.0,)" /> + <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="[17.8.0,)" /> + <PackageVersion Include="MSTest.TestAdapter" Version="[4.0.0,)" /> + <PackageVersion Include="MSTest.TestFramework" Version="[4.0.0,)" /> + <PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="[7.0.0,)" /> + <PackageVersion Include="System.Memory" Version="[4.5.5,)" /> + <PackageVersion Include="System.Net.Http" Version="[4.3.4, )" /> + <PackageVersion Include="System.Threading.Tasks.Extensions" Version="[4.5.4,)" /> + <PackageVersion Include="xunit" Version="[2.6.3,)" /> + <PackageVersion Include="xunit.runner.visualstudio" Version="[2.5.3,)" /> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/dotnet/FunctionalTests/AntiSSRFHandlerTests.cs b/dotnet/FunctionalTests/AntiSSRFHandlerTests.cs new file mode 100644 index 0000000..bedf306 --- /dev/null +++ b/dotnet/FunctionalTests/AntiSSRFHandlerTests.cs @@ -0,0 +1,623 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +#if NET5_0_OR_GREATER +using System.Text.Json; +#endif +using Xunit; + +namespace Microsoft.Security.AntiSSRF.FunctionalTests +{ + public class AntiSSRFHandlerTests + { + private static readonly string testUrl = "https://ambitious-flower-0611c910f.2.azurestaticapps.net/"; + + [Fact] + public async Task AllowAutoRedirect_Tests() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + using var handler = policy.GetHandler(); + + // 1. Default value: true + Assert.True(handler.AllowAutoRedirect); + + // 2. Can set property to a new value + handler.AllowAutoRedirect = false; + + Assert.False(handler.AllowAutoRedirect); + + // 3. Test with actual requests - verify redirects are NOT followed when AllowAutoRedirect = false + using var client = new HttpClient(handler); + var response = await client.GetAsync("https://httpbin.org/redirect/1", CancellationToken.None); + + // Should return redirect status code instead of following + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + Assert.NotNull(response.Headers.Location); + + // Test with AllowAutoRedirect = true + var handler2 = policy.GetHandler(); + handler2.AllowAutoRedirect = true; + using var client2 = new HttpClient(handler2); + var response2 = await client2.GetAsync("https://httpbin.org/redirect/1", CancellationToken.None); + + // Should follow redirect and return final status + Assert.Equal(HttpStatusCode.OK, response2.StatusCode); + + // 4. Cannot be edited after a request is sent + Assert.Throws<InvalidOperationException>(() => handler.AllowAutoRedirect = true); + + // 5. One handler shouldn't affect another + using var handler3 = policy.GetHandler(); + Assert.True(handler3.AllowAutoRedirect); + + // 6. Cannot be edited after disposed + handler3.Dispose(); + Assert.Throws<ObjectDisposedException>(() => handler3.AllowAutoRedirect = false); + } + +#if NET5_0_OR_GREATER + [Fact] + public async Task CookieContainer_Tests() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + using var handler = policy.GetHandler(); + + // 1. Default value: non-null instance of CookieContainer + Assert.NotNull(handler.CookieContainer); + + // 2. Can set property to a new value + var container = new CookieContainer(); + container.Add(new Uri(testUrl), new Cookie("test", "value")); + handler.CookieContainer = container; + handler.UseCookies = true; // Ensure cookies are used + + Assert.Same(container, handler.CookieContainer); + Assert.Equal(1, handler.CookieContainer.Count); + + // 3. New value is respected on requests + container.Add(new Uri("https://httpbin.org/"), new Cookie("testcookie", "testvalue")); + using var client = new HttpClient(handler); + var response = await client.GetAsync("https://httpbin.org/cookies", CancellationToken.None); + var content = await response.Content.ReadAsStringAsync(); + + Assert.Contains("testcookie", content); + + // 4. Cannot be edited after a request is sent + Assert.Throws<InvalidOperationException>(() => handler.CookieContainer = new CookieContainer()); + + // 5. One handler shouldn't affect another + using var handler2 = policy.GetHandler(); + + Assert.NotSame(handler.CookieContainer, handler2.CookieContainer); + Assert.Equal(0, handler2.CookieContainer.Count); + + // 6. Cannot be edited after disposed + handler2.Dispose(); + + Assert.Throws<ObjectDisposedException>(() => handler2.CookieContainer = new CookieContainer()); + } +#endif + + [Fact] + public async Task Credentials_Tests() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + using var handler = policy.GetHandler(); + + // 1. Default value: null + Assert.Null(handler.Credentials); + + // 2. Can set property to a new value + var credentials = new NetworkCredential("testuser", "testpass"); + handler.Credentials = credentials; + Assert.Same(credentials, handler.Credentials); + + // 3. Test actual functionality - use httpbin's basic auth endpoint + var correctCredentials = new NetworkCredential("user", "pass"); + handler.Credentials = correctCredentials; + + using var client = new HttpClient(handler); + + // This endpoint requires basic auth with username "user" and password "pass" + var response1 = await client.GetAsync("https://httpbin.org/basic-auth/user/pass", CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response1.StatusCode); + + // Test with wrong credentials + var handler2 = policy.GetHandler(); + var wrongCredentials = new NetworkCredential("wrong", "wrong"); + handler2.Credentials = wrongCredentials; + + using var client2 = new HttpClient(handler2); + var response2 = await client2.GetAsync("https://httpbin.org/basic-auth/user/pass", CancellationToken.None); + Assert.Equal(HttpStatusCode.Unauthorized, response2.StatusCode); + + // 4. Cannot be edited after a request is sent + Assert.Throws<InvalidOperationException>(() => handler.Credentials = new NetworkCredential("new", "new")); + + // 5. One handler shouldn't affect another + using var handler3 = policy.GetHandler(); + Assert.Null(handler3.Credentials); + + // 6. Cannot be edited after disposed + handler3.Dispose(); + Assert.Throws<ObjectDisposedException>(() => handler3.Credentials = correctCredentials); + } + + [Fact] + public async Task MaxConnectionsPerServer_Tests() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + using var handler = policy.GetHandler(); + + // 1. Default value: int.MaxValue + Assert.True(int.MaxValue == handler.MaxConnectionsPerServer || 2 == handler.MaxConnectionsPerServer); + + // 2. Can set property to a new value + handler.MaxConnectionsPerServer = 5; + + Assert.Equal(5, handler.MaxConnectionsPerServer); + + // 3. New value is respected on requests + using var client = new HttpClient(handler); + var response1 = await client.GetAsync(testUrl, CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response1.StatusCode); + + var response2 = await client.GetAsync(testUrl, CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response2.StatusCode); + + // 4. Cannot be edited after a request is sent + Assert.Throws<InvalidOperationException>(() => handler.MaxConnectionsPerServer = 10); + + // 5. One handler shouldn't affect another + using var handler2 = policy.GetHandler(); + Assert.True(int.MaxValue == handler2.MaxConnectionsPerServer || 2 == handler2.MaxConnectionsPerServer); + + // 6. Cannot be edited after disposed + handler2.Dispose(); + Assert.Throws<ObjectDisposedException>(() => handler2.MaxConnectionsPerServer = 10); + } + + [Fact] + public async Task MaxAutomaticRedirections_Tests() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + using var handler = policy.GetHandler(); + + // 1. Default value: 50 + Assert.Equal(50, handler.MaxAutomaticRedirections); + + // 2. Can set property to a new value + handler.MaxAutomaticRedirections = 2; + + Assert.Equal(2, handler.MaxAutomaticRedirections); + + // 3. Test with actual requests - verify limit is enforced + using var client = new HttpClient(handler); + + // Should fail with too many redirects + var response = await client.GetAsync("https://httpbin.org/redirect/5", CancellationToken.None); + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + + // Test with higher limit - should succeed + var handler2 = policy.GetHandler(); + handler2.MaxAutomaticRedirections = 10; + using var client2 = new HttpClient(handler2); + var response2 = await client2.GetAsync("https://httpbin.org/redirect/5", CancellationToken.None); + + Assert.Equal(HttpStatusCode.OK, response2.StatusCode); + + // 4. Cannot be edited after a request is sent + Assert.Throws<InvalidOperationException>(() => handler.MaxAutomaticRedirections = 100); + + // 5. One handler shouldn't affect another + using var handler3 = policy.GetHandler(); + Assert.Equal(50, handler3.MaxAutomaticRedirections); + + // 6. Cannot be edited after disposed + handler3.Dispose(); + Assert.Throws<ObjectDisposedException>(() => handler3.MaxAutomaticRedirections = 20); + } + + [Fact] + public async Task MaxResponseHeadersLength_Tests() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + using var handler = policy.GetHandler(); + + // 1. Default value: 64 + Assert.Equal(64, handler.MaxResponseHeadersLength); + + // 2. Can set property to a new value + handler.MaxResponseHeadersLength = 128 * 1024; + + Assert.Equal(128 * 1024, handler.MaxResponseHeadersLength); + + // 3. New value is respected on requests + handler.MaxResponseHeadersLength = 1; + using var client = new HttpClient(handler); + + try + { + await client.GetAsync("https://httpbin.org/response-headers?X-Large-Header=" + new string('A', 2000), CancellationToken.None); + Assert.Fail("Request should fail with HttpRequestException due to response headers exceeding limit"); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + ; + } + + // 4. Cannot be edited after a request is sent + Assert.Throws<InvalidOperationException>(() => handler.MaxResponseHeadersLength = 256 * 1024); + + // 5. One handler shouldn't affect another + using var handler2 = policy.GetHandler(); + Assert.Equal(64, handler2.MaxResponseHeadersLength); + + // 6. Cannot be edited after disposed + handler2.Dispose(); + Assert.Throws<ObjectDisposedException>(() => handler2.MaxResponseHeadersLength = 64 * 1024); + } + +#if NET5_0_OR_GREATER + [Fact] + public async Task UseCookies_Tests() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + using var handler = policy.GetHandler(); + + // 1. Default value: true + Assert.True(handler.UseCookies); + + // 2. Can set property to a new value + handler.UseCookies = false; + + Assert.False(handler.UseCookies); + + // 3. Test with actual requests - verify cookies are NOT sent when UseCookies = false + using var client = new HttpClient(handler); + var response = await client.GetAsync("https://httpbin.org/cookies", CancellationToken.None); + var content = await response.Content.ReadAsStringAsync(); + + // Verify cookies property is an empty object + var jsonDoc = JsonDocument.Parse(content); + Assert.True(jsonDoc.RootElement.TryGetProperty("cookies", out var cookiesProperty)); + Assert.Equal(JsonValueKind.Object, cookiesProperty.ValueKind); + Assert.Empty(cookiesProperty.EnumerateObject()); + + // 4. Cannot be edited after a request is sent + Assert.Throws<InvalidOperationException>(() => handler.UseCookies = true); + + // 5. One handler shouldn't affect another + using var handler2 = policy.GetHandler(); + Assert.True(handler2.UseCookies); + + // 6. Cannot be edited after disposed + handler2.Dispose(); + Assert.Throws<ObjectDisposedException>(() => handler2.UseCookies = false); + } +#endif + + [Fact] + public async Task SslProtocols_Tests() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + using var handler = policy.GetHandler(); + + // 1. Default value: None +#if NET5_0_OR_GREATER + Assert.Equal(SslProtocols.None, handler.SslOptions.EnabledSslProtocols); +#else + Assert.Equal(SslProtocols.None, handler.SslProtocols); +#endif + + // Roslyn doesn't like hardcoding the SslProtocols, even in tests + } + + [Fact] + public async Task CheckCertificateRevocationList_Tests() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + using var handler = policy.GetHandler(); + + // 1. Default value: false +#if NET5_0_OR_GREATER + Assert.Equal(X509RevocationMode.NoCheck, handler.SslOptions.CertificateRevocationCheckMode); +#else + Assert.False(handler.CheckCertificateRevocationList); +#endif + + // 2. Can set property to a new value +#if NET5_0_OR_GREATER + handler.SslOptions.CertificateRevocationCheckMode = X509RevocationMode.Online; + Assert.Equal(X509RevocationMode.Online, handler.SslOptions.CertificateRevocationCheckMode); +#else + handler.CheckCertificateRevocationList = true; + Assert.True(handler.CheckCertificateRevocationList); +#endif + + // 3. Test with actual requests - verify it works with CRL checking enabled + using var client = new HttpClient(handler); + + try + { + await client.GetAsync("https://revoked.badssl.com/", CancellationToken.None); + Assert.Fail("Request should have failed due to revoked certificate"); + } + catch (Exception e) when (e is not AntiSSRFException) + { + ; + } + + // Test with valid certificate - should work regardless of CRL setting + var response1 = await client.GetAsync(testUrl, CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response1.StatusCode); + + // 4. Cannot be edited after a request is sent +#if NET5_0_OR_GREATER + Assert.Throws<InvalidOperationException>(() => handler.SslOptions = new SslClientAuthenticationOptions() { + CertificateRevocationCheckMode = X509RevocationMode.Online + }); +#else + Assert.Throws<InvalidOperationException>(() => handler.CheckCertificateRevocationList = false); +#endif + + // 5. One handler shouldn't affect another + using var handler2 = policy.GetHandler(); +#if NET5_0_OR_GREATER + Assert.Equal(X509RevocationMode.NoCheck, handler2.SslOptions.CertificateRevocationCheckMode); +#else + Assert.False(handler2.CheckCertificateRevocationList); +#endif + + // 6. Cannot be edited after disposed + handler2.Dispose(); +#if NET5_0_OR_GREATER + Assert.Throws<ObjectDisposedException>(() => handler2.SslOptions = new SslClientAuthenticationOptions() { + CertificateRevocationCheckMode = X509RevocationMode.Online + }); +#else + Assert.Throws<ObjectDisposedException>(() => handler2.CheckCertificateRevocationList = true); +#endif + } + +#if NET5_0_OR_GREATER + [Fact] + public async Task ConnectTimeout_Tests() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + using var handler = policy.GetHandler(); + + // 1. Default value: InfiniteTimeSpan + Assert.Equal(Timeout.InfiniteTimeSpan, handler.ConnectTimeout); + + // 2. Can set property to a new value + handler.ConnectTimeout = TimeSpan.FromSeconds(30); + + Assert.Equal(TimeSpan.FromSeconds(30), handler.ConnectTimeout); + + // 3. Test with actual requests - set a very short timeout that should fail + handler.ConnectTimeout = TimeSpan.FromMilliseconds(1); // Very short timeout + using var client = new HttpClient(handler); + + await Assert.ThrowsAsync<TaskCanceledException>(async () => + await client.GetAsync("https://httpbin.org/delay/1", CancellationToken.None)); + + // 4. Cannot be edited after a request is sent + Assert.Throws<InvalidOperationException>(() => handler.ConnectTimeout = TimeSpan.FromSeconds(60)); + + // 5. One handler shouldn't affect another + using var handler2 = policy.GetHandler(); + Assert.Equal(Timeout.InfiniteTimeSpan, handler2.ConnectTimeout); + + // 6. Cannot be edited after disposed + handler2.Dispose(); + Assert.Throws<ObjectDisposedException>(() => handler2.ConnectTimeout = TimeSpan.FromSeconds(30)); + } + + [Fact] + public async Task ResponseDrainTimeout_Tests() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + using var handler = policy.GetHandler(); + + // 1. Default value: 2 seconds + Assert.Equal(TimeSpan.FromSeconds(2), handler.ResponseDrainTimeout); + + // 2. Can set property to a new value + handler.ResponseDrainTimeout = TimeSpan.FromSeconds(5); + + Assert.Equal(TimeSpan.FromSeconds(5), handler.ResponseDrainTimeout); + + // 3. Test with actual requests - verify it works with reasonable timeout + handler.ResponseDrainTimeout = TimeSpan.FromMilliseconds(1); + using var client = new HttpClient(handler); + var response = await client.GetAsync("https://httpbin.org/bytes/10000", CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // 4. Cannot be edited after a request is sent + Assert.Throws<InvalidOperationException>(() => handler.ResponseDrainTimeout = TimeSpan.FromSeconds(10)); + + // 5. One handler shouldn't affect another + using var handler2 = policy.GetHandler(); + Assert.Equal(TimeSpan.FromSeconds(2), handler2.ResponseDrainTimeout); + + // 6. Cannot be edited after disposed + handler2.Dispose(); + Assert.Throws<ObjectDisposedException>(() => handler2.ResponseDrainTimeout = TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task PooledConnectionIdleTimeout_Tests() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + using var handler = policy.GetHandler(); + + // 1. Default value: TimeSpan.FromMinutes(1) + Assert.Equal(TimeSpan.FromMinutes(1), handler.PooledConnectionIdleTimeout); + + // 2. Can set property to a new value + handler.PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5); + + Assert.Equal(TimeSpan.FromMinutes(5), handler.PooledConnectionIdleTimeout); + + // 3. Test with actual requests - Hard to test, just checking requests still happen + using var client = new HttpClient(handler); + var response1 = await client.GetAsync(testUrl, CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response1.StatusCode); + + var response2 = await client.GetAsync(testUrl, CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response2.StatusCode); + + // 4. Cannot be edited after a request is sent + Assert.Throws<InvalidOperationException>(() => handler.PooledConnectionIdleTimeout = TimeSpan.FromMinutes(10)); + + // 5. One handler shouldn't affect another + using var handler2 = policy.GetHandler(); + Assert.Equal(TimeSpan.FromMinutes(1), handler2.PooledConnectionIdleTimeout); + + // 6. Cannot be edited after disposed + handler2.Dispose(); + Assert.Throws<ObjectDisposedException>(() => handler2.PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5)); + } + + [Fact] + public async Task PooledConnectionLifetime_Tests() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + using var handler = policy.GetHandler(); + + // 1. Default value: InfiniteTimeSpan + var defaultValue = Timeout.InfiniteTimeSpan; + Assert.Equal(Timeout.InfiniteTimeSpan, defaultValue); + + // 2. Can set property to a new value + handler.PooledConnectionLifetime = TimeSpan.FromMinutes(10); + + Assert.Equal(TimeSpan.FromMinutes(10), handler.PooledConnectionLifetime); + + // 3. Test with actual requests - Hard to test, just checking requests still happen + handler.PooledConnectionLifetime = TimeSpan.FromMinutes(5); + using var client = new HttpClient(handler); + var response1 = await client.GetAsync(testUrl, CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response1.StatusCode); + + var response2 = await client.GetAsync(testUrl, CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response2.StatusCode); + + // 4. Cannot be edited after a request is sent + Assert.Throws<InvalidOperationException>(() => handler.PooledConnectionLifetime = TimeSpan.FromMinutes(15)); + + // 5. One handler shouldn't affect another + using var handler2 = policy.GetHandler(); + Assert.Equal(defaultValue, handler2.PooledConnectionLifetime); + + // 6. Cannot be edited after disposed + handler2.Dispose(); + Assert.Throws<ObjectDisposedException>(() => handler2.PooledConnectionLifetime = TimeSpan.FromMinutes(5)); + } + +#pragma warning disable CA5359 // Do not disable certificate validation - intentionally testing SSL callback behavior + [Fact] + public async Task RemoteCertificateValidationCallback_Tests() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + using var handler = policy.GetHandler(); + + // 1. Default value: null + Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback); + + // 2. Can set property to a new value + handler.SslOptions.RemoteCertificateValidationCallback = new RemoteCertificateValidationCallback((sender, cert, chain, errors) => true); + Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback); + + // 3. Test actual functionality - use callback that always returns true + handler.SslOptions.RemoteCertificateValidationCallback = new RemoteCertificateValidationCallback((sender, cert, chain, errors) => true); + using var client = new HttpClient(handler); + var response1 = await client.GetAsync("https://self-signed.badssl.com/", CancellationToken.None); + + Assert.Equal(HttpStatusCode.OK, response1.StatusCode); + + // Test with callback that always returns false + var handler2 = policy.GetHandler(); + handler2.SslOptions.RemoteCertificateValidationCallback = new RemoteCertificateValidationCallback((sender, cert, chain, errors) => false); + using var client2 = new HttpClient(handler2); + + await Assert.ThrowsAsync<AuthenticationException>(async () => + await client2.GetAsync(testUrl, CancellationToken.None)); + + // 4. Cannot be edited after a request is sent + Assert.Throws<InvalidOperationException>(() => handler.SslOptions = new SslClientAuthenticationOptions() { + RemoteCertificateValidationCallback = null + }); + + // 5. One handler shouldn't affect another + using var handler3 = policy.GetHandler(); + Assert.Null(handler3.SslOptions.RemoteCertificateValidationCallback); + + // 6. Cannot be edited after disposed + handler3.Dispose(); + Assert.Throws<ObjectDisposedException>(() => handler3.SslOptions = new SslClientAuthenticationOptions() { + RemoteCertificateValidationCallback = null + }); + } +#pragma warning restore CA5359 +#else +#pragma warning disable CA5359 // Do not disable certificate validation - intentionally testing SSL callback behavior + [Fact] + public async Task ServerCertificateCustomValidationCallback_Tests() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + using var handler = policy.GetHandler(); + + // 1. Default value: null + Assert.Null(handler.ServerCertificateCustomValidationCallback); + + // 2. Can set property to a new value + Func<object, X509Certificate2?, X509Chain?, SslPolicyErrors, bool> alwaysTrue = + (request, cert, chain, errors) => true; + + handler.ServerCertificateCustomValidationCallback = alwaysTrue; + Assert.Same(alwaysTrue, handler.ServerCertificateCustomValidationCallback); + + // 3. Test actual functionality - use callback that always returns true + handler.ServerCertificateCustomValidationCallback = (request, cert, chain, errors) => true; + using var client = new HttpClient(handler); + var response1 = await client.GetAsync("https://self-signed.badssl.com/", CancellationToken.None); + + Assert.Equal(HttpStatusCode.OK, response1.StatusCode); + + // Test with callback that always returns false + var handler2 = policy.GetHandler(); + handler2.ServerCertificateCustomValidationCallback = (request, cert, chain, errors) => false; + using var client2 = new HttpClient(handler2); + + try + { + await client2.GetAsync(testUrl, CancellationToken.None); + Assert.Fail("Request should have failed due to certificate validation failure"); + } + catch (Exception e) when (e is not AntiSSRFException) + { + ; + } + + // 4. Cannot be edited after a request is sent + Assert.Throws<InvalidOperationException>(() => handler.ServerCertificateCustomValidationCallback = null); + + // 5. One handler shouldn't affect another + using var handler3 = policy.GetHandler(); + Assert.Null(handler3.ServerCertificateCustomValidationCallback); + + // 6. Cannot be edited after disposed + handler3.Dispose(); + Assert.Throws<ObjectDisposedException>(() => handler3.ServerCertificateCustomValidationCallback = alwaysTrue); + } +#pragma warning restore CA5359 +#endif + } +} diff --git a/dotnet/FunctionalTests/AntiSSRFPolicy.AddXFFHeaderTests.cs b/dotnet/FunctionalTests/AntiSSRFPolicy.AddXFFHeaderTests.cs new file mode 100644 index 0000000..c3038dc --- /dev/null +++ b/dotnet/FunctionalTests/AntiSSRFPolicy.AddXFFHeaderTests.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Security.AntiSSRF.FunctionalTests +{ + public class AntiSSRFPolicy_AddXFFHeaderTests + { + private static readonly string TestDomain = "ambitious-flower-0611c910f.2.azurestaticapps.net"; + + [Fact] + public void CheckDefaults() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.InternalOnly); + var policy2 = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + var policy3 = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); + var policy4 = new AntiSSRFPolicy(PolicyConfigOptions.None); + Assert.False(policy.AddXFFHeader); + Assert.True(policy2.AddXFFHeader); + Assert.True(policy3.AddXFFHeader); + Assert.False(policy4.AddXFFHeader); + } + + [Fact] + public async Task OnTrue() + { + AntiSSRFPolicy policy = new(PolicyConfigOptions.None) + { + AddXFFHeader = true + }; + using var client = new HttpClient(policy.GetHandler()); + + var response = await client.GetAsync($"https://{TestDomain}/api/header-check?header=X-Forwarded-For", CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task DoesNotOverwriteHeader() + { + AntiSSRFPolicy policy = new(PolicyConfigOptions.None) + { + AddXFFHeader = true + }; + using var client = new HttpClient(policy.GetHandler()); + + using var request = new HttpRequestMessage(HttpMethod.Get, $"https://{TestDomain}/api/header-check?header=X-Forwarded-For"); + request.Headers.Add("X-Forwarded-For", "1.2.3.4"); + + using var response = await client.SendAsync(request, CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var contents = await response.Content.ReadAsStringAsync(); + Assert.Contains("1.2.3.4", contents); + } + + [Fact] + public void NoEditsAfterHandler() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + policy.GetHandler(); + + Assert.Throws<AntiSSRFException>(() => policy.AddXFFHeader = true); + } + } +} diff --git a/dotnet/FunctionalTests/AntiSSRFPolicy.AddressTests.cs b/dotnet/FunctionalTests/AntiSSRFPolicy.AddressTests.cs new file mode 100644 index 0000000..a936f03 --- /dev/null +++ b/dotnet/FunctionalTests/AntiSSRFPolicy.AddressTests.cs @@ -0,0 +1,773 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Security.AntiSSRF.FunctionalTests +{ + public class AntiSSRFPolicy_AddressTests + { + private static readonly string TestDomain = "ambitious-flower-0611c910f.2.azurestaticapps.net"; + private static readonly uint BlockedByAzureFirewall = 470; + + [Fact] + public void BadInputs() + { + AntiSSRFPolicy policy = new(PolicyConfigOptions.None) + { + DenyAllUnspecifiedIPs = true + }; + Assert.Throws<AntiSSRFException>(() => policy.AddDeniedAddresses(["1.2.3.4"])); + + // Test null arrays + AntiSSRFPolicy policy2 = new(PolicyConfigOptions.None); + Assert.Throws<ArgumentNullException>(() => policy2.AddAllowedAddresses(null)); + Assert.Throws<ArgumentNullException>(() => policy2.AddDeniedAddresses(null)); + + // Test empty arrays - these should be allowed (no-op) + policy2.AddAllowedAddresses([]); + policy2.AddDeniedAddresses([]); + + // Test invalid IP address formats + Assert.Throws<FormatException>(() => policy2.AddDeniedAddresses(["invalid.ip.address"])); + Assert.Throws<FormatException>(() => policy2.AddDeniedAddresses(["256.256.256.256/24"])); + Assert.Throws<FormatException>(() => policy2.AddDeniedAddresses(["192.168.1.1/33"])); // Invalid subnet mask + Assert.Throws<FormatException>(() => policy2.AddAllowedAddresses(["not-an-ip"])); + + // Invalid arrays should not partially mutate the policy. + Assert.Throws<FormatException>(() => policy2.AddAllowedAddresses(["10.0.0.0/8", "not-an-ip"])); + Assert.Empty(policy2.AllowedAddresses); + + Assert.Throws<FormatException>(() => policy2.AddDeniedAddresses(["192.168.1.0/24", "invalid.ip.address"])); + Assert.Empty(policy2.DeniedAddresses); + + // Test array containing null addresses + AntiSSRFPolicy policy3 = new(PolicyConfigOptions.None); + Assert.Throws<ArgumentNullException>(() => policy3.AddDeniedAddresses(["192.168.1.0/24", null!, "10.0.0.0/8"])); + Assert.Throws<ArgumentNullException>(() => policy3.AddAllowedAddresses([null!])); + } + + [Fact] + public void AddDeniedAddresses_FromIPAddressRanges_PopulatesDeniedAddresses() + { + AntiSSRFPolicy policy = new(PolicyConfigOptions.None); + List<string> deniedRanges = new(); + deniedRanges.AddRange(IPAddressRanges.imds); + deniedRanges.AddRange(IPAddressRanges.wireserver); + deniedRanges.AddRange(IPAddressRanges.loopback); + + policy.AddDeniedAddresses(deniedRanges.ToArray()); + + IReadOnlyList<string> deniedAddresses = policy.DeniedAddresses; + Assert.Contains("169.254.169.254/32", deniedAddresses); + Assert.Contains("168.63.129.16/32", deniedAddresses); + Assert.Contains("127.0.0.0/8", deniedAddresses); + Assert.Contains("::1/128", deniedAddresses); + } + + [Fact] + public void AddAllowedAddresses_FromIPAddressRanges_PopulatesAllowedAddresses() + { + AntiSSRFPolicy policy = new(PolicyConfigOptions.None) + { + DenyAllUnspecifiedIPs = true + }; + List<string> allowedRanges = new(); + allowedRanges.AddRange(IPAddressRanges.privateUse); + allowedRanges.AddRange(IPAddressRanges.documentation); + + policy.AddAllowedAddresses(allowedRanges.ToArray()); + + IReadOnlyList<string> allowedAddresses = policy.AllowedAddresses; + Assert.Contains("10.0.0.0/8", allowedAddresses); + Assert.Contains("172.16.0.0/12", allowedAddresses); + Assert.Contains("192.168.0.0/16", allowedAddresses); + Assert.Contains("192.0.2.0/24", allowedAddresses); + Assert.Contains("2001:db8::/32", allowedAddresses); + } + + [Fact] + public async Task CheckDefaults_IMDSAsync() + { + using HttpClient client = new(new AntiSSRFPolicy(PolicyConfigOptions.InternalOnly).GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + using HttpClient client2 = new(new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1).GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + using HttpClient client3 = new(new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest).GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + using HttpClient client4 = new(new AntiSSRFPolicy(PolicyConfigOptions.None).GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + + var ipUrl = "https://169.254.169.254/latest/meta-data/"; + using (var cts1 = new CancellationTokenSource(TimeSpan.FromSeconds(1))) + await Assert.ThrowsAsync<AntiSSRFException>(async () => await client.GetAsync(ipUrl, cts1.Token)); + using (var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(1))) + await Assert.ThrowsAsync<AntiSSRFException>(async () => await client2.GetAsync(ipUrl, cts2.Token)); + using (var cts3 = new CancellationTokenSource(TimeSpan.FromSeconds(1))) + await Assert.ThrowsAsync<AntiSSRFException>(async () => await client3.GetAsync(ipUrl, cts3.Token)); + try + { + using var cts4 = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); + await client4.GetAsync(ipUrl, cts4.Token); + // If we get here, the call succeeded (no AntiSSRFException thrown as expected) + Assert.True(true, "Call completed successfully without throwing AntiSSRFException as expected"); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + Assert.True(true, "Call failed with expected non-AntiSSRF exception: " + ex.GetType().Name); + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + + var nonStandardIpUrl = "https://0xA9.0xFE.0xA9.0xFE/latest/meta-data/"; + using (var cts1 = new CancellationTokenSource(TimeSpan.FromSeconds(1))) + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync(nonStandardIpUrl, cts1.Token)); + using (var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(1))) + await Assert.ThrowsAsync<AntiSSRFException>(() => client2.GetAsync(nonStandardIpUrl, cts2.Token)); + using (var cts3 = new CancellationTokenSource(TimeSpan.FromSeconds(1))) + await Assert.ThrowsAsync<AntiSSRFException>(() => client3.GetAsync(nonStandardIpUrl, cts3.Token)); + try + { + using (var cts4 = new CancellationTokenSource(TimeSpan.FromSeconds(1))) + await client4.GetAsync(nonStandardIpUrl, cts4.Token); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + + var mappedIpUrl = "https://[::ffff:169.254.169.254]/latest/meta-data/"; + using (var cts5 = new CancellationTokenSource(TimeSpan.FromSeconds(1))) + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync(mappedIpUrl, cts5.Token)); + using (var cts6 = new CancellationTokenSource(TimeSpan.FromSeconds(1))) + await Assert.ThrowsAsync<AntiSSRFException>(() => client2.GetAsync(mappedIpUrl, cts6.Token)); + using (var cts7 = new CancellationTokenSource(TimeSpan.FromSeconds(1))) + await Assert.ThrowsAsync<AntiSSRFException>(() => client3.GetAsync(mappedIpUrl, cts7.Token)); + try + { + using (var cts8 = new CancellationTokenSource(TimeSpan.FromSeconds(1))) + await client4.GetAsync(mappedIpUrl, cts8.Token); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + + var mappedIpUrl2 = "https://[::ffff:A9FE:A9FE]/latest/meta-data/"; + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync(mappedIpUrl2, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client2.GetAsync(mappedIpUrl2, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client3.GetAsync(mappedIpUrl2, CancellationToken.None)); + try + { + await client4.GetAsync(mappedIpUrl2, CancellationToken.None); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + + var redirectUrl = $"https://{TestDomain}/api/imds-ip?code=301"; + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync(redirectUrl, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client2.GetAsync(redirectUrl, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client3.GetAsync(redirectUrl, CancellationToken.None)); + try + { + await client4.GetAsync(redirectUrl, CancellationToken.None); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + + var redirectUrl2 = $"https://{TestDomain}/api/imds-ip?code=302"; + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync(redirectUrl2, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client2.GetAsync(redirectUrl2, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client3.GetAsync(redirectUrl2, CancellationToken.None)); + try + { + await client4.GetAsync(redirectUrl2, CancellationToken.None); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + + var redirectUrl3 = $"https://{TestDomain}/api/imds?redirectNum=3"; + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync(redirectUrl3, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client2.GetAsync(redirectUrl3, CancellationToken.None)); + + await Assert.ThrowsAsync<AntiSSRFException>(() => client3.GetAsync(redirectUrl3, CancellationToken.None)); + try + { + await client4.GetAsync(redirectUrl3, CancellationToken.None); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + } + + [Fact] + public async Task CheckDefaults_WireServerAsync() + { + using HttpClient client = new(new AntiSSRFPolicy(PolicyConfigOptions.InternalOnly) + { + AllowPlainTextHttp = true + }.GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + using HttpClient client2 = new(new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1) + { + AllowPlainTextHttp = true + }.GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + using HttpClient client3 = new(new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest) + { + AllowPlainTextHttp = true + }.GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + using HttpClient client4 = new(new AntiSSRFPolicy(PolicyConfigOptions.None) + { + AllowPlainTextHttp = true + }.GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + + var ipUrl = "http://168.63.129.16/"; + await Assert.ThrowsAsync<AntiSSRFException>(async () => await client.GetAsync(ipUrl, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token)); + await Assert.ThrowsAsync<AntiSSRFException>(async () => await client2.GetAsync(ipUrl, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token)); + await Assert.ThrowsAsync<AntiSSRFException>(async () => await client3.GetAsync(ipUrl, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token)); + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await client4.GetAsync(ipUrl, cts.Token); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + + var nonStandardIpUrl = "http://0xA8.0x3F.0x81.0x10/"; + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync(nonStandardIpUrl, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client2.GetAsync(nonStandardIpUrl, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client3.GetAsync(nonStandardIpUrl, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token)); + try + { + await client4.GetAsync(nonStandardIpUrl, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + + + var mappedIpUrl = "http://[::ffff:168.63.129.16]/"; + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync(mappedIpUrl, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client2.GetAsync(mappedIpUrl, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client3.GetAsync(mappedIpUrl, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token)); + try + { + await client4.GetAsync(mappedIpUrl, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + + + var mappedIpUrl2 = "http://[::ffff:A83F:8110]/"; + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync(mappedIpUrl2, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client2.GetAsync(mappedIpUrl2, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client3.GetAsync(mappedIpUrl2, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token)); + try + { + await client4.GetAsync(mappedIpUrl2, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + + var redirectUrl = $"https://{TestDomain}/api/wireserver"; + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync(redirectUrl, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client2.GetAsync(redirectUrl, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client3.GetAsync(redirectUrl, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token)); + try + { + await client4.GetAsync(redirectUrl, new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + } + + [Fact] + public async Task CheckDefaults_LocalHostAsync() + { + using HttpClient client = new(new AntiSSRFPolicy(PolicyConfigOptions.InternalOnly) + { + AllowPlainTextHttp = true + }.GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + using HttpClient client2 = new(new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1) + { + AllowPlainTextHttp = true + }.GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + using HttpClient client3 = new(new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest) + { + AllowPlainTextHttp = true + }.GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + using HttpClient client4 = new(new AntiSSRFPolicy(PolicyConfigOptions.None) + { + AllowPlainTextHttp = true + }.GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + + var ipUrl = "http://127.0.0.1/"; + await Assert.ThrowsAsync<AntiSSRFException>(async () => await client.GetAsync(ipUrl, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(async () => await client2.GetAsync(ipUrl, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(async () => await client3.GetAsync(ipUrl, CancellationToken.None)); + try + { + await client4.GetAsync(ipUrl, CancellationToken.None); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + + var nonStandardIpUrl = "http://0x7F.0x0.0x0.0x1/"; + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync(nonStandardIpUrl, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client2.GetAsync(nonStandardIpUrl, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client3.GetAsync(nonStandardIpUrl, CancellationToken.None)); + try + { + await client4.GetAsync(nonStandardIpUrl, CancellationToken.None); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + + var mappedIpUrl = "http://[::ffff:127.0.0.1]/"; + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync(mappedIpUrl, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client2.GetAsync(mappedIpUrl, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client3.GetAsync(mappedIpUrl, CancellationToken.None)); + try + { + await client4.GetAsync(mappedIpUrl, CancellationToken.None); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + + var mappedIpUrl2 = "http://[::ffff:7F00:1]/"; + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync(mappedIpUrl2, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client2.GetAsync(mappedIpUrl2, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client3.GetAsync(mappedIpUrl2, CancellationToken.None)); + try + { + await client4.GetAsync(mappedIpUrl2, CancellationToken.None); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + + var redirectUrl = $"https://{TestDomain}/api/localhost"; + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync(redirectUrl, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client2.GetAsync(redirectUrl, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client3.GetAsync(redirectUrl, CancellationToken.None)); + try + { + await client4.GetAsync(redirectUrl, CancellationToken.None); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + + var localhostUrl = "http://localhost/"; + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync(localhostUrl, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client2.GetAsync(localhostUrl, CancellationToken.None)); + await Assert.ThrowsAsync<AntiSSRFException>(() => client3.GetAsync(localhostUrl, CancellationToken.None)); + try + { + await client4.GetAsync(localhostUrl, CancellationToken.None); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Expected: timeout, socket exception, etc. - but not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for localhost since it is not in the denied list"); + } + } + + [Fact] + public async Task Allow_IPv4() + { + AntiSSRFPolicy policy = new(PolicyConfigOptions.None) + { + AllowPlainTextHttp = true, + DenyAllUnspecifiedIPs = true + }; + IPAddress[] testIpArr = Dns.GetHostAddresses(TestDomain); + policy.AddAllowedAddresses(testIpArr.Select(ip => ip.ToString()).ToArray()); + using HttpClient client = new(policy.GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + + // Allowed IPv4 + using var response = await client.GetAsync("http://" + testIpArr[0], CancellationToken.None); + Assert.True(response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NotFound || (uint)response.StatusCode == BlockedByAzureFirewall, $"Request to IPv4 address {testIpArr[0]} should be allowed since it is in the allowed list, but got status code {response.StatusCode}"); + + // Allowed IPv4-mapped IPv6 + try + { + using var response2 = await client.GetAsync("http://[" + testIpArr[0].MapToIPv6() + "]:80", CancellationToken.None); + Assert.True(response2.StatusCode == HttpStatusCode.OK || response2.StatusCode == HttpStatusCode.NotFound || (uint)response2.StatusCode == BlockedByAzureFirewall, $"Request to IPv4-mapped IPv6 address {testIpArr[0].MapToIPv6()} should be allowed since it is in the allowed list, but got status code {response2.StatusCode}"); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Pipeline struggles with some IPv6 addresses, so we catch exceptions that are not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for IPv4-mapped IPv6 address since it is in the allowed list"); + } + + // Disallowed IPv4 + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync("http://1.2.3.4", CancellationToken.None)); + + // Disallowed IPv6 + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync("http://[1:2:3:4:5:6:7:8]", CancellationToken.None)); + + using var response3 = await client.GetAsync("http://" + TestDomain, CancellationToken.None); + Assert.True(response3.StatusCode == HttpStatusCode.OK || response3.StatusCode == HttpStatusCode.NotFound || (uint)response3.StatusCode == BlockedByAzureFirewall, $"Request to domain {TestDomain} should be allowed since it is in the allowed list, but got status code {response3.StatusCode}"); + } + + [Fact] + public async Task Allow_IPv4MappedIPv6() + { + AntiSSRFPolicy policy = new(PolicyConfigOptions.None) + { + AllowPlainTextHttp = true, + DenyAllUnspecifiedIPs = true + }; + IPAddress[] testIpArr = Dns.GetHostAddresses(TestDomain); + policy.AddAllowedAddresses(testIpArr.Select(ip => ip.MapToIPv6().ToString()).ToArray()); + using HttpClient client = new(policy.GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + + // Allowed IPv4 + using var response = await client.GetAsync("http://" + testIpArr[0], CancellationToken.None); + Assert.True(response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NotFound || (uint)response.StatusCode == BlockedByAzureFirewall, $"Request to IPv4 address {testIpArr[0]} should be allowed since its IPv4-mapped IPv6 address is in the allowed list, but got status code {response.StatusCode}"); + + // Allowed IPv4-mapped IPv6 + try + { + using var response2 = await client.GetAsync("http://[" + testIpArr[0].MapToIPv6() + "]:80", CancellationToken.None); + Assert.True(response2.StatusCode == HttpStatusCode.OK || response2.StatusCode == HttpStatusCode.NotFound || (uint)response2.StatusCode == BlockedByAzureFirewall, $"Request to IPv4-mapped IPv6 address {testIpArr[0].MapToIPv6()} should be allowed since it is in the allowed list, but got status code {response2.StatusCode}"); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Pipeline struggles with some IPv6 addresses, so we catch exceptions that are not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for IPv4-mapped IPv6 address since it is in the allowed list"); + } + + using var response3 = await client.GetAsync("https://" + TestDomain, CancellationToken.None); + Assert.True(response3.StatusCode == HttpStatusCode.OK || response3.StatusCode == HttpStatusCode.NotFound || (uint)response3.StatusCode == BlockedByAzureFirewall, $"Request to domain {TestDomain} should be allowed since its IPv4-mapped IPv6 address is in the allowed list, but got status code {response3.StatusCode}"); + + // Disallowed IPv4 + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync("http://1.2.3.4", CancellationToken.None)); + + // Disallowed IPv6 + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync("http://[1:2:3:4:5:6:7:8]", CancellationToken.None)); + } + + [Fact] + public async Task Allow_IPv6() + { + AntiSSRFPolicy policy = new(PolicyConfigOptions.None) + { + AllowPlainTextHttp = true, + DenyAllUnspecifiedIPs = true + }; + string testIPv6 = "::1"; + policy.AddAllowedAddresses(new[] { testIPv6 }); + using HttpClient client = new(policy.GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + + // Allowed IPv6 + try + { + await client.GetAsync($"http://[{testIPv6}]", CancellationToken.None); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Pipeline struggles with some IPv6 addresses, so we catch exceptions that are not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for client3 since defaults are not used, but a timeout or socket exception may occur instead"); + } + + // Disallowed IPv4 + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync("https://1.2.3.4", CancellationToken.None)); + + // Disallowed different IPv6 + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync("https://[2606:4700:4700::1111]", CancellationToken.None)); + } + + [Fact] + public async Task Deny_IPv4() + { + AntiSSRFPolicy policy = new(PolicyConfigOptions.None); + IPAddress testIp = Dns.GetHostAddresses(TestDomain)[0]; + policy.AddDeniedAddresses(new[] { testIp.ToString() }); + using HttpClient client = new(policy.GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + + // Denied IPv4 + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync("https://" + testIp, CancellationToken.None)); + + // Denied IPv4-mapped IPv6 (should also be denied since it's the same address) + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync("https://[" + testIp.MapToIPv6() + "]", CancellationToken.None)); + + // Allowed different IPv4 + using var response = await client.GetAsync("https://github.com", CancellationToken.None); + Assert.True(HttpStatusCode.OK == response.StatusCode || (uint)response.StatusCode == BlockedByAzureFirewall, $"Request to different IPv4 address should be allowed since only {testIp} is in the denied list, but got status code {response.StatusCode}"); + + // Allowed IPv6 + try + { + using var response2 = await client.GetAsync("https://ipv6.google.com", CancellationToken.None); + Assert.True(HttpStatusCode.OK == response2.StatusCode || (uint)response2.StatusCode == BlockedByAzureFirewall, $"Request to IPv6 address should be allowed since it is not in the denied list, but got status code {response2.StatusCode}"); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Pipeline struggles with some IPv6 addresses + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for IPv6 address since it is not in the denied list"); + } + } + + [Fact] + public async Task Deny_IPv4MappedIPv6() + { + AntiSSRFPolicy policy = new(PolicyConfigOptions.None) + { + AllowPlainTextHttp = true + }; + IPAddress testIp = Dns.GetHostAddresses(TestDomain)[0]; + policy.AddDeniedAddresses(new[] { testIp.MapToIPv6().ToString() }); + using HttpClient client = new(policy.GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + + // Denied IPv4 (should be denied since IPv4-mapped IPv6 denies the underlying IPv4) + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync("http://" + testIp, CancellationToken.None)); + + // Denied IPv4-mapped IPv6 + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync("http://[" + testIp.MapToIPv6() + "]", CancellationToken.None)); + + // Allowed different IPv4 + using var response = await client.GetAsync($"https://github.com", CancellationToken.None); + Assert.True(HttpStatusCode.OK == response.StatusCode || (uint)response.StatusCode == BlockedByAzureFirewall, $"Request to different IPv4 address should be allowed since it is not in the denied list, but got status code {response.StatusCode}"); + + // Allowed IPv6 + try + { + using var response2 = await client.GetAsync("https://ipv6.google.com", CancellationToken.None); + Assert.True(HttpStatusCode.OK == response2.StatusCode || (uint)response2.StatusCode == BlockedByAzureFirewall, $"Request to IPv6 address should be allowed since it is not in the denied list, but got status code {response2.StatusCode}"); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Pipeline struggles with some IPv6 addresses + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for IPv6 address since it is not in the denied list"); + } + } + + [Fact] + public async Task Deny_IPv6Async() + { + AntiSSRFPolicy policy = new(PolicyConfigOptions.None) + { + AllowPlainTextHttp = true + }; + string testIPv6 = "2001:4860:4860::8888"; + policy.AddDeniedAddresses(new[] { testIPv6 }); + using HttpClient client = new(policy.GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + + // Denied IPv6 + await Assert.ThrowsAsync<AntiSSRFException>(async () => await client.GetAsync($"http://[{testIPv6}]", CancellationToken.None)); + + // Allowed IPv4 + using var response = await client.GetAsync($"https://{TestDomain}", CancellationToken.None); + Assert.True(HttpStatusCode.OK == response.StatusCode || (uint)response.StatusCode == BlockedByAzureFirewall, $"Request to domain {TestDomain} should be allowed since it is not in the denied list, but got status code {response.StatusCode}"); + + // Allowed different IPv6 + try + { + using var response2 = await client.GetAsync("http://[2606:4700:4700::1111]", CancellationToken.None); + Assert.True(HttpStatusCode.OK == response2.StatusCode || (uint)response2.StatusCode == BlockedByAzureFirewall, $"Request to different IPv6 address should be allowed since it is not in the denied list, but got status code {response2.StatusCode}"); + } + catch (Exception ex) when (ex is not AntiSSRFException) + { + // Pipeline struggles with some IPv6 addresses, so we catch exceptions that are not AntiSSRFException + } + catch (AntiSSRFException) + { + Assert.Fail("AntiSSRFException should not be thrown for different IPv6 address since it is not in the denied list"); + } + + } + + [Fact] + public async Task BothAllowAndDeny() + { + AntiSSRFPolicy policy = new(PolicyConfigOptions.None); + IPAddress testIp = Dns.GetHostAddresses(TestDomain)[0]; + policy.AddDeniedAddresses(new[] { testIp.ToString() }); + policy.AddAllowedAddresses(new[] { testIp.MapToIPv6().ToString() }); + using HttpClient client = new(policy.GetHandler()) + { + Timeout = TimeSpan.FromSeconds(3) + }; + + using var response = await client.GetAsync("https://" + TestDomain, CancellationToken.None); + Assert.True(HttpStatusCode.OK == response.StatusCode || (uint)response.StatusCode == BlockedByAzureFirewall, $"Request to domain {TestDomain} should be allowed since it is in the allowed list, but got status code {response.StatusCode}"); + } + + [Fact] + public void NoEditsAfterHandler() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + policy.GetHandler(); + + Assert.Throws<AntiSSRFException>(() => policy.AddAllowedAddresses(["1.2.3.4"])); + Assert.Throws<AntiSSRFException>(() => policy.AddDeniedAddresses(["1.2.3.4"])); + } + } +} diff --git a/dotnet/FunctionalTests/AntiSSRFPolicy.HeaderTests.cs b/dotnet/FunctionalTests/AntiSSRFPolicy.HeaderTests.cs new file mode 100755 index 0000000..8f4035d --- /dev/null +++ b/dotnet/FunctionalTests/AntiSSRFPolicy.HeaderTests.cs @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Security.AntiSSRF.FunctionalTests +{ + public class AntiSSRFPolicy_HeaderTests + { + private static readonly string TestDomain = "ambitious-flower-0611c910f.2.azurestaticapps.net"; + + [Fact] + public void BadInputs() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + + // Invalid arrays + Assert.Throws<ArgumentNullException>(() => policy.AddDeniedHeaders(null)); + Assert.Throws<ArgumentNullException>(() => policy.AddRequiredHeaders(null)); + + // Invalid array elements + Assert.Throws<ArgumentException>(() => policy.AddDeniedHeaders([""])); + Assert.Throws<ArgumentException>(() => policy.AddRequiredHeaders([""])); + Assert.Throws<ArgumentNullException>(() => policy.AddDeniedHeaders(["X-Valid-Header", null!, "Another-Header"])); + Assert.Throws<ArgumentNullException>(() => policy.AddRequiredHeaders([null!, "X-Test-Header"])); + } + + [Fact] + public void CheckDefaults() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.InternalOnly); + var policy2 = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + var policy3 = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); + var policy4 = new AntiSSRFPolicy(PolicyConfigOptions.None); + Assert.Empty(policy.RequiredHeaders); + Assert.Empty(policy2.RequiredHeaders); + Assert.Empty(policy3.RequiredHeaders); + Assert.Empty(policy4.RequiredHeaders); + Assert.Empty(policy.DeniedHeaders); + Assert.Empty(policy2.DeniedHeaders); + Assert.Empty(policy3.DeniedHeaders); + Assert.Empty(policy4.DeniedHeaders); + } + + [Fact] + public void AddedHeaders_AppearInDeniedHeadersAndRequiredHeaders() + { + AntiSSRFPolicy policy = new(PolicyConfigOptions.None); + + policy.AddDeniedHeaders(["X-Denied-Header", "X-Another-Denied-Header"]); + policy.AddRequiredHeaders(["X-Required-Header", "X-Another-Required-Header"]); + + IReadOnlyList<string> deniedHeaders = policy.DeniedHeaders; + IReadOnlyList<string> requiredHeaders = policy.RequiredHeaders; + + Assert.Contains("X-Denied-Header", deniedHeaders); + Assert.Contains("X-Another-Denied-Header", deniedHeaders); + Assert.Contains("X-Required-Header", requiredHeaders); + Assert.Contains("X-Another-Required-Header", requiredHeaders); + } + + [Fact] + public async Task RequiredHeader() + { + AntiSSRFPolicy policy = new(PolicyConfigOptions.None); + policy.AddRequiredHeaders(["X-Test-Header"]); + using HttpClient client = new(policy.GetHandler()); + string Url = $"https://{TestDomain}/api/header-check?header=X-Test-Header"; + + using HttpRequestMessage request = new(HttpMethod.Get, Url); + request.Headers.Add("Not-X-Test-Header", "test-value"); + await Assert.ThrowsAsync<AntiSSRFException>(() => client.SendAsync(request, CancellationToken.None)); + + using HttpRequestMessage request2 = new(HttpMethod.Get, Url); + request2.Headers.Add("X-Test-Header", "test-value"); + var response = await client.SendAsync(request2, CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task DeniedHeader() + { + AntiSSRFPolicy policy = new(PolicyConfigOptions.None); + policy.AddDeniedHeaders(["X-Test-Header"]); + using HttpClient client = new(policy.GetHandler()); + string url = $"https://{TestDomain}/"; + + using HttpRequestMessage request = new(HttpMethod.Get, url); + request.Headers.Add("X-Test-Header", "test-value"); + await Assert.ThrowsAsync<AntiSSRFException>(() => client.SendAsync(request, CancellationToken.None)); + + using HttpRequestMessage request2 = new(HttpMethod.Get, url); + request2.Headers.Add("Not-X-Test-Header", "test-value"); + var response = await client.SendAsync(request2, CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Headers_AreCaseInsensitive() + { + string url = $"https://{TestDomain}/api/header-check?header=x-test-header"; + + AntiSSRFPolicy requiredPolicy = new(PolicyConfigOptions.None); + requiredPolicy.AddRequiredHeaders(["X-Test-Header"]); + using HttpClient requiredClient = new(requiredPolicy.GetHandler()); + + using HttpRequestMessage requiredRequest = new(HttpMethod.Get, url); + requiredRequest.Headers.Add("x-test-header", "test-value"); + var requiredResponse = await requiredClient.SendAsync(requiredRequest, CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, requiredResponse.StatusCode); + + AntiSSRFPolicy deniedPolicy = new(PolicyConfigOptions.None); + deniedPolicy.AddDeniedHeaders(["X-Test-Header"]); + using HttpClient deniedClient = new(deniedPolicy.GetHandler()); + + using HttpRequestMessage deniedRequest = new(HttpMethod.Get, $"https://{TestDomain}/"); + deniedRequest.Headers.Add("x-test-header", "test-value"); + await Assert.ThrowsAsync<AntiSSRFException>(() => deniedClient.SendAsync(deniedRequest, CancellationToken.None)); + } + + [Fact] + public async Task BothRequiredAndDenied() + { + AntiSSRFPolicy policy = new(PolicyConfigOptions.None); + policy.AddRequiredHeaders(["X-Test-Header"]); + policy.AddDeniedHeaders(["X-Test-Header"]); + HttpClient client = new(policy.GetHandler()); + string Url = $"https://{TestDomain}/api/header-check?header=X-Test-Header"; + + using HttpRequestMessage request = new(HttpMethod.Get, Url); + request.Headers.Add("X-Test-Header", "test-value"); + await Assert.ThrowsAsync<AntiSSRFException>(() => client.SendAsync(request, CancellationToken.None)); + + using HttpRequestMessage request2 = new(HttpMethod.Get, Url); + request2.Headers.Add("Not-X-Test-Header", "test-value"); + await Assert.ThrowsAsync<AntiSSRFException>(() => client.SendAsync(request2, CancellationToken.None)); + } + + [Fact] + public async Task WithAddXFFHeader() + { + string Url = $"https://{TestDomain}/api/header-check?header=X-Forwarded-For"; + + AntiSSRFPolicy policy = new(PolicyConfigOptions.None) + { + AddXFFHeader = true + }; + policy.AddRequiredHeaders(["X-Forwarded-For", "X-Test-Header"]); + HttpClient client = new(policy.GetHandler()); + + using HttpRequestMessage request = new(HttpMethod.Get, Url); + request.Headers.Add("X-Test-Header", "test-value"); + var response = await client.SendAsync(request, CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + AntiSSRFPolicy policy2 = new(PolicyConfigOptions.None) + { + AddXFFHeader = true + }; + policy2.AddDeniedHeaders(["X-Forwarded-For", "Not-X-Test-Header"]); + HttpClient client2 = new(policy2.GetHandler()); + + using HttpRequestMessage request2 = new(HttpMethod.Get, Url); + request2.Headers.Add("X-Test-Header", "test-value"); + await Assert.ThrowsAsync<AntiSSRFException>(() => client2.SendAsync(request2, CancellationToken.None)); + } + + [Fact] + public async Task HoldsOnRedirect() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.AddRequiredHeaders(["X-Required-Header"]); + HttpClient client = new(policy.GetHandler()); + + using HttpRequestMessage req = new(HttpMethod.Get, $"https://{TestDomain}/api/redirect?num=3"); + req.Headers.Add("X-Required-Header", "test-value"); + + // Should succeed since the required header is present and maintained through redirects + var response = await client.SendAsync(req, CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task RequiredHeaderDroppedOnRedirect() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.AddRequiredHeaders(["Authorization"]); + HttpClient client = new(policy.GetHandler()); + + using HttpRequestMessage req = new(HttpMethod.Get, $"https://{TestDomain}/api/redirect?num=3"); + req.Headers.Add("Authorization", "Bearer test-token"); + + // Should fail because Authorization header gets dropped by HttpClient during redirects + await Assert.ThrowsAsync<AntiSSRFException>(() => client.SendAsync(req, CancellationToken.None)); + } + + [Fact] + public void NoEditsAfterHandler() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + policy.GetHandler(); + + Assert.Throws<AntiSSRFException>(() => policy.AddRequiredHeaders(["X-Test-Header"])); + Assert.Throws<AntiSSRFException>(() => policy.AddDeniedHeaders(["X-Test-Header"])); + } + } +} diff --git a/dotnet/FunctionalTests/AntiSSRFPolicy.SchemeTests.cs b/dotnet/FunctionalTests/AntiSSRFPolicy.SchemeTests.cs new file mode 100644 index 0000000..662501b --- /dev/null +++ b/dotnet/FunctionalTests/AntiSSRFPolicy.SchemeTests.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Security.AntiSSRF.FunctionalTests +{ + public class AntiSSRFPolicy_SchemeTests + { + private static readonly string TestDomain = "ambitious-flower-0611c910f.2.azurestaticapps.net"; + + [Fact] + public void CheckDefaults() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.InternalOnly); + var policy2 = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + var policy3 = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); + var policy4 = new AntiSSRFPolicy(PolicyConfigOptions.None); + Assert.False(policy.AllowPlainTextHttp); + Assert.False(policy2.AllowPlainTextHttp); + Assert.False(policy3.AllowPlainTextHttp); + Assert.False(policy4.AllowPlainTextHttp); + } + + [Fact] + public async Task OnFalse() + { + AntiSSRFPolicy policy = new(PolicyConfigOptions.None) + { + AllowPlainTextHttp = false + }; + HttpClient client = new(policy.GetHandler()); + + var response = await client.GetAsync($"https://{TestDomain}", CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + await Assert.ThrowsAsync<AntiSSRFException>(() => client.GetAsync($"http://{TestDomain}", CancellationToken.None)); + } + + [Fact] + public async Task OnTrue() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.None) + { + AllowPlainTextHttp = true + }; + HttpClient client = new(policy.GetHandler()); + + var response = await client.GetAsync($"https://{TestDomain}/", CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var response2 = await client.GetAsync($"http://{TestDomain}/", CancellationToken.None); + Assert.Equal(HttpStatusCode.OK, response2.StatusCode); + } + + [Fact] + public async Task RejectsNonHttpSchemes() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.None) + { + AllowPlainTextHttp = true + }; + HttpClient client = new(policy.GetHandler()); + + // Test various non-HTTP schemes that should all be rejected + var nonHttpSchemes = new[] + { + "ws://example.com", // WebSocket + "wss://example.com", // WebSocket Secure + "ftp://example.com", // FTP + "gopher://example.com", // Gopher + "file:///etc/passwd", // File + "ldap://example.com", // LDAP + "ldaps://example.com", // LDAP Secure + "mailto:test@example.com", // Email + "tel:+1234567890", // Telephone + "data:text/plain;base64,SGVsbG8=", // Data URL + "javascript:alert('xss')", // JavaScript + "custom://example.com" // Custom scheme + }; + + foreach (var url in nonHttpSchemes) + { + try + { + await client.GetAsync(url, CancellationToken.None); + Assert.Fail($"Expected AntiSSRFException for scheme: {url}"); + } + catch (AntiSSRFException) + { + // Expected - this is the correct behavior + continue; + } + catch (Exception) + { + // Other exceptions (like ArgumentException for invalid URIs) are also acceptable + // as long as the request doesn't succeed + continue; + } + } + } + + [Fact] + public void NoEditsAfterHandler() + { + var policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + policy.GetHandler(); + + Assert.Throws<AntiSSRFException>(() => policy.AllowPlainTextHttp = true); + } + } +} diff --git a/dotnet/FunctionalTests/InAzureKeyVaultDomainTests.cs b/dotnet/FunctionalTests/InAzureKeyVaultDomainTests.cs new file mode 100644 index 0000000..c6957c3 --- /dev/null +++ b/dotnet/FunctionalTests/InAzureKeyVaultDomainTests.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Security.AntiSSRF.FunctionalTests; +using Xunit; + +using Microsoft.Security.AntiSSRF; + +namespace Microsoft.Security.AntiSSRF.FunctionalTests +{ + public class InAzureKeyVaultDomainTests + { + [Fact] + public void Should_ReturnFalse_ForNullAndEmptyInputs() + { + Assert.False(URIValidator.InAzureKeyVaultDomain((string)null!)); + Assert.False(URIValidator.InAzureKeyVaultDomain((Uri)null!)); + Assert.False(URIValidator.InAzureKeyVaultDomain("")); + } + + [Theory] + [InlineData("https://contoso.vault.azure.net/")] + [InlineData("https://fabrikam42.managedhsm.azure.net")] + [InlineData("https://corp-hsm.vault.azure.cn")] + [InlineData("https://devkeys99.managedhsm.azure.cn/")] + [InlineData("https://govvault1.vault.usgovcloudapi.net")] + [InlineData("https://securehsm.managedhsm.usgovcloudapi.net/")] + public void Should_ReturnTrue_ForValidAzureKeyVaultDomains(string url) + { + Assert.True(URIValidator.InAzureKeyVaultDomain(url)); + Assert.True(URIValidator.InAzureKeyVaultDomain(new Uri(url))); + } + + [Theory] + [InlineData("https://contoso.privatelink.vault.azure.net")] + [InlineData("https://fabrikam42.privatelink.managedhsm.azure.net")] + [InlineData("https://corp-hsm.privatelink.vault.azure.cn")] + [InlineData("https://devkeys99.privatelink.managedhsm.azure.cn")] + [InlineData("https://govvault1.privatelink.vault.usgovcloudapi.net")] + [InlineData("https://securehsm.privatelink.managedhsm.usgovcloudapi.net")] + public void Should_ReturnTrue_ForPrivateAzureKeyVaultDomains(string url) + { + Assert.True(URIValidator.InAzureKeyVaultDomain(url)); + Assert.True(URIValidator.InAzureKeyVaultDomain(new Uri(url))); + } + + [Theory] + [InlineData("https://my--vault.vault.azure.net")] + [InlineData("https://contoso.managedhsm.azure.net.evil.com")] + [InlineData("https://fabrikam.vault.azure.net.attacker.org")] + [InlineData("https://corp.vault.azuree.net")] + [InlineData("https://devkeys.vault.azure.nett")] + [InlineData("https://contoso.vault.azure.netmalicious")] + [InlineData("https://contoso.managedhsm.azure.usgovcloudapi.net")] + [InlineData("https://contoso.azure.vault.net")] + [InlineData("https://contoso.vault.azure.cn.fake")] + [InlineData("https://securehsm.managedhsm.usgovcloudapi.net.phishing")] + [InlineData("https://corp.managedhsm.azure.cnn")] + [InlineData("https://contoso.vaultazure.net")] + [InlineData("https://contoso.vault.azure")] + public void Should_ReturnFalse_ForInvalidAzureKeyVaultDomains(string url) + { + Assert.False(URIValidator.InAzureKeyVaultDomain(url)); + Assert.False(URIValidator.InAzureKeyVaultDomain(new Uri(url))); + } + + [Theory] + [InlineData("http://accountname.vault.azure.net/some/path", true)] + [InlineData("http://accountname.vault.azure.net#fragment", true)] + [InlineData("http://accountname.vault.azure.net/?query=hi", true)] + [InlineData("http://accountname.vault.azure.net:45", true)] + [InlineData("https://username@accountname.vault.azure.net", true)] + [InlineData("https://username:password@accountname.vault.azure.net", true)] + [InlineData("https:accountname.vault.azure.net", false)] + [InlineData("http:/accountname.vault.azure.net", false)] + [InlineData("http:/\\accountname.vault.azure.net", true)] + [InlineData("http:\\/accountname.vault.azure.net", true)] + [InlineData("http://accountname.vault.azure.net:badPort", false)] + [InlineData("http://:accountname.vault.azure.net", false)] + public void Should_ReturnCorrectResult_ForUrlsWithVariousComponents(string url, bool expectedResult) + { + Assert.Equal(expectedResult, URIValidator.InAzureKeyVaultDomain(url)); + + // Only test Uri overload for valid URI formats + try { + Uri parsedUri = new(url); + Assert.Equal(expectedResult, URIValidator.InAzureKeyVaultDomain(parsedUri)); + } + catch (UriFormatException) + { + // Ignore exceptions for invalid URI formats + } + } + + [Theory] + [InlineData("http://ñame.vault.azure.net/")] + [InlineData("https://contøso.managedhsm.azure.net")] + [InlineData("https://fabrikäm.vault.azure.cn/")] + [InlineData("https://corp.vàult.azure.net")] + [InlineData("https://devkeys.vault.àzure.net")] + [InlineData("https://contoso.vault.azure.cñ")] + [InlineData("https://сontoso.vault.usgovcloudapi.net")] + [InlineData("https://myapp.mànagedhsm.azure.cn")] + [InlineData("http://evil.c℁.vault.azure.net")] + [InlineData("https://データ.vault.usgovcloudapi.net")] + [InlineData("https://файлы.managedhsm.usgovcloudapi.net")] + public void Should_ReturnFalse_ForUnicodeCharactersInDomains(string url) + { + Assert.False(URIValidator.InAzureKeyVaultDomain(url)); + + // Test Uri overload if the string can be parsed as a Uri + try + { + Uri parsedUri = new(url); + Assert.False(URIValidator.InAzureKeyVaultDomain(parsedUri)); + } + catch (UriFormatException) + { + // Ignore exceptions for invalid URI formats + } + } + + [Theory] + [InlineData("http://CONTOSO.vault.azure.net")] + [InlineData("https://fabrikam42.VAULT.azure.net")] + [InlineData("https://corp-hsm.vault.AZURE.net")] + [InlineData("https://DEVKEYS99.managedhsm.azure.cn/")] + [InlineData("https://govvault1.MANAGEDHSM.azure.cn")] + [InlineData("https://SECUREHSM.vault.USGOVCLOUDAPI.net")] + [InlineData("https://contoso.managedhsm.USGOVCLOUDAPI.NET")] + [InlineData("HTTPS://fabrikam42.vault.azure.net")] + [InlineData("hTtPs://corp-hsm.managedhsm.azure.net")] + [InlineData("https://CONTOSO.VAULT.AZURE.NET")] + [InlineData("HtTpS://devkeys99.vault.azure.cn")] + [InlineData("https://GovVault1.Vault.Azure.Net")] + public void Should_ReturnTrue_ForMixedCaseDomains(string url) + { + Assert.True(URIValidator.InAzureKeyVaultDomain(url)); + Assert.True(URIValidator.InAzureKeyVaultDomain(new Uri(url))); + } + + [Theory] + [InlineData("http://accountname.vault.azure.net", true)] + [InlineData("https://accountname.vault.azure.net", true)] + [InlineData("ws://accountname.vault.azure.net", false)] + [InlineData("wss://accountname.vault.azure.net", false)] + [InlineData("ftp://accountname.vault.azure.net", false)] + [InlineData("file://accountname.vault.azure.net", false)] + [InlineData("gopher://accountname.vault.azure.net", false)] + [InlineData("mailto:accountname.vault.azure.net", false)] + [InlineData("data://accountname.vault.azure.net", false)] + [InlineData("javascript:alert('XSS')", false)] + [InlineData("evil.com://accountname.vault.azure.net", false)] + public void Should_ReturnCorrectResult_BasedOnProtocolScheme(string url, bool expectedResult) + { + Assert.Equal(expectedResult, URIValidator.InAzureKeyVaultDomain(url)); + + // Only test Uri overload for schemes that can create valid Uris + try { + Uri parsedUri = new(url); + Assert.Equal(expectedResult, URIValidator.InAzureKeyVaultDomain(parsedUri)); + } + catch (UriFormatException) + { + // Ignore exceptions from invalid URI formats + } + } + } +} diff --git a/dotnet/FunctionalTests/InAzureStorageDomainTests.cs b/dotnet/FunctionalTests/InAzureStorageDomainTests.cs new file mode 100644 index 0000000..dbe678f --- /dev/null +++ b/dotnet/FunctionalTests/InAzureStorageDomainTests.cs @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Security.AntiSSRF.FunctionalTests; +using Xunit; + +using Microsoft.Security.AntiSSRF; + +namespace Microsoft.Security.AntiSSRF.FunctionalTests +{ + public class InAzureStorageDomainTests + { + [Fact] + public void Should_ReturnFalse_ForNullAndEmptyInputs() + { + Assert.False(URIValidator.InAzureStorageDomain((string)null!)); + Assert.False(URIValidator.InAzureStorageDomain((Uri)null!)); + Assert.False(URIValidator.InAzureStorageDomain("")); + } + + [Theory] + [InlineData("https://myapp.blob.core.windows.net")] + [InlineData("https://frontend3.web.core.windows.net")] + [InlineData("https://data-lake.dfs.core.windows.net")] + [InlineData("https://files123.file.core.windows.net")] + [InlineData("https://queue-svc.queue.core.windows.net")] + [InlineData("https://tables01.table.core.windows.net")] + [InlineData("https://secure.blob.storage.azure.net")] + [InlineData("https://internal9.web.storage.azure.net")] + [InlineData("https://private-ep.dfs.storage.azure.net")] + [InlineData("https://corp-files.file.storage.azure.net")] + [InlineData("https://company2.queue.storage.azure.net")] + [InlineData("https://enterprise.table.storage.azure.net")] + [InlineData("https://gov-data.blob.core.usgovcloudapi.net")] + [InlineData("https://portal456.web.core.usgovcloudapi.net")] + [InlineData("https://analytics.dfs.core.usgovcloudapi.net")] + [InlineData("https://docs-gov.file.core.usgovcloudapi.net")] + [InlineData("https://notify123.queue.core.usgovcloudapi.net")] + [InlineData("https://records.table.core.usgovcloudapi.net")] + [InlineData("https://china-app.blob.core.chinacloudapi.cn")] + [InlineData("https://website7.web.core.chinacloudapi.cn")] + [InlineData("https://bigdata99.dfs.core.chinacloudapi.cn")] + [InlineData("https://storage.file.core.chinacloudapi.cn")] + [InlineData("https://events-cn.queue.core.chinacloudapi.cn")] + [InlineData("https://metadata2.table.core.chinacloudapi.cn")] + public void Should_ReturnTrue_ForValidAzureStorageDomains(string url) + { + Assert.True(URIValidator.InAzureStorageDomain(url)); + Assert.True(URIValidator.InAzureStorageDomain(new Uri(url))); + } + + [Theory] + [InlineData("https://myapp-secondary.blob.storage.azure.net")] + [InlineData("https://website-secondary.web.core.windows.net")] + [InlineData("https://files5-secondary.dfs.core.usgovcloudapi.net")] + [InlineData("https://messages-secondary.queue.core.chinacloudapi.cn")] + [InlineData("https://corp99-secondary.table.storage.azure.net")] + [InlineData("https://backup-secondary.file.core.windows.net")] + public void Should_ReturnTrue_ForSecondaryAzureStorageDomains(string url) + { + Assert.True(URIValidator.InAzureStorageDomain(url)); + Assert.True(URIValidator.InAzureStorageDomain(new Uri(url))); + } + + [Theory] + [InlineData("https://acct.privatelink.blob.storage.azure.net")] + [InlineData("https://web12.privatelink.web.core.windows.net")] + [InlineData("https://data.privatelink.dfs.storage.azure.net")] + [InlineData("https://files99.privatelink.file.core.usgovcloudapi.net")] + [InlineData("https://queue.privatelink.queue.core.chinacloudapi.cn")] + [InlineData("https://tables5-secondary.privatelink.table.storage.azure.net")] + public void Should_ReturnTrue_ForPrivateAzureStorageDomains(string url) + { + Assert.True(URIValidator.InAzureStorageDomain(url)); + Assert.True(URIValidator.InAzureStorageDomain(new Uri(url))); + } + + [Theory] + [InlineData("https://contosostaticsite.z22.web.core.windows.net")] + [InlineData("https://webapp5.z03.web.storage.azure.net")] + [InlineData("https://frontend.z45.blob.core.usgovcloudapi.net")] + [InlineData("https://portal99.z01.web.core.chinacloudapi.cn")] + [InlineData("https://static-site.privatelink.z88.dfs.storage.azure.net")] + [InlineData("https://demo-secondary.z0.web.core.windows.net")] + public void Should_ReturnTrue_ForStaticSitesAndDNSZones(string url) + { + Assert.True(URIValidator.InAzureStorageDomain(url)); + Assert.True(URIValidator.InAzureStorageDomain(new Uri(url))); + } + + [Theory] + [InlineData("https://my--app.blob.core.windows.net")] + [InlineData("https://data--lake.dfs.storage.azure.net")] + [InlineData("https://web--site.web.core.usgovcloudapi.net")] + [InlineData("https://myapp.blob.core.windwos.net")] + [InlineData("https://storage.table.core.windoes.net")] + [InlineData("https://files.dfs.core.chinacloudapi.com")] + [InlineData("https://queue.queue.stoarge.azure.net")] + [InlineData("https://myapp.database.core.windows.net")] + [InlineData("https://storage.cache.storage.azure.net")] + [InlineData("https://files.storage.core.usgovcloudapi.net")] + [InlineData("https://myapp.core.blob.windows.net")] + [InlineData("https://storage.azure.storage.net")] + [InlineData("https://files.windows.core.net")] + [InlineData("https://myapp.blob.core.blob.windows.net")] + [InlineData("https://storage.web.core.windows.net.storage.azure.net")] + [InlineData("https://myapp.blob.core.windows.net.evil.com")] + [InlineData("https://storage.table.core.windows.netmalicious")] + [InlineData("https://files.dfs.storage.azure.net.attacker.org")] + [InlineData("https://queue.queue.core.chinacloudapi.cnbad")] + [InlineData("https://secure.web.storage.azure.netphishing")] + [InlineData("https://corp.file.core.usgovcloudapi.net.fake")] + public void Should_ReturnFalse_ForInvalidAzureStorageDomains(string url) + { + Assert.False(URIValidator.InAzureStorageDomain(url)); + Assert.False(URIValidator.InAzureStorageDomain(new Uri(url))); + } + + [Theory] + [InlineData("http://accountname.blob.core.windows.net/some/path", true)] + [InlineData("http://accountname.blob.core.windows.net#fragment", true)] + [InlineData("http://accountname.blob.core.windows.net/?query=hi", true)] + [InlineData("http://accountname.blob.core.windows.net:45", true)] + [InlineData("https://username@accountname.blob.core.windows.net", true)] + [InlineData("https://username:password@accountname.blob.core.windows.net", true)] + [InlineData("https:accountname.blob.core.windows.net", false)] + [InlineData("http:/accountname.blob.core.windows.net", false)] + [InlineData("http:/\\accountname.blob.core.windows.net", true)] + [InlineData("http:\\/accountname.blob.core.windows.net", true)] + [InlineData("http://accountname.blob.core.windows.net:badPort", false)] + [InlineData("http://:accountname.blob.core.windows.net", false)] + public void Should_ReturnCorrectResult_ForUrlsWithVariousComponents(string url, bool expectedResult) + { + Assert.Equal(expectedResult, URIValidator.InAzureStorageDomain(url)); + + // Only test Uri overload for valid URI formats + try { + Uri parsedUri = new(url); + Assert.Equal(expectedResult, URIValidator.InAzureStorageDomain(parsedUri)); + } + catch (UriFormatException) + { + // Ignore exceptions for invalid URI formats + } + } + + [Theory] + [InlineData("http://ñame.blob.core.windows.net/")] + [InlineData("http://name.blob.core.wiñdows.net/")] + [InlineData("http://evil.c℁.blob.core.windows.net")] + [InlineData("https://tëst.web.storage.azure.net")] + [InlineData("https://app.blob.core.windöws.net")] + [InlineData("https://файлы.file.core.chinacloudapi.cn")] + [InlineData("https://データ.dfs.core.usgovcloudapi.net")] + [InlineData("https://myapp.bløb.core.windows.net")] + public void Should_ReturnFalse_ForUnicodeCharactersInDomains(string url) + { + Assert.False(URIValidator.InAzureStorageDomain(url)); + + // Test Uri overload if the string can be parsed as a Uri + try + { + Uri parsedUri = new(url); + Assert.False(URIValidator.InAzureStorageDomain(parsedUri)); + } + catch (UriFormatException) + { + // Ignore exceptions for invalid URI formats + } + } + + [Theory] + [InlineData("http://ACCOUNTNAME.blob.core.windows.net")] + [InlineData("http://accountname.BLOB.core.windows.net")] + [InlineData("http://ACCOUNTNAME.BLOB.CORE.WINDOWS.NET")] + [InlineData("hTtP://test.blob.core.windows.net/")] + [InlineData("HTTPS://myapp.WEB.storage.azure.net")] + [InlineData("https://DATA.dfs.STORAGE.AZURE.NET")] + [InlineData("HtTpS://files.FILE.core.usgovcloudapi.net")] + [InlineData("http://QUEUE.queue.CORE.chinacloudapi.cn")] + [InlineData("https://TABLES.table.core.WINDOWS.net")] + public void Should_ReturnTrue_ForMixedCaseDomains(string url) + { + Assert.True(URIValidator.InAzureStorageDomain(url)); + Assert.True(URIValidator.InAzureStorageDomain(new Uri(url))); + } + + [Theory] + [InlineData("http://accountname.blob.core.windows.net", true)] + [InlineData("https://accountname.blob.core.windows.net", true)] + [InlineData("ws://accountname.blob.core.windows.net", false)] + [InlineData("wss://accountname.blob.core.windows.net", false)] + [InlineData("ftp://accountname.blob.core.windows.net", false)] + [InlineData("file://accountname.blob.core.windows.net", false)] + [InlineData("gopher://accountname.blob.core.windows.net", false)] + [InlineData("mailto:accountname.blob.core.windows.net", false)] + [InlineData("data://accountname.blob.core.windows.net", false)] + [InlineData("javascript:alert('XSS')", false)] + [InlineData("evil.com://accountname.blob.core.windows.net", false)] + public void Should_ReturnCorrectResult_BasedOnProtocolScheme(string url, bool expectedResult) + { + Assert.Equal(expectedResult, URIValidator.InAzureStorageDomain(url)); + + // Only test Uri overload for schemes that can create valid Uris + try { + Uri parsedUri = new(url); + Assert.Equal(expectedResult, URIValidator.InAzureStorageDomain(parsedUri)); + } + catch (UriFormatException) + { + // Ignore exceptions from invalid URI formats + } + } + } +} diff --git a/dotnet/FunctionalTests/InDomainTests.cs b/dotnet/FunctionalTests/InDomainTests.cs new file mode 100644 index 0000000..98ef0fe --- /dev/null +++ b/dotnet/FunctionalTests/InDomainTests.cs @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Security.AntiSSRF.FunctionalTests; +using Xunit; + +using Microsoft.Security.AntiSSRF; + +namespace Microsoft.Security.AntiSSRF.FunctionalTests +{ + public class InDomainTests + { + [Fact] + public void Should_ReturnFalse_ForNullAndEmptyInputs() + { + Uri nullUri = null!; + string nullString = null!; + string[] nullStrings = null!; + + Assert.False(URIValidator.InDomain(nullUri, "bing.com")); + Assert.False(URIValidator.InDomain(nullUri, ["bing.com"])); + Assert.False(URIValidator.InDomain(nullString, "bing.com")); + Assert.False(URIValidator.InDomain(nullString, ["bing.com"])); + + Assert.False(URIValidator.InDomain("http://bing.com", (string)null!)); + Assert.False(URIValidator.InDomain("http://bing.com", string.Empty)); + Assert.False(URIValidator.InDomain(new Uri("http://bing.com"), (string)null!)); + Assert.False(URIValidator.InDomain(new Uri("http://bing.com"), string.Empty)); + + Assert.False(URIValidator.InDomain("http://bing.com", nullStrings)); + Assert.False(URIValidator.InDomain("http://bing.com", Array.Empty<string>())); + Assert.False(URIValidator.InDomain(new Uri("http://bing.com"), nullStrings)); + Assert.False(URIValidator.InDomain(new Uri("http://bing.com"), Array.Empty<string>())); + } + + [Theory] + [InlineData("http://office.com", "office.com")] + [InlineData("https://office.com", "office.com")] + [InlineData("https://azure.com", ".azure.com")] + [InlineData("https://subdomain.microsoft.com", "microsoft.com")] + [InlineData("https://subdomain.microsoft.com", ".microsoft.com")] + public void Should_ReturnTrue_ForValidSingleDomains(string url, string trustedDomain) + { + Assert.True(URIValidator.InDomain(url, trustedDomain)); + Assert.True(URIValidator.InDomain(new Uri(url), trustedDomain)); + } + + [Theory] + [InlineData("https://subdomain.one.com", new[] { "one.com", ".two.com" })] + [InlineData("http://subdomain.two.com", new[] { "one.com", ".two.com" })] + [InlineData("https://one.com", new[] { "one.com", ".two.com" })] + [InlineData("https://two.net", new[] { "one.com", ".two.net" })] + public void Should_ReturnTrue_ForValidTrustedDomainArrays(string url, string[] trustedDomains) + { + Assert.True(URIValidator.InDomain(url, trustedDomains)); + Assert.True(URIValidator.InDomain(new Uri(url), trustedDomains)); + } + + [Theory] + [InlineData("http://azure.com", "office.com")] + [InlineData("https://office.com", "subdomain.office.com")] + [InlineData("https://azure.com", ".office.com")] + [InlineData("https://subdomain.microsoft.com", "differentsubdomain.microsoft.com")] + public void Should_ReturnFalse_ForInvalidSingleDomains(string url, string trustedDomain) + { + Assert.False(URIValidator.InDomain(url, trustedDomain)); + Assert.False(URIValidator.InDomain(new Uri(url), trustedDomain)); + } + + [Theory] + [InlineData("http://azure.com", new[] { "one.com", "office.com" })] + [InlineData("https://office.com", new[] { "subdomain.office.com" })] + [InlineData("https://azure.com", new[] { ".office.com", "two.com" })] + [InlineData("https://subdomain.microsoft.com", new[] { "differentsubdomain.microsoft.com" })] + public void Should_ReturnFalse_ForInvalidTrustedDomainArrays(string url, string[] trustedDomains) + { + Assert.False(URIValidator.InDomain(url, trustedDomains)); + Assert.False(URIValidator.InDomain(new Uri(url), trustedDomains)); + } + + [Theory] + [InlineData("http://username@bing.com:/")] + [InlineData("http://username:password@bing.com")] + [InlineData("http://bing.com:45")] + [InlineData("http://bing.com/some/path")] + [InlineData("http://bing.com#fragment")] + [InlineData("http://bing.com/?query=hi")] + [InlineData("http:/\\bing.com")] + [InlineData("http:\\/bing.com")] + public void Should_ReturnTrue_ForUrlsWithVariousComponents(string url) + { + Assert.True(URIValidator.InDomain(url, "bing.com")); + Assert.True(URIValidator.InDomain(url, ["bing.com"])); + Assert.True(URIValidator.InDomain(new Uri(url), "bing.com")); + Assert.True(URIValidator.InDomain(new Uri(url), ["bing.com"])); + } + + [Theory] + [InlineData("http://bing.com:badPort")] + [InlineData("http://:bing.com")] + public void Should_ReturnFalse_ForStringOnlyInvalidUrlComponents(string url) + { + Assert.False(URIValidator.InDomain(url, "bing.com")); + Assert.False(URIValidator.InDomain(url, ["bing.com"])); + } + + [Theory] + [InlineData("http://español.test.net/", "test.net")] + [InlineData("http://español.test.net/", "xn--espaol-zwa.test.net")] + [InlineData("http://你好/", "xn--6qq79v")] + [InlineData("http://test.你好/", "你好")] + [InlineData("http://bing.hi.com/", "hi.com")] + [InlineData("http://bing.hı.com/", "hı.com")] + [InlineData("http://bing.hí.com/", "hí.com")] + [InlineData("http://😉", "😉")] + public void Should_ReturnTrue_ForUnicodeSingleDomains(string url, string trustedDomain) + { + Assert.True(URIValidator.InDomain(url, trustedDomain)); + Assert.True(URIValidator.InDomain(new Uri(url), trustedDomain)); + } + + [Theory] + [InlineData("http://español.test.net/", new[] { "notempty", "test.net" })] + [InlineData("http://español.test.net/", new[] { "hello", "xn--espaol-zwa.test.net" })] + [InlineData("http://你好/", new[] { "xn--6qq79v", "not_the_domain.com" })] + [InlineData("http://bing.hı.com/", new[] { "hi.com", "hı.com" })] + [InlineData("http://bing.hí.com/", new[] { "hí.com", "notempty" })] + [InlineData("http://test.你好/", new[] { "你好", "bing.com" })] + public void Should_ReturnTrue_ForUnicodeTrustedDomainArrays(string url, string[] trustedDomains) + { + Assert.True(URIValidator.InDomain(url, trustedDomains)); + Assert.True(URIValidator.InDomain(new Uri(url), trustedDomains)); + } + + [Theory] + [InlineData("http://bing.hı.com/", "hi.com")] + [InlineData("http://bing.hí.com/", "hi.com")] + [InlineData("http://😉", "🔨")] + public void Should_ReturnFalse_ForInvalidUnicodeSingleDomains(string url, string trustedDomain) + { + Assert.False(URIValidator.InDomain(url, trustedDomain)); + Assert.False(URIValidator.InDomain(new Uri(url), trustedDomain)); + } + + [Theory] + [InlineData("http://bing.hı.com/", new[] { "hi.com", "hí.com" })] + [InlineData("http://bing.hí.com/", new[] { "hi.com", "hı.com" })] + [InlineData("http://😉", new[] { "🔨", "" })] + public void Should_ReturnFalse_ForInvalidUnicodeTrustedDomainArrays(string url, string[] trustedDomains) + { + Assert.False(URIValidator.InDomain(url, trustedDomains)); + Assert.False(URIValidator.InDomain(new Uri(url), trustedDomains)); + } + + [Theory] + [InlineData("http://evil.c℁.core.azure.net", "azure.net")] + public void Should_ReturnFalse_ForStringOnlyInvalidUnicodeDomains(string url, string trustedDomain) + { + Assert.False(URIValidator.InDomain(url, trustedDomain)); + } + + [Theory] + [InlineData("hTtP://test.net", "test.net")] + [InlineData("wSS://test.net", "test.net")] + [InlineData("http://HELLO.com", "hello.com")] + [InlineData("http://Hello.你好/", "xn--6qq79v")] + [InlineData("http://español.test.net/", "TeSt.net")] + [InlineData("http://hello.COM", "HELLO.com")] + public void Should_ReturnTrue_ForMixedCaseSingleDomains(string url, string trustedDomain) + { + Assert.True(URIValidator.InDomain(url, trustedDomain)); + Assert.True(URIValidator.InDomain(new Uri(url), trustedDomain)); + } + + [Theory] + [InlineData("http://HELLO.com", new[] { "notempty", "hello.com" })] + [InlineData("http://Hello.你好/", new[] { "xn--6qq79v", "not_the_domain.com" })] + [InlineData("http://español.test.net/", new[] { "TeSt.net", "asdf" })] + [InlineData("http://hello.COM", new[] { "HELLO.com" })] + public void Should_ReturnTrue_ForMixedCaseTrustedDomainArrays(string url, string[] trustedDomains) + { + Assert.True(URIValidator.InDomain(url, trustedDomains)); + Assert.True(URIValidator.InDomain(new Uri(url), trustedDomains)); + } + + [Theory] + [InlineData("http://bing.com", "bing.com")] + [InlineData("https://bing.com", "bing.com")] + [InlineData("ws://bing.com", "bing.com")] + [InlineData("wss://bing.com", "bing.com")] + public void Should_ReturnTrue_ForAllowedProtocols(string url, string trustedDomain) + { + Assert.True(URIValidator.InDomain(url, trustedDomain)); + Assert.True(URIValidator.InDomain(new Uri(url), trustedDomain)); + } + + [Theory] + [InlineData("ftp://bing.com", "bing.com")] + [InlineData("file://bing.com", "bing.com")] + [InlineData("gopher://bing.com", "bing.com")] + [InlineData("mailto:bing.com", "bing.com")] + [InlineData("data://bing.com", "bing.com")] + [InlineData("javascript:alert('XSS')", "bing.com")] + [InlineData("evil.com://bing.com", "bing.com")] + public void Should_ReturnFalse_ForDisallowedProtocols(string url, string trustedDomain) + { + Assert.False(URIValidator.InDomain(url, trustedDomain)); + Assert.False(URIValidator.InDomain(new Uri(url), trustedDomain)); + } + + [Theory] + [InlineData("c:\\foo\\bar", "somedomain.com")] + [InlineData("file:///filepath", "somedomain.com")] + [InlineData("CCCCCCCCCCCCCCCCCCCCCCC:\\\\\\\\\\\\foo\\bar", "somedomain.com")] + [InlineData("/foo/bar", "somedomain.com")] + [InlineData("//////////////////////////////////////", "somedomain.com")] + [InlineData("\\\\.\\a\\a\\a\\", "somedomain.com")] + [InlineData("\\\\\\.\\a\\a\\a\\", "somedomain.com")] + public void Should_ReturnFalse_ForFilePathStrings(string url, string trustedDomain) + { + Assert.False(URIValidator.InDomain(url, trustedDomain)); + } + } +} diff --git a/dotnet/FunctionalTests/Microsoft.Security.AntiSSRF.FunctionalTests.csproj b/dotnet/FunctionalTests/Microsoft.Security.AntiSSRF.FunctionalTests.csproj new file mode 100644 index 0000000..0054eb1 --- /dev/null +++ b/dotnet/FunctionalTests/Microsoft.Security.AntiSSRF.FunctionalTests.csproj @@ -0,0 +1,30 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFrameworks>net8.0;net48</TargetFrameworks> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <LangVersion>latest</LangVersion> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + <PackageReference Include="coverlet.collector"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + <PackageReference Include="System.Net.Http" Condition="'$(TargetFramework)' == 'net48'" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="../src/Microsoft.Security.AntiSSRF.csproj" /> + </ItemGroup> + +</Project> \ No newline at end of file diff --git a/dotnet/Microsoft.Security.AntiSSRF.sln b/dotnet/Microsoft.Security.AntiSSRF.sln new file mode 100644 index 0000000..741a291 --- /dev/null +++ b/dotnet/Microsoft.Security.AntiSSRF.sln @@ -0,0 +1,62 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Security.AntiSSRF", "src\Microsoft.Security.AntiSSRF.csproj", "{1E54F5AE-BEE8-4E3F-B0D0-ECC5172A911B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Security.AntiSSRF.UnitTests", "UnitTests\Microsoft.Security.AntiSSRF.UnitTests.csproj", "{0099754E-B40C-4459-860B-B5092231974E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Security.AntiSSRF.FunctionalTests", "FunctionalTests\Microsoft.Security.AntiSSRF.FunctionalTests.csproj", "{1091AE9C-086A-4CE0-A54D-52A762C8E296}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1E54F5AE-BEE8-4E3F-B0D0-ECC5172A911B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E54F5AE-BEE8-4E3F-B0D0-ECC5172A911B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E54F5AE-BEE8-4E3F-B0D0-ECC5172A911B}.Debug|x64.ActiveCfg = Debug|Any CPU + {1E54F5AE-BEE8-4E3F-B0D0-ECC5172A911B}.Debug|x64.Build.0 = Debug|Any CPU + {1E54F5AE-BEE8-4E3F-B0D0-ECC5172A911B}.Debug|x86.ActiveCfg = Debug|Any CPU + {1E54F5AE-BEE8-4E3F-B0D0-ECC5172A911B}.Debug|x86.Build.0 = Debug|Any CPU + {1E54F5AE-BEE8-4E3F-B0D0-ECC5172A911B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E54F5AE-BEE8-4E3F-B0D0-ECC5172A911B}.Release|Any CPU.Build.0 = Release|Any CPU + {1E54F5AE-BEE8-4E3F-B0D0-ECC5172A911B}.Release|x64.ActiveCfg = Release|Any CPU + {1E54F5AE-BEE8-4E3F-B0D0-ECC5172A911B}.Release|x64.Build.0 = Release|Any CPU + {1E54F5AE-BEE8-4E3F-B0D0-ECC5172A911B}.Release|x86.ActiveCfg = Release|Any CPU + {1E54F5AE-BEE8-4E3F-B0D0-ECC5172A911B}.Release|x86.Build.0 = Release|Any CPU + {0099754E-B40C-4459-860B-B5092231974E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0099754E-B40C-4459-860B-B5092231974E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0099754E-B40C-4459-860B-B5092231974E}.Debug|x64.ActiveCfg = Debug|Any CPU + {0099754E-B40C-4459-860B-B5092231974E}.Debug|x64.Build.0 = Debug|Any CPU + {0099754E-B40C-4459-860B-B5092231974E}.Debug|x86.ActiveCfg = Debug|Any CPU + {0099754E-B40C-4459-860B-B5092231974E}.Debug|x86.Build.0 = Debug|Any CPU + {0099754E-B40C-4459-860B-B5092231974E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0099754E-B40C-4459-860B-B5092231974E}.Release|Any CPU.Build.0 = Release|Any CPU + {0099754E-B40C-4459-860B-B5092231974E}.Release|x64.ActiveCfg = Release|Any CPU + {0099754E-B40C-4459-860B-B5092231974E}.Release|x64.Build.0 = Release|Any CPU + {0099754E-B40C-4459-860B-B5092231974E}.Release|x86.ActiveCfg = Release|Any CPU + {0099754E-B40C-4459-860B-B5092231974E}.Release|x86.Build.0 = Release|Any CPU + {1091AE9C-086A-4CE0-A54D-52A762C8E296}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1091AE9C-086A-4CE0-A54D-52A762C8E296}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1091AE9C-086A-4CE0-A54D-52A762C8E296}.Debug|x64.ActiveCfg = Debug|Any CPU + {1091AE9C-086A-4CE0-A54D-52A762C8E296}.Debug|x64.Build.0 = Debug|Any CPU + {1091AE9C-086A-4CE0-A54D-52A762C8E296}.Debug|x86.ActiveCfg = Debug|Any CPU + {1091AE9C-086A-4CE0-A54D-52A762C8E296}.Debug|x86.Build.0 = Debug|Any CPU + {1091AE9C-086A-4CE0-A54D-52A762C8E296}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1091AE9C-086A-4CE0-A54D-52A762C8E296}.Release|Any CPU.Build.0 = Release|Any CPU + {1091AE9C-086A-4CE0-A54D-52A762C8E296}.Release|x64.ActiveCfg = Release|Any CPU + {1091AE9C-086A-4CE0-A54D-52A762C8E296}.Release|x64.Build.0 = Release|Any CPU + {1091AE9C-086A-4CE0-A54D-52A762C8E296}.Release|x86.ActiveCfg = Release|Any CPU + {1091AE9C-086A-4CE0-A54D-52A762C8E296}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/dotnet/UnitTests/CIDRBlockTests.cs b/dotnet/UnitTests/CIDRBlockTests.cs new file mode 100644 index 0000000..8884141 --- /dev/null +++ b/dotnet/UnitTests/CIDRBlockTests.cs @@ -0,0 +1,263 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Reflection; +using Xunit; + +using Microsoft.Security.AntiSSRF; + +namespace Microsoft.Security.AntiSSRF.UnitTests +{ + public class CIDRBlockTests + { + [Fact] + public void BadInputs_ThrowsException() + { + // Parse - null input + Assert.Throws<ArgumentNullException>(() => CIDRBlock.Parse(null!)); + + // Parse - too many / + Assert.Throws<FormatException>(() => CIDRBlock.Parse("192.168.1.0/24/16")); + Assert.Throws<FormatException>(() => CIDRBlock.Parse("10.0.0.0/8/")); + Assert.Throws<FormatException>(() => CIDRBlock.Parse("2001:db8::/32/64")); + + // Parse - invalid IP address + Assert.Throws<FormatException>(() => CIDRBlock.Parse("256.256.256.256/24")); + Assert.Throws<FormatException>(() => CIDRBlock.Parse("192.168.1.300/24")); + Assert.Throws<FormatException>(() => CIDRBlock.Parse("not-an-ip/24")); + Assert.Throws<FormatException>(() => CIDRBlock.Parse("999.999.999.999")); + Assert.Throws<FormatException>(() => CIDRBlock.Parse("gggg::1/64")); + + // Parse - invalid prefix format + Assert.Throws<FormatException>(() => CIDRBlock.Parse("192.168.1.0/abc")); + Assert.Throws<FormatException>(() => CIDRBlock.Parse("192.168.1.0/24.5")); + Assert.Throws<FormatException>(() => CIDRBlock.Parse("192.168.1.0/")); + Assert.Throws<FormatException>(() => CIDRBlock.Parse("/24")); + Assert.Throws<FormatException>(() => CIDRBlock.Parse("192.168.1.0/+24")); + Assert.Throws<FormatException>(() => CIDRBlock.Parse("127.0.0.0/024")); + + // Parse - invalid prefix length + Assert.Throws<FormatException>(() => CIDRBlock.Parse("192.168.1.0/33")); + Assert.Throws<FormatException>(() => CIDRBlock.Parse("192.168.1.0/-1")); + Assert.Throws<FormatException>(() => CIDRBlock.Parse("192.168.1.0/255")); + Assert.Throws<FormatException>(() => CIDRBlock.Parse("2001:db8::/129")); + Assert.Throws<FormatException>(() => CIDRBlock.Parse("2001:db8::/-5")); + + // Contains - contains null input + var block = CIDRBlock.Parse("10.0.0.0/8"); + Assert.Throws<ArgumentNullException>(() => block.Contains((IPAddress)null!)); + // Do not need the next line - CIDRBlock is a non-nullable value type + // Assert.Throws<ArgumentNullException>(() => block.Contains((CIDRBlock)null!)); + } + + [Fact] + public void Contains_IPv4Address_ReturnsExpectedResult() + { + // Standard decimal format + var block1 = CIDRBlock.Parse("192.168.1.0/24"); + Assert.True(block1.Contains(IPAddress.Parse("192.168.1.1"))); + Assert.True(block1.Contains(IPAddress.Parse("192.168.1.255"))); + Assert.False(block1.Contains(IPAddress.Parse("192.168.2.1"))); + + // Octal format + var block2 = CIDRBlock.Parse("0300.0250.001.000/24"); + Assert.True(block2.Contains(IPAddress.Parse("192.168.1.1"))); + Assert.False(block2.Contains(IPAddress.Parse("192.168.2.1"))); + + // Hexadecimal format + var block3 = CIDRBlock.Parse("0xC0.0xA8.0x1.0x0/24"); + Assert.True(block3.Contains(IPAddress.Parse("192.168.1.1"))); + Assert.False(block3.Contains(IPAddress.Parse("192.168.2.1"))); + + // Mixed formats + var block4 = CIDRBlock.Parse("192.0250.1.0x0/24"); + Assert.True(block4.Contains(IPAddress.Parse("192.168.1.1"))); + Assert.False(block4.Contains(IPAddress.Parse("192.168.2.1"))); + + // 3 octets format + var block5 = CIDRBlock.Parse("192.168.256/24"); + Assert.True(block5.Contains(IPAddress.Parse("192.168.1.1"))); + Assert.False(block5.Contains(IPAddress.Parse("192.168.2.1"))); + + // 2 octets format + var block6 = CIDRBlock.Parse("192.11010304/24"); + Assert.True(block6.Contains(IPAddress.Parse("192.168.1.1"))); + Assert.False(block6.Contains(IPAddress.Parse("192.168.2.1"))); + + // Single number format + var block7 = CIDRBlock.Parse("3232235776/24"); + Assert.True(block7.Contains(IPAddress.Parse("192.168.1.1"))); + Assert.False(block7.Contains(IPAddress.Parse("192.168.2.1"))); + + // Test without prefix length + var block9 = CIDRBlock.Parse("0xC0A80101"); + Assert.True(block9.Contains(IPAddress.Parse("192.168.1.1"))); + Assert.False(block9.Contains(IPAddress.Parse("192.168.1.2"))); + + var block10 = CIDRBlock.Parse("192.168.257"); + Assert.True(block10.Contains(IPAddress.Parse("192.168.1.1"))); + Assert.False(block10.Contains(IPAddress.Parse("192.168.1.2"))); + } + + [Fact] + public void Contains_IPv6Address_ReturnsExpectedResult() + { + // Standard full format + var block1 = CIDRBlock.Parse("2001:0db8:0000:0000:0000:0000:0000:0000/32"); + Assert.True(block1.Contains(IPAddress.Parse("2001:db8::1"))); + Assert.True(block1.Contains(IPAddress.Parse("2001:db8:ffff::1"))); + Assert.False(block1.Contains(IPAddress.Parse("2001:db9::1"))); + + // Leading compression + var block3 = CIDRBlock.Parse("::1/128"); + Assert.True(block3.Contains(IPAddress.Parse("::1"))); + Assert.False(block3.Contains(IPAddress.Parse("::2"))); + + // Trailing compression + var block4 = CIDRBlock.Parse("2001:db8:1::/48"); + Assert.True(block4.Contains(IPAddress.Parse("2001:db8:1::1"))); + Assert.True(block4.Contains(IPAddress.Parse("2001:db8:1:ffff::1"))); + Assert.False(block4.Contains(IPAddress.Parse("2001:db8:2::1"))); + + // Middle compression + var block5 = CIDRBlock.Parse("2001:db8::1:0:0:1/64"); + Assert.True(block5.Contains(IPAddress.Parse("2001:db8::1"))); + Assert.True(block5.Contains(IPAddress.Parse("2001:db8:0:0:ffff::"))); + Assert.False(block5.Contains(IPAddress.Parse("2001:db9::1"))); + + // ::<ipv4> format + var block6 = CIDRBlock.Parse("::ffff:192.168.1.0/120"); + Assert.True(block6.Contains(IPAddress.Parse("::ffff:192.168.1.1"))); + Assert.True(block6.Contains(IPAddress.Parse("::ffff:192.168.1.255"))); + Assert.False(block6.Contains(IPAddress.Parse("::ffff:192.168.2.1"))); + + // Mixed case hex digits + var block8 = CIDRBlock.Parse("2001:DB8:aBCD:Ef01::/64"); + Assert.True(block8.Contains(IPAddress.Parse("2001:db8:abcd:ef01::1"))); + Assert.False(block8.Contains(IPAddress.Parse("2001:db8:abcd:ef02::1"))); + + // Test without prefix length (should default to /128) + var block9 = CIDRBlock.Parse("2001:db8::1"); + Assert.True(block9.Contains(IPAddress.Parse("2001:db8::1"))); + Assert.False(block9.Contains(IPAddress.Parse("2001:db8::2"))); + + var block10 = CIDRBlock.Parse("::1"); + Assert.True(block10.Contains(IPAddress.Parse("::1"))); + Assert.False(block10.Contains(IPAddress.Parse("::2"))); + + var block11 = CIDRBlock.Parse("::ffff:192.168.1.1"); + Assert.True(block11.Contains(IPAddress.Parse("::ffff:192.168.1.1"))); + Assert.False(block11.Contains(IPAddress.Parse("::ffff:192.168.1.2"))); + } + + [Fact] + public void Contains_IPv6AddressWithScope_ScopeIsStripped() + { + // Scoped addresses (e.g. fe80::1%eth0) contain a zone ID that must be + // stripped before prefix matching; the scope does not affect containment. + var block = CIDRBlock.Parse("fe80::/10"); + +#if NET5_0_OR_GREATER + Assert.True(block.Contains(IPAddress.Parse("fe80::1%eth0"))); + Assert.True(block.Contains(IPAddress.Parse("fe80::1%1"))); + Assert.False(block.Contains(IPAddress.Parse("2001:db8::1%eth0"))); +#else + Assert.Throws<FormatException>(() => block.Contains(IPAddress.Parse("fe80::1%eth0"))); +#endif + } + + [Fact] + public void Contains_IPAddress_NetworkBoundaryAddress_ReturnsTrue() + { + var block = CIDRBlock.Parse("192.168.1.0/24"); + + Assert.True(block.Contains(IPAddress.Parse("192.168.1.0"))); + Assert.True(block.Contains(IPAddress.Parse("192.168.1.255"))); + } + + [Fact] + public void Contains_CIDRBlock_NoOverlap_ReturnsFalse() + { + var block1 = CIDRBlock.Parse("192.168.1.0/24"); + var block2 = CIDRBlock.Parse("192.168.2.0/24"); + var block3 = CIDRBlock.Parse("192.168.3.128/26"); + + Assert.False(block1.Contains(block2)); + Assert.False(block2.Contains(block1)); + Assert.False(block1.Contains(block3)); + Assert.False(block3.Contains(block1)); + Assert.False(block2.Contains(block3)); + Assert.False(block3.Contains(block2)); + } + + [Fact] + public void Contains_CIDRBlock_PartialOverlap_ReturnsFalse() + { + var block1 = CIDRBlock.Parse("192.168.1.0/28"); + var block2 = CIDRBlock.Parse("192.168.1.32/28"); + var block3 = CIDRBlock.Parse("192.168.1.48/28"); + + Assert.False(block1.Contains(block2)); + Assert.False(block2.Contains(block1)); + Assert.False(block1.Contains(block3)); + Assert.False(block3.Contains(block1)); + Assert.False(block2.Contains(block3)); + Assert.False(block3.Contains(block2)); + } + + [Fact] + public void Contains_CIDRBlock_NestedContainment_ReturnsExpectedResult() + { + var block1 = CIDRBlock.Parse("192.168.1.0/24"); + var block2 = CIDRBlock.Parse("192.168.1.128/25"); + var block3 = CIDRBlock.Parse("192.168.1.200"); + + // block1 contains block2 and block3 + Assert.True(block1.Contains(block2)); + Assert.True(block1.Contains(block3)); + + // block2 contains block3 + Assert.True(block2.Contains(block3)); + + // Each block contains itself + Assert.True(block1.Contains(block1)); + Assert.True(block2.Contains(block2)); + Assert.True(block3.Contains(block3)); + + // Narrower blocks cannot contain broader blocks + Assert.False(block2.Contains(block1)); + Assert.False(block3.Contains(block1)); + Assert.False(block3.Contains(block2)); + } + + [Fact] + public void ToCIDR_ReturnsExpectedString() + { + // Standard IPv4 with prefix + Assert.Equal("192.168.1.0/24", CIDRBlock.Parse("192.168.1.0/24").ToCIDR()); + + // IPv4 host address (no prefix given defaults to /32) + Assert.Equal("10.0.0.1/32", CIDRBlock.Parse("10.0.0.1").ToCIDR()); + + // IPv4 with /0 + Assert.Equal("0.0.0.0/0", CIDRBlock.Parse("0.0.0.0/0").ToCIDR()); + + // IPv6 with prefix + Assert.Equal("2001:db8::/32", CIDRBlock.Parse("2001:db8::/32").ToCIDR()); + + // IPv6 host address (no prefix given defaults to /128) + Assert.Equal("::1/128", CIDRBlock.Parse("::1").ToCIDR()); + + // IPv4-mapped IPv6 input round-trips back to IPv4 notation + Assert.Equal("192.168.1.0/24", CIDRBlock.Parse("::ffff:192.168.1.0/120").ToCIDR()); + + // Scoped IPv6 address - scope is stripped in output +#if NET5_0_OR_GREATER + Assert.Equal("fe80::1/128", CIDRBlock.Parse("fe80::1%eth0").ToCIDR()); +#else + Assert.Throws<FormatException>(() => CIDRBlock.Parse("fe80::1%eth0").ToCIDR()); +#endif + } + } +} \ No newline at end of file diff --git a/dotnet/UnitTests/Microsoft.Security.AntiSSRF.UnitTests.csproj b/dotnet/UnitTests/Microsoft.Security.AntiSSRF.UnitTests.csproj new file mode 100644 index 0000000..f0f141f --- /dev/null +++ b/dotnet/UnitTests/Microsoft.Security.AntiSSRF.UnitTests.csproj @@ -0,0 +1,23 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFrameworks>net8.0;net48</TargetFrameworks> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <LangVersion>latest</LangVersion> + <IsPackable>false</IsPackable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="coverlet.collector" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio" /> + <PackageReference Include="System.Net.Http" Condition="'$(TargetFramework)' == 'net48'" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="../src/Microsoft.Security.AntiSSRF.csproj" /> + </ItemGroup> + +</Project> \ No newline at end of file diff --git a/dotnet/src/AntiSSRFException.cs b/dotnet/src/AntiSSRFException.cs new file mode 100644 index 0000000..16bf6cf --- /dev/null +++ b/dotnet/src/AntiSSRFException.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace Microsoft.Security.AntiSSRF +{ + public sealed class AntiSSRFException : Exception + { + internal AntiSSRFException() : base() { } + internal AntiSSRFException(string message) : base(message) { } + } +} \ No newline at end of file diff --git a/dotnet/src/AntiSSRFHandler.cs b/dotnet/src/AntiSSRFHandler.cs new file mode 100644 index 0000000..348d431 --- /dev/null +++ b/dotnet/src/AntiSSRFHandler.cs @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Threading; +using System.Threading.Tasks; + +#if NET5_0_OR_GREATER +#else +using System.Security.Cryptography.X509Certificates; +using System.Security.Authentication; +#endif + +#if NET5_0_OR_GREATER +using InnerHandlerType = System.Net.Http.SocketsHttpHandler; +#else +using InnerHandlerType = System.Net.Http.HttpClientHandler; +#endif + +namespace Microsoft.Security.AntiSSRF +{ + public sealed class AntiSSRFHandler : HttpMessageHandler + { + private readonly InnerHandlerType _innerHandler; + private readonly RedirectHandler _redirectHandler; + private volatile bool _disposed; + private volatile bool _editLock; + + internal AntiSSRFHandler(AntiSSRFPolicy policy) + { + if (policy is null) + throw new ArgumentNullException(nameof(policy)); + +#if NET5_0_OR_GREATER + _innerHandler = InnerHandler.GetHandler(policy); +#else + _innerHandler = new InnerHandler(policy); +#endif + _redirectHandler = new RedirectHandler(_innerHandler, policy); + } + + private void CheckDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(AntiSSRFHandler)); + } + + private void CheckDisposedOrLocked() + { + CheckDisposed(); + if (_editLock) + throw new InvalidOperationException("Cannot edit properties after a request has been sent."); + } + +#if NET5_0_OR_GREATER + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + CheckDisposed(); + _editLock = true; + return _redirectHandler.SendWrapper(request, cancellationToken); + } +#endif + + protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + CheckDisposed(); + _editLock = true; + return _redirectHandler.SendAsyncWrapper(request, cancellationToken); + } + + protected override void Dispose(bool disposing) + { + if (!_disposed) + { + _redirectHandler.Dispose(); + _disposed = true; + } + } + + // ----- Properties handled directly ----- + + public bool AllowAutoRedirect + { + get => _redirectHandler.AllowAutoRedirect; + set + { + CheckDisposedOrLocked(); + + _redirectHandler.AllowAutoRedirect = value; + } + } + + public int MaxAutomaticRedirections + { + get => _redirectHandler.MaxAutomaticRedirections; + set + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(MaxAutomaticRedirections)); + } + CheckDisposedOrLocked(); + + _redirectHandler.MaxAutomaticRedirections = value; + } + } + + // ----- Properties that pass through to the underlying handler ----- + +#if NET5_0_OR_GREATER + public CookieContainer CookieContainer + { + get => _innerHandler.CookieContainer; + set + { + CheckDisposedOrLocked(); + _innerHandler.CookieContainer = value; + } + } +#endif + + public ICredentials? Credentials + { + get => _innerHandler.Credentials; + set + { + CheckDisposedOrLocked(); + _innerHandler.Credentials = value; + } + } + + public int MaxConnectionsPerServer + { + get => _innerHandler.MaxConnectionsPerServer; + set + { + CheckDisposedOrLocked(); + _innerHandler.MaxConnectionsPerServer = value; + } + } + + public int MaxResponseHeadersLength + { + get => _innerHandler.MaxResponseHeadersLength; + set + { + CheckDisposedOrLocked(); + _innerHandler.MaxResponseHeadersLength = value; + } + } + +#if NET5_0_OR_GREATER + public bool UseCookies + { + get => _innerHandler.UseCookies; + set + { + CheckDisposedOrLocked(); + _innerHandler.UseCookies = value; + } + } +#endif + +#if NET5_0_OR_GREATER + public TimeSpan ConnectTimeout + { + get => _innerHandler.ConnectTimeout; + set + { + CheckDisposedOrLocked(); + _innerHandler.ConnectTimeout = value; + } + } + + public TimeSpan ResponseDrainTimeout + { + get => _innerHandler.ResponseDrainTimeout; + set + { + CheckDisposedOrLocked(); + _innerHandler.ResponseDrainTimeout = value; + } + } + + public TimeSpan PooledConnectionIdleTimeout + { + get => _innerHandler.PooledConnectionIdleTimeout; + set + { + CheckDisposedOrLocked(); + _innerHandler.PooledConnectionIdleTimeout = value; + } + } + + public TimeSpan PooledConnectionLifetime + { + get => _innerHandler.PooledConnectionLifetime; + set + { + CheckDisposedOrLocked(); + _innerHandler.PooledConnectionLifetime = value; + } + } + + public SslClientAuthenticationOptions SslOptions + { + get => _innerHandler.SslOptions; + set + { + CheckDisposedOrLocked(); + _innerHandler.SslOptions = value; + } + } +#else + // Maps to SslOptions.CertificateRevocationCheckMode + public bool CheckCertificateRevocationList + { + get => _innerHandler.CheckCertificateRevocationList; + set + { + CheckDisposedOrLocked(); + _innerHandler.CheckCertificateRevocationList = value; + } + } + + // Maps to SslOptions.RemoteCertificateValidationCallback + public Func<HttpRequestMessage, X509Certificate2?, X509Chain?, SslPolicyErrors, bool>? ServerCertificateCustomValidationCallback + { + get => _innerHandler.ServerCertificateCustomValidationCallback; + set + { + CheckDisposedOrLocked(); + _innerHandler.ServerCertificateCustomValidationCallback = value; + } + } + + // Maps to SslOptions.EnabledSslProtocols + public SslProtocols SslProtocols + { + get => _innerHandler.SslProtocols; + set + { + CheckDisposedOrLocked(); + _innerHandler.SslProtocols = value; + } + } +#endif + } +} diff --git a/dotnet/src/AntiSSRFPolicy.cs b/dotnet/src/AntiSSRFPolicy.cs new file mode 100644 index 0000000..ecf4a0c --- /dev/null +++ b/dotnet/src/AntiSSRFPolicy.cs @@ -0,0 +1,354 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; + +namespace Microsoft.Security.AntiSSRF +{ + + /// <summary> + /// Defines the available configuration options for AntiSSRF policies. + /// </summary> + public enum PolicyConfigOptions + { + /// <summary> + /// Block all IP addresses by default. Users must explicitly add allowed address ranges. + /// </summary> + InternalOnly, + + /// <summary> + /// Block IP addresses from the recommendedV1 list. Adds X-Forwarded-For header. + /// </summary> + ExternalOnlyV1, + + /// <summary> + /// Block IP addresses from the latest recommended list. Adds X-Forwarded-For header. + /// </summary> + ExternalOnlyLatest, + + /// <summary> + /// No IP address restrictions by default. + /// </summary> + None + } + + public class AntiSSRFPolicy + { + private readonly List<string> _deniedHeaders; + private readonly List<string> _requiredHeaders; + private readonly List<CIDRBlock> _allowedAddresses; + private readonly List<CIDRBlock> _deniedAddresses; + private volatile bool _editLock = false; + + /// <summary> + /// Gets or sets whether plain text HTTP requests are allowed. If false, any request with the "http" scheme will be blocked by the handler. + /// </summary> + /// <exception cref="AntiSSRFException">Thrown when attempting to change the property after the policy has been used to create a handler.</exception> + public bool AllowPlainTextHttp + { + get => _allowPlainTextHttp; + set + { + if (_editLock) + throw new AntiSSRFException("Can't change AllowPlainTextHttp after policy has been used to create a handler"); + _allowPlainTextHttp = value; + } + } + private bool _allowPlainTextHttp = false; + + /// <summary> + /// Gets or sets whether the handler should add an "X-Forwarded-For: true" header to outgoing HTTP requests. + /// </summary> + /// <exception cref="AntiSSRFException">Thrown when attempting to change the property after the policy has been used to create a handler.</exception> + public bool AddXFFHeader + { + get => _addXFFHeader; + set + { + if (_editLock) + throw new AntiSSRFException("Can't change AddXFFHeader after policy has been used to create a handler"); + _addXFFHeader = value; + } + } + private bool _addXFFHeader = false; + + /// <summary> + /// Gets or sets whether the handler should deny all unspecified IP addresses. If true, any request to an IP address not explicitly allowed will be blocked. + /// </summary> + /// <exception cref="AntiSSRFException">Thrown when attempting to change the property after the policy has been used to create a handler.</exception> + public bool DenyAllUnspecifiedIPs + { + get => _denyAllUnspecifiedIPs; + set + { + if (_editLock) + throw new AntiSSRFException("Can't change DenyAllUnspecifiedIPs after policy has been used to create a handler"); + _denyAllUnspecifiedIPs = value; + } + } + private bool _denyAllUnspecifiedIPs = false; + + /// <summary> + /// Gets a read-only view of the currently denied headers. + /// </summary> + public IReadOnlyList<string> DeniedHeaders => _deniedHeaders.AsReadOnly(); + + /// <summary> + /// Gets a read-only view of the currently required headers. + /// </summary> + public IReadOnlyList<string> RequiredHeaders => _requiredHeaders.AsReadOnly(); + + /// <summary> + /// Gets a read-only view of the currently allowed IP address ranges as CIDR notation strings. + /// </summary> + public IReadOnlyList<string> AllowedAddresses => _allowedAddresses.ConvertAll(cidr => cidr.ToCIDR()).AsReadOnly(); + + /// <summary> + /// Gets a read-only view of the currently denied IP address ranges as CIDR notation strings. + /// </summary> + public IReadOnlyList<string> DeniedAddresses => _deniedAddresses.ConvertAll(cidr => cidr.ToCIDR()).AsReadOnly(); + + /// <summary> + /// Creates a new AntiSSRFPolicy instance with the specified configuration. + /// </summary> + /// <param name="config">The policy configuration option to use.</param> + /// <exception cref="ArgumentOutOfRangeException">Thrown when an invalid policy option is provided.</exception> + public AntiSSRFPolicy(PolicyConfigOptions config) + { + _deniedHeaders = new List<string>(); + _requiredHeaders = new List<string>(); + _allowedAddresses = new List<CIDRBlock>(); + _deniedAddresses = new List<CIDRBlock>(); + + switch (config) + { + // Block all IPs by default. Users must add their intended internal ranges. + case PolicyConfigOptions.InternalOnly: + DenyAllUnspecifiedIPs = true; + AddXFFHeader = false; + break; + // Block recommendedV1 IPs. Blocks IMDS, so add XFF. + case PolicyConfigOptions.ExternalOnlyV1: + AddDeniedAddresses(IPAddressRanges.recommendedV1); + AddXFFHeader = true; + break; + // Block recommendedLatest IPs. Blocks IMDS, so add XFF. + case PolicyConfigOptions.ExternalOnlyLatest: + AddDeniedAddresses(IPAddressRanges.recommendedLatest); + AddXFFHeader = true; + break; + // No restrictions by default. + case PolicyConfigOptions.None: + AddXFFHeader = false; + break; + default: + throw new ArgumentOutOfRangeException(nameof(config), config, "Argument must be a valid PolicyConfigOptions value"); + } + } + + /// <summary> + /// Adds IP networks to the allow list. Requests to any IP address that matches any of the specified networks will be allowed by the handler. + /// Allowed addresses take precedence over denied addresses, so if an IP address matches a network on both the allow and deny list, it will be allowed. + /// </summary> + /// <param name="networks">The IP networks to allow.</param> + /// <exception cref="ArgumentNullException">Thrown when the networks parameter is null or contains null values.</exception> + /// <exception cref="FormatException">Thrown when a network string is not in valid CIDR format.</exception> + /// <exception cref="AntiSSRFException">Thrown on attempts to edit the policy after it has been used to create a handler.</exception> + public void AddAllowedAddresses(string[]? networks) + { + if (networks is null) + throw new ArgumentNullException(nameof(networks)); + + if (_editLock) + throw new AntiSSRFException("Can't AddAllowedAddresses after policy is used to create a handler"); + + List<CIDRBlock> parsedNetworks = new List<CIDRBlock>(networks.Length); + + foreach (string network in networks) + { + parsedNetworks.Add(CIDRBlock.Parse(network)); + } + + _allowedAddresses.AddRange(parsedNetworks); + } + + /// <summary> + /// Adds IP networks to the deny list. Requests to any IP address that matches any of the specified networks will be blocked by the handler. + /// Allowed addresses take precedence over denied addresses, so if an IP address matches a network on both the allow and deny list, it will be allowed. + /// </summary> + /// <param name="networks">The IP networks to deny.</param> + /// <exception cref="ArgumentNullException">Thrown when the networks parameter is null or contains null values.</exception> + /// <exception cref="FormatException">Thrown when a network string is not in valid CIDR format.</exception> + /// <exception cref="AntiSSRFException">Thrown on invalid operations or attempts to edit the policy after it has been used to create a handler.</exception> + public void AddDeniedAddresses(string[]? networks) + { + if (networks is null) + throw new ArgumentNullException(nameof(networks)); + + if (_editLock) + throw new AntiSSRFException("Can't AddDeniedAddresses after policy is used to create a handler"); + + if (DenyAllUnspecifiedIPs) + throw new AntiSSRFException("Can't add denied networks when DenyAllUnspecifiedIPs is true"); + + List<CIDRBlock> parsedNetworks = new List<CIDRBlock>(networks.Length); + + foreach (string network in networks) + { + parsedNetworks.Add(CIDRBlock.Parse(network)); + } + + _deniedAddresses.AddRange(parsedNetworks); + } + + /// <summary> + /// Adds headers to the list of denied headers. + /// HTTP requests containing any of these headers will be blocked by the handler. + /// </summary> + /// <param name="deniedHeaders">An array of denied header names.</param> + /// <exception cref="ArgumentNullException">Thrown when the deniedHeaders parameter is null or contains null values.</exception> + /// <exception cref="ArgumentException">Thrown when a header name is empty or whitespace.</exception> + /// <exception cref="AntiSSRFException">Thrown on attempts to edit the policy after it has been used to create a handler.</exception> + public void AddDeniedHeaders(string[]? deniedHeaders) + { + if (deniedHeaders is null) + throw new ArgumentNullException(nameof(deniedHeaders)); + + if (_editLock) + throw new AntiSSRFException("Can't AddDeniedHeaders after policy is used to create a handler"); + + foreach (string headerName in deniedHeaders) + { + if (headerName is null) + throw new ArgumentNullException(nameof(deniedHeaders), "Headers cannot be null"); + + if (string.IsNullOrWhiteSpace(headerName)) + throw new ArgumentException($"Headers cannot be empty or whitespace", nameof(deniedHeaders)); + } + + _deniedHeaders.AddRange(deniedHeaders); + } + + /// <summary> + /// Adds headers to the list of required headers. + /// HTTP requests missing any of these headers will be blocked by the handler. + /// </summary> + /// <param name="requiredHeaders">An array of required header names.</param> + /// <exception cref="ArgumentNullException">Thrown when the requiredHeaders parameter is null or contains null values.</exception> + /// <exception cref="ArgumentException">Thrown when a header name is empty or whitespace.</exception> + /// <exception cref="AntiSSRFException">Thrown on attempts to edit the policy after it has been used to create a handler.</exception> + public void AddRequiredHeaders(string[]? requiredHeaders) + { + if (requiredHeaders is null) + throw new ArgumentNullException(nameof(requiredHeaders)); + + if (_editLock) + throw new AntiSSRFException("Can't AddRequiredHeaders after policy is used to create a handler"); + + foreach (string headerName in requiredHeaders) + { + if (headerName is null) + throw new ArgumentNullException(nameof(requiredHeaders), "Headers cannot be null"); + + if (string.IsNullOrWhiteSpace(headerName)) + throw new ArgumentException($"Headers cannot be empty or whitespace", nameof(requiredHeaders)); + } + + _requiredHeaders.AddRange(requiredHeaders); + } + + /// <summary> + /// Creates and returns a new <see cref="AntiSSRFHandler"/> instance based on the current policy configuration. + /// </summary> + /// <returns>A new <see cref="AntiSSRFHandler"/> instance based on this policy.</returns> + public AntiSSRFHandler GetHandler() + { + _editLock = true; + return new AntiSSRFHandler(this); + } + + // --- Private and internal methods --- // + + internal bool IsNetworkConnectionAllowed(IPAddress[]? dnsResolvedIPAddresses) + { + if (dnsResolvedIPAddresses is null) + { + return false; + } + + foreach (IPAddress ipAddress in dnsResolvedIPAddresses) + { + IPAddress ipv6Address = ipAddress.MapToIPv6(); + + if (DenyAllUnspecifiedIPs) + { + // If the address is not in an allow list, it's not allowed. + if (!NetworksContainAddress(_allowedAddresses, ipv6Address)) + { + return false; + } + } + else if (_deniedAddresses != null && _deniedAddresses.Count > 0) + { + // If the address is in a deny list and not in an allow list, it's not allowed. + if (NetworksContainAddress(_deniedAddresses, ipv6Address) && + !NetworksContainAddress(_allowedAddresses, ipv6Address)) + { + return false; + } + } + } + + // No ip addresses returned by DNS resolution was denied + return true; + } + + private static bool NetworksContainAddress(List<CIDRBlock> networks, IPAddress address) + { + foreach (var network in networks) + { + if (network.Contains(address)) + { + return true; + } + } + + return false; + } + + internal bool IsHttpRequestAllowed(string? scheme, HttpRequestHeaders headers) + { + if (!AllowPlainTextHttp && !"https".Equals(scheme, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (AddXFFHeader && !headers.Contains("X-Forwarded-For")) + { + headers.Add("X-Forwarded-For", "true"); + } + + foreach (string header in _deniedHeaders) + { + if (headers.Contains(header)) + { + return false; + } + } + + foreach (string header in _requiredHeaders) + { + if (!headers.Contains(header)) + { + return false; + } + } + + return true; + } + } +} diff --git a/dotnet/src/Helpers/CIDRBlock.cs b/dotnet/src/Helpers/CIDRBlock.cs new file mode 100644 index 0000000..1a243c9 --- /dev/null +++ b/dotnet/src/Helpers/CIDRBlock.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Linq; +using System.Net.Sockets; +using System.Net; +using System; + +namespace Microsoft.Security.AntiSSRF +{ + // Based on [System.Net IPNetwork Struct](https://learn.microsoft.com/en-us/dotnet/api/system.net.ipnetwork?view=net-10.0) + internal readonly struct CIDRBlock + { + private readonly byte[] _networkBytes; + private readonly byte[] _maskBytes; + private readonly int _prefixLength; + + private CIDRBlock(IPAddress ipv6, int prefixLength) + { + Debug.Assert(ipv6.AddressFamily == AddressFamily.InterNetworkV6); + + _prefixLength = prefixLength; + +#if NETCOREAPP2_1_OR_GREATER + Span<byte> ipBytes = stackalloc byte[16]; + ipv6.TryWriteBytes(ipBytes, out _); +#else + var ipBytes = ipv6.GetAddressBytes(); +#endif + + // Precompute the mask + _maskBytes = new byte[16]; + var fullBytes = Math.DivRem(prefixLength, 8, out int remainingBits); + + // Set full mask bytes to 0xFF + for (int i = 0; i < fullBytes; i++) + { + _maskBytes[i] = 0xFF; + } + + // Set partial mask byte + if (remainingBits > 0 && fullBytes < 16) + { + _maskBytes[fullBytes] = (byte)(0xFF << (8 - remainingBits)); + } + + // Precompute the network address (ip & mask) + _networkBytes = new byte[16]; + for (int i = 0; i < 16; i++) + { + _networkBytes[i] = (byte)(ipBytes[i] & _maskBytes[i]); + } + } + + /// <exception cref="ArgumentNullException">Thrown when cidrString is null</exception> + /// <exception cref="FormatException">Thrown when cidrString is not valid CIDR notation</exception> + internal static CIDRBlock Parse(string cidrString) + { + if (cidrString is null) + { + throw new ArgumentNullException(nameof(cidrString)); + } + + string[] _parts = cidrString.Split('/'); + if (IPAddress.TryParse(_parts[0], out IPAddress? ipAddress)) + { + switch (_parts.Length) + { + case 1: + return new CIDRBlock(ipAddress.MapToIPv6(), 128); + + case 2: + // Validate that the prefix part contains only digits (no signs, whitespace, or leading zeros except "0") + string prefixPart = _parts[1]; + if (!string.IsNullOrEmpty(prefixPart) && + (prefixPart == "0" || (prefixPart[0] != '0' && prefixPart.All(char.IsDigit))) && + int.TryParse(prefixPart, out int prefixLength)) + { + switch (ipAddress.AddressFamily) + { + case AddressFamily.InterNetwork when (uint)prefixLength <= 32: + return new CIDRBlock(ipAddress.MapToIPv6(), prefixLength + 96); + + case AddressFamily.InterNetworkV6 when (uint)prefixLength <= 128: + return new CIDRBlock(ipAddress, prefixLength); + } + } + break; + } + } + + throw new FormatException("Invalid CIDR notation"); + } + + /// <exception cref="ArgumentNullException">Thrown when ip is null</exception> + internal bool Contains(IPAddress ip) + { + if (ip is null) + { + throw new ArgumentNullException(nameof(ip)); + } + + // Convert to IPv6 and get bytes + var ipv6 = ip.MapToIPv6(); + +#if NETCOREAPP2_1_OR_GREATER + Span<byte> ipBytes = stackalloc byte[16]; + ipv6.TryWriteBytes(ipBytes, out _); +#else + var ipBytes = ipv6.GetAddressBytes(); +#endif + + // Apply mask and compare with precomputed network address + for (int i = 0; i < 16; i++) + { + if ((ipBytes[i] & _maskBytes[i]) != _networkBytes[i]) + { + return false; + } + } + + return true; + } + + internal bool Contains(CIDRBlock other) + { + // For one CIDR to contain another, this CIDR must have broader or equal scope (shorter or equal prefix) + if (_prefixLength > other._prefixLength) + { + return false; + } + + // Apply this CIDR's mask to the other CIDR's network address and compare + for (int i = 0; i < 16; i++) + { + if ((other._networkBytes[i] & _maskBytes[i]) != _networkBytes[i]) + { + return false; + } + } + + return true; + } + + internal string ToCIDR() + { + var networkAddress = new IPAddress(_networkBytes); + + // Check if this is an IPv4 address mapped to IPv6 (prefix length > 96) + if (_prefixLength >= 96 && networkAddress.IsIPv4MappedToIPv6) + { + // Extract the IPv4 part and adjust prefix length + var ipv4Address = networkAddress.MapToIPv4(); + int ipv4PrefixLength = _prefixLength - 96; + return $"{ipv4Address}/{ipv4PrefixLength}"; + } + else + { + // Pure IPv6 address + return $"{networkAddress}/{_prefixLength}"; + } + } + } +} \ No newline at end of file diff --git a/dotnet/src/Helpers/Domains.cs b/dotnet/src/Helpers/Domains.cs new file mode 100644 index 0000000..d805f1f --- /dev/null +++ b/dotnet/src/Helpers/Domains.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This file is auto-generated from config/Domains.json +// Do not edit this file directly. + +namespace Microsoft.Security.AntiSSRF +{ + /// <summary> + /// Well-known Azure service domains for internal use. + /// </summary> + internal static class Domains + { + /// <summary> + /// Azure Key Vault service domains across all public Azure environments. + /// </summary> + internal static readonly string[] AzureKeyVaultDomains = new string[] + { + "vault.azure.net", + "managedhsm.azure.net", + "vault.azure.cn", + "managedhsm.azure.cn", + "vault.usgovcloudapi.net", + "managedhsm.usgovcloudapi.net", + }; + + /// <summary> + /// Azure Storage service domains across all public Azure environments. + /// </summary> + internal static readonly string[] AzureStorageDomains = new string[] + { + "blob.core.windows.net", + "web.core.windows.net", + "dfs.core.windows.net", + "file.core.windows.net", + "queue.core.windows.net", + "table.core.windows.net", + "blob.storage.azure.net", + "web.storage.azure.net", + "dfs.storage.azure.net", + "file.storage.azure.net", + "queue.storage.azure.net", + "table.storage.azure.net", + "blob.core.usgovcloudapi.net", + "web.core.usgovcloudapi.net", + "dfs.core.usgovcloudapi.net", + "file.core.usgovcloudapi.net", + "queue.core.usgovcloudapi.net", + "table.core.usgovcloudapi.net", + "blob.core.chinacloudapi.cn", + "web.core.chinacloudapi.cn", + "dfs.core.chinacloudapi.cn", + "file.core.chinacloudapi.cn", + "queue.core.chinacloudapi.cn", + "table.core.chinacloudapi.cn", + }; + } +} diff --git a/dotnet/src/Helpers/InnerHandler.NetCore.cs b/dotnet/src/Helpers/InnerHandler.NetCore.cs new file mode 100644 index 0000000..35532d3 --- /dev/null +++ b/dotnet/src/Helpers/InnerHandler.NetCore.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#if NET5_0_OR_GREATER + +using System.Net; +using System.Net.Http; +using System.Net.Sockets; + +namespace Microsoft.Security.AntiSSRF +{ + internal class InnerHandler + { + internal static SocketsHttpHandler GetHandler(AntiSSRFPolicy policy) + { + return new SocketsHttpHandler() + { + AllowAutoRedirect = false, + ConnectCallback = async (ConnectionContext, CancellationToken) => + { + IPAddress[] resolvedIPs = await Dns.GetHostAddressesAsync(ConnectionContext.DnsEndPoint.Host, CancellationToken); + if (resolvedIPs.Length == 0) + throw new AntiSSRFException($"DNS lookup failed for {ConnectionContext.DnsEndPoint.Host}."); + + if (!policy.IsNetworkConnectionAllowed(resolvedIPs)) + throw new AntiSSRFException($"The connection to {ConnectionContext.DnsEndPoint.Host} is not allowed per policy."); + + Socket socket = new(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + try + { + await socket.ConnectAsync(resolvedIPs, ConnectionContext.DnsEndPoint.Port, CancellationToken); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } + }; + } + } +} + +#endif \ No newline at end of file diff --git a/dotnet/src/Helpers/InnerHandler.NetStandard.cs b/dotnet/src/Helpers/InnerHandler.NetStandard.cs new file mode 100644 index 0000000..70f0f38 --- /dev/null +++ b/dotnet/src/Helpers/InnerHandler.NetStandard.cs @@ -0,0 +1,78 @@ +#if !NET5_0_OR_GREATER + +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Security.AntiSSRF +{ + internal sealed class InnerHandler : HttpClientHandler + { + private readonly AntiSSRFPolicy _policy; + + internal InnerHandler(AntiSSRFPolicy policy) : base() + { + _policy = policy; + AllowAutoRedirect = false; + } + + protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri is null) + throw new AntiSSRFException($"No URI was specified."); + + string host = request.RequestUri.Host; + + bool isIPAddressHost = IPAddress.TryParse(host, out IPAddress? parsedIpAddress); + IPAddress[] resolvedIPs; + if (isIPAddressHost && parsedIpAddress is not null) + { + resolvedIPs = new[] { parsedIpAddress }; + } + else + { + resolvedIPs = await Dns.GetHostAddressesAsync(host).ConfigureAwait(false); + if (resolvedIPs.Length == 0) + throw new AntiSSRFException($"DNS lookup failed for {host}."); + } + + if (!_policy.IsNetworkConnectionAllowed(resolvedIPs)) + throw new AntiSSRFException($"The connection to {host} is not allowed per policy."); + + if (!isIPAddressHost) + { + request.Headers.Host = host; + + if (resolvedIPs[0].AddressFamily == AddressFamily.InterNetwork || resolvedIPs[0].AddressFamily == AddressFamily.InterNetworkV6) + { + try + { + var uriBuilder = new UriBuilder(request.RequestUri) + { + Host = resolvedIPs[0].ToString() + }; + request.RequestUri = uriBuilder.Uri; + } + catch + { + throw new AntiSSRFException($"Unsupported URI format"); + } + } + else + { + throw new AntiSSRFException($"Unsupported AddressFamily: {resolvedIPs[0].AddressFamily}"); + } + } + + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + } +} + +#endif diff --git a/dotnet/src/Helpers/RedirectHandler.cs b/dotnet/src/Helpers/RedirectHandler.cs new file mode 100644 index 0000000..826599d --- /dev/null +++ b/dotnet/src/Helpers/RedirectHandler.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Security.AntiSSRF +{ + internal class RedirectHandler : DelegatingHandler + { + private readonly AntiSSRFPolicy _policy; + + internal RedirectHandler(HttpMessageHandler innerHandler, AntiSSRFPolicy policy) : base(innerHandler) + { + _policy = policy; + } + +#if NET5_0_OR_GREATER + internal HttpResponseMessage SendWrapper(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Send(request, cancellationToken); + } + + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (!_policy.IsHttpRequestAllowed(request.RequestUri?.Scheme, request.Headers)) + throw new AntiSSRFException("This request is not allowed per policy."); + + HttpResponseMessage response; + try + { + response = base.Send(request, cancellationToken); + } + catch (HttpRequestException ex) when (ex.InnerException is not null) + { + throw ex.InnerException; + } + + if (!AllowAutoRedirect) + return response; + + Uri? redirectUri; + for (int redirectCount = 0; redirectCount < MaxAutomaticRedirections && (redirectUri = GetRedirectUri(request.RequestUri!, response)) is not null; redirectCount++) + { + HttpStatusCode statusCode = response.StatusCode; + response.Dispose(); + request.RequestUri = redirectUri; + SetUpForRedirect(request, statusCode); + + if (!_policy.IsHttpRequestAllowed(request.RequestUri?.Scheme, request.Headers)) + throw new AntiSSRFException("This request is not allowed per policy."); + + try + { + response = base.Send(request, cancellationToken); + } + catch (HttpRequestException ex) when (ex.InnerException is not null) + { + throw ex.InnerException; + } + } + + return response; + } +#endif + + internal async Task<HttpResponseMessage> SendAsyncWrapper(HttpRequestMessage request, CancellationToken cancellationToken) + { + return await SendAsync(request, cancellationToken); + } + + protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (!_policy.IsHttpRequestAllowed(request.RequestUri?.Scheme, request.Headers)) + throw new AntiSSRFException("This request is not allowed per policy."); + + HttpResponseMessage response; + try + { + response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException ex) when (ex.InnerException is not null) + { + throw ex.InnerException; + } + + if (!AllowAutoRedirect) + return response; + + Uri? redirectUri; + for (int redirectCount = 0; redirectCount < MaxAutomaticRedirections && (redirectUri = GetRedirectUri(request.RequestUri!, response)) is not null; redirectCount++) + { + HttpStatusCode statusCode2 = response.StatusCode; + response.Dispose(); + request.RequestUri = redirectUri; + SetUpForRedirect(request, statusCode2); + + if (!_policy.IsHttpRequestAllowed(request.RequestUri?.Scheme, request.Headers)) + throw new AntiSSRFException("This request is not allowed per policy."); + + try + { + response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException ex) when (ex.InnerException is not null) + { + throw ex.InnerException; + } + } + + return response; + } + + private readonly HttpStatusCode[] RedirectCodes = new HttpStatusCode[] { HttpStatusCode.Moved, HttpStatusCode.Found, HttpStatusCode.SeeOther, HttpStatusCode.TemporaryRedirect, HttpStatusCode.MultipleChoices, (HttpStatusCode)308 /*HttpStatusCode.PermanentRedirect*/ }; + + private Uri? GetRedirectUri(Uri requestUri, HttpResponseMessage response) + { + if (!RedirectCodes.Contains(response.StatusCode)) + return null; + + Uri? location = response.Headers.Location; + if (location is null) + return null; + + // Ensure the redirect location is an absolute URI. + if (!location.IsAbsoluteUri) + location = new Uri(requestUri, location); + + // Per https://tools.ietf.org/html/rfc7231#section-7.1.2, a redirect location without a + // fragment should inherit the fragment from the original URI. + if (!string.IsNullOrEmpty(requestUri.Fragment) && string.IsNullOrEmpty(location.Fragment)) + location = new UriBuilder(location) { Fragment = requestUri.Fragment }.Uri; + + return location; + } + + private static void SetUpForRedirect(HttpRequestMessage request, HttpStatusCode statusCode) + { + // Clear the authorization header. + request.Headers.Authorization = null; + + // Switch to GET if required by the status code and original method + if ( + (request.Method == HttpMethod.Post + && (statusCode == HttpStatusCode.Moved || statusCode == HttpStatusCode.Found || statusCode == HttpStatusCode.MultipleChoices)) + || (request.Method != HttpMethod.Get && request.Method != HttpMethod.Head + && statusCode == HttpStatusCode.SeeOther)) + { + request.Method = HttpMethod.Get; + request.Content = null; + if (request.Headers.TransferEncodingChunked == true) + request.Headers.TransferEncodingChunked = false; + } + } + + // ----- Properties handled directly ----- + + internal bool AllowAutoRedirect = true; + internal int MaxAutomaticRedirections = 50; + } +} diff --git a/dotnet/src/IPAddressRanges.cs b/dotnet/src/IPAddressRanges.cs new file mode 100644 index 0000000..2b79bbb --- /dev/null +++ b/dotnet/src/IPAddressRanges.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Auto-generated from IPAddressRanges.json +// Do not edit this file manually + +namespace Microsoft.Security.AntiSSRF +{ + /// <summary> + /// Static IP address ranges for AntiSSRF protection + /// </summary> + public static class IPAddressRanges + { + public static readonly string[] amt = { "192.52.193.0/24", "2001:3::/32" }; + public static readonly string[] as112 = { "192.31.196.0/24", "192.175.48.0/24", "2001:4:112::/48", "2620:4f:8000::/48" }; + public static readonly string[] benchmarking = { "198.18.0.0/15", "2001:2::/48" }; + public static readonly string[] deprecated = { "192.88.99.0/24", "2001:10::/28" }; + public static readonly string[] detsPrefix = { "2001:30::/28" }; + public static readonly string[] discardOnly = { "100::/64" }; + public static readonly string[] documentation = { "192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "2001:db8::/32", "3fff::/20" }; + public static readonly string[] dummy = { "192.0.0.8/32", "100:0:0:1::/64" }; + public static readonly string[] ietfProtocol = { "192.0.0.0/24", "2001::/23" }; + public static readonly string[] imds = { "169.254.169.254/32" }; + public static readonly string[] ipv4Ipv6Translat = { "64:ff9b::/96", "64:ff9b:1::/48" }; + public static readonly string[] ipv4ServiceContinuity = { "192.0.0.0/29" }; + public static readonly string[] broadcast = { "255.255.255.255/32" }; + public static readonly string[] linkLocal = { "169.254.0.0/16", "fe80::/10" }; + public static readonly string[] loopback = { "127.0.0.0/8", "::1/128" }; + public static readonly string[] multicast = { "224.0.0.0/4", "ff00::/8" }; + public static readonly string[] orchidv2 = { "2001:20::/28" }; + public static readonly string[] privateUse = { "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16" }; + public static readonly string[] reserved = { "240.0.0.0/4" }; + public static readonly string[] sharedAddressSpace = { "100.64.0.0/10" }; + public static readonly string[] siteLocal = { "fec0::/10" }; + public static readonly string[] sixto4 = { "2002::/16" }; + public static readonly string[] srv6Sid = { "5f00::/16" }; + public static readonly string[] teredo = { "2001::/32" }; + public static readonly string[] uniqueLocal = { "fc00::/7" }; + public static readonly string[] unspecified = { "0.0.0.0/8", "::/128" }; + public static readonly string[] wireserver = { "168.63.129.16/32" }; + public static readonly string[] recommendedV1 = { "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "168.63.129.16/32", "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", "192.31.196.0/24", "192.52.193.0/24", "192.88.99.0/24", "192.168.0.0/16", "192.175.48.0/24", "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", "::1/128", "::/128", "64:ff9b::/96", "64:ff9b:1::/48", "100::/64", "100:0:0:1::/64", "2001::/23", "2001:db8::/32", "2002::/16", "2620:4f:8000::/48", "3fff::/20", "5f00::/16", "fc00::/7", "fe80::/10", "fec0::/10", "ff00::/8" }; + + /// <summary> + /// recommendedLatest always points to the latest version + /// </summary> + public static readonly string[] recommendedLatest = recommendedV1; + } +} diff --git a/dotnet/src/Microsoft.Security.AntiSSRF.csproj b/dotnet/src/Microsoft.Security.AntiSSRF.csproj new file mode 100644 index 0000000..7bc4a69 --- /dev/null +++ b/dotnet/src/Microsoft.Security.AntiSSRF.csproj @@ -0,0 +1,84 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <!-- ASSEMBLY IDENTITY --> + <PropertyGroup> + <RootNamespace>Microsoft.Security.AntiSSRF</RootNamespace> + <AssemblyName>$(RootNamespace)</AssemblyName> + </PropertyGroup> + + <!-- PACKAGE METADATA --> + <PropertyGroup> + <PackageId>Microsoft.Security.AntiSSRF</PackageId> + <Authors>Microsoft</Authors> + <Copyright>© Microsoft Corporation. All rights reserved.</Copyright> + <Description>Microsoft AntiSSRF is a security library that helps .NET developers protect their applications from Server-Side Request Forgery (SSRF) attacks. The library provides configurable policies to validate and filter outbound HTTP requests, preventing attackers from exploiting server-side functionality to access internal resources or perform unauthorized network requests. Features include IP address validation, URL filtering, and custom policy configuration.</Description> + <PackageTags>security ssrf anti-ssrf antissrf http request protection vulnerability filtering validation web-security</PackageTags> + </PropertyGroup> + + <!-- PACKAGE LICENSING --> + <PropertyGroup> + <PackageLicenseExpression>MIT</PackageLicenseExpression> + <RequireLicenseAcceptance>true</RequireLicenseAcceptance> + </PropertyGroup> + + <!-- PACKAGE RESOURCES --> + <PropertyGroup> + <PackageProjectUrl>https://github.com/Microsoft/AntiSSRF</PackageProjectUrl> + <RepositoryUrl>https://github.com/Microsoft/AntiSSRF</RepositoryUrl> + <RepositoryType>git</RepositoryType> + <PackageIcon>microsoft.png</PackageIcon> + <PackageReadmeFile>README.md</PackageReadmeFile> + </PropertyGroup> + + <!-- LANGUAGE DETAILS --> + <PropertyGroup> + <TargetFrameworks>net8.0;netstandard2.0</TargetFrameworks> + <LangVersion>Latest</LangVersion> + <Nullable>enable</Nullable> + </PropertyGroup> + + <!-- ASSEMBLY ATTRIBUTES --> + <!-- InternalsVisibleTo - without strong name signing --> + <ItemGroup Condition="'$(SignAssembly)' != 'true'"> + <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> + <_Parameter1>Microsoft.Security.AntiSSRF.UnitTests</_Parameter1> + </AssemblyAttribute> + </ItemGroup> + + <!-- InternalsVisibleTo - with strong name signing using PublicKey MSBuild property --> + <ItemGroup Condition="'$(SignAssembly)' == 'true'"> + <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> + <_Parameter1>Microsoft.Security.AntiSSRF.UnitTests, PublicKey=$(PublicKey)</_Parameter1> + </AssemblyAttribute> + </ItemGroup> + + <!-- VERSION --> + <PropertyGroup> + <Major Condition="'$(Major)'==''">1</Major> + <Minor Condition="'$(Minor)'==''">0</Minor> + <Patch Condition="'$(Patch)'==''">0</Patch> + <VersionPrefix>$(Major).$(Minor).$(Patch)</VersionPrefix> + <!-- If VersionSuffix is blank => PackageVersion is just VersionPrefix --> + <PackageVersion Condition="'$(VersionSuffix)'=='' or '$(VersionSuffix)'==' '">$(VersionPrefix)</PackageVersion> + <PackageVersion Condition="'$(VersionSuffix)'!='' and '$(VersionSuffix)'!=' '">$(VersionPrefix)-$(VersionSuffix)</PackageVersion> + <!-- Assembly identity (stable) --> + <AssemblyVersion>$(Major).$(Minor).0.0</AssemblyVersion> + <!-- Windows file version (precise) --> + <FileVersion>$(Major).$(Minor).$(Patch).0</FileVersion> + <!-- What 'dotnet \-\-info', logs, and many tools show --> + <InformationalVersion>$(PackageVersion)</InformationalVersion> + </PropertyGroup> + + <!-- FRAMEWORK SUPPORT --> + <ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'"> + <PackageReference Include="System.Memory" /> + <PackageReference Include="System.Threading.Tasks.Extensions" /> + </ItemGroup> + + <!-- PACKAGE ASSETS --> + <ItemGroup> + <None Include="microsoft.png" Pack="true" PackagePath="" /> + <None Include="README.md" Pack="true" PackagePath="\" /> + </ItemGroup> + +</Project> \ No newline at end of file diff --git a/dotnet/src/README.md b/dotnet/src/README.md new file mode 100644 index 0000000..8039cbe --- /dev/null +++ b/dotnet/src/README.md @@ -0,0 +1,57 @@ +## Microsoft AntiSSRF Library for .NET + +The Microsoft AntiSSRF Library for .NET is a security-developed, exhaustively-tested library that provides robust URL validation to protect .NET applications from Server-Side Request Forgery (SSRF) vulnerabilities. Designed specifically for .NET Framework and .NET Core applications, it integrates seamlessly with `HttpClient` through the `AntiSSRFHandler` class, allowing developers to secure outbound HTTP requests with minimal code changes. + +## How to Use + +The AntiSSRF Library provides validation for different scenarios based on your trust requirements: + +| Use Case | Description | Documentation Link | +| --- | --- | --- | +| **General Case** | The untrusted URL can belong to **any domain** or an **untrusted domain**. | [AntiSSRFPolicy](https://microsoft.github.io/AntiSSRF/dotnet-api/antissrfpolicy) | +| **Azure Key Vault Domain** | The untrusted URL must be an **Azure Key Vault endpoint**. | [URIValidator.InAzureKeyVaultDomain](https://microsoft.github.io/AntiSSRF/dotnet-api/urivalidator/inazurekeyvaultdomain) | +| **Azure Storage Domain** | The untrusted URL must be an **Azure Storage endpoint**. | [URIValidator.InAzureStorageDomain](https://microsoft.github.io/AntiSSRF/dotnet-api/urivalidator/inazurestoragedomain) | +| **Allowlist of Trusted Domains** | The untrusted URL must belong to a **specific, trusted domain**. | [URIValidator.InDomain](https://microsoft.github.io/AntiSSRF/dotnet-api/urivalidator/indomain) | + +## Key Features + +* **SSRF Attack Prevention** - Blocks malicious server-side request forgery attempts + +* **Private Network Protection** - Separate built-in configuration options for internal vs. external address HTTP clients + +* **DNS Rebinding Protection** - Guards against DNS-based attacks + +* **Redirect Protection** - Re-validates on all redirects to prevent bypass attempts + +* **Protocol Validation** - Ensures only safe protocols are used + +* **Fully Customizable** - Configure domain allowlists, IP ranges, headers, and validation policies + +## Additional Documentation + +Explore our comprehensive documentation to get the most out of Microsoft AntiSSRF: + +### Getting Started +- [Microsoft AntiSSRF Documentation](https://microsoft.github.io/AntiSSRF/) +- [Quick Start Guide](https://microsoft.github.io/AntiSSRF/getting-started) +- [Security Best Practices](https://microsoft.github.io/AntiSSRF/getting-started#best-practices) +- [Frequently Asked Questions](https://microsoft.github.io/AntiSSRF/faq) +- [Changelog](https://microsoft.github.io/AntiSSRF/dotnet-api/changelog) + +### API Documentation +- [Complete .NET API Documentation](https://microsoft.github.io/AntiSSRF/dotnet-api) +- [AntiSSRFHandler](https://microsoft.github.io/AntiSSRF/dotnet-api/antissrfhandler) +- [AntiSSRFPolicy](https://microsoft.github.io/AntiSSRF/dotnet-api/antissrfpolicy) +- [URIValidator](https://microsoft.github.io/AntiSSRF/dotnet-api/urivalidator) + +## Feedback & Contributing + +We welcome feedback and contributions from the community! Here's how you can get involved: + +- **Report Issues**: [GitHub Issues](https://github.com/Microsoft/AntiSSRF/issues) - Report bugs or request new features +- **Contribute**: [Contributing Guide](https://github.com/Microsoft/AntiSSRF/blob/main/CONTRIBUTING.md) - Learn how to contribute to the project +- **Contact**: [antissrf-oss@microsoft.com](mailto:antissrf-oss@microsoft.com) - Direct email for questions and feedback + +## Support Policy + +For support inquiries, contact [antissrf-oss@microsoft.com](mailto:antissrf-oss@microsoft.com). \ No newline at end of file diff --git a/dotnet/src/URIValidator.cs b/dotnet/src/URIValidator.cs new file mode 100644 index 0000000..68e2e20 --- /dev/null +++ b/dotnet/src/URIValidator.cs @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Net; +using System.Runtime.InteropServices; + +namespace Microsoft.Security.AntiSSRF +{ + public static class URIValidator + { + /// <summary> + /// Validates if the given URI belongs to the specified domain. + /// Only supports HTTP, HTTPS, WS, and WSS protocols. + /// </summary> + /// <param name="untrustedUri">The URI to validate.</param> + /// <param name="trustedDomain">The domain to check against.</param> + /// <returns>True if the URI belongs to the domain; otherwise, false.</returns> + public static bool InDomain(Uri? untrustedUri, string? trustedDomain) + { + return InOneDomain(untrustedUri, trustedDomain); + } + + /// <summary> + /// Validates if the given URI string belongs to the specified domain. + /// Only supports HTTP, HTTPS, WS, and WSS protocols. + /// </summary> + /// <param name="uristring">The URI string to validate.</param> + /// <param name="domain">The domain to check against.</param> + /// <returns>True if the URI belongs to the domain; otherwise, false.</returns> + public static bool InDomain(string? untrustedAddress, string? trustedDomain) + { + return InOneDomain(untrustedAddress, trustedDomain); + } + + /// <summary> + /// Validates if the given URI belongs to any of the specified domains. + /// Only supports HTTP, HTTPS, WS, and WSS protocols. + /// </summary> + /// <param name="untrustedUri">The URI to validate.</param> + /// <param name="trustedDomains">The domains to check against.</param> + /// <returns>True if the URI belongs to any of the domains; otherwise, false.</returns> + public static bool InDomain(Uri? untrustedUri, string[]? trustedDomains) + { + return InAnyDomain(untrustedUri, trustedDomains); + } + + /// <summary> + /// Validates if the given URI string belongs to any of the specified domains. + /// Only supports HTTP, HTTPS, WS, and WSS protocols. + /// </summary> + /// <param name="untrustedAddress">The URI string to validate.</param> + /// <param name="trustedDomains">The domains to check against.</param> + /// <returns>True if the URI belongs to any of the domains; otherwise, false.</returns> + public static bool InDomain(string? untrustedAddress, string[]? trustedDomains) + { + return InAnyDomain(untrustedAddress, trustedDomains); + } + + /// <summary> + /// Validates if the given URI belongs to an Azure Key Vault domain. + /// Only supports HTTP and HTTPS protocols. + /// </summary> + /// <param name="untrustedUri">The URI to validate.</param> + /// <returns>True if the URI belongs to an Azure Key Vault domain; otherwise, false.</returns> + public static bool InAzureKeyVaultDomain(Uri? untrustedUri) + { + return InAzureServiceDomain(untrustedUri, Domains.AzureKeyVaultDomains); + } + + /// <summary> + /// Validates if the given URI string belongs to an Azure Key Vault domain. + /// Only supports HTTP and HTTPS protocols. + /// </summary> + /// <param name="untrustedAddress">The URI string to validate.</param> + /// <returns>True if the URI belongs to an Azure Key Vault domain; otherwise, false.</returns> + public static bool InAzureKeyVaultDomain(string? untrustedAddress) + { + return InAzureServiceDomain(untrustedAddress, Domains.AzureKeyVaultDomains); + } + + /// <summary> + /// Validates if the given URI belongs to an Azure Storage domain. + /// Only supports HTTP and HTTPS protocols. + /// </summary> + /// <param name="untrustedUri">The URI to validate.</param> + /// <returns>True if the URI belongs to an Azure Storage domain; otherwise, false.</returns> + public static bool InAzureStorageDomain(Uri? untrustedUri) + { + return InAzureServiceDomain(untrustedUri, Domains.AzureStorageDomains); + } + + /// <summary> + /// Validates if the given URI string belongs to an Azure Storage domain. + /// Only supports HTTP and HTTPS protocols. + /// </summary> + /// <param name="untrustedAddress">The URI string to validate.</param> + /// <returns>True if the URI belongs to an Azure Storage domain; otherwise, false.</returns> + public static bool InAzureStorageDomain(string? untrustedAddress) + { + return InAzureServiceDomain(untrustedAddress, Domains.AzureStorageDomains); + } + + private static readonly string[] _azureSDKProtocols = { "http", "https" }; + + private static readonly string[] _inDomainProtocols = { "http", "https", "ws", "wss" }; + + private static string GetValidHostname(object? input, bool isSdk) + { + Uri uri = input switch + { + Uri u => u, + string address => new UriBuilder(address).Uri, + null => throw new AntiSSRFException(), + _ => throw new AntiSSRFException() + }; + + if (isSdk && !_azureSDKProtocols.Contains(uri.Scheme)) + throw new AntiSSRFException(); + + if (!isSdk && !_inDomainProtocols.Contains(uri.Scheme)) + throw new AntiSSRFException(); + + return uri.IdnHost; + } + + private static string ParseDomain(string domain) + { + if (domain.StartsWith(".")) + domain = domain.Substring(1); + return new UriBuilder("https://", domain).Uri.IdnHost; + } + + private static string[] ParseDomains(string[] domains) + { + string[] parsedDomains = new string[domains.Length]; + for (int i = 0; i < domains.Length; i++) + { + parsedDomains[i] = ParseDomain(domains[i]); + } + return parsedDomains; + } + + private static bool ValidatedInDomain(string idnHost, string parsedDomain) + { + int hostLen = idnHost.Length; + int domainLen = parsedDomain.Length; + + return (hostLen >= domainLen) + && (string.Compare(idnHost, hostLen - domainLen, parsedDomain, 0, domainLen, StringComparison.OrdinalIgnoreCase) == 0) + && (hostLen == domainLen || idnHost[hostLen - domainLen - 1] == '.'); + } + + private static bool InOneDomain(object? untrustedInput, string? trustedDomain) + { + if (untrustedInput == null || trustedDomain == null || trustedDomain.Length == 0) + return false; + + try + { + string idnHost = GetValidHostname(untrustedInput, false); + string parsedDomain = ParseDomain(trustedDomain); + return ValidatedInDomain(idnHost, parsedDomain); + } + catch + { + return false; + } + } + + private static bool InAnyDomain(object? untrustedInput, string[]? trustedDomains) + { + if (untrustedInput == null || trustedDomains == null || trustedDomains.Length == 0) + return false; + + try + { + string idnHost = GetValidHostname(untrustedInput, false); + string[] parsedDomains = ParseDomains(trustedDomains); + foreach (var parsedDomain in parsedDomains) + { + if (ValidatedInDomain(idnHost, parsedDomain)) + return true; + } + return false; + } + catch + { + return false; + } + } + + private static bool InAzureServiceDomain(object? untrustedInput, string[] sdkDomains) + { + if (untrustedInput == null) + return false; + + try + { + string idnHost = GetValidHostname(untrustedInput, true); + + if (idnHost.Contains("--")) + return false; + + foreach (var domain in sdkDomains) + { + if (ValidatedInDomain(idnHost, domain)) + return true; + } + return false; + } + catch + { + return false; + } + } + } +} diff --git a/dotnet/src/microsoft.png b/dotnet/src/microsoft.png new file mode 100644 index 0000000000000000000000000000000000000000..1e58d764e3b2a499f3f2ed11f011688a5f715e1f GIT binary patch literal 393 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r51|<6gKdl8)oCO|{#S9F5M?jcysy3fA0|TSA zr;B4q#jUru4YQaWMH(K~i?4QN`N&qb@r+{9YsMn+gPSWDZa?A5)X@p5Y;|BdJSpi~ zQpmAGS`+ph_#duq*6*LTv4T0RKC-y-N6cBqV~uIvlkXZbuZhoW)H@MV*|4QjOWy6O zc=7{nfd|YV>gwuP?k)PWf%i;<+=8zH|8y8xMFJcceI0=G1r`F#58F@uXWbC@(Ra$$ zTN<*fpF1zOY4qXk&CL?A>n%6@&CtC4A<*iB&0EGcajOKu7aZ?rr&ju3e}ACQBAoNV zw^@gkF63~oxBOubvoFAblaP%U-i7C{NIUf{bxP5No0n`4oPL!x<<)AI-wB()&a>^l a$oXleu8_#$?>m8E%i!ti=d#Wzp$P!^ypzZP literal 0 HcmV?d00001 diff --git a/nodejs/.prettierignore b/nodejs/.prettierignore new file mode 100644 index 0000000..a091145 --- /dev/null +++ b/nodejs/.prettierignore @@ -0,0 +1,11 @@ +node_modules/** +out/** +*.md +.gitignore +.npmrc +.config +*.json +**/*.json +**/*.tgz +temp-lib +src/IPAddressRanges.ts diff --git a/nodejs/.prettierrc b/nodejs/.prettierrc new file mode 100644 index 0000000..d522883 --- /dev/null +++ b/nodejs/.prettierrc @@ -0,0 +1,9 @@ +{ + "arrowParens": "always", + "bracketSpacing": true, + "endOfLine": "lf", + "printWidth": 120, + "singleQuote": false, + "tabWidth": 4, + "trailingComma": "none" +} diff --git a/nodejs/README.md b/nodejs/README.md new file mode 100644 index 0000000..b835695 --- /dev/null +++ b/nodejs/README.md @@ -0,0 +1,57 @@ +## Microsoft AntiSSRF Library for Node.js + +The Microsoft AntiSSRF Library for Node.js is a security-developed, exhaustively-tested library that provides robust URL validation to protect Node.js applications from Server-Side Request Forgery (SSRF) vulnerabilities. It integrates seamlessly with Node.js HTTP/HTTPS agents, allowing developers to secure outbound HTTP requests with minimal code changes. + +## How to Use + +The AntiSSRF Library provides validation for different scenarios based on your trust requirements: + +| Use Case | Description | Documentation Link | +| --- | --- | --- | +| **General Case** | The untrusted URL can belong to **any domain** or an **untrusted domain**. | [AntiSSRFPolicy](https://microsoft.github.io/AntiSSRF/nodejs-api/antissrfpolicy) | +| **Azure Key Vault Domain** | The untrusted URL must be an **Azure Key Vault endpoint**. | [URIValidator.inAzureKeyVaultDomain](https://microsoft.github.io/AntiSSRF/nodejs-api/urivalidator/inazurekeyvaultdomain) | +| **Azure Storage Domain** | The untrusted URL must be an **Azure Storage endpoint**. | [URIValidator.inAzureStorageDomain](https://microsoft.github.io/AntiSSRF/nodejs-api/urivalidator/inazurestoragedomain) | +| **Allowlist of Trusted Domains** | The untrusted URL must belong to a **specific, trusted domain**. | [URIValidator.inDomain](https://microsoft.github.io/AntiSSRF/nodejs-api/urivalidator/indomain) | + +## Key Features + +* **SSRF Attack Prevention** - Blocks malicious server-side request forgery attempts + +* **Private Network Protection** - Separate built-in configuration options for internal vs. external address HTTP clients + +* **DNS Rebinding Protection** - Guards against DNS-based attacks + +* **Redirect Protection** - Re-validates on all redirects to prevent bypass attempts + +* **Protocol Validation** - Ensures only safe protocols are used + +* **Fully Customizable** - Configure domain allowlists, IP ranges, headers, and validation policies + +## Additional Documentation + +Explore our comprehensive documentation to get the most out of Microsoft AntiSSRF: + +### Getting Started +- [Microsoft AntiSSRF Documentation](https://microsoft.github.io/AntiSSRF/) +- [Quick Start Guide](https://microsoft.github.io/AntiSSRF/getting-started) +- [Security Best Practices](https://microsoft.github.io/AntiSSRF/getting-started#best-practices) +- [Frequently Asked Questions](https://microsoft.github.io/AntiSSRF/faq) +- [Changelog](https://microsoft.github.io/AntiSSRF/nodejs-api/changelog) + +### API Documentation +- [Complete Node.js API Documentation](https://microsoft.github.io/AntiSSRF/nodejs-api) +- [AntiSSRF Node.js Agent](https://microsoft.github.io/AntiSSRF/nodejs-api/antissrfhandler) +- [AntiSSRFPolicy](https://microsoft.github.io/AntiSSRF/nodejs-api/antissrfpolicy) +- [URIValidator](https://microsoft.github.io/AntiSSRF/nodejs-api/urivalidator) + +## Feedback & Contributing + +We welcome feedback and contributions from the community! Here's how you can get involved: + +- **Report Issues**: [GitHub Issues](https://github.com/Microsoft/AntiSSRF/issues) - Report bugs or request new features +- **Contribute**: [Contributing Guide](https://github.com/Microsoft/AntiSSRF/blob/main/CONTRIBUTING.md) - Learn how to contribute to the project +- **Contact**: [antissrf-oss@microsoft.com](mailto:antissrf-oss@microsoft.com) - Direct email for questions and feedback + +## Support Policy + +For support inquiries, contact [antissrf-oss@microsoft.com](mailto:antissrf-oss@microsoft.com). \ No newline at end of file diff --git a/nodejs/eslint.config.mjs b/nodejs/eslint.config.mjs new file mode 100644 index 0000000..83b9c4c --- /dev/null +++ b/nodejs/eslint.config.mjs @@ -0,0 +1,20 @@ +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; +import security from "eslint-plugin-security"; + +export default tseslint.config({ + files: ["**/*.ts"], + extends: [eslint.configs.recommended, tseslint.configs.recommendedTypeChecked, security.configs.recommended], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname + } + }, + plugins: { + eslint: eslint + }, + rules: { + "func-style": ["error", "declaration", { "allowArrowFunctions": true }] + } +}); diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json new file mode 100644 index 0000000..8a0bc0a --- /dev/null +++ b/nodejs/package-lock.json @@ -0,0 +1,3117 @@ +{ + "name": "@microsoft/antissrf", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@microsoft/antissrf", + "version": "1.0.0", + "devDependencies": { + "@eslint/js": "^9.20.0", + "@types/follow-redirects": "^1.14.4", + "@types/mocha": "^10.0.10", + "@types/node-fetch": "^2.6.13", + "axios": "^1.12.2", + "eslint": "^9.20.0", + "eslint-plugin-security": "^3.0.1", + "follow-redirects": "^1.15.9", + "mocha": "^11.7.5", + "node-fetch": "^3.3.2", + "prettier": "^3.5.3", + "tar": "^7.4.3", + "ts-node": "^10.9.2", + "typescript": "^5.7.3", + "typescript-eslint": "^8.59.1" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/follow-redirects": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@types/follow-redirects/-/follow-redirects-1.14.4.tgz", + "integrity": "sha512-GWXfsD0Jc1RWiFmMuMFCpXMzi9L7oPDVwxUnZdg89kDNnqsRfUKXEtUYtA98A6lig1WXH/CYY/fvPW9HuN5fTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", + "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/type-utils": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", + "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", + "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.1", + "@typescript-eslint/types": "^8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", + "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", + "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", + "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", + "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.1", + "@typescript-eslint/tsconfig-utils": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", + "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", + "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-security": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-3.0.1.tgz", + "integrity": "sha512-XjVGBhtDZJfyuhIxnQ/WMm385RbX3DBu7H1J7HNNhmB2tnGxMeqVSnYv79oAj992ayvIBZghsymwkYFS6cGH4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-regex": "^2.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mocha": { + "version": "11.7.5", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", + "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-stdout": "^1.3.1", + "chokidar": "^4.0.1", + "debug": "^4.3.5", + "diff": "^7.0.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^10.4.5", + "he": "^1.2.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^9.0.5", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^9.2.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/mocha/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", + "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.1", + "@typescript-eslint/parser": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workerpool": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/nodejs/package.json b/nodejs/package.json new file mode 100644 index 0000000..ec64620 --- /dev/null +++ b/nodejs/package.json @@ -0,0 +1,38 @@ +{ + "name": "@microsoft/antissrf", + "version": "1.0.0", + "description": "A library to prevent SSRF vulnerabilities in Node.js applications", + "main": "./out/src/index.js", + "types": "./out/src/index.d.ts", + "files": [ + "out/src/**/*", + "CHANGELOG.md" + ], + "scripts": { + "build": "tsc --project ./tsconfig.json", + "format": "prettier --write .", + "lint": "eslint src", + "test": "mocha --recursive --timeout 15000 --require ts-node/register --no-strip-types \"tests/{UnitTests,FunctionalTests}/**/*.test.ts\"", + "test:unit": "mocha --recursive --timeout 15000 --require ts-node/register --no-strip-types tests/UnitTests/**/*.test.ts", + "test:functional": "mocha --recursive --timeout 15000 --require ts-node/register --no-strip-types tests/FunctionalTests/**/*.test.ts", + "test:prepublish": "mocha --recursive --timeout 15000 tests/PrePublishTests/**/*.test.js" + }, + "author": "Microsoft", + "devDependencies": { + "@eslint/js": "^9.20.0", + "@types/follow-redirects": "^1.14.4", + "@types/mocha": "^10.0.10", + "@types/node-fetch": "^2.6.13", + "axios": "^1.12.2", + "eslint": "^9.20.0", + "eslint-plugin-security": "^3.0.1", + "follow-redirects": "^1.15.9", + "mocha": "^11.7.5", + "node-fetch": "^3.3.2", + "prettier": "^3.5.3", + "tar": "^7.4.3", + "ts-node": "^10.9.2", + "typescript": "^5.7.3", + "typescript-eslint": "^8.59.1" + } +} diff --git a/nodejs/src/AntiSSRFError.ts b/nodejs/src/AntiSSRFError.ts new file mode 100644 index 0000000..09c3137 --- /dev/null +++ b/nodejs/src/AntiSSRFError.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export class AntiSSRFError extends Error {} diff --git a/nodejs/src/AntiSSRFPolicy.ts b/nodejs/src/AntiSSRFPolicy.ts new file mode 100644 index 0000000..1e6063e --- /dev/null +++ b/nodejs/src/AntiSSRFPolicy.ts @@ -0,0 +1,346 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ClientRequest, AgentOptions as HttpAgentOptions } from "http"; +import { AgentOptions as HttpsAgentOptions } from "https"; +import { BlockList } from "net"; + +import { AntiSSRFError, IPAddressRanges } from "."; +import { CIDRBlock } from "./Helpers/CIDRBlock"; +import { AntiSSRFHttpsAgent } from "./Helpers/AntiSSRFHttpsAgent"; +import { AntiSSRFHttpAgent } from "./Helpers/AntiSSRFHttpAgent"; + +export enum PolicyConfigOptions { + InternalOnly = "InternalOnly", + ExternalOnlyV1 = "ExternalOnlyV1", + ExternalOnlyLatest = "ExternalOnlyLatest", + None = "None" +} + +export class AntiSSRFPolicy { + // IP address related variables + private _allowedAddresses: BlockList; // maintains all networks are in IPv6 + private _deniedAddresses: BlockList; // maintains all networks are in IPv6 + private _denyAllUnspecifiedIPs: boolean; + + // Headers related variables + private _requiredHeaders: string[]; // maintains all headers are lowercase + private _deniedHeaders: string[]; // maintains all headers are lowercase + private _addXFFHeader: boolean = false; + private _allowPlainTextHttp: boolean = false; + + /** + * Creates a new AntiSSRF policy with the specified default configuration. + * + * @param config The policy configuration: + * - `InternalOnly`: Denies all connections by default. Use `addAllowedAddresses()` to permit specific ranges. + * - `ExternalOnlyV1`: Blocks recommendedV1 IP ranges. Adds the `X-Forwarded-For` header to requests when missing. + * - `ExternalOnlyLatest`: Blocks recommendedLatest IP ranges. Adds the `X-Forwarded-For` header to requests when missing. + * - `None`: No restrictions. + */ + constructor(config: PolicyConfigOptions) { + if (config == null) { + throw new AntiSSRFError("Null argument"); + } + + this._allowedAddresses = new BlockList(); + this._deniedAddresses = new BlockList(); + this._denyAllUnspecifiedIPs = false; + + this._requiredHeaders = []; + this._deniedHeaders = []; + this._addXFFHeader = false; + this._allowPlainTextHttp = false; + + switch (config) { + // Block all IPs by default. Users must add their intended internal ranges. + case PolicyConfigOptions.InternalOnly: + this._denyAllUnspecifiedIPs = true; + break; + // Block recommendedV1 IPs. Blocks IMDS, so add XFF. + case PolicyConfigOptions.ExternalOnlyV1: + this.addDeniedAddresses(IPAddressRanges.recommendedV1); + this._addXFFHeader = true; + break; + // Block recommendedLatest IPs. Blocks IMDS, so add XFF. + case PolicyConfigOptions.ExternalOnlyLatest: + this.addDeniedAddresses(IPAddressRanges.recommendedLatest); + this._addXFFHeader = true; + break; + // No restrictions. + case PolicyConfigOptions.None: + break; + default: + throw new AntiSSRFError("Argument must be a valid PolicyConfigOptions value"); + } + } + + /** + * ===== The IP addresses related functionality ===== + */ + + /** + * Gets or sets whether all unspecified IPs are denied by default. + * When true, only explicitly allowed addresses can connect. + * When false, addresses are evaluated against the denied addresses list. + */ + + get denyAllUnspecifiedIPs(): boolean { + return this._denyAllUnspecifiedIPs; + } + + set denyAllUnspecifiedIPs(value: boolean) { + if (value == null) { + throw new AntiSSRFError("Null argument"); + } + this._denyAllUnspecifiedIPs = value; + } + + /** + * Gets the allowed IP addresses BlockList (readonly). + */ + get allowedAddresses(): Readonly<BlockList> { + return this._allowedAddresses; + } + + /** + * Adds the specified IP addresses and/or range of IP addresses to the + * collection of allowed addresses. + * + * @param networks List of IPv4 and/or IPv6 addresses and/or subnets in + * CIDR notation + * @throws AntiSSRFError on improperly formatted network + */ + public addAllowedAddresses(networks: string[]): void { + if (networks == null) { + throw new AntiSSRFError("Null argument"); + } + + const parsedNetworks = networks.map((n) => CIDRBlock._parseCIDR(n)); + + for (const network of parsedNetworks) { + this._allowedAddresses.addSubnet(network.getAddress(), network.getPrefix(), "ipv6"); + } + } + + /** + * Gets the denied IP addresses BlockList (readonly). + */ + get deniedAddresses(): Readonly<BlockList> { + return this._deniedAddresses; + } + + /** + * Adds the specified IP addresses and/or range subnets to the collection + * of denied addresses. + * + * @param networks List of IPv4 and/or IPv6 addresses and/or subnets in + * CIDR notation + * @throws AntiSSRFError on improperly formatted network + */ + public addDeniedAddresses(networks: string[]): void { + if (networks == null) { + throw new AntiSSRFError("Null argument"); + } + + if (this._denyAllUnspecifiedIPs) { + throw new AntiSSRFError("Can't add denied networks when denyAllUnspecifiedIPs is true"); + } + + const parsedNetworks = networks.map((n) => CIDRBlock._parseCIDR(n)); + + for (const network of parsedNetworks) { + this._deniedAddresses.addSubnet(network.getAddress(), network.getPrefix(), "ipv6"); + } + } + + /** + * @internal + * This method is intended for internal use by AntiSSRF agents only. + * Use getHttpAgent() or getHttpsAgent() for public API. + */ + public _isNetworkConnectionAllowed(ipaddresses: string[]): boolean { + if (ipaddresses == null) { + return false; + } + + for (const ipaddress of ipaddresses) { + if (ipaddress == null) { + return false; + } + + try { + const [ipv6] = CIDRBlock._parseIPAddress(ipaddress); + + if (this._allowedAddresses.check(ipv6, "ipv6")) { + // If the address is in the allow list, it is allowed + continue; + } + + if (this._denyAllUnspecifiedIPs || this._deniedAddresses.check(ipv6, "ipv6")) { + // If the address is not in an allow list, it's not allowed + return false; + } + } catch { + return false; + } + } + + // No IP address was denied + return true; + } + + /** + * ===== The headers policy related functionality ===== + */ + + /** + * Gets the list of required headers (readonly copy). + */ + get requiredHeaders(): readonly string[] { + return [...this._requiredHeaders]; + } + + /** + * Adds headers to the collection of required headers. + * + * @param headers List of headers to require + * @throws AntiSSRFError on null/undefined headers array and on any + * null/undefined or empty string header + */ + public addRequiredHeaders(headers: string[]): void { + if (headers == null) { + throw new AntiSSRFError("Null argument"); + } + + for (const header of headers) { + if (header == null) { + throw new AntiSSRFError("Headers cannot be null or undefined"); + } + if (header.trim() === "") { + throw new AntiSSRFError("Headers cannot be an empty string"); + } + } + + this._requiredHeaders.push(...headers.map((h) => h.toLowerCase())); + } + + /** + * Gets the list of denied headers (readonly copy). + */ + get deniedHeaders(): readonly string[] { + return [...this._deniedHeaders]; + } + + /** + * Adds headers to the collection of denied headers. + * + * @param headers List of headers to deny + * @throws AntiSSRFError on null/undefined headers array and on any + * null/undefined or empty string header + */ + public addDeniedHeaders(headers: string[]): void { + if (headers == null) { + throw new AntiSSRFError("Null argument"); + } + + for (const header of headers) { + if (header == null) { + throw new AntiSSRFError("Headers cannot be null or undefined"); + } + if (header.trim() === "") { + throw new AntiSSRFError("Headers cannot be an empty string"); + } + } + + this._deniedHeaders.push(...headers.map((h) => h.toLowerCase())); + } + + /** + * Gets or sets whether to add the XFF header to all requests. + * True to add the XFF header to all requests, false otherwise. + */ + + get addXFFHeader(): boolean { + return this._addXFFHeader; + } + + set addXFFHeader(value: boolean) { + if (value == null) { + throw new AntiSSRFError("Null argument"); + } + this._addXFFHeader = value; + } + + /** + * Gets or sets whether to allow http or to require https. + * True to allow http, false to require https. + */ + + get allowPlainTextHttp(): boolean { + return this._allowPlainTextHttp; + } + + set allowPlainTextHttp(value: boolean) { + if (value == null) { + throw new AntiSSRFError("Null argument"); + } + this._allowPlainTextHttp = value; + } + + /** + * @internal + * This method is intended for internal use by AntiSSRF agents only. + * Use getHttpAgent() or getHttpsAgent() for public API. + */ + public _isHttpRequestAllowed(req: ClientRequest): boolean { + if (req == null || req.protocol == null) { + return false; + } + + // Node URL protocol property returns the protocol with the ':' + // Check if the protocol is plain text AND plain text is disallowed + if (!(req.protocol.toLowerCase() === "https:" || this._allowPlainTextHttp)) { + return false; + } + + // Node URL protocol property returns the protocol with the ':' + // Ensure the protocol is http(s) + if (!(req.protocol.toLowerCase() === "http:" || req.protocol.toLowerCase() === "https:")) { + return false; + } + + // Add the XFF header if required + if (this._addXFFHeader && !req.getHeaderNames().includes("x-forwarded-for")) { + req.setHeader("X-Forwarded-For", "true"); + } + + // Ensure none of the denied headers are present + for (const header of this._deniedHeaders) { + if (req.hasHeader(header)) { + return false; + } + } + + // Ensure all of the required headers are present + for (const header of this._requiredHeaders) { + if (!req.hasHeader(header)) { + return false; + } + } + + return true; + } + + /** + * ===== The Agent for easy policy enforcement ===== + */ + + public getHttpsAgent(options?: HttpsAgentOptions) { + return new AntiSSRFHttpsAgent(this, options); + } + + public getHttpAgent(options?: HttpAgentOptions) { + return new AntiSSRFHttpAgent(this, options); + } +} diff --git a/nodejs/src/Helpers/AntiSSRFDnsLookup.ts b/nodejs/src/Helpers/AntiSSRFDnsLookup.ts new file mode 100644 index 0000000..79d1a22 --- /dev/null +++ b/nodejs/src/Helpers/AntiSSRFDnsLookup.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { lookup, LookupAddress, LookupAllOptions, LookupOneOptions } from "dns"; +import { LookupFunction } from "net"; + +import { AntiSSRFError, AntiSSRFPolicy } from ".."; + +class LookupWithPolicy { + private _policy: AntiSSRFPolicy; + + constructor(policy: AntiSSRFPolicy) { + this._policy = policy; + } + + private _lookupAll = ( + hostname: string, + options: LookupAllOptions, // options.all == true + callback: (err: NodeJS.ErrnoException | null, addresses: LookupAddress[]) => void + ) => { + return lookup(hostname, options, (err, addresses) => { + // Errored in dns.lookup, forward error to callback + if (err != null) { + return callback(err, []); + } + + // This case should error in dns.lookup, so we should never hit this + /* istanbul ignore next */ + if (addresses == null) { + return callback(new AntiSSRFError("Error in DNS lookup"), []); + } + + // This case should never happen, since we only get to this function + // if options.all == true, but we are including this to satisfy + // typescript typechecking issues + /* istanbul ignore next */ + if (!(addresses instanceof Array)) { + return callback(new AntiSSRFError("Error in DNS lookup"), []); + } + + // Handle the case where no valid address was found + // [] instead of error for dns.lookup backwards compatibility + if (addresses.length === 0) { + return callback(null, []); + } + + // If all addresses are allowed by policy, forward to callback + if (this._policy._isNetworkConnectionAllowed(addresses.map((address) => address.address))) { + return callback(null, addresses); + } + + // If any address is disallowed by policy, return error + return callback(new AntiSSRFError("IP address disallowed by policy"), []); + }); + }; + + private _lookupOne = ( + hostname: string, + options: LookupOneOptions, + callback: (err: NodeJS.ErrnoException | null, address: string | LookupAddress[], family: number) => void + ) => { + return lookup(hostname, options, (err, address, family) => { + // Errored in dns.lookup, forward error to callback + if (err != null) { + return callback(err, null, family); + } + + // Handle the case where no valid address was found + // null instead of error for dns.lookup backwards compatibility + if (address == null) { + return callback(null, null, family); + } + + // If the address is allowed by policy, forward to callback + if (this._policy._isNetworkConnectionAllowed([address])) { + return callback(null, address, family); + } + + // If the address is disallowed by policy, return error + return callback(new AntiSSRFError("IP address disallowed by policy"), null, family); + }); + }; + + /** + * @internal + */ + public _lookup: LookupFunction = (hostname, options, callback) => { + if (options?.all == true) { + return this._lookupAll(hostname, options as LookupAllOptions, callback); + } else { + return this._lookupOne(hostname, options as LookupOneOptions, callback); + } + }; +} + +/** + * @internal + * Intended for internal use only. Use AntiSSRFPolicy agents for public use. + */ +export function antiSSRFDnsLookup(policy: AntiSSRFPolicy): LookupFunction { + if (policy == null) { + throw new AntiSSRFError("Null argument"); + } + + const lookupWithPolicy = new LookupWithPolicy(policy); + return lookupWithPolicy._lookup; +} diff --git a/nodejs/src/Helpers/AntiSSRFHttpAgent.ts b/nodejs/src/Helpers/AntiSSRFHttpAgent.ts new file mode 100644 index 0000000..1b406c5 --- /dev/null +++ b/nodejs/src/Helpers/AntiSSRFHttpAgent.ts @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ + +/** + * Any changes in this file need to be copied into AntiSSRFHttpsAgent.ts as well. + * The two files must always be the same, except that this one extends + * http.Agent while AntiSSRFHttpsAgent extends https.Agent. + * + * The two parent Agents have different default options and implement + * createConnection differently. + */ + +import { Agent as HttpAgent, AgentOptions as HttpAgentOptions, ClientRequest } from "http"; +import { isIP, LookupFunction } from "net"; + +import { antiSSRFDnsLookup } from "./AntiSSRFDnsLookup"; +import { AntiSSRFError, AntiSSRFPolicy } from "../"; + +export class AntiSSRFHttpAgent extends HttpAgent { + private _antiSSRFPolicy: AntiSSRFPolicy; + private _antiSSRFLookup: LookupFunction; + + constructor(policy: AntiSSRFPolicy, options?: HttpAgentOptions) { + // Ensure the user does not expect to use a different dns.lookup function + if (options?.lookup != null) { + throw new AntiSSRFError("Cannot use AntiSSRFHttpAgent with custom lookup function"); + } + + // Call the parent constructor + super(options); + + // Set custom variables + this._antiSSRFPolicy = policy; + this._antiSSRFLookup = antiSSRFDnsLookup(policy); + } + + /** + * This function is a wrapper around the addAgent function in http.Agent. + * It is used to check the headers portion of the policy and to add the + * XFF header if required. + */ + addRequest = (req: ClientRequest, ...args: any[]) => { + // Check if the request headers are allowed by the policy, and add the + // XFF header if required. + // Check if plaintext requests are allowed by the policy. + if (!this._antiSSRFPolicy._isHttpRequestAllowed(req)) { + process.nextTick(() => { + req.emit("error", new AntiSSRFError("Request headers or protocol disallowed by policy")); + }); + return; + } + + // @ts-expect-error 'addRequest' isn't defined in '@types/node' + return super.addRequest(req, ...args); // eslint-disable-line + }; + + /** + * This function is a wrapper around the createConnection function in + * http.Agent. It is used to check if the host is an IP address, and if so, + * to check if it is allowed by the policy. Then, it sets the lookup + * function for the request to our AntiSSRFDnsLookup function. + * + * This wrapper function is needed because the createConnection function in + * http.Agent first checks if isIP(host), and if so, it skips our + * policy-based lookup function. To make sure the policy is still checked + * when the host is an IP address, this wrapper function explicitly checks + * if the host is an allowed IP address before letting the http.Agent + * createConnection function to take over. + * + * Note: NodeJS has known, inconsistent type documentation for the Agent + * createConnection methods. In the Agent code, createConnection is only + * used with this signature. + */ + createConnection = (options: any, callback: any) => { + // Ensure the user does not expect to use a different dns.lookup function + if (options?.lookup != null) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call + return callback(new AntiSSRFError("Cannot use AntiSSRFHttpAgent with custom lookup function"), null); + } + + // http.request host is supposed to default to localhost. We want to + // ensure we know what the host is ahead of time for the policy check, + // so we are explicitly setting the default host if it is not already + // set. + // Not sure it is possible to get here, excluding from test cases + /* istanbul ignore next 5 */ + if (options == null) { + options = { host: "localhost" }; + } else if (options.host == null) { + options.host = "localhost"; + } + + // If host is an IP address, check if it is allowed by the policy. + if (isIP(options.host) && !this._antiSSRFPolicy._isNetworkConnectionAllowed([options.host])) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call + return callback(new AntiSSRFError("IP address disallowed by policy"), null); + } + + // Set the lookup function for the request to the AntiSSRFDnsLookup + // function. Since we created this agent with lookup = null and we + // checked that options.lookup = null, we can safely set it to our + // custom lookup function. + options.lookup = this._antiSSRFLookup; + + return super.createConnection(options, callback); + }; +} diff --git a/nodejs/src/Helpers/AntiSSRFHttpsAgent.ts b/nodejs/src/Helpers/AntiSSRFHttpsAgent.ts new file mode 100644 index 0000000..545d535 --- /dev/null +++ b/nodejs/src/Helpers/AntiSSRFHttpsAgent.ts @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ + +/** + * Any changes in this file need to be copied into AntiSSRFHttpAgent.ts as well. + * The two files must always be the same, except that this one extends + * https.Agent while AntiSSRFHttpAgent extends http.Agent. + * + * The two parent Agents have different default options and implement + * createConnection differently. + */ + +import { ClientRequest } from "http"; +import { Agent as HttpsAgent, AgentOptions as HttpsAgentOptions } from "https"; +import { isIP, LookupFunction } from "net"; + +import { antiSSRFDnsLookup } from "./AntiSSRFDnsLookup"; +import { AntiSSRFError, AntiSSRFPolicy } from "../"; + +export class AntiSSRFHttpsAgent extends HttpsAgent { + private _antiSSRFPolicy: AntiSSRFPolicy; + private _antiSSRFLookup: LookupFunction; + + constructor(policy: AntiSSRFPolicy, options?: HttpsAgentOptions) { + // Ensure the user does not expect to use a different dns.lookup function + if (options?.lookup != null) { + throw new AntiSSRFError("Cannot use AntiSSRFHttpsAgent with custom lookup function"); + } + + // Call the parent constructor + super(options); + + // Set custom variables + this._antiSSRFPolicy = policy; + this._antiSSRFLookup = antiSSRFDnsLookup(policy); + } + + /** + * This function is a wrapper around the addAgent function in https.Agent. + * It is used to check the headers portion of the policy and to add the + * XFF header if required. + */ + addRequest = (req: ClientRequest, ...args: any[]) => { + // Check if the request headers are allowed by the policy, and add the + // XFF header if required. + // Check if plaintext requests are allowed by the policy. + if (!this._antiSSRFPolicy._isHttpRequestAllowed(req)) { + process.nextTick(() => { + req.emit("error", new AntiSSRFError("Request headers or protocol disallowed by policy")); + }); + return; + } + + // @ts-expect-error 'addRequest' isn't defined in '@types/node' + return super.addRequest(req, ...args); // eslint-disable-line + }; + + /** + * This function is a wrapper around the createConnection function in + * https.Agent. It is used to check if the host is an IP address, and if so, + * to check if it is allowed by the policy. Then, it sets the lookup + * function for the request to our AntiSSRFDnsLookup function. + * + * This wrapper function is needed because the createConnection function in + * https.Agent first checks if isIP(host), and if so, it skips our + * policy-based lookup function. To make sure the policy is still checked + * when the host is an IP address, this wrapper function explicitly checks + * if the host is an allowed IP address before letting the https.Agent + * createConnection function to take over. + * + * Note: NodeJS has known, inconsistent type documentation for the Agent + * createConnection methods. In the Agent code, createConnection is only + * used with this signature. + */ + createConnection = (options: any, callback: any) => { + // Ensure the user does not expect to use a different dns.lookup function + if (options?.lookup != null) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call + return callback(new AntiSSRFError("Cannot use AntiSSRFHttpsAgent with custom lookup function"), null); + } + + // https.request host is supposed to default to localhost. We want to + // ensure we know what the host is ahead of time for the policy check, + // so we are explicitly setting the default host if it is not already + // set. + // Not sure it is possible to get here, excluding from test cases + /* istanbul ignore next 5 */ + if (options == null) { + options = { host: "localhost" }; + } else if (options.host == null) { + options.host = "localhost"; + } + + // If host is an IP address, check if it is allowed by the policy. + if (isIP(options.host) && !this._antiSSRFPolicy._isNetworkConnectionAllowed([options.host])) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call + return callback(new AntiSSRFError("IP address disallowed by policy"), null); + } + + // Set the lookup function for the request to the AntiSSRFDnsLookup + // function. Since we created this agent with lookup = null and we + // checked that options.lookup = null, we can safely set it to our + // custom lookup function. + options.lookup = this._antiSSRFLookup; + + return super.createConnection(options, callback); + }; +} diff --git a/nodejs/src/Helpers/CIDRBlock.ts b/nodejs/src/Helpers/CIDRBlock.ts new file mode 100644 index 0000000..db75322 --- /dev/null +++ b/nodejs/src/Helpers/CIDRBlock.ts @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { isIPv4, isIPv6, SocketAddress } from "net"; + +import { AntiSSRFError } from ".."; + +// Exactly 1 zero OR a non-zero decimal digit followed by 0-2 decimal digits (max 3 chars) +const decRegex = /^(0|[1-9][0-9]{0,2})$/; + +export class CIDRBlock { + private _address: string; + private _prefix: number; + + public getAddress(): string { + return this._address; + } + + public getPrefix(): number { + return this._prefix; + } + + /** + * @internal + * @throws AntiSSRFError If arguments are null or prefix is invalid + */ + constructor(address: string, prefix: number) { + if (address == null || prefix == null) { + throw new AntiSSRFError("Null argument"); + } + + if (isNaN(prefix) || prefix < 0 || prefix > 128) { + throw new AntiSSRFError("Invalid prefix"); + } + + if (!isIPv6(address)) { + throw new AntiSSRFError(`Invalid IPv6 address: ${address}`); + } + + this._address = address; + this._prefix = prefix; + } + + /** + * @internal + * @throws AntiSSRFError If the IP address is null or invalid + */ + public static _parseIPAddress(ipaddress: string): [string, 4 | 6] { + if (ipaddress == null) { + throw new AntiSSRFError("Null argument"); + } + + if (ipaddress.includes(":")) { + return [this._parseIPv6(ipaddress), 6]; + } else { + return [this._parseIPv4(ipaddress), 4]; + } + } + + /** + * @internal + * Returns a tuple with (IP address mapped to IPv6, the original IP version) + * @throws AntiSSRFError If the CIDR string is null, malformed, or contains invalid components + */ + public static _parseCIDR(cidr: string): CIDRBlock { + if (cidr == null) { + throw new AntiSSRFError("Null argument"); + } + + const parts = cidr.split("/"); + try { + const [ipv6, oldVersion] = this._parseIPAddress(parts[0]); + + if (parts.length == 1) { + return new CIDRBlock(ipv6, 128); + } else if (parts.length == 2) { + if (decRegex.test(parts[1])) { + const prefixLength = parseInt(parts[1], 10); + if (oldVersion === 4) { + // IPv4-mapped IPv6 address, adjust the prefix length accordingly + return new CIDRBlock(ipv6, prefixLength + 96); + } else { + return new CIDRBlock(ipv6, prefixLength); + } + } else { + throw new AntiSSRFError(`Invalid prefix length: ${parts[1]}`); + } + } else { + throw new AntiSSRFError(`Invalid CIDR block: ${cidr}`); + } + } catch { + throw new AntiSSRFError(`Invalid CIDR block: ${cidr}`); + } + } + + /** + * 1. x:x:x:x:x:x:x:x, where the 'x's are 1-4 hexadecimal digits + * 2. The same as (1) with exactly 1 :: to compress 1+ consecutive groups + * of 0s. + * 3. x:x:x:x:x:x:d.d.d.d, with or without compression in the xs, where the + * d.d.d.d is in standard IPv4 representation without leading 0s. + * 4. [IPv6], to support IPv6 as hostnames + */ + private static _parseIPv6(address: string): string { + // Strip brackets if the address is in the form [IPv6] + const len = address.length; + if (len > 2 && address[0] == "[" && address[len - 1] == "]") { + address = address.substring(1, len - 1); + } + + if (isIPv6(address)) { + try { + const socketAddress = new SocketAddress({ address, family: "ipv6" }); + return socketAddress.address; + } catch { + throw new AntiSSRFError(`Invalid IPv6 address: ${address}`); + } + } else { + throw new AntiSSRFError(`Invalid IPv6 address: ${address}`); + } + } + + /** + * Node.js requires IPv4 addresses to be in dotted-quad notation, with + * exactly 4 sections, without any leading 0s. + */ + private static _parseIPv4(address: string): string { + if (isIPv4(address)) { + return `::FFFF:${address}`; + } else { + throw new AntiSSRFError(`Invalid IPv4 address: ${address}`); + } + } +} diff --git a/nodejs/src/Helpers/Domains.ts b/nodejs/src/Helpers/Domains.ts new file mode 100644 index 0000000..afe8523 --- /dev/null +++ b/nodejs/src/Helpers/Domains.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This file is auto-generated from config/Domains.json +// Do not edit this file directly. + +/** + * Well-known Azure service domains for internal use. + */ + +/** + * Azure Key Vault service domains across all public Azure environments. + */ +export const _azureKeyVaultDomains: readonly string[] = [ + "vault.azure.net", + "managedhsm.azure.net", + "vault.azure.cn", + "managedhsm.azure.cn", + "vault.usgovcloudapi.net", + "managedhsm.usgovcloudapi.net", +]; + +/** + * Azure Storage service domains across all public Azure environments. + */ +export const _azureStorageDomains: readonly string[] = [ + "blob.core.windows.net", + "web.core.windows.net", + "dfs.core.windows.net", + "file.core.windows.net", + "queue.core.windows.net", + "table.core.windows.net", + "blob.storage.azure.net", + "web.storage.azure.net", + "dfs.storage.azure.net", + "file.storage.azure.net", + "queue.storage.azure.net", + "table.storage.azure.net", + "blob.core.usgovcloudapi.net", + "web.core.usgovcloudapi.net", + "dfs.core.usgovcloudapi.net", + "file.core.usgovcloudapi.net", + "queue.core.usgovcloudapi.net", + "table.core.usgovcloudapi.net", + "blob.core.chinacloudapi.cn", + "web.core.chinacloudapi.cn", + "dfs.core.chinacloudapi.cn", + "file.core.chinacloudapi.cn", + "queue.core.chinacloudapi.cn", + "table.core.chinacloudapi.cn", +]; diff --git a/nodejs/src/IPAddressRanges.ts b/nodejs/src/IPAddressRanges.ts new file mode 100644 index 0000000..130e9a9 --- /dev/null +++ b/nodejs/src/IPAddressRanges.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Auto-generated from IPAddressRanges.json +// Do not edit this file manually + +/** + * Static IP address ranges for AntiSSRF protection + */ +export class IPAddressRanges { + public static readonly amt: string[] = ["192.52.193.0/24", "2001:3::/32"]; + public static readonly as112: string[] = ["192.31.196.0/24", "192.175.48.0/24", "2001:4:112::/48", "2620:4f:8000::/48"]; + public static readonly benchmarking: string[] = ["198.18.0.0/15", "2001:2::/48"]; + public static readonly deprecated: string[] = ["192.88.99.0/24", "2001:10::/28"]; + public static readonly detsPrefix: string[] = ["2001:30::/28"]; + public static readonly discardOnly: string[] = ["100::/64"]; + public static readonly documentation: string[] = ["192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "2001:db8::/32", "3fff::/20"]; + public static readonly dummy: string[] = ["192.0.0.8/32", "100:0:0:1::/64"]; + public static readonly ietfProtocol: string[] = ["192.0.0.0/24", "2001::/23"]; + public static readonly imds: string[] = ["169.254.169.254/32"]; + public static readonly ipv4Ipv6Translat: string[] = ["64:ff9b::/96", "64:ff9b:1::/48"]; + public static readonly ipv4ServiceContinuity: string[] = ["192.0.0.0/29"]; + public static readonly broadcast: string[] = ["255.255.255.255/32"]; + public static readonly linkLocal: string[] = ["169.254.0.0/16", "fe80::/10"]; + public static readonly loopback: string[] = ["127.0.0.0/8", "::1/128"]; + public static readonly multicast: string[] = ["224.0.0.0/4", "ff00::/8"]; + public static readonly orchidv2: string[] = ["2001:20::/28"]; + public static readonly privateUse: string[] = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]; + public static readonly reserved: string[] = ["240.0.0.0/4"]; + public static readonly sharedAddressSpace: string[] = ["100.64.0.0/10"]; + public static readonly siteLocal: string[] = ["fec0::/10"]; + public static readonly sixto4: string[] = ["2002::/16"]; + public static readonly srv6Sid: string[] = ["5f00::/16"]; + public static readonly teredo: string[] = ["2001::/32"]; + public static readonly uniqueLocal: string[] = ["fc00::/7"]; + public static readonly unspecified: string[] = ["0.0.0.0/8", "::/128"]; + public static readonly wireserver: string[] = ["168.63.129.16/32"]; + public static readonly recommendedV1: string[] = ["0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "168.63.129.16/32", "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", "192.31.196.0/24", "192.52.193.0/24", "192.88.99.0/24", "192.168.0.0/16", "192.175.48.0/24", "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", "::1/128", "::/128", "64:ff9b::/96", "64:ff9b:1::/48", "100::/64", "100:0:0:1::/64", "2001::/23", "2001:db8::/32", "2002::/16", "2620:4f:8000::/48", "3fff::/20", "5f00::/16", "fc00::/7", "fe80::/10", "fec0::/10", "ff00::/8"]; + + /** + * recommendedLatest always points to the latest version + */ + public static readonly recommendedLatest: string[] = IPAddressRanges.recommendedV1; +} diff --git a/nodejs/src/URIValidator.ts b/nodejs/src/URIValidator.ts new file mode 100644 index 0000000..7a3424a --- /dev/null +++ b/nodejs/src/URIValidator.ts @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { domainToASCII } from "url"; + +import { _azureKeyVaultDomains, _azureStorageDomains } from "./Helpers/Domains"; +import { AntiSSRFError } from "./AntiSSRFError"; + +export class URIValidator { + private static _domainProtocols = ["http:", "https:", "ws:", "wss:"]; + private static _azureSdkProtocols = ["http:", "https:"]; + + private static _getValidHostname(address: string | URL, allowedProtocols: string[]): string { + let url: URL; + if (typeof address == "string") { + url = new URL(address); + } else { + url = address; + } + + if (url.hostname == null || url.hostname == "") { + throw new AntiSSRFError(); + } + + if (!allowedProtocols.includes(url.protocol)) { + throw new AntiSSRFError(); + } + + return url.hostname; + } + + private static _hostnameInSingleDomain(parsedHostname: string, parsedDomain: string): boolean { + if (("." + parsedHostname).endsWith(parsedDomain)) { + if (parsedHostname.length == parsedDomain.length) { + return true; + } + + if (parsedDomain[0] == ".") { + return true; + } + + if (parsedHostname.length > parsedDomain.length + && parsedHostname[parsedHostname.length - parsedDomain.length - 1] == ".") { + return true; + } + } + return false; + } + + /** + * Verifies if an address is in any of the provided domains. + * + * @param untrustedAddress The address to verify + * @param trustedDomain The domain or domains to verify against + * @return True if address is in any provided domain, false otherwise. + */ + public static inDomain(untrustedAddress: string | URL, trustedDomain: string | string[]): boolean { + if (untrustedAddress == null || trustedDomain == null) { + return false; + } + + // Standardize untrustedAddress + let validHostname: string; + try { + validHostname = this._getValidHostname(untrustedAddress, this._domainProtocols); + } catch { + return false; + } + + // Standardize trustedDomain and remove invalid domains + let parsedDomains: string[]; + if (typeof trustedDomain == "string") { + parsedDomains = [domainToASCII(trustedDomain)]; + } else { + parsedDomains = trustedDomain.map((d) => domainToASCII(d)); + } + if (parsedDomains.filter((d) => d == null || d.length == 0).length > 0) { + return false; + } + + // Check if the hostname is in any of the provided domains + for (const parsedDomain of parsedDomains) { + if (this._hostnameInSingleDomain(validHostname, parsedDomain)) { + return true; + } + } + return false; + } + + /** + * Verifies if an address is in an Azure Key Vault domain. + * + * @param untrustedAddress The address to verify + * @return True if address is in any Azure Key Vault domain, false otherwise. + */ + public static inAzureKeyVaultDomain(untrustedAddress: string | URL): boolean { + if (untrustedAddress == null) { + return false; + } + + let validHostname: string; + try { + validHostname = this._getValidHostname(untrustedAddress, this._azureSdkProtocols); + } catch { + return false; + } + + if (validHostname.includes("--")) { + return false; + } + + + for (const domain of _azureKeyVaultDomains) { + if (this._hostnameInSingleDomain(validHostname, domain)) { + return true; + } + } + return false; + } + + /** + * Verifies if an address is in an Azure Storage domain. + * + * @param untrustedAddress The address to verify + * @return True if address is in any Azure Storage domain, false otherwise. + */ + public static inAzureStorageDomain(untrustedAddress: URL | string): boolean { + if (untrustedAddress == null) { + return false; + } + + let validHostname: string; + try { + validHostname = this._getValidHostname(untrustedAddress, this._azureSdkProtocols); + } catch { + return false; + } + + if (validHostname.includes("--")) { + return false; + } + + for (const domain of _azureStorageDomains) { + if (this._hostnameInSingleDomain(validHostname, domain)) { + return true; + } + } + return false; + } +} diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts new file mode 100644 index 0000000..1b82b39 --- /dev/null +++ b/nodejs/src/index.ts @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from "./AntiSSRFError"; +export * from "./AntiSSRFPolicy"; +export * from "./IPAddressRanges"; +export * from "./URIValidator"; diff --git a/nodejs/tests/FunctionalTests/AxiosDefaults.test.ts b/nodejs/tests/FunctionalTests/AxiosDefaults.test.ts new file mode 100644 index 0000000..fdf82f3 --- /dev/null +++ b/nodejs/tests/FunctionalTests/AxiosDefaults.test.ts @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * The Axios library allows you to set default http/https agents. + * + * This test suite sets Axios default agents with an AntiSSRFPolicy and tests + * various scenarios, including absolute addresses with and without redirects + * and absolute addresses that are redirected through a local HTTP server. + */ + +import axios from "axios"; +import assert from "assert"; +import { createServer, Server } from "http"; +import { lookup, promises } from "dns"; + +import { AntiSSRFPolicy, PolicyConfigOptions } from "../../src"; + +const allowedAddressesNoRedirect = ["https://github.com/"]; + +const allowedAddressesWithRedirect = [ + "https://google.com/", + "http://localhost:3000/?redirectTo=https://github.com/", + "http://localhost:3000/?redirectTo=https://google.com/" +]; + +const deniedAddressesNoRedirect = [ + "https://apple.com/", + "https://www.facebook.com", + "https://www.bing.com", + "https://outlook.live.com", + "https://www.etsy.com", + "https://169.254.169.254/" +]; + +const deniedAddressesWithRedirect = [ + "http://localhost:3000/?redirectTo=https://apple.com/", + "http://localhost:3000/?redirectTo=https://www.facebook.com", + "http://localhost:3000/?redirectTo=https://www.bing.com", + "http://localhost:3000/?redirectTo=https://outlook.live.com", + "http://localhost:3000/?redirectTo=https://www.etsy.com", + "http://localhost:3000/?redirectTo=https://169.254.169.254/" +]; + +describe("Axios Defaults tests", () => { + let server: Server; + + /** + * Set up policy: + * - Add XFF header + * - Require header "test-required-header" + * - Deny header "test-denied-header" + * - Deny all unspecifieid addresses + * - Allow addresses from GitHub, Google, and NYT + * + * - Allow plain text HTTP required for local HTTP server + * - Allow localhost required for local HTTP server + */ + before(async () => { + // Set up policy + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.allowPlainTextHttp = true; + policy.addXFFHeader = true; + policy.addRequiredHeaders(["test-required-header"]); + policy.addDeniedHeaders(["test-denied-header"]); + policy.denyAllUnspecifiedIPs = true; + + // Set up allowed IPs + const githubIPs = await promises.lookup("github.com", { family: 0, all: true }); + const googleIPs = await promises.lookup("google.com", { family: 0, all: true }); + const moregoogleIPs = await promises.lookup("www.google.com", { family: 0, all: true }); + policy.addAllowedAddresses([...githubIPs, ...googleIPs, ...moregoogleIPs].map((ip) => ip.address)); + policy.addAllowedAddresses(["::1", "127.0.0.1"]); + + // Set up HTTP server to redirect requests + server = createServer((req, res) => { + const { url } = req; + // Ensure the request has the XFF header + assert.equal(req.headers["x-forwarded-for"], "true"); + res.writeHead(301, { Location: url?.substring(url.indexOf("=") + 1) ?? "" }); + res.end(); + }); + server.listen(3000); + + // Set the Axios default agents + axios.defaults.httpAgent = policy.getHttpAgent(); + axios.defaults.httpsAgent = policy.getHttpsAgent(); + }); + + describe("Allowed addresses that don't cause redirects", () => { + allowedAddressesNoRedirect.forEach((url) => { + it(`GET ${url} - maxRedirects = 0`, async () => { + try { + const res = await axios.get(url, { + maxRedirects: 0, + headers: { "test-required-header": "true" } + }); + assert.ok(res.status == 200); + } catch (err) { + assert.fail(err as Error); + } + }); + + it(`GET ${url} - no maxRedirects specified`, async () => { + try { + const res = await axios.get(url, { headers: { "test-required-header": "true" } }); + assert.ok(res.status == 200); + } catch (err) { + assert.fail(err as Error); + } + }); + }); + }); + + describe("Allowed addresses that cause redirects", () => { + allowedAddressesWithRedirect.forEach((url) => { + it(`GET ${url} - maxRedirects = 0`, async () => { + try { + await axios.get(url, { + maxRedirects: 0, + headers: { "test-required-header": "true" } + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, "Request failed with status code 301"); + } + }); + + it(`GET ${url} - no maxRedirects`, async () => { + try { + const res = await axios.get(url, { headers: { "test-required-header": "true" } }); + assert.ok(res.status == 200); + } catch (err) { + assert.fail(err as Error); + } + }); + }); + }); + + describe("Denied addresses that don't cause redirects", () => { + deniedAddressesNoRedirect.forEach((url) => { + it(`GET ${url} - maxRedirects = 0`, async () => { + try { + await axios.get(url, { + maxRedirects: 0, + headers: { "test-required-header": "true" } + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, "IP address disallowed by policy"); + } + }); + + it(`GET ${url} - no maxRedirects`, async () => { + try { + await axios.get(url, { headers: { "test-required-header": "true" } }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, "IP address disallowed by policy"); + } + }); + }); + }); + + describe("Denied addresses that cause redirects", () => { + deniedAddressesWithRedirect.forEach((url) => { + it(`GET ${url} - maxRedirects = 0`, async () => { + try { + await axios.get(url, { + maxRedirects: 0, + headers: { "test-required-header": "true" } + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, "Request failed with status code 301"); + } + }); + + it(`GET ${url} - no maxRedirects`, async () => { + try { + await axios.get(url, { headers: { "test-required-header": "true" } }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, "IP address disallowed by policy"); + } + }); + }); + }); + + it("Contains denied header", async () => { + try { + await axios.get("https://github.com", { + headers: { "test-required-header": "true", "test-denied-header": "true" } + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, "Request headers or protocol disallowed by policy"); + } + }); + + it("Missing required header", async () => { + try { + await axios.get("https://github.com", { + headers: {} + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, "Request headers or protocol disallowed by policy"); + } + }); + + it("Tries to overwrite lookup", async () => { + try { + await axios.get("https://github.com", { + headers: { "test-required-header": "true" }, + // @ts-ignore Testing that custom lookup is rejected + lookup: lookup + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, "Cannot use AntiSSRFHttpsAgent with custom lookup function"); + } + }); + + after(() => { + axios.defaults.httpAgent = undefined; + axios.defaults.httpsAgent = undefined; + + server.close(); + }); +}); diff --git a/nodejs/tests/FunctionalTests/AxiosInstance.test.ts b/nodejs/tests/FunctionalTests/AxiosInstance.test.ts new file mode 100644 index 0000000..256c624 --- /dev/null +++ b/nodejs/tests/FunctionalTests/AxiosInstance.test.ts @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * The Axios library allows you to create an instance with custom configuration, + * which can include custom headers, base URLs, Agents, timeouts, interceptors, + * and more. MaxRedirects controls if Axios should use the http/https libraries + * or the follow-redirects library. + * + * This test suite create an Axios instance with an AntiSSRFPolicy and tests + * various scenarios, including absolute addresses with and without redirects, + * absolute addresses that are redirected through a local HTTP server, and + * relative addresses that use the base URL feature. + */ + +import axios, { AxiosInstance } from "axios"; +import assert from "assert"; +import { createServer, Server } from "http"; +import { lookup, promises } from "dns"; + +import { AntiSSRFPolicy, PolicyConfigOptions } from "../../src"; + +const allowedAddressesNoRedirect = ["https://github.com/"]; + +const allowedAddressesWithRedirect = [ + "https://google.com/", + "http://localhost:3000/?redirectTo=https://github.com/", + "http://localhost:3000/?redirectTo=https://google.com/" +]; + +const deniedAddressesNoRedirect = [ + "https://apple.com/", + "https://www.facebook.com", + "https://www.bing.com", + "https://outlook.live.com", + "https://www.etsy.com", + "https://169.254.169.254/" +]; + +const deniedAddressesWithRedirect = [ + "http://localhost:3000/?redirectTo=https://apple.com/", + "http://localhost:3000/?redirectTo=https://www.facebook.com", + "http://localhost:3000/?redirectTo=https://www.bing.com", + "http://localhost:3000/?redirectTo=https://outlook.live.com", + "http://localhost:3000/?redirectTo=https://www.etsy.com", + "http://localhost:3000/?redirectTo=https://169.254.169.254/" +]; + +describe("Axios Instance tests", () => { + let server: Server; + let instance: AxiosInstance; + + /** + * Set up policy: + * - Add XFF header + * - Require header "test-required-header" + * - Deny header "test-denied-header" + * - Deny all unspecifieid addresses + * - Allow addresses from GitHub, Google, and NYT + * + * - Allow plain text HTTP required for local HTTP server + * - Allow localhost required for local HTTP server + */ + before(async () => { + // Set up policy + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.allowPlainTextHttp = true; + policy.addXFFHeader = true; + policy.addRequiredHeaders(["test-required-header"]); + policy.addDeniedHeaders(["test-denied-header"]); + policy.denyAllUnspecifiedIPs = true; + + // Set up allowed IPs + const githubIPs = await promises.lookup("github.com", { family: 0, all: true }); + const googleIPs = await promises.lookup("google.com", { family: 0, all: true }); + const moregoogleIPs = await promises.lookup("www.google.com", { family: 0, all: true }); + policy.addAllowedAddresses([...githubIPs, ...googleIPs, ...moregoogleIPs].map((ip) => ip.address)); + policy.addAllowedAddresses(["::1", "127.0.0.1"]); + + // Set up HTTP server to redirect requests + server = createServer((req, res) => { + const { url } = req; + // Ensure the request has the XFF header + assert.equal(req.headers["x-forwarded-for"], "true"); + res.writeHead(301, { Location: url?.substring(url.indexOf("=") + 1) ?? "" }); + res.end(); + }); + server.listen(3000); + + // Create the Axios instance + instance = axios.create({ + headers: { "test-required-header": "true" }, + httpAgent: policy.getHttpAgent(), + httpsAgent: policy.getHttpsAgent(), + baseURL: "https://google.com" + }); + }); + + describe("Allowed addresses that don't cause redirects", () => { + allowedAddressesNoRedirect.forEach((url) => { + it(`GET ${url} - maxRedirects = 0`, async () => { + try { + const res = await instance.get(url, { + maxRedirects: 0 + }); + assert.ok(res.status == 200); + } catch (err) { + assert.fail(`Unexpected error for ${url}: ${(err as Error).message}\n${(err as Error).stack}`); + } + }); + + it(`GET ${url} - no maxRedirects specified`, async () => { + try { + const res = await instance.get(url); + assert.ok(res.status == 200); + } catch (err) { + assert.fail(`Unexpected error for ${url}: ${(err as Error).message}\n${(err as Error).stack}`); + } + }); + }); + }); + + describe("Allowed addresses that cause redirects", () => { + allowedAddressesWithRedirect.forEach((url) => { + it(`GET ${url} - maxRedirects = 0`, async () => { + try { + await instance.get(url, { + maxRedirects: 0 + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, "Request failed with status code 301"); + } + }); + + it(`GET ${url} - no maxRedirects`, async () => { + try { + const res = await instance.get(url); + assert.ok(res.status == 200); + } catch (err) { + assert.fail(`Unexpected error for ${url}: ${(err as Error).message}\n${(err as Error).stack}`); + } + }); + }); + }); + + describe("Denied addresses that don't cause redirects", () => { + deniedAddressesNoRedirect.forEach((url) => { + it(`GET ${url} - maxRedirects = 0`, async () => { + try { + await instance.get(url, { + maxRedirects: 0 + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, "IP address disallowed by policy"); + } + }); + + it(`GET ${url} - no maxRedirects`, async () => { + try { + await instance.get(url); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, "IP address disallowed by policy"); + } + }); + }); + }); + + describe("Denied addresses that cause redirects", () => { + deniedAddressesWithRedirect.forEach((url) => { + it(`GET ${url} - maxRedirects = 0`, async () => { + try { + await instance.get(url, { + maxRedirects: 0 + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, "Request failed with status code 301"); + } + }); + + it(`GET ${url} - no maxRedirects`, async () => { + try { + await instance.get(url); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, "IP address disallowed by policy"); + } + }); + }); + }); + + it("Contains denied header", async () => { + try { + await instance.get("https://github.com", { + headers: { "test-required-header": "true", "test-denied-header": "true" } + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, "Request headers or protocol disallowed by policy"); + } + }); + + it("Tries to overwrite lookup", async () => { + try { + await instance.get("https://github.com", { + headers: { "test-required-header": "true" }, + lookup: lookup + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, "Cannot use AntiSSRFHttpsAgent with custom lookup function"); + } + }); + + it("Uses baseURL", async () => { + try { + const res = await instance.get("/", { + headers: { "test-required-header": "true" } + }); + assert.ok(res.status == 200); + } catch (err) { + assert.fail(`Unexpected error for baseURL test: ${(err as Error).message}\n${(err as Error).stack}`); + } + }); + + it("Incorrectly uses baseURL", async () => { + try { + await instance.get("www.bing.com", { + headers: { "test-required-header": "true" } + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, "Request failed with status code 404", `Unexpected error message: ${(err as Error).message}\n${(err as Error).stack}`); + } + }); + + after(() => { + server.close(); + }); +}); diff --git a/nodejs/tests/FunctionalTests/FollowRedirects.test.ts b/nodejs/tests/FunctionalTests/FollowRedirects.test.ts new file mode 100644 index 0000000..31e5cf3 --- /dev/null +++ b/nodejs/tests/FunctionalTests/FollowRedirects.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Library: https://github.com/follow-redirects/follow-redirects + * + * Description: follow-redirects provides request and get methods that behave + * identically to those found on the native http and https modules, with the + * exception that they will seamlessly follow redirects. + * + * Notes: Used by Axios whenever maxRedirects != 0. + */ + +import assert from "assert"; +import { createServer, Server, Agent as NodeHttpAgent } from "http"; +import { Agent as NodeHttpsAgent } from "https"; +import { promises } from "dns"; +import { http } from "follow-redirects"; + +import { AntiSSRFPolicy, PolicyConfigOptions } from "../../src"; + +describe("Follow-Redirects Library Tests", () => { + describe("Redirect proxy", () => { + let server: Server; + let httpAgent: NodeHttpAgent; + let httpsAgent: NodeHttpsAgent; + + before(async () => { + // Set up server to redirect requests + server = createServer((req, res) => { + const { url } = req; + assert.equal(req.headers["x-forwarded-for"], "true"); + res.writeHead(301, { Location: url?.substring(url.indexOf("=") + 1) ?? "" }); + res.end(); + }); + server.listen(3000); + + // Set up policy + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.allowPlainTextHttp = true; + policy.addXFFHeader = true; + policy.denyAllUnspecifiedIPs = true; + + // Set up allowed IPs + const githubIPs = await promises.lookup("github.com", { family: 0, all: true }); + const googleIPs = await promises.lookup("google.com", { family: 0, all: true }); + const moreGoogleIPs = await promises.lookup("www.google.com", { family: 0, all: true }); + const portalAzureIPs = await promises.lookup("portal.azure.com", { family: 0, all: true }); + policy.addAllowedAddresses( + [...githubIPs, ...googleIPs, ...moreGoogleIPs, ...portalAzureIPs].map((ip) => ip.address) + ); + policy.addAllowedAddresses(["::1", "127.0.0.1"]); + + httpAgent = policy.getHttpAgent({ keepAlive: false }); + httpsAgent = policy.getHttpsAgent({ keepAlive: false }); + }); + + const allowedRedirects = [ + "https://github.com/", + "https://google.com/", + "https://portal.azure.com/", + "http://localhost:3000/?redirectTo=https://google.com/" + ]; + allowedRedirects.forEach((url) => { + it(`GET http://localhost:3000/?redirectTo=${url}`, (done) => { + const req = http.get( + `http://localhost:3000/?redirectTo=${url}`, + { agents: { http: httpAgent, https: httpsAgent } }, + (res) => { + res.on("data", () => {}); + res.on("end", () => { + assert.equal(res.statusCode, 200); + if (!url.includes("google")) { + assert.equal(url, res.responseUrl); + } + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + }); + + const disallowedRedirects = ["https://apple.com/", "https://cmu.edu/", "https://www.bing.com/"]; + disallowedRedirects.forEach((url) => { + it(`GET http://localhost:3000/?redirectTo=${url}`, (done) => { + const req = http.get( + `http://localhost:3000/?redirectTo=${url}`, + { trackRedirects: true, agents: { http: httpAgent, https: httpsAgent } }, + (res) => { + res.on("data", () => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + } + ); + + req.on("error", (err) => { + assert.equal(err.message, "IP address disallowed by policy"); + done(); + }); + + req.end(); + }); + }); + + after(() => { + server.close(); + httpAgent.destroy(); + httpsAgent.destroy(); + }); + }); +}); diff --git a/nodejs/tests/FunctionalTests/HttpAgent.test.ts b/nodejs/tests/FunctionalTests/HttpAgent.test.ts new file mode 100644 index 0000000..419651f --- /dev/null +++ b/nodejs/tests/FunctionalTests/HttpAgent.test.ts @@ -0,0 +1,466 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "assert"; +import http from "http"; +import { lookup, LookupAddress, promises } from "dns"; + +import { AntiSSRFPolicy, PolicyConfigOptions } from "../../src"; + +describe("HttpAgent Tests - default policy", () => { + let antiSSRFHttpAgent: http.Agent; + let microsoftIP: string; + + before(async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); + policy.allowPlainTextHttp = true; + antiSSRFHttpAgent = policy.getHttpAgent({ keepAlive: true }); + microsoftIP = await promises.lookup("microsoft.com", { family: 4 }).then((address) => address.address); + }); + + it("Successful lookup - get, URL", (done) => { + const req = http.get("http://www.apple.com/", { agent: antiSSRFHttpAgent, family: 4 }, (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + assert.equal(res.statusCode, 301); + done(); + }); + }); + + req.on("error", done); + + req.end(); + }); + + it("Successful lookup - get, options", (done) => { + const req = http.get( + { + agent: antiSSRFHttpAgent, + host: microsoftIP, + headers: { Host: "microsoft.com" } + }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + assert.equal(res.statusCode, 307); + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + + it("Successful lookup - request, URL", (done) => { + const req = http.request( + "http://twin-cities.umn.edu/academics-admissions/majors-programs", + { agent: antiSSRFHttpAgent }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + assert.equal(res.statusCode, 301); + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + + it("Successful lookup - request, options", (done) => { + const req = http.request( + { + agent: antiSSRFHttpAgent, + hostname: "learn.microsoft.com", + path: "/en-us/training/paths/describe-basic-concepts-of-cybersecurity/" + }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + assert.equal(res.statusCode, 301); + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + + it("Reject lookup - get, URL", (done) => { + const req = http.get("http://[0::1]/", { agent: antiSSRFHttpAgent }, (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + }); + + req.on("error", (err) => { + assert.equal(err.message, "IP address disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Reject lookup - get, options", (done) => { + const req = http.get({ agent: antiSSRFHttpAgent, hostname: "127.0.0.3", host: "google.com" }, (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + }); + + req.on("error", (err) => { + assert.equal(err.message, "IP address disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Reject lookup - request, URL", (done) => { + const req = http.request( + "http://169.254.169.254:443", + { + agent: antiSSRFHttpAgent, + host: "www.google.com" + }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + } + ); + + req.on("error", (err) => { + assert.equal(err.message, "IP address disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Reject lookup - request, options", (done) => { + const req = http.request( + { + agent: antiSSRFHttpAgent, + host: "www.imds.michaelhendrickx.com" + }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + } + ); + + req.on("error", (err) => { + assert.equal(err.message, "getaddrinfo ENOTFOUND www.imds.michaelhendrickx.com"); + done(); + }); + + req.end(); + }); + + it("Successful lookup - any, ensure XFF is added", (done) => { + const req = http.request("http://www.apple.com/", { agent: antiSSRFHttpAgent }, (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + assert.equal(res.statusCode, 301); + done(); + }); + }); + + req.on("error", done); + + req.end(() => { + assert.equal(req.getHeader("X-Forwarded-For"), "true"); + }); + }); + + it("Successful lookup - any, ensure XFF is not overwritten", (done) => { + const req = http.request( + "http://www.apple.com/", + { agent: antiSSRFHttpAgent, headers: { "X-Forwarded-For": "127.0.0.1" } }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + assert.equal(res.statusCode, 301); + done(); + }); + } + ); + + req.on("error", done); + + req.end(() => { + assert.equal(req.getHeader("x-forwarded-for"), "127.0.0.1"); + }); + }); + + it("Reject lookup - tried to add lookup to request", (done) => { + const req = http.get("http://google.com", { agent: antiSSRFHttpAgent, lookup: lookup }, (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + }); + + req.on("error", (err) => { + assert.equal(err.message, "Cannot use AntiSSRFHttpAgent with custom lookup function"); + done(); + }); + + req.end(); + }); + + after(() => { + antiSSRFHttpAgent.destroy(); + }); +}); + +describe("HttpAgent Tests - custom policy", () => { + let antiSSRFHttpAgent: http.Agent; + let googleIPs: LookupAddress[]; + let appleIPs: LookupAddress[]; + + before(async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.allowPlainTextHttp = true; + policy.addRequiredHeaders(["test-required-header"]); + policy.addDeniedHeaders(["test-denied-header"]); + + googleIPs = await promises.lookup("www.google.com", { family: 0, all: true }); + appleIPs = await promises.lookup("apple.com", { family: 0, all: true }); + policy.addDeniedAddresses([...googleIPs, ...appleIPs].map((address) => address.address)); + + antiSSRFHttpAgent = policy.getHttpAgent(); + }); + + it("Successful lookup - get, URL", (done) => { + const req = http.get( + "http://www.bing.com/", + { agent: antiSSRFHttpAgent, headers: { "test-required-header": 25 } }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + assert.equal(res.statusCode, 200); + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + + it("Successful lookup - get, options", (done) => { + const req = http.get( + { agent: antiSSRFHttpAgent, hostname: "github.com", headers: { "test-required-header": 25 } }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + assert.equal(res.statusCode, 301); + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + + it("Successful lookup - request, URL", (done) => { + const req = http.get( + "http://www.cmu.edu/", + { agent: antiSSRFHttpAgent, headers: { "test-required-header": 25 } }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + assert.equal(res.statusCode, 301); + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + + it("Successful lookup - request, options", (done) => { + const req = http.request( + { + agent: antiSSRFHttpAgent, + hostname: "learn.microsoft.com", + path: "/en-us/training/paths/describe-basic-concepts-of-cybersecurity/", + headers: { "test-required-header": 25 } + }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + assert.equal(res.statusCode, 301); + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + + it("Reject lookup - get, URL", (done) => { + const req = http.get( + "http://apple.com", + { + agent: antiSSRFHttpAgent, + host: "www.bing.com", + headers: { "test-required-header": 25 } + }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + } + ); + + req.on("error", (err) => { + assert.equal(err.message, "IP address disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Reject lookup - get, options", (done) => { + const req = http.get( + { + agent: antiSSRFHttpAgent, + host: appleIPs.find((address) => address.family === 4)?.address, + headers: { "test-required-header": 25 } + }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + } + ); + + req.on("error", (err) => { + assert.equal(err.message, "IP address disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Reject lookup - request, options", (done) => { + const req = http.request( + { + agent: antiSSRFHttpAgent, + host: "www.google.com", + family: 0, + headers: { "test-required-header": 25 } + }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + } + ); + + req.on("error", (err) => { + assert.equal(err.message, "IP address disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Reject lookup - any, missing required header", (done) => { + const req = http.get("http://www.google.com/", { agent: antiSSRFHttpAgent }, (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + }); + + req.on("error", (err) => { + assert.equal(err.message, "Request headers or protocol disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Reject lookup - any, include denied header", (done) => { + const req = http.get( + "http://www.bing.com/", + { agent: antiSSRFHttpAgent, headers: { "test-required-header": "true", "test-denied-header": "false" } }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + } + ); + + req.on("error", (err) => { + assert.equal(err.message, "Request headers or protocol disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Reject lookup - any, tried to overwrite lookup", (done) => { + // @ts-expect-error - trying to overwrite lookup should cause error + antiSSRFHttpAgent.lookup = lookup; + + const req = http.get( + "http://apple.com", + { + agent: antiSSRFHttpAgent, + host: "www.google.com", + headers: { "test-required-header": 25 } + }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + } + ); + + req.on("error", (err) => { + assert.equal(err.message, "IP address disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Bad agent construction", () => { + assert.throws(() => { + const newPolicy = new AntiSSRFPolicy(PolicyConfigOptions.None); + newPolicy.getHttpAgent({ lookup: lookup }); + }); + }); + + after(() => { + antiSSRFHttpAgent.destroy(); + }); +}); diff --git a/nodejs/tests/FunctionalTests/HttpsAgent.test.ts b/nodejs/tests/FunctionalTests/HttpsAgent.test.ts new file mode 100644 index 0000000..1c35f62 --- /dev/null +++ b/nodejs/tests/FunctionalTests/HttpsAgent.test.ts @@ -0,0 +1,722 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "assert"; +import https from "https"; +import { lookup, LookupAddress, promises } from "dns"; + +import { AntiSSRFPolicy, PolicyConfigOptions } from "../../src"; + +describe("HttpsAgent Tests - default policy", () => { + let antiSSRFHttpsAgent: https.Agent; + let microsoftIP: string; + + before(async () => { + antiSSRFHttpsAgent = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest).getHttpsAgent({ + keepAlive: true + }); + microsoftIP = await promises.lookup("microsoft.com", { family: 4 }).then((address) => address.address); + }); + + it("Successful lookup - get, URL", (done) => { + const req = https.get("https://www.apple.com/", { agent: antiSSRFHttpsAgent, family: 4 }, (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + assert.equal(res.statusCode, 200); + done(); + }); + }); + + req.on("error", done); + + req.end(); + }); + + it("Successful lookup - get, options", (done) => { + const req = https.get( + { + agent: antiSSRFHttpsAgent, + host: microsoftIP, + servername: "microsoft.com", + headers: { Host: "microsoft.com" } + }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + assert.equal(res.statusCode, 301); + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + + it("Successful lookup - request, URL", (done) => { + const req = https.request( + "https://twin-cities.umn.edu/academics-admissions/majors-programs", + { agent: antiSSRFHttpsAgent }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + assert.equal(res.statusCode, 200); + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + + it("Successful lookup - request, options", (done) => { + const req = https.request( + { + agent: antiSSRFHttpsAgent, + hostname: "learn.microsoft.com", + path: "/en-us/training/paths/describe-basic-concepts-of-cybersecurity/" + }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + assert.equal(res.statusCode, 200); + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + + it("Reject lookup - get, URL", (done) => { + const req = https.get("https://[0::1]/", { agent: antiSSRFHttpsAgent }, (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + }); + + req.on("error", (err) => { + assert.equal(err.message, "IP address disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Reject lookup - get, options", (done) => { + const req = https.get({ agent: antiSSRFHttpsAgent, hostname: "127.0.0.3", host: "google.com" }, (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + }); + + req.on("error", (err) => { + assert.equal(err.message, "IP address disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Reject lookup - request, URL", (done) => { + const req = https.request( + "https://169.254.169.254:443", + { + agent: antiSSRFHttpsAgent, + host: "www.google.com" + }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + } + ); + + req.on("error", (err) => { + assert.equal(err.message, "IP address disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Reject lookup - request, options", (done) => { + const req = https.request( + { + agent: antiSSRFHttpsAgent, + host: "www.imds.michaelhendrickx.com" + }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + } + ); + + req.on("error", (err) => { + assert.equal(err.message, "getaddrinfo ENOTFOUND www.imds.michaelhendrickx.com"); + done(); + }); + + req.end(); + }); + + it("Successful lookup - any, ensure XFF is added", (done) => { + const req = https.request("https://www.apple.com/", { agent: antiSSRFHttpsAgent }, (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + assert.equal(res.statusCode, 200); + done(); + }); + }); + + req.on("error", done); + + req.end(() => { + assert.equal(req.getHeader("X-Forwarded-For"), "true"); + }); + }); + + it("Successful lookup - any, ensure XFF is not overwritten", (done) => { + const req = https.request( + "https://www.apple.com/", + { agent: antiSSRFHttpsAgent, headers: { "X-Forwarded-For": "127.0.0.1" } }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + assert.equal(res.statusCode, 200); + done(); + }); + } + ); + + req.on("error", done); + + req.end(() => { + assert.equal(req.getHeader("x-forwarded-for"), "127.0.0.1"); + }); + }); + + it("Reject lookup - tried to add lookup to request", (done) => { + const req = https.get("https://google.com", { agent: antiSSRFHttpsAgent, lookup: lookup }, (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + }); + + req.on("error", (err) => { + assert.equal(err.message, "Cannot use AntiSSRFHttpsAgent with custom lookup function"); + done(); + }); + + req.end(); + }); + + after(() => { + antiSSRFHttpsAgent.destroy(); + }); +}); + +describe("HttpsAgent Tests - custom policy", () => { + let antiSSRFHttpsAgent: https.Agent; + let googleIPs: LookupAddress[]; + let appleIPs: LookupAddress[]; + + before(async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.addRequiredHeaders(["test-required-header"]); + policy.addDeniedHeaders(["test-denied-header"]); + + googleIPs = await promises.lookup("www.google.com", { family: 0, all: true }); + appleIPs = await promises.lookup("apple.com", { family: 0, all: true }); + policy.addDeniedAddresses([...googleIPs, ...appleIPs].map((address) => address.address)); + + antiSSRFHttpsAgent = policy.getHttpsAgent(); + }); + + it("Successful lookup - get, URL", (done) => { + const req = https.get( + "https://www.bing.com/", + { agent: antiSSRFHttpsAgent, port: 443, headers: { "test-required-header": 25 } }, + (res) => { + res.on("data", () => {}); + res.on("end", () => { + assert.equal(res.statusCode, 200); + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + + it("Successful lookup - get, options", (done) => { + const req = https.get( + { agent: antiSSRFHttpsAgent, hostname: "github.com", headers: { "test-required-header": 25 } }, + (res) => { + res.on("data", () => {}); + res.on("end", () => { + if (res.statusCode == 200) { + done(); + } else { + done(new Error(`Expected 200, got ${res.statusCode}`)); + } + }); + } + ); + + req.on("error", done); + + req.end(); + }); + + it("Successful lookup - request, URL", (done) => { + const req = https.get( + "https://www.cmu.edu/", + { agent: antiSSRFHttpsAgent, headers: { "test-required-header": 25 } }, + (res) => { + res.on("data", () => {}); + res.on("end", () => { + assert.equal(res.statusCode, 200); + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + + it("Successful lookup - request, options", (done) => { + const req = https.request( + { + agent: antiSSRFHttpsAgent, + hostname: "learn.microsoft.com", + path: "/en-us/training/paths/describe-basic-concepts-of-cybersecurity/", + headers: { "test-required-header": 25 } + }, + (res) => { + res.on("data", () => {}); + res.on("end", () => { + assert.equal(res.statusCode, 200); + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + + it("Reject lookup - get, URL", (done) => { + const req = https.get( + "https://apple.com", + { + agent: antiSSRFHttpsAgent, + host: "www.bing.com", + headers: { "test-required-header": 25 } + }, + (res) => { + res.on("data", () => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + } + ); + + req.on("error", (err) => { + assert.equal(err.message, "IP address disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Reject lookup - get, options", (done) => { + const req = https.get( + { + agent: antiSSRFHttpsAgent, + host: appleIPs.find((address) => address.family === 4)?.address, + headers: { "test-required-header": 25 }, + servername: "apple.com" + }, + (res) => { + res.on("data", () => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + } + ); + + req.on("error", (err) => { + assert.equal(err.message, "IP address disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Reject lookup - request, options", (done) => { + const req = https.request( + { + agent: antiSSRFHttpsAgent, + host: "www.google.com", + family: 0, + headers: { "test-required-header": 25 } + }, + (res) => { + res.on("data", () => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + } + ); + + req.on("error", (err) => { + assert.equal(err.message, "IP address disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Reject lookup - any, missing required header", (done) => { + const req = https.get("https://www.google.com/", { agent: antiSSRFHttpsAgent }, (res) => { + res.on("data", () => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + }); + + req.on("error", (err) => { + assert.equal(err.message, "Request headers or protocol disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Reject lookup - any, include denied header", (done) => { + const req = https.get( + "https://www.bing.com/", + { agent: antiSSRFHttpsAgent, headers: { "test-required-header": "true", "test-denied-header": "false" } }, + (res) => { + res.on("data", () => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + } + ); + + req.on("error", (err) => { + assert.equal(err.message, "Request headers or protocol disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Reject lookup - any, tried to overwrite lookup", (done) => { + // @ts-expect-error - trying to overwrite lookup should cause error + antiSSRFHttpsAgent.lookup = lookup; + + const req = https.get( + "https://apple.com", + { + agent: antiSSRFHttpsAgent, + host: "www.google.com", + headers: { "test-required-header": 25 } + }, + (res) => { + res.on("data", () => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + } + ); + + req.on("error", (err) => { + assert.equal(err.message, "IP address disallowed by policy"); + done(); + }); + + req.end(); + }); + + it("Bad agent construction", () => { + assert.throws(() => { + const newPolicy = new AntiSSRFPolicy(PolicyConfigOptions.None); + newPolicy.getHttpsAgent({ lookup: lookup }); + }); + }); + + after(() => { + antiSSRFHttpsAgent.destroy(); + }); +}); + +describe("HttpsAgent Tests - other methods", () => { + const testUrl = "https://ambitious-flower-0611c910f.2.azurestaticapps.net/api/method"; + let allowAgent: https.Agent; + let disallowAgent: https.Agent; + + before(async () => { + const allowPolicy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); + allowAgent = allowPolicy.getHttpsAgent(); + + const disallowPolicy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); + const disallowedIPs = await promises.lookup(new URL(testUrl).hostname, { family: 0, all: true }); + disallowPolicy.addDeniedAddresses(disallowedIPs.map((addr) => addr.address)); + disallowAgent = disallowPolicy.getHttpsAgent(); + }); + + const methods = ["GET", "POST", "PUT", "DELETE", "HEAD", "PATCH", "OPTIONS"]; + methods.map((method) => { + it(`${method} allowed`, (done) => { + const req = https.request(testUrl, { method, agent: allowAgent }, (res) => { + let responseData = ""; + res.on("data", (chunk) => { + responseData += chunk.toString(); + }); + res.on("end", () => { + if (method === "HEAD") { + assert.equal(res.statusCode, 200); + return done(); + } + + try { + assert.equal(res.statusCode, 200); + const parsedData = JSON.parse(responseData); + assert.equal(parsedData.method, method); + done(); + } catch (error) { + done(error); + } + }); + }); + + req.on("error", done); + + req.end(); + }); + + it(`${method} disallowed`, (done) => { + const req = https.request(testUrl, { method, agent: disallowAgent }, (res) => { + res.on("data", () => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + }); + + req.on("error", () => { + done(); + }); + + req.end(); + }); + }); + + after(() => { + allowAgent.destroy(); + disallowAgent.destroy(); + }); +}); + +describe("HttpsAgent Tests - certificates", () => { + let antiSSRFHttpsAgent: https.Agent; + + before(() => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); + antiSSRFHttpsAgent = policy.getHttpsAgent(); + }); + + describe("Valid Certificate Tests", () => { + it("Valid certificate should succeed", (done) => { + const req = https.get("https://www.google.com", { agent: antiSSRFHttpsAgent }, (res) => { + res.on("data", () => {}); + res.on("end", () => { + assert.equal(res.statusCode, 200); + done(); + }); + }); + + req.on("error", done); + + req.end(); + }); + }); + + describe("Expired Certificate Tests", () => { + it("Expired certificate should fail with certificate verification enabled", (done) => { + const req = https.get("https://expired.badssl.com/", { agent: antiSSRFHttpsAgent }, (res) => { + res.on("data", () => {}); + res.on("end", () => { + done(new Error("Expected SSL error, but got response")); + }); + }); + + req.on("error", (err: any) => { + assert.ok(err.message.includes("certificate") || err.code === "CERT_HAS_EXPIRED"); + done(); + }); + + req.end(); + }); + + it("Expired certificate should succeed with certificate verification disabled", (done) => { + const req = https.get( + "https://expired.badssl.com/", + { agent: antiSSRFHttpsAgent, rejectUnauthorized: false }, + (res) => { + res.on("data", () => {}); + res.on("end", () => { + assert.equal(res.statusCode, 200); + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + }); + + describe("Wrong Host Certificate Tests", () => { + it("Wrong host certificate should fail with certificate verification enabled", (done) => { + const req = https.get("https://wrong.host.badssl.com/", { agent: antiSSRFHttpsAgent }, (res) => { + res.on("data", () => {}); + res.on("end", () => { + done(new Error("Expected SSL error, but got response")); + }); + }); + + req.on("error", (err: any) => { + assert.ok( + err.message.includes("certificate") || + err.message.includes("Hostname/IP does not match") || + err.code === "ERR_TLS_CERT_ALTNAME_INVALID" + ); + done(); + }); + + req.end(); + }); + + it("Wrong host certificate should succeed with certificate verification disabled", (done) => { + const req = https.get( + "https://wrong.host.badssl.com/", + { agent: antiSSRFHttpsAgent, rejectUnauthorized: false }, + (res) => { + res.on("data", () => {}); + res.on("end", () => { + assert.equal(res.statusCode, 200); + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + }); + + describe("Self-Signed Certificate Tests", () => { + it("Self-signed certificate should fail with certificate verification enabled", (done) => { + const req = https.get("https://self-signed.badssl.com/", { agent: antiSSRFHttpsAgent }, (res) => { + res.on("data", () => {}); + res.on("end", () => { + done(new Error("Expected SSL error, but got response")); + }); + }); + + req.on("error", (err: any) => { + assert.ok( + err.message.includes("certificate") || + err.message.includes("self-signed") || + err.code === "DEPTH_ZERO_SELF_SIGNED_CERT" + ); + done(); + }); + + req.end(); + }); + + it("Self-signed certificate should succeed with certificate verification disabled", (done) => { + const req = https.get( + "https://self-signed.badssl.com/", + { agent: antiSSRFHttpsAgent, rejectUnauthorized: false }, + (res) => { + res.on("data", () => {}); + res.on("end", () => { + assert.equal(res.statusCode, 200); + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + }); + + describe("Untrusted Root Certificate Tests", () => { + it("Untrusted root certificate should fail with certificate verification enabled", (done) => { + const req = https.get("https://untrusted-root.badssl.com/", { agent: antiSSRFHttpsAgent }, (res) => { + res.on("data", () => {}); + res.on("end", () => { + done(new Error("Expected SSL error, but got response")); + }); + }); + + req.on("error", (err: any) => { + assert.ok( + err.message.includes("certificate") || + err.message.includes("unable to verify") || + err.code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE" + ); + done(); + }); + + req.end(); + }); + + it("Untrusted root certificate should succeed with certificate verification disabled", (done) => { + const req = https.get( + "https://untrusted-root.badssl.com/", + { agent: antiSSRFHttpsAgent, rejectUnauthorized: false }, + (res) => { + res.on("data", () => {}); + res.on("end", () => { + assert.equal(res.statusCode, 200); + done(); + }); + } + ); + + req.on("error", done); + + req.end(); + }); + }); + + after(() => { + antiSSRFHttpsAgent.destroy(); + }); +}); diff --git a/nodejs/tests/FunctionalTests/NodeFetch.test.ts b/nodejs/tests/FunctionalTests/NodeFetch.test.ts new file mode 100644 index 0000000..ad3c426 --- /dev/null +++ b/nodejs/tests/FunctionalTests/NodeFetch.test.ts @@ -0,0 +1,359 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Library: https://github.com/node-fetch/node-fetch/tree/2.x#readme + * + * Description: The node-fetch library provides a window.fetch compatible API for making + * HTTP requests in Node.js. It only accepts absolute URLs, without any support for + * relative URLs or protocol-relative URLs. It allows for a custom agent function to + * choose the agent for the correct protocol automatically. + */ + +import assert from "assert"; +import http, { createServer, Server } from "http"; +import https from "https"; +import { promises } from "dns"; +const fetch = require("node-fetch").default; + +import { AntiSSRFPolicy, PolicyConfigOptions } from "../../src"; + +const allowedAddressesNoRedirect = ["https://github.com/"]; + +const allowedAddressesWithRedirect = [ + "https://google.com/", + "http://localhost:3000/?redirectTo=https://github.com/", + "http://localhost:3000/?redirectTo=https://google.com/" +]; + +const deniedAddressesNoRedirect = [ + "https://apple.com/", + "https://www.facebook.com", + "https://www.bing.com", + "https://outlook.live.com", + "https://www.etsy.com", + "https://169.254.169.254/" +]; + +const deniedAddressesWithRedirect = [ + "http://localhost:3000/?redirectTo=https://apple.com/", + "http://localhost:3000/?redirectTo=https://www.facebook.com", + "http://localhost:3000/?redirectTo=https://www.bing.com", + "http://localhost:3000/?redirectTo=https://outlook.live.com", + "http://localhost:3000/?redirectTo=https://www.etsy.com", + "http://localhost:3000/?redirectTo=https://169.254.169.254/" +]; + +describe("Node-Fetch Tests", () => { + let server: Server; + let httpAgent: http.Agent; + let httpsAgent: https.Agent; + let agentFn: (parsedURL: URL) => http.Agent | https.Agent; + + /** + * Set up policy: + * - Add XFF header + * - Require header "test-required-header" + * - Deny header "test-denied-header" + * - Deny all unspecified addresses + * - Allow addresses from GitHub, Google + * + * - Allow plain text HTTP required for local HTTP server + * - Allow localhost required for local HTTP server + */ + before(async () => { + // Set up policy + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.allowPlainTextHttp = true; + policy.addXFFHeader = true; + policy.addRequiredHeaders(["test-required-header"]); + policy.addDeniedHeaders(["test-denied-header"]); + policy.denyAllUnspecifiedIPs = true; + + // Set up allowed IPs + const githubIPs = await promises.lookup("github.com", { family: 0, all: true }); + const googleIPs = await promises.lookup("google.com", { family: 0, all: true }); + const moregoogleIPs = await promises.lookup("www.google.com", { family: 0, all: true }); + policy.addAllowedAddresses([...githubIPs, ...googleIPs, ...moregoogleIPs].map((ip) => ip.address)); + policy.addAllowedAddresses(["::1", "127.0.0.1"]); + + // Set up HTTP server to redirect requests + server = createServer((req, res) => { + const { url } = req; + // Ensure the request has the XFF header + assert.equal(req.headers["x-forwarded-for"], "true"); + res.writeHead(301, { Location: url?.substring(url.indexOf("=") + 1) }); + res.end(); + }); + server.listen(3000); + + // Get agents from policy + httpAgent = policy.getHttpAgent({ keepAlive: false }); + httpsAgent = policy.getHttpsAgent({ keepAlive: false }); + agentFn = (_parsedURL) => { + return _parsedURL.protocol === "https:" ? httpsAgent : httpAgent; + }; + }); + + describe("Allowed addresses that don't cause redirects", () => { + allowedAddressesNoRedirect.forEach((url) => { + it(`GET ${url} - follow = 0`, async () => { + try { + const res = await fetch(url, { + follow: 0, + headers: { "test-required-header": "true" }, + agent: agentFn + }); + assert.ok(res.status === 200); + } catch (err) { + assert.fail(err as Error); + } + }); + + it(`GET ${url} - redirect = "manual"`, async () => { + try { + const res = await fetch(url, { + redirect: "manual", + headers: { "test-required-header": "true" }, + agent: agentFn + }); + assert.ok(res.status === 200); + } catch (err) { + assert.fail(err as Error); + } + }); + + it(`GET ${url} - redirect = "follow"`, async () => { + try { + const res = await fetch(url, { + redirect: "follow", + headers: { "test-required-header": "true" }, + agent: agentFn + }); + assert.ok(res.status === 200); + } catch (err) { + assert.fail(err as Error); + } + }); + + it(`GET ${url} - redirect = "error"`, async () => { + try { + const res = await fetch(url, { + redirect: "error", + headers: { "test-required-header": "true" }, + agent: agentFn + }); + assert.ok(res.status === 200); + } catch (err) { + assert.fail(err as Error); + } + }); + }); + }); + + describe("Allowed addresses that cause redirects", () => { + allowedAddressesWithRedirect.forEach((url) => { + it(`GET ${url} - follow = 0`, async () => { + try { + await fetch(url, { + follow: 0, + headers: { "test-required-header": "true" }, + agent: agentFn + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, `maximum redirect reached at: ${url}`); + } + }); + + it(`GET ${url} - redirect = "manual"`, async () => { + try { + const res = await fetch(url, { + redirect: "manual", + headers: { "test-required-header": "true" }, + agent: agentFn + }); + assert.ok(res.status === 301); + } catch (err) { + assert.fail(err as Error); + } + }); + + it(`GET ${url} - redirect = "follow"`, async () => { + try { + const res = await fetch(url, { + redirect: "follow", + headers: { "test-required-header": "true" }, + agent: agentFn + }); + assert.ok(res.status === 200); + } catch (err) { + assert.fail(err as Error); + } + }); + + it(`GET ${url} - redirect = "error"`, async () => { + try { + await fetch(url, { + redirect: "error", + headers: { "test-required-header": "true" }, + agent: agentFn + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal( + (err as Error).message, + `uri requested responds with a redirect, redirect mode is set to error: ${url}` + ); + } + }); + }); + }); + + describe("Denied addresses that don't cause redirects", () => { + deniedAddressesNoRedirect.forEach((url) => { + it(`GET ${url} - follow = 0`, async () => { + try { + await fetch(url, { + follow: 0, + headers: { "test-required-header": "true" }, + agent: agentFn + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.ok((err as Error).message.includes("IP address disallowed by policy")); + } + }); + + it(`GET ${url} - redirect = "manual"`, async () => { + try { + await fetch(url, { + redirect: "manual", + headers: { "test-required-header": "true" }, + agent: agentFn + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.ok((err as Error).message.includes("IP address disallowed by policy")); + } + }); + + it(`GET ${url} - redirect = "follow"`, async () => { + try { + await fetch(url, { + redirect: "follow", + headers: { "test-required-header": "true" }, + agent: agentFn + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.ok((err as Error).message.includes("IP address disallowed by policy")); + } + }); + + it(`GET ${url} - redirect = "error"`, async () => { + try { + await fetch(url, { + redirect: "error", + headers: { "test-required-header": "true" }, + agent: agentFn + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.ok((err as Error).message.includes("IP address disallowed by policy")); + } + }); + }); + }); + + describe("Denied addresses that cause redirects", () => { + deniedAddressesWithRedirect.forEach((url) => { + it(`GET ${url} - follow = 0`, async () => { + try { + await fetch(url, { + follow: 0, + headers: { "test-required-header": "true" }, + agent: agentFn + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal((err as Error).message, `maximum redirect reached at: ${url}`); + } + }); + + it(`GET ${url} - redirect = "manual"`, async () => { + try { + const res = await fetch(url, { + redirect: "manual", + headers: { "test-required-header": "true" }, + agent: agentFn + }); + assert.ok(res.status === 301); + } catch (err) { + assert.fail(err as Error); + } + }); + + it(`GET ${url} - redirect = "follow"`, async () => { + try { + await fetch(url, { + redirect: "follow", + headers: { "test-required-header": "true" }, + agent: agentFn + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.ok((err as Error).message.includes("IP address disallowed by policy")); + } + }); + + it(`GET ${url} - redirect = "error"`, async () => { + try { + await fetch(url, { + redirect: "error", + headers: { "test-required-header": "true" }, + agent: agentFn + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.equal( + (err as Error).message, + `uri requested responds with a redirect, redirect mode is set to error: ${url}` + ); + } + }); + }); + }); + + describe("Header policy enforcement", () => { + it("Contains denied header", async () => { + try { + await fetch("https://github.com", { + headers: { "test-required-header": "true", "test-denied-header": "true" }, + agent: agentFn + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.ok((err as Error).message.includes("Request headers or protocol disallowed by policy")); + } + }); + + it("Missing required header", async () => { + try { + await fetch("https://github.com", { + headers: {}, + agent: agentFn + }); + assert.fail("Expected error, but got response"); + } catch (err) { + assert.ok((err as Error).message.includes("Request headers or protocol disallowed by policy")); + } + }); + }); + + after(() => { + httpAgent.destroy(); + httpsAgent.destroy(); + server.close(); + }); +}); diff --git a/nodejs/tests/PrePublishTests/PrePublish.test.js b/nodejs/tests/PrePublishTests/PrePublish.test.js new file mode 100644 index 0000000..acfdcd6 --- /dev/null +++ b/nodejs/tests/PrePublishTests/PrePublish.test.js @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const fs = require("fs"); +const path = require("path"); +const tar = require("tar"); +const assert = require("assert"); +const dns = require("dns"); +const https = require("https"); + +describe("Tests for most recent .tgz package", function () { + const baseDir = path.join(__dirname, "../.."); + const extractPath = path.join(baseDir, "temp-lib"); + let URIValidator; + let AntiSSRFPolicy; + let PolicyConfigOptions; + + before(async function () { + // Find the .tgz file dynamically + const files = fs.readdirSync(baseDir); + const tgzFile = files.find((file) => file.match(/^.*\.tgz$/)); + + if (!tgzFile) { + throw new Error("No matching .tgz file found."); + } + + const tgzPath = path.join(baseDir, tgzFile); // Ensure the temp directory exists + + if (!fs.existsSync(extractPath)) { + fs.mkdirSync(extractPath); + } // Extract the .tgz file + + await tar.x({ + file: tgzPath, + cwd: extractPath, + sync: true, + strip: 1 + }); // Dynamically require the AddOne function + + const lib = require(path.join(extractPath, "out/src/index.js")); // Adjust if needed + URIValidator = lib.URIValidator; + AntiSSRFPolicy = lib.AntiSSRFPolicy; + PolicyConfigOptions = lib.PolicyConfigOptions; + }); + + it("URIValidator.inDomain test", () => { + assert.equal(URIValidator.inDomain("https://example.com", ".example.com"), true); + assert.equal(URIValidator.inDomain("https://example.com.evil.com", "example.com"), false); + }); + + describe("AntiSSRFPolicy tests", () => { + let antiSSRFHttpsAgent; + + before(async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.addRequiredHeaders(["test-required-header"]); + policy.addDeniedHeaders(["test-denied-header"]); + + const googleIPs = await dns.promises.lookup("www.google.com", { + family: 0, + all: true + }); + const appleIPs = await dns.promises.lookup("apple.com", { family: 0, all: true }); + policy.addDeniedAddresses([...googleIPs, ...appleIPs].map((address) => address.address)); + + antiSSRFHttpsAgent = policy.getHttpsAgent(); + }); + + it("Allow valid IP", (done) => { + const req = https.get( + "https://www.bing.com", + { + agent: antiSSRFHttpsAgent, + port: 443, + headers: { "test-required-header": 25 } + }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + assert.equal(res.statusCode, 200); + done(); + }); + } + ); + + req.on("error", (err) => { + done(err); + }); + + req.end(); + }); + + it("Deny blocked IP", (done) => { + const req = https.get( + "https://apple.com", + { + agent: antiSSRFHttpsAgent, + host: "www.bing.com", + headers: { "test-required-header": 25 } + }, + (res) => { + res.on("data", (data) => {}); + res.on("end", () => { + done("Expected error, but got response"); + }); + } + ); + + req.on("error", (err) => { + assert.equal(err.message, "IP address disallowed by policy"); + done(); + }); + + req.end(); + }); + + after(() => { + antiSSRFHttpsAgent.destroy(); + }); + }); +}); diff --git a/nodejs/tests/UnitTests/AntiSSRFDnsLookup.test.ts b/nodejs/tests/UnitTests/AntiSSRFDnsLookup.test.ts new file mode 100644 index 0000000..207cd58 --- /dev/null +++ b/nodejs/tests/UnitTests/AntiSSRFDnsLookup.test.ts @@ -0,0 +1,577 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "assert"; +import { ADDRCONFIG, ALL, lookup, LookupAddress, LookupOptions, V4MAPPED } from "dns"; +import { isIP, isIPv4, isIPv6, LookupFunction } from "net"; +import { promisify } from "util"; + +import { antiSSRFDnsLookup } from "../../src/Helpers/AntiSSRFDnsLookup"; +import { AntiSSRFPolicy, AntiSSRFError, PolicyConfigOptions } from "../../src"; + +/** + * Converts a callback-based lookup function to a promise-based lookup function + * for easier testing. + */ +const customPromisify = (lookup: LookupFunction) => (hostname: string, options: LookupOptions) => + new Promise((resolve, reject) => { + lookup(hostname, options, (err, address, family) => { + if (err) { + return reject(err); + } + if (family != null) { + return resolve({ address: address as string, family }); + } else { + return resolve(address as LookupAddress[]); + } + }); + }); + +const optionsToString = (options: LookupOptions | null | undefined) => { + if (!options) { + return options; + } + + return ( + "{" + + Object.entries(options) + .map(([key, value]) => { + return `${key}: ${value}`; + }) + .join(", ") + + "}" + ); +}; + +/** + * Asserts that the result of AntiSSRFDnsLookup matches the result of dns.lookup + * for the given hostname and options. + */ +const AssertMatchResult = async (policy: AntiSSRFPolicy, hostname: string, options: LookupOptions) => { + let expected; + try { + expected = await customPromisify(lookup)(hostname, options); + } catch (err) { + assert.fail( + `Expected dns.lookup to not error: hostname - ${hostname}, options - ${optionsToString(options)}, error - ${err}` + ); + } + + let actual; + try { + actual = await customPromisify(antiSSRFDnsLookup(policy))(hostname, options); + } catch (err) { + assert.fail( + `Expected AntiSSRFDnsLookup to not error: hostname - ${hostname}, options - ${optionsToString(options)}, error - ${err}` + ); + } + + // If both are arrays, compare them as sets (same elements, order doesn't matter) + if (Array.isArray(actual) && Array.isArray(expected)) { + const sortedActual = [...actual].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + const sortedExpected = [...expected].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + assert.deepStrictEqual( + sortedActual, + sortedExpected, + `Expected results to match: hostname - ${hostname}, options - ${optionsToString(options)}` + ); + } else if ( + actual && + expected && + typeof actual === "object" && + typeof expected === "object" && + "address" in actual && + "family" in actual && + "address" in expected && + "family" in expected && + (actual as any).address !== (expected as any).address && + (actual as any).family === (expected as any).family + ) { + // If families match but addresses differ, warn instead of fail + console.warn( + `Address mismatch (family match): hostname - ${hostname}, expected address - ${(expected as any).address}, actual address - ${(actual as any).address}, family - ${(actual as any).family}, options - ${optionsToString(options)}` + ); + } else { + assert.deepStrictEqual( + actual, + expected, + `Expected results to match: hostname - ${hostname}, options - ${optionsToString(options)}` + ); + } +}; + +/** + * Asserts that the error from AntiSSRFDnsLookup matches the error from + * dns.lookup for the given hostname and options. + */ +const AssertMatchError = async (policy: AntiSSRFPolicy, hostname: string, options: LookupOptions) => { + let expectedError: Error | null = null; + try { + await customPromisify(lookup)(hostname, options); + } catch (err) { + expectedError = err as Error; + } + if (!expectedError) { + assert.fail(`Expected dns.lookup to error: hostname - ${hostname}, options - ${optionsToString(options)}`); + } + + let actualError: Error | null = null; + try { + await customPromisify(antiSSRFDnsLookup(policy))(hostname, options); + } catch (err) { + actualError = err as Error; + } + if (!actualError) { + assert.fail( + `Expected AntiSSRFDnsLookup to error: hostname - ${hostname}, options - ${optionsToString(options)}` + ); + } + + assert.deepStrictEqual( + actualError, + expectedError, + `Expected errors to match: hostname - ${hostname}, options - ${optionsToString(options)}` + ); +}; + +/** + * Asserts that the error from AntiSSRFDnsLookup matches the error from + * dns.lookup for the given hostname and options OR asserts that the result of + * AntiSSRFDnsLookup matches the result of dns.lookup for the given hostname and + * options. + * + * Only used for hostnames/options that behave difference between local and + * Azure pipeline tests. + */ +const AssertMatchResultOrError = async (policy: AntiSSRFPolicy, hostname: string, options: LookupOptions) => { + let expectedResult; + let expectedError: Error | null = null; + try { + expectedResult = await customPromisify(lookup)(hostname, options); + } catch (err) { + expectedError = err as Error; + } + + let actualResult; + let actualError: Error | null = null; + try { + actualResult = await customPromisify(antiSSRFDnsLookup(policy))(hostname, options); + } catch (err) { + actualError = err as Error; + } + + if (expectedError) { + assert.deepStrictEqual( + actualError, + expectedError, + `Expected errors to match: hostname - ${hostname}, options - ${optionsToString(options)}` + ); + } else { + if (Array.isArray(actualResult) && Array.isArray(expectedResult)) { + const sortedActual = [...actualResult].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + const sortedExpected = [...expectedResult].sort((a, b) => + JSON.stringify(a).localeCompare(JSON.stringify(b)) + ); + assert.deepStrictEqual( + sortedActual, + sortedExpected, + `Expected results to match: hostname - ${hostname}, options - ${optionsToString(options)}` + ); + } else if ( + actualResult && + expectedResult && + typeof actualResult === "object" && + typeof expectedResult === "object" && + "address" in actualResult && + "family" in actualResult && + "address" in expectedResult && + "family" in expectedResult && + (actualResult as any).address !== (expectedResult as any).address && + (actualResult as any).family === (expectedResult as any).family + ) { + // If families match but addresses differ, warn instead of fail + console.warn( + `Address mismatch (family match): hostname - ${hostname}, expected address - ${(expectedResult as any).address}, actual address - ${(actualResult as any).address}, family - ${(actualResult as any).family}, options - ${optionsToString(options)}` + ); + } else { + assert.deepStrictEqual( + actualResult, + expectedResult, + `Expected results to match: hostname - ${hostname}, options - ${optionsToString(options)}` + ); + } + } +}; + +describe("AntiSSRFDnsLookup", () => { + describe("Bad inputs", () => { + it("Null hostname", async () => { + // Different behavior across versions of NodeJS + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + await AssertMatchResultOrError(policy, null as unknown as string, {}); + await AssertMatchResultOrError(policy, null as unknown as string, { all: false }); + await AssertMatchResultOrError(policy, null as unknown as string, { all: true }); + await AssertMatchResultOrError(policy, null as unknown as string, { family: 0 }); + await AssertMatchResultOrError(policy, null as unknown as string, { family: 6 }); + }); + + it("Undefined hostname", async () => { + // Different behavior across versions of NodeJS + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + await AssertMatchResultOrError(policy, undefined as unknown as string, {}); + await AssertMatchResultOrError(policy, undefined as unknown as string, { all: false }); + await AssertMatchResultOrError(policy, undefined as unknown as string, { all: true }); + await AssertMatchResultOrError(policy, undefined as unknown as string, { family: 4 }); + await AssertMatchResultOrError(policy, undefined as unknown as string, { family: 6 }); + await AssertMatchResultOrError(policy, undefined as unknown as string, { family: 0, all: true }); + }); + + it("Generally bad hostname", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + await AssertMatchError(policy, "hello", {}); + await AssertMatchError(policy, "https://google.com", { all: false }); + await AssertMatchError(policy, "https://www.google.com", null as any); + await AssertMatchError(policy, "google.com:60", { all: true }); + await AssertMatchError(policy, "google.com/path", { family: 4 }); + await AssertMatchError(policy, "google.com/search?q=hi", { family: 6 }); + await AssertMatchError(policy, "username@sup.com", { family: 0, all: true }); + await AssertMatchError(policy, "#fragment", null as any); + }); + + it("Bad options", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + const hostname = "bing.com"; + + // options.all must be true or false + await AssertMatchError(policy, hostname, { all: 1 as unknown as boolean }); + + // options.family must by 0, 4, 6, "IPv4", or "IPv6" + await AssertMatchError(policy, hostname, { family: 3 as unknown as 0 }); + await AssertMatchError(policy, hostname, { family: "IPv0" as unknown as "IPv4" }); + + // options.hints can only be specific flags + await AssertMatchError(policy, hostname, { hints: -1 }); + + // options.order can only be "verbatim", "ipv4first", or "ipv6first" + // Behavior different across environments + await AssertMatchResultOrError(policy, hostname, { order: "NotAnOrder" as unknown as "verbatim" }); + + // options.verbatim can only be true or false + await AssertMatchError(policy, hostname, { verbatim: 1 as unknown as boolean }); + }); + }); + + const hostnames = ["google.com", "bing.com", "learn.microsoft.com"]; + + it("Lookup options - order", async () => { + // order: verbatim, ipv4first, ipv6first, undefined + // checking ipv4first and ipv6first work as expected. + // only checking verbatim and undefined have the right elements. + const promisified = promisify(antiSSRFDnsLookup(new AntiSSRFPolicy(PolicyConfigOptions.None))); + + for (const hostname of hostnames) { + const addresses_ipv4first = await promisified(hostname, { all: true, order: "ipv4first" }) as LookupAddress[]; + assert.ok( + addresses_ipv4first.every((addr, i, a) => i === 0 || a[i - 1].family <= addr.family), + `Addresses should be sorted with IPv4 first, but got ${JSON.stringify(addresses_ipv4first, null, 2)}` + ); + + const addresses_ipv6first = await promisified(hostname, { all: true, order: "ipv6first" }) as LookupAddress[]; + assert.ok( + addresses_ipv6first.every((addr, i, a) => i === 0 || a[i - 1].family >= addr.family), + `Addresses should be sorted with IPv6 first, but got ${JSON.stringify(addresses_ipv6first, null, 2)}` + ); + + const addresses_verbatim = await promisified(hostname, { all: true, order: "verbatim" }) as LookupAddress[]; + const addresses_undefined = await promisified(hostname, { all: true, order: undefined as unknown as "verbatim" }) as LookupAddress[]; + + const sorted_ipv4first = addresses_ipv4first.map((addr) => `${addr.address}|${addr.family}`).sort(); + const sorted_ipv6first = addresses_ipv6first.map((addr) => `${addr.address}|${addr.family}`).sort(); + const sorted_verbatim = addresses_verbatim.map((addr) => `${addr.address}|${addr.family}`).sort(); + const sorted_undefined = addresses_undefined.map((addr) => `${addr.address}|${addr.family}`).sort(); + + assert.deepStrictEqual(sorted_ipv4first, sorted_ipv6first, `${hostname}: ipv4first and ipv6first returned different address sets`); + assert.deepStrictEqual(sorted_ipv4first, sorted_verbatim, `${hostname}: ipv4first and verbatim returned different address sets`); + assert.deepStrictEqual(sorted_ipv4first, sorted_undefined, `${hostname}: ipv4first and undefined-order returned different address sets`); + } + }); + + it("Lookup options - verbatim", async () => { + // verbatim: true, false, undefined + // only checking they all have the right elements. + const promisified = promisify(antiSSRFDnsLookup(new AntiSSRFPolicy(PolicyConfigOptions.None))); + + for (const hostname of hostnames) { + const addresses_true = await promisified(hostname, { all: true, verbatim: true }) as LookupAddress[]; + const addresses_false = await promisified(hostname, { all: true, verbatim: false }) as LookupAddress[]; + const addresses_undefined = await promisified(hostname, { all: true, verbatim: undefined as unknown as boolean }) as LookupAddress[]; + + const sorted_true = addresses_true.map((addr) => `${addr.address}|${addr.family}`).sort(); + const sorted_false = addresses_false.map((addr) => `${addr.address}|${addr.family}`).sort(); + const sorted_undefined = addresses_undefined.map((addr) => `${addr.address}|${addr.family}`).sort(); + + assert.deepStrictEqual(sorted_true, sorted_false, `${hostname}: verbatim=true and verbatim=false returned different address sets`); + assert.deepStrictEqual(sorted_true, sorted_undefined, `${hostname}: verbatim=true and verbatim=undefined returned different address sets`); + } + }); + + it("Lookup options - family", async () => { + // family: 0, 4, 6, IPv4, IPv6, undefined + // checking: + // 1) family=0 includes both families when family=4/family=6 resolve (DNS may rotate exact IPs) + // 2) family=undefined matches family=0 + // 3) family=4 and family=IPv4 match, all IPv4 addresses + // 4) family=6 and family=IPv6 match, all IPv6 addresses + const promisified = promisify(antiSSRFDnsLookup(new AntiSSRFPolicy(PolicyConfigOptions.None))); + + for (const hostname of hostnames) { + const addresses_0 = await promisified(hostname, { all: true, family: 0, hints: ALL }) as LookupAddress[]; + const addresses_4 = await promisified(hostname, { all: true, family: 4, hints: ALL }) as LookupAddress[]; + const addresses_6 = await promisified(hostname, { all: true, family: 6, hints: ALL | V4MAPPED }) as LookupAddress[]; + const addresses_ipv4 = await promisified(hostname, { + all: true, + family: "IPv4", + hints: ALL + }) as LookupAddress[]; + const addresses_ipv6 = await promisified(hostname, { + all: true, + family: "IPv6", + hints: ALL | V4MAPPED + }) as LookupAddress[]; + const addresses_undefined = await promisified(hostname, { + all: true, + family: undefined as unknown as 0, + hints: ALL + }) as LookupAddress[]; + + assert.ok( + addresses_4.every((addr) => addr.family === 4 && isIPv4(addr.address)), + `${hostname}: family=4 returned non-IPv4 address(es): ${JSON.stringify(addresses_4, null, 2)}` + ); + assert.ok( + addresses_6.every((addr) => addr.family === 6 && isIPv6(addr.address)), + `${hostname}: family=6 returned non-IPv6 address(es): ${JSON.stringify(addresses_6, null, 2)}` + ); + + const sorted_0 = addresses_0.map((addr) => `${addr.address}|${addr.family}`).sort(); + const sorted_4 = addresses_4.map((addr) => `${addr.address}|${addr.family}`).sort(); + const sorted_6 = addresses_6.map((addr) => `${addr.address}|${addr.family}`).sort(); + const sorted_ipv4 = addresses_ipv4.map((addr) => `${addr.address}|${addr.family}`).sort(); + const sorted_ipv6 = addresses_ipv6.map((addr) => `${addr.address}|${addr.family}`).sort(); + const sorted_undefined = addresses_undefined.map((addr) => `${addr.address}|${addr.family}`).sort(); + + const has_ipv4_in_0 = addresses_0.some((addr) => addr.family === 4); + const has_ipv6_in_0 = addresses_0.some((addr) => addr.family === 6); + + if (addresses_4.length > 0) { + assert.ok(has_ipv4_in_0, `${hostname}: family=0 should include IPv4 results when family=4 resolves`); + } + if (addresses_6.length > 0) { + assert.ok(has_ipv6_in_0, `${hostname}: family=0 should include IPv6 results when family=6 resolves`); + } + + assert.deepStrictEqual(sorted_0, sorted_undefined, `${hostname}: family=0 and family=undefined returned different address sets`); + assert.deepStrictEqual(sorted_4, sorted_ipv4, `${hostname}: family=4 and family=IPv4 returned different address sets`); + assert.deepStrictEqual(sorted_6, sorted_ipv6, `${hostname}: family=6 and family=IPv6 returned different address sets`); + } + }); + + it("Lookup options - all", async () => { + // all: true, false, undefined + // checking: + // 1) all=false returns a single IP string and is contained in all=true results + // 2) all=undefined returns a single IP string and is contained in all=true results + // 3) all=false and all=undefined do not need to return the same address + const promisified = promisify(antiSSRFDnsLookup(new AntiSSRFPolicy(PolicyConfigOptions.None))); + + for (const hostname of hostnames) { + const addresses_true = await promisified(hostname, { all: true }) as LookupAddress[]; + const address_false = await promisified(hostname, { all: false }) as string; + const address_undefined = await promisified(hostname, { + all: undefined as unknown as boolean + }) as string; + + assert.ok( + addresses_true.some( + (addr) => addr.address === address_false && addr.family === isIP(address_false) + ), + `${hostname}: all=false address ${address_false} (family ${isIP(address_false)}) was not found in all=true results ${JSON.stringify(addresses_true, null, 2)}` + ); + + assert.ok( + addresses_true.some( + (addr) => addr.address === address_undefined && addr.family === isIP(address_undefined) + ), + `${hostname}: all=undefined address ${address_undefined} (family ${isIP(address_undefined)}) was not found in all=true results ${JSON.stringify(addresses_true, null, 2)}` + ); + } + }); + + /** + * All addresses are allowed, so dns.lookup and AntiSSRFDnsLookup should + * always return the same result or throw the same error. + */ + describe("Lookup with accepting policy", () => { + // If all addresses are allowed, dns.lookup and AntiSSRFDnsLookup should be the same + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + + const OPT_HINTS: number[] = [ + V4MAPPED, // 2048 + ALL, // 256 + ADDRCONFIG, // 1024 + V4MAPPED | ALL, + V4MAPPED | ADDRCONFIG, + ALL | ADDRCONFIG, + V4MAPPED | ALL | ADDRCONFIG, + undefined as unknown as number + ]; + + const hostnames = ["google.com", "bing.com", "learn.microsoft.com", "azure.com", "github.com"]; + for (const hostname of hostnames) { + it(`Common domain tests - ${hostname}`, async () => { + for (const hints of OPT_HINTS) { + await AssertMatchResult(policy, hostname, { + hints + }); + } + }); + } + }); + + describe("Lookup with policy functionality", () => { + it("Default policy", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); + const promisifiedAntiSSRFLookup = customPromisify(antiSSRFDnsLookup(policy)); + + // Allowed by policy + await AssertMatchResult(policy, "google.com", { family: 4 }); + await AssertMatchResult(policy, "yAhOo.com", { family: 4, all: false }); + + // Disallowed by policy - IMDS + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("169.254.169.254", { family: 4 }), + AntiSSRFError + ); + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("0xA9.0Xfe.0xA9.0xFe", { family: 4 }), + (err: Error) => { + if (process.platform === 'win32') { + return err.message.includes("getaddrinfo ENOTFOUND 0xA9.0Xfe.0xA9.0xFe"); + } else { + return err.message === "IP address disallowed by policy"; + } + } + ); + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("169.254.169.254", { family: 6 }), + AntiSSRFError + ); + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("::FFFF:169.254.169.254", { family: 4 }), + AntiSSRFError + ); + + // Rejects with different error in local vs Azure pipeline + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("imds.michaelhendrickx.com", { family: 0, all: true }) + ); + + // Disallowed by policy - WireServer + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("168.63.129.16", { family: 4 }), + AntiSSRFError + ); + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("0xA8.0X3F.0x81.0x10", { family: 4 }), + (err: Error) => { + if (process.platform === 'win32') { + return err.message.includes("getaddrinfo ENOTFOUND 0xA8.0X3F.0x81.0x10"); + } else { + return err.message === "IP address disallowed by policy"; + } + } + ); + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("168.63.129.16", { family: 6 }), + AntiSSRFError + ); + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("::FFFF:168.63.129.16", { family: 4 }), + AntiSSRFError + ); + + // Disallowed by policy - localhost + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("localhost", { family: 4 }), + AntiSSRFError + ); + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("localhost", { family: 6 }), + AntiSSRFError + ); + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("127.0.0.1", { family: 4 }), + AntiSSRFError + ); + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("127.0.0.1", { family: 6 }), + AntiSSRFError + ); + + // Disallowed by policy - other + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("100.64.0.10", { family: 4 }), + AntiSSRFError + ); + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("100.64.0.10", { family: 6 }), + AntiSSRFError + ); + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("::FFFF:100.64.0.10", { family: 4 }), + AntiSSRFError + ); + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("::FFFF:100.64.0.10", { family: 6 }), + AntiSSRFError + ); + + // More allowed by policy + await AssertMatchResult(policy, "bing.com", { family: 4 }); + await AssertMatchResult(policy, "microsoft.com", { family: 0 }); + await AssertMatchResult(policy, "docs.github.com", { family: 0, all: true }); + await AssertMatchResult(policy, "223.6.7.8", { family: 0, all: true }); + await AssertMatchResult(policy, "::fffF:223.6.7.8", { family: 0, all: true }); + }); + + it("addAllowedAddresses", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); + const promisifiedAntiSSRFLookup = customPromisify(antiSSRFDnsLookup(policy)); + + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("169.254.0.2", { all: true }), + AntiSSRFError + ); + + policy.addAllowedAddresses(["169.254.0.2"]); + + await AssertMatchResult(policy, "169.254.0.2", null as any); + await AssertMatchResult(policy, "google.com", null as any); + }); + + it("addDeniedAddresses", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + const promisifiedAntiSSRFLookup = customPromisify(antiSSRFDnsLookup(policy)); + + await AssertMatchResult(policy, "192.168.0.0", null as any); + + policy.addDeniedAddresses(["192.168.0.0"]); + + await assert.rejects( + async () => await promisifiedAntiSSRFLookup("192.168.0.0", null as any), + AntiSSRFError + ); + await AssertMatchResult(policy, "google.com", null as any); + }); + }); +}); diff --git a/nodejs/tests/UnitTests/AntiSSRFPolicy.AddXFFHeader.test.ts b/nodejs/tests/UnitTests/AntiSSRFPolicy.AddXFFHeader.test.ts new file mode 100644 index 0000000..d92b390 --- /dev/null +++ b/nodejs/tests/UnitTests/AntiSSRFPolicy.AddXFFHeader.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "assert"; +import axios from "axios"; + +import { AntiSSRFPolicy, PolicyConfigOptions } from "../../src"; + +describe("AntiSSRFPolicy AddXFFHeader Tests", () => { + const TEST_DOMAIN = "ambitious-flower-0611c910f.2.azurestaticapps.net"; + + it("check defaults", () => { + assert.strictEqual(new AntiSSRFPolicy(PolicyConfigOptions.InternalOnly).addXFFHeader, false); + assert.strictEqual(new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1).addXFFHeader, true); + assert.strictEqual(new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest).addXFFHeader, true); + assert.strictEqual(new AntiSSRFPolicy(PolicyConfigOptions.None).addXFFHeader, false); + }); + + it("on true", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.addXFFHeader = true; + const instance = axios.create({ + httpAgent: policy.getHttpAgent(), + httpsAgent: policy.getHttpsAgent(), + validateStatus: () => true + }); + + const res = await instance.get(`https://${TEST_DOMAIN}/api/header-check?header=X-Forwarded-For`); + assert.strictEqual(res.status, 200); + assert.ok(res.data.headerValue, "Expected server to observe the X-Forwarded-For header added by policy"); + }); + + it("does not overwrite header", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.addXFFHeader = true; + const instance = axios.create({ + httpAgent: policy.getHttpAgent(), + httpsAgent: policy.getHttpsAgent(), + validateStatus: () => true + }); + + const res = await instance.get(`https://${TEST_DOMAIN}/api/header-check?header=X-Forwarded-For`, { + headers: { + "X-Forwarded-For": "1.2.3.4" + } + }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.data.headerValue.includes("1.2.3.4"), true); + }); +}); diff --git a/nodejs/tests/UnitTests/AntiSSRFPolicy.Address.test.ts b/nodejs/tests/UnitTests/AntiSSRFPolicy.Address.test.ts new file mode 100644 index 0000000..f8495e1 --- /dev/null +++ b/nodejs/tests/UnitTests/AntiSSRFPolicy.Address.test.ts @@ -0,0 +1,821 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "assert"; +import axios from "axios"; +import { promises } from "dns"; + +import { AntiSSRFError, AntiSSRFPolicy, IPAddressRanges, PolicyConfigOptions } from "../../src"; + +describe("AntiSSRFPolicy Address Tests", function () { + this.timeout(30000); + const BAD_IP_MESSAGE = "IP address disallowed by policy"; + const TEST_DOMAIN = "ambitious-flower-0611c910f.2.azurestaticapps.net"; + + let instance1: axios.AxiosInstance; + let instance2: axios.AxiosInstance; + let instance3: axios.AxiosInstance; + let instance4: axios.AxiosInstance; + + before(() => { + const policy1 = new AntiSSRFPolicy(PolicyConfigOptions.InternalOnly); + policy1.allowPlainTextHttp = true; + instance1 = axios.create({ + httpAgent: policy1.getHttpAgent({ keepAlive: false }), + httpsAgent: policy1.getHttpsAgent({ keepAlive: false }) + }); + + const policy2 = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + policy2.allowPlainTextHttp = true; + instance2 = axios.create({ + httpAgent: policy2.getHttpAgent({ keepAlive: false }), + httpsAgent: policy2.getHttpsAgent({ keepAlive: false }) + }); + + const policy3 = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); + policy3.allowPlainTextHttp = true; + instance3 = axios.create({ + httpAgent: policy3.getHttpAgent({ keepAlive: false }), + httpsAgent: policy3.getHttpsAgent({ keepAlive: false }) + }); + + const policy4 = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy4.allowPlainTextHttp = true; + instance4 = axios.create({ + httpAgent: policy4.getHttpAgent({ keepAlive: false }), + httpsAgent: policy4.getHttpsAgent({ keepAlive: false }), + timeout: 1, + signal: AbortSignal.timeout(1) + }); + }); + + it("bad inputs", () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.denyAllUnspecifiedIPs = true; + assert.throws( + () => policy.addDeniedAddresses(["1.2.3.4"]), + AntiSSRFError, + "Expected addDeniedAddresses to throw when denyAllUnspecifiedIPs is true" + ); + + // Test null arrays + const policy2 = new AntiSSRFPolicy(PolicyConfigOptions.None); + assert.throws( + () => policy2.addAllowedAddresses(null as any), + AntiSSRFError, + "Expected addAllowedAddresses(null) to throw AntiSSRFError" + ); + assert.throws( + () => policy.addAllowedAddresses([null as any]), + AntiSSRFError, + "Expected addAllowedAddresses([null]) to throw AntiSSRFError" + ); + assert.throws( + () => policy.addAllowedAddresses(undefined as any), + AntiSSRFError, + "Expected addAllowedAddresses(undefined) to throw AntiSSRFError" + ); + assert.throws( + () => policy.addAllowedAddresses([undefined as any]), + AntiSSRFError, + "Expected addAllowedAddresses([undefined]) to throw AntiSSRFError" + ); + assert.throws( + () => policy2.addDeniedAddresses(null as any), + AntiSSRFError, + "Expected addDeniedAddresses(null) to throw AntiSSRFError" + ); + assert.throws( + () => policy.addDeniedAddresses([null as any]), + AntiSSRFError, + "Expected addDeniedAddresses([null]) to throw AntiSSRFError" + ); + assert.throws( + () => policy.addDeniedAddresses(undefined as any), + AntiSSRFError, + "Expected addDeniedAddresses(undefined) to throw AntiSSRFError" + ); + assert.throws( + () => policy.addDeniedAddresses([undefined as any]), + AntiSSRFError, + "Expected addDeniedAddresses([undefined]) to throw AntiSSRFError" + ); + + // Test empty arrays + policy2.addAllowedAddresses([]); + policy2.addDeniedAddresses([]); + + // Test invalid IP address formats + assert.throws( + () => policy2.addDeniedAddresses(["invalid.ip.address"]), + AntiSSRFError, + "Expected addDeniedAddresses to throw for invalid IP format" + ); + assert.throws( + () => policy2.addDeniedAddresses(["256.256.256.256/24"]), + AntiSSRFError, + "Expected addDeniedAddresses to throw for out-of-range IPv4 values" + ); + assert.throws( + () => policy2.addDeniedAddresses(["192.168.1.1/33"]), + AntiSSRFError, + "Expected addDeniedAddresses to throw for invalid IPv4 prefix length" + ); + assert.throws( + () => policy2.addAllowedAddresses(["not-an-ip"]), + AntiSSRFError, + "Expected addAllowedAddresses to throw for invalid IP format" + ); + + // Test array containing null addresses + const policy3 = new AntiSSRFPolicy(PolicyConfigOptions.None); + assert.throws( + () => policy3.addDeniedAddresses(["192.168.1.0/24", null!, "10.0.0.0/8"]), + AntiSSRFError, + "Expected addDeniedAddresses to throw when address list contains null" + ); + assert.throws( + () => policy3.addAllowedAddresses([null!]), + AntiSSRFError, + "Expected addAllowedAddresses to throw when address list contains null" + ); + }); + + it("check defaults IMDS", async () => { + const urls = [ + "https://169.254.169.254/latest/meta-data/", + "https://0xA9.0xFE.0xA9.0xFE/latest/meta-data/", + "https://[::ffff:169.254.169.254]/latest/meta-data/", + "https://[::ffff:A9FE:A9FE]/latest/meta-data/", + `https://${TEST_DOMAIN}/api/imds-ip?code=301`, + `https://${TEST_DOMAIN}/api/imds-ip?code=302`, + `https://${TEST_DOMAIN}/api/imds?redirectNum=3` + ]; + + for (const url of urls) { + await assert.rejects( + async () => { + await instance1.get(url); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + `Expected AntiSSRFError for URL: ${url}` + ); + await assert.rejects( + async () => { + await instance2.get(url); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + `Expected AntiSSRFError for URL: ${url}` + ); + await assert.rejects( + async () => { + await instance3.get(url); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + `Expected AntiSSRFError for URL: ${url}` + ); + try { + await instance4.get(url); + } catch (err) { + assert.notEqual( + (err as Error).message, + BAD_IP_MESSAGE, + `Expected non-AntiSSRFError for URL: ${url} but got ${err}` + ); + } + } + }); + + it("check defaults wireserver", async () => { + const urls = [ + "http://168.63.129.16/", + "http://0xA8.0x3F.0x81.0x10/", + "http://[::ffff:168.63.129.16]/", + "http://[::ffff:A83F:8110]/", + `https://${TEST_DOMAIN}/api/wireserver` + ]; + + for (const url of urls) { + await assert.rejects( + async () => { + await instance1.get(url); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + `Expected AntiSSRFError for URL: ${url}` + ); + await assert.rejects( + async () => { + await instance2.get(url); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + `Expected AntiSSRFError for URL: ${url}` + ); + await assert.rejects( + async () => { + await instance3.get(url); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + `Expected AntiSSRFError for URL: ${url}` + ); + try { + await instance4.get(url); + } catch (err) { + assert.notEqual( + (err as Error).message, + BAD_IP_MESSAGE, + `Expected non-AntiSSRFError for URL: ${url} but got ${err}` + ); + } + } + }); + + it("check defaults localhost", async () => { + const urls = [ + "http://127.0.0.1/", + "http://0x7F.0x0.0x0.0x1/", + "http://[::ffff:127.0.0.1]/", + "http://[::ffff:7F00:1]/", + `https://${TEST_DOMAIN}/api/localhost`, + "http://localhost/" + ]; + + for (const url of urls) { + await assert.rejects( + async () => { + await instance1.get(url); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + `Expected AntiSSRFError for URL: ${url}` + ); + await assert.rejects( + async () => { + await instance2.get(url); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + `Expected AntiSSRFError for URL: ${url}` + ); + await assert.rejects( + async () => { + await instance3.get(url); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + `Expected AntiSSRFError for URL: ${url}` + ); + try { + await instance4.get(url); + } catch (err) { + assert.notEqual( + (err as Error).message, + BAD_IP_MESSAGE, + `Expected non-AntiSSRFError for URL: ${url} but got ${err}` + ); + } + } + }); + + it("default with IpAddressRanges", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + policy.addAllowedAddresses([ + ...IPAddressRanges.imds, + ...IPAddressRanges.wireserver, + ...IPAddressRanges.loopback + ]); + const instance = axios.create({ + httpAgent: policy.getHttpAgent({ keepAlive: false }), + httpsAgent: policy.getHttpsAgent({ keepAlive: false }), + validateStatus: () => true, + timeout: 1, + signal: AbortSignal.timeout(1) + }); + + try { + await instance.get(`http://${TEST_DOMAIN}/api/localhost`); + } catch (err) { + assert.notEqual( + (err as Error).message, + BAD_IP_MESSAGE, + `Expected non-AntiSSRFError for URL: http://${TEST_DOMAIN}/api/localhost but got ${err}` + ); + } + + try { + await instance.get(`http://${TEST_DOMAIN}/api/wireserver`); + } catch (err) { + assert.notEqual( + (err as Error).message, + BAD_IP_MESSAGE, + `Expected non-AntiSSRFError for URL: http://${TEST_DOMAIN}/api/wireserver but got ${err}` + ); + } + + try { + await instance.get(`http://${TEST_DOMAIN}/api/imds`); + } catch (err) { + assert.notEqual( + (err as Error).message, + BAD_IP_MESSAGE, + `Expected non-AntiSSRFError for URL: http://${TEST_DOMAIN}/api/imds but got ${err}` + ); + } + + try { + await instance.get("http://127.0.0.1"); + } catch (err) { + assert.notEqual( + (err as Error).message, + BAD_IP_MESSAGE, + `Expected non-AntiSSRFError for URL: http://127.0.0.1 but got ${err}` + ); + } + + try { + await instance.get("http://168.63.129.16"); + } catch (err) { + assert.notEqual( + (err as Error).message, + BAD_IP_MESSAGE, + `Expected non-AntiSSRFError for URL: http://168.63.129.16 but got ${err}` + ); + } + + try { + await instance.get("http://169.254.169.254"); + } catch (err) { + assert.notEqual( + (err as Error).message, + BAD_IP_MESSAGE, + `Expected non-AntiSSRFError for URL: http://169.254.169.254 but got ${err}` + ); + } + }); + + it("allow IPv4 addresses", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.allowPlainTextHttp = true; + policy.denyAllUnspecifiedIPs = true; + const testIpArr = await promises.lookup(TEST_DOMAIN, { family: 4, all: true }); + policy.addAllowedAddresses(testIpArr.map((ip) => ip.address)); + const instance = axios.create({ + httpAgent: policy.getHttpAgent({ keepAlive: false }), + httpsAgent: policy.getHttpsAgent({ keepAlive: false }), + validateStatus: () => true + }); + + // Allowed IPv4 - allowed by policy + await assert.doesNotReject(async () => { + await instance.get(`http://${testIpArr[0].address}`); + }); + + // Allowed IPv4-mapped IPv6 - allowed by policy but might fail on some systems due to IPv6 handling + try { + await instance.get(`http://[::ffff:${testIpArr[0].address}]:80`); + } catch (err) { + assert.notEqual((err as Error).message, BAD_IP_MESSAGE); + } + + // Disallowed IPv4 - not allowed by policy + await assert.rejects( + async () => { + await instance.get("http://1.2.3.4"); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + "Expected request to disallowed IPv4 address to be rejected" + ); + + // Disallowed IPv6 - not allowed by policy + await assert.rejects( + async () => { + await instance.get("http://[1:2:3:4:5:6:7:8]"); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + "Expected request to disallowed IPv6 address to be rejected" + ); + }); + + it("allow IPv4-mapped IPv6 addresses", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.allowPlainTextHttp = true; + policy.denyAllUnspecifiedIPs = true; + const testIpArr = await promises.lookup(TEST_DOMAIN, { family: 4, all: true }); + policy.addAllowedAddresses(testIpArr.map((ip) => `::ffff:${ip.address}`)); + const instance = axios.create({ + httpAgent: policy.getHttpAgent({ keepAlive: false }), + httpsAgent: policy.getHttpsAgent({ keepAlive: false }), + validateStatus: () => true + }); + + // Allowed IPv4 - allowed by policy + await assert.doesNotReject(async () => { + await instance.get("http://" + testIpArr[0].address); + }); + + // Allowed IPv4-mapped IPv6 - allowed by policy but might fail on some systems due to IPv6 handling + try { + await instance.get(`http://[::ffff:${testIpArr[0].address}]:80`); + } catch (err) { + assert.notEqual((err as Error).message, BAD_IP_MESSAGE); + } + + // Disallowed IPv4 - not allowed by policy + await assert.rejects( + async () => { + await instance.get("http://1.2.3.4"); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + "Expected request to disallowed IPv4 address to be rejected" + ); + + // Disallowed IPv6 - not allowed by policy + await assert.rejects( + async () => { + await instance.get("http://[1:2:3:4:5:6:7:8]"); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + "Expected request to disallowed IPv6 address to be rejected" + ); + }); + + it("allow IPv6 addresses", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.allowPlainTextHttp = true; + policy.denyAllUnspecifiedIPs = true; + const testIPv6 = "::1"; + policy.addAllowedAddresses([testIPv6]); + const instance = axios.create({ + httpAgent: policy.getHttpAgent({ keepAlive: false }), + httpsAgent: policy.getHttpsAgent({ keepAlive: false }), + validateStatus: () => true + }); + + // Allowed IPv6 - allowed by policy but might fail on some systems due to IPv6 handling + try { + await instance.get(`http://[${testIPv6}]`); + } catch (err) { + assert.notEqual((err as Error).message, BAD_IP_MESSAGE); + } + + // Disallowed IPv4 - not allowed by policy + await assert.rejects( + async () => { + await instance.get("https://1.2.3.4"); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + "Expected disallowed IPv4 request to be rejected when only IPv6 is allowed" + ); + + // Disallowed different IPv6 - not allowed by policy + await assert.rejects( + async () => { + await instance.get("https://[2606:4700:4700::1111]"); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + "Expected disallowed IPv6 request to be rejected when only ::1 is allowed" + ); + }); + + it("deny IPv4 address", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + const testIpArr = await promises.lookup(TEST_DOMAIN, { family: 4, all: true }); + policy.addDeniedAddresses(testIpArr.map((ip) => ip.address)); + const instance = axios.create({ + httpAgent: policy.getHttpAgent({ keepAlive: false }), + httpsAgent: policy.getHttpsAgent({ keepAlive: false }), + validateStatus: () => true + }); + + // Denied IPv4 - not allowed by policy + await assert.rejects( + async () => { + await instance.get(`https://${testIpArr[0].address}`); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + `Expected denied IPv4 request to be rejected: ${testIpArr[0].address}` + ); + + // Denied IPv4-mapped IPv6 - not allowed by policy + await assert.rejects( + async () => { + await instance.get(`https://[::ffff:${testIpArr[0].address}]`); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + `Expected denied IPv4-mapped IPv6 request to be rejected: ::ffff:${testIpArr[0].address}` + ); + + // Allowed different IPv4 - allowed by policy + await assert.doesNotReject(async () => { + await instance.get("https://github.com"); + }); + + // Allowed IPv6 - allowed by policy but might fail on some systems due to IPv6 handling + try { + await instance.get("https://ipv6.google.com"); + } catch (err) { + assert.notEqual((err as Error).message, BAD_IP_MESSAGE); + } + }); + + it("deny IPv4-mapped IPv6 address", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.allowPlainTextHttp = true; + const testIpArr = await promises.lookup(TEST_DOMAIN, { family: 4, all: true }); + policy.addDeniedAddresses(testIpArr.map((ip) => ip.address)); + const instance = axios.create({ + httpAgent: policy.getHttpAgent({ keepAlive: false }), + httpsAgent: policy.getHttpsAgent({ keepAlive: false }), + validateStatus: () => true + }); + + // Denied IPv4 - not allowed by policy + await assert.rejects( + async () => { + await instance.get(`http://${testIpArr[0].address}`); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + `Expected denied IPv4 request to be rejected over HTTP: ${testIpArr[0].address}` + ); + + // Denied IPv4-mapped IPv6 - not allowed by policy + await assert.rejects( + async () => { + await instance.get(`http://[::ffff:${testIpArr[0].address}]`); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + `Expected denied IPv4-mapped IPv6 request to be rejected over HTTP: ::ffff:${testIpArr[0].address}` + ); + + // Allowed different IPv4 - allowed by policy + await assert.doesNotReject(async () => { + await instance.get("https://github.com"); + }); + + // Allowed IPv6 - allowed by policy but might fail on some systems due to IPv6 handling + try { + await instance.get("https://ipv6.google.com"); + } catch (err) { + assert.notEqual((err as Error).message, BAD_IP_MESSAGE); + } + }); + + it("deny IPv6 address", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.allowPlainTextHttp = true; + const testIPv6 = "2001:4860:4860::8888"; + policy.addDeniedAddresses([testIPv6]); + const instance = axios.create({ + httpAgent: policy.getHttpAgent({ keepAlive: false }), + httpsAgent: policy.getHttpsAgent({ keepAlive: false }), + validateStatus: () => true + }); + + // Denied IPv6 - not allowed by policy + await assert.rejects( + async () => { + await instance.get(`http://[${testIPv6}]`); + }, + (err: Error) => { + return err.message === BAD_IP_MESSAGE; + }, + `Expected denied IPv6 request to be rejected: ${testIPv6}` + ); + + // Allowed IPv4 - allowed by policy + await assert.doesNotReject(async () => { + await instance.get("https://github.com"); + }); + }); + + it("both allow and deny", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + const testIps = await promises.lookup(TEST_DOMAIN, { all: true }); + policy.addDeniedAddresses(testIps.map((ip) => ip.address)); + policy.addAllowedAddresses(testIps.map((ip) => ip.address)); + const instance = axios.create({ + httpAgent: policy.getHttpAgent({ keepAlive: false }), + httpsAgent: policy.getHttpsAgent({ keepAlive: false }), + validateStatus: () => true + }); + + await assert.doesNotReject(async () => { + await instance.get(`https://${TEST_DOMAIN}`); + }); + }); + + describe("direct IPs - wireserver", () => { + // IPv4: 168.63.129.16 = 0xA83F8110, IPv6: N/A + const wireServerIPs = [ + "168.63.129.16", + + // IPv4-mapped-IPv6 + "::FFFF:168.63.129.16", + "[0:0:0:0:0:FFFF:A83F:8110]" + ]; + + const badWireserverIPs = [ + // dddd + "0xA83f8110", // hex + "2822734096", // dec + "025017700420", // oct + + // d.ddd + "0xA8.0x3F8110", // hex + "168.4161808", // dec + "0250.017700420", // oct + "168.017700420", // mixed + "0250.4161808", + + // d.d.dd + "0xA8.0x3F.0x8110", // hex + "168.63.33040", // dec + "0250.077.0100420", // oct + "168.077.33040", // mixed + "0250.0x3f.0100420", + + // d.d.d.d + "0xA8.0x3F.0x81.0x10", // hex + "0250.077.0201.020", // oct + "168.077.0x81.16", // mixed + "0250.0x3f.0201.020" + ]; + + it("defaults", () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + + for (const ip of wireServerIPs) { + assert.equal(policy._isNetworkConnectionAllowed([ip]), false); + } + + for (const ip of badWireserverIPs) { + assert.equal( + policy._isNetworkConnectionAllowed([ip]), + false, + `Expected IP ${ip} to be denied by policy as invalid IP` + ); + } + }); + + it("explicit deny", () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.addDeniedAddresses(["168.63.129.16"]); + + for (const ip of wireServerIPs) { + assert.equal(policy._isNetworkConnectionAllowed([ip]), false); + } + + for (const ip of badWireserverIPs) { + assert.equal( + policy._isNetworkConnectionAllowed([ip]), + false, + `Expected IP ${ip} to be denied by policy as invalid IP` + ); + } + }); + + it("explicit allow", () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + policy.addAllowedAddresses(["168.63.129.16"]); + + for (const ip of wireServerIPs) { + assert.equal(policy._isNetworkConnectionAllowed([ip]), true); + } + + for (const ip of badWireserverIPs) { + assert.equal( + policy._isNetworkConnectionAllowed([ip]), + false, + `Expected IP ${ip} to be denied by policy as invalid IP` + ); + } + }); + }); + + describe("direct IPs - IMDS", () => { + // IPv4: 169.254.169.254 = 0xA9FEA9FE, IPv6: N/A + + const imdsIPs = [ + "169.254.169.254", + + // IPv4-mapped-IPv6 + "::FFFF:169.254.169.254", + "[0:0:0:0:0:FFFF:A9FE:A9FE]" + ]; + + const badImdsIPs = [ + // dddd + "0xA9FEA9FE", // hex + "2852039166", // dec + "025177524776", // oct + + // d.ddd + "0xA9.0xFEA9FE", // hex + "169.16689662", // dec + "0251.077524776", // oct + "169.077524776", // mixed + "0251.16689662", + + // d.d.dd + "0xA9.0xFE.0xA9FE", // hex + "169.254.43518", // dec + "0251.0376.0124776", // oct + "169.0376.43518", // mixed + "0251.0xfe.0124776", + + // d.d.d.d + "0xA9.0xFE.0xA9.0xFE", // hex + "0251.0376.0251.0376", // oct + "169.0376.0xA9.254", // mixed + "0251.0xfe.0251.0376" + ]; + + it("defaults", () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + + for (const ip of imdsIPs) { + assert.equal(policy._isNetworkConnectionAllowed([ip]), false); + } + + for (const ip of badImdsIPs) { + assert.equal( + policy._isNetworkConnectionAllowed([ip]), + false, + `Expected IP ${ip} to be denied by policy as invalid IP` + ); + } + }); + + it("explicit deny", () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.addDeniedAddresses(["169.254.169.254"]); + + for (const ip of imdsIPs) { + assert.equal(policy._isNetworkConnectionAllowed([ip]), false); + } + + for (const ip of badImdsIPs) { + assert.equal( + policy._isNetworkConnectionAllowed([ip]), + false, + `Expected IP ${ip} to be denied by policy as invalid IP` + ); + } + }); + + it("explicit allow", () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + policy.addAllowedAddresses(["169.254.169.254"]); + + for (const ip of imdsIPs) { + assert.equal( + policy._isNetworkConnectionAllowed([ip]), + true, + `Expected IP ${ip} to be allowed by policy` + ); + } + + for (const ip of badImdsIPs) { + assert.equal( + policy._isNetworkConnectionAllowed([ip]), + false, + `Expected IP ${ip} to be denied by policy as invalid IP` + ); + } + }); + }); +}); diff --git a/nodejs/tests/UnitTests/AntiSSRFPolicy.Header.test.ts b/nodejs/tests/UnitTests/AntiSSRFPolicy.Header.test.ts new file mode 100644 index 0000000..5a1294c --- /dev/null +++ b/nodejs/tests/UnitTests/AntiSSRFPolicy.Header.test.ts @@ -0,0 +1,233 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "assert"; +import axios from "axios"; + +import { AntiSSRFError, AntiSSRFPolicy, PolicyConfigOptions } from "../../src"; + +describe("AntiSSRFPolicy Header Tests", () => { + const BAD_HEADER_MESSAGE = "Request headers or protocol disallowed by policy"; + const TEST_DOMAIN = "ambitious-flower-0611c910f.2.azurestaticapps.net"; + + it("bad inputs", () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1); + + // Invalid arrays + assert.throws( + () => policy.addDeniedHeaders(null as any), + (err: AntiSSRFError) => err.message === "Null argument", + "Expected addDeniedHeaders(null) to throw 'Null argument'" + ); + assert.throws( + () => policy.addRequiredHeaders(null as any), + (err: AntiSSRFError) => err.message === "Null argument", + "Expected addRequiredHeaders(null) to throw 'Null argument'" + ); + + // Invalid array elements + assert.throws( + () => policy.addDeniedHeaders([""]), + (err: AntiSSRFError) => err.message === "Headers cannot be an empty string", + "Expected addDeniedHeaders(['']) to throw empty-header validation error" + ); + assert.throws( + () => policy.addRequiredHeaders([""]), + (err: AntiSSRFError) => err.message === "Headers cannot be an empty string", + "Expected addRequiredHeaders(['']) to throw empty-header validation error" + ); + assert.throws( + () => policy.addDeniedHeaders(["X-Valid-Header", null as any, "Another-Header"]), + (err: AntiSSRFError) => err.message === "Headers cannot be null or undefined", + "Expected addDeniedHeaders(['X-Valid-Header', null, 'Another-Header']) to throw null-header validation error" + ); + assert.throws( + () => policy.addRequiredHeaders([null as any, "X-Test-Header"]), + (err: AntiSSRFError) => err.message === "Headers cannot be null or undefined", + "Expected addRequiredHeaders([null, 'X-Test-Header']) to throw null-header validation error" + ); + }); + + it("check defaults", () => { + assert.strictEqual(new AntiSSRFPolicy(PolicyConfigOptions.InternalOnly).requiredHeaders.length, 0); + assert.strictEqual(new AntiSSRFPolicy(PolicyConfigOptions.InternalOnly).deniedHeaders.length, 0); + assert.strictEqual(new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1).requiredHeaders.length, 0); + assert.strictEqual(new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1).deniedHeaders.length, 0); + assert.strictEqual(new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1).deniedHeaders.length, 0); + assert.strictEqual(new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest).requiredHeaders.length, 0); + assert.strictEqual(new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest).deniedHeaders.length, 0); + assert.strictEqual(new AntiSSRFPolicy(PolicyConfigOptions.None).requiredHeaders.length, 0); + assert.strictEqual(new AntiSSRFPolicy(PolicyConfigOptions.None).deniedHeaders.length, 0); + }); + + it("required header", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.addRequiredHeaders(["X-Test-Header"]); + const instance = axios.create({ + httpAgent: policy.getHttpAgent(), + httpsAgent: policy.getHttpsAgent(), + validateStatus: () => true + }); + + await assert.rejects( + async () => { + await instance.get(`https://${TEST_DOMAIN}/api/header-check?header=X-Test-Header`, { + headers: { + "Not-X-Test-Header": "test-value" + } + }); + }, + (err: Error) => err.message === BAD_HEADER_MESSAGE, + "Expected request missing required header to be rejected" + ); + + await assert.doesNotReject(async () => { + await instance.get(`https://${TEST_DOMAIN}/api/header-check?header=X-Test-Header`, { + headers: { + "X-Test-Header": "test-value" + } + }); + }); + }); + + it("denied header", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.addDeniedHeaders(["X-Test-Header"]); + const instance = axios.create({ + httpAgent: policy.getHttpAgent(), + httpsAgent: policy.getHttpsAgent(), + validateStatus: () => true + }); + + await assert.rejects( + async () => { + await instance.get(`https://${TEST_DOMAIN}/api/header-check?header=X-Test-Header`, { + headers: { + "X-Test-Header": "test-value" + } + }); + }, + (err: Error) => err.message === BAD_HEADER_MESSAGE, + "Expected request containing denied header to be rejected" + ); + + await assert.doesNotReject(async () => { + await instance.get(`https://${TEST_DOMAIN}/api/header-check?header=X-Test-Header`, { + headers: { + "Not-X-Test-Header": "test-value" + } + }); + }); + }); + + it("both required and denied", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.addRequiredHeaders(["X-Test-Header"]); + policy.addDeniedHeaders(["X-Test-Header"]); + const instance = axios.create({ + httpAgent: policy.getHttpAgent(), + httpsAgent: policy.getHttpsAgent(), + validateStatus: () => true + }); + + await assert.rejects( + async () => { + await instance.get(`https://${TEST_DOMAIN}/api/header-check?header=X-Test-Header`, { + headers: { + "X-Test-Header": "test-value" + } + }); + }, + (err: Error) => err.message === BAD_HEADER_MESSAGE, + "Expected request to be rejected when same header is both required and denied" + ); + + await assert.rejects( + async () => { + await instance.get(`https://${TEST_DOMAIN}/api/header-check?header=X-Test-Header`, { + headers: { + "Not-X-Test-Header": "test-value" + } + }); + }, + (err: Error) => err.message === BAD_HEADER_MESSAGE, + "Expected request to be rejected when required header is missing and denied header config also exists" + ); + }); + + it("with XFF header", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.addXFFHeader = true; + policy.addRequiredHeaders(["X-Forwarded-For", "X-Test-Header"]); + const instance = axios.create({ + httpAgent: policy.getHttpAgent(), + httpsAgent: policy.getHttpsAgent(), + validateStatus: () => true + }); + + await assert.doesNotReject(async () => { + await instance.get(`https://${TEST_DOMAIN}/api/header-check?header=X-Forwarded-For`, { + headers: { + "X-Test-Header": "test-value" + } + }); + }); + + const policy2 = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy2.addXFFHeader = true; + policy2.addDeniedHeaders(["X-Forwarded-For", "Not-X-Test-Header"]); + const instance2 = axios.create({ + httpAgent: policy2.getHttpAgent(), + httpsAgent: policy2.getHttpsAgent(), + validateStatus: () => true + }); + + await assert.rejects( + async () => { + await instance2.get(`https://${TEST_DOMAIN}/api/header-check?header=X-Forwarded-For`, { + headers: { + "X-Test-Header": "test-value" + } + }); + }, + (err: Error) => err.message === BAD_HEADER_MESSAGE, + "Expected request to be rejected when X-Forwarded-For is denied and addXFFHeader is enabled" + ); + }); + + it("holds on redirect", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.addRequiredHeaders(["X-Required-Header"]); + const instance = axios.create({ + httpAgent: policy.getHttpAgent(), + httpsAgent: policy.getHttpsAgent(), + validateStatus: () => true + }); + + await assert.doesNotReject(async () => { + await instance.get(`https://${TEST_DOMAIN}/api/redirect?num=3`, { + headers: { + "X-Required-Header": "test-value" + } + }); + }); + }); + + it("case insensitive headers", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.addRequiredHeaders(["X-Test-Header"]); + const instance = axios.create({ + httpAgent: policy.getHttpAgent(), + httpsAgent: policy.getHttpsAgent(), + validateStatus: () => true + }); + + await assert.doesNotReject(async () => { + await instance.get(`https://${TEST_DOMAIN}/api/header-check?header=X-Test-Header`, { + headers: { + "x-test-header": "test-value" + } + }); + }); + }); +}); diff --git a/nodejs/tests/UnitTests/AntiSSRFPolicy.Scheme.test.ts b/nodejs/tests/UnitTests/AntiSSRFPolicy.Scheme.test.ts new file mode 100644 index 0000000..9108e15 --- /dev/null +++ b/nodejs/tests/UnitTests/AntiSSRFPolicy.Scheme.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "assert"; +import axios from "axios"; + +import { AntiSSRFPolicy, PolicyConfigOptions } from "../../src"; + +describe("AntiSSRFPolicy Scheme Tests", () => { + const BAD_SCHEME_MESSAGE = "Request headers or protocol disallowed by policy"; + const TEST_DOMAIN = "ambitious-flower-0611c910f.2.azurestaticapps.net"; + + it("check defaults", () => { + assert.strictEqual(new AntiSSRFPolicy(PolicyConfigOptions.InternalOnly).allowPlainTextHttp, false); + assert.strictEqual(new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyV1).allowPlainTextHttp, false); + assert.strictEqual(new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest).allowPlainTextHttp, false); + assert.strictEqual(new AntiSSRFPolicy(PolicyConfigOptions.None).allowPlainTextHttp, false); + }); + + it("on true", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.allowPlainTextHttp = true; + const instance = axios.create({ + httpAgent: policy.getHttpAgent(), + httpsAgent: policy.getHttpsAgent(), + validateStatus: () => true + }); + + await assert.doesNotReject(async () => { + await instance.get(`http://${TEST_DOMAIN}`); + }); + + await assert.doesNotReject(async () => { + await instance.get(`https://${TEST_DOMAIN}`); + }); + }); + + it("on false", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.allowPlainTextHttp = false; + const instance = axios.create({ + httpAgent: policy.getHttpAgent(), + httpsAgent: policy.getHttpsAgent(), + validateStatus: () => true + }); + + await assert.rejects( + async () => { + await instance.get(`http://${TEST_DOMAIN}`); + }, + (err: Error) => err.message === BAD_SCHEME_MESSAGE, + "Expected HTTP request to be rejected when allowPlainTextHttp is false" + ); + + await assert.doesNotReject(async () => { + await instance.get(`https://${TEST_DOMAIN}`); + }); + }); + + it("rejects non-http schemes", async () => { + const policy = new AntiSSRFPolicy(PolicyConfigOptions.None); + policy.allowPlainTextHttp = true; + const instance = axios.create({ + httpAgent: policy.getHttpAgent(), + httpsAgent: policy.getHttpsAgent(), + validateStatus: () => true + }); + + const nonHttpSchemes = [ + "ws://example.com", + "wss://example.com", + "ftp://example.com", + "gopher://example.com", + "file:///etc/passwd", + "ldap://example.com", + "ldaps://example.com", + "mailto:test@example.com", + "tel:+1234567890", + // "data:text/plain;base64,SGVsbG8=", Axios handles data: separately + "javascript:alert('xss')", + "custom://example.com" + ]; + + for (const url of nonHttpSchemes) { + await assert.rejects(async () => { + await instance.get(url); + }, `Expected request with non-HTTP scheme to be rejected: ${url}`); + } + }); +}); diff --git a/nodejs/tests/UnitTests/CIDRBlock.test.ts b/nodejs/tests/UnitTests/CIDRBlock.test.ts new file mode 100644 index 0000000..07de7e0 --- /dev/null +++ b/nodejs/tests/UnitTests/CIDRBlock.test.ts @@ -0,0 +1,326 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "assert"; + +import { CIDRBlock } from "../../src/Helpers/CIDRBlock"; +import { AntiSSRFError } from "../../src"; +import { BlockList } from "net"; + +const toParsedAddress = (ip: string): string => CIDRBlock._parseIPAddress(ip)[0]; + +describe("CIDRBlock Tests", () => { + it("bad inputs", () => { + // Parse - null input + assert.throws( + () => CIDRBlock._parseIPAddress(null!), + (err: AntiSSRFError) => err.message === "Null argument" + ); + + // Parse - too many / + assert.throws( + () => CIDRBlock._parseIPAddress("192.168.1.0/24"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv4 address") + ); + + // Parse - invalid IP address + assert.throws( + () => CIDRBlock._parseIPAddress("256.256.256.256"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv4 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress("192.168.1.300"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv4 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress("not-an-ip"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv4 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress("999.999.999.999"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv4 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress("[127.0.0.1]"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv4 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress("127.0.0.01"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv4 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress(""), + (err: AntiSSRFError) => err.message.includes("Invalid IPv4 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress(" 127.0.0.1"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv4 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress("127.0.0.1 "), + (err: AntiSSRFError) => err.message.includes("Invalid IPv4 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress("127.0.0"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv4 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress("127.0.0.1.5"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv4 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress("127.-1.0.1"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv4 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress("127.0.0.+1"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv4 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress("01.2.3.4"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv4 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress("gggg::1"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv6 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress("2001:::1"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv6 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress("2001::db8::1"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv6 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress("2001:db8:1:2:3:4:5:6:7"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv6 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress("2001:db8::g1"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv6 address") + ); + assert.throws( + () => CIDRBlock._parseIPAddress("::ffff:192.168.1.999"), + (err: AntiSSRFError) => err.message.includes("Invalid IPv6 address") + ); + + // Parse - null input + assert.throws( + () => CIDRBlock._parseCIDR(null!), + (err: AntiSSRFError) => err.message === "Null argument" + ); + + // Parse - too many / + assert.throws( + () => CIDRBlock._parseCIDR("192.168.1.0/24/16"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("10.0.0.0/8/"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("2001:db8::/32/64"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + + // Parse - invalid IP address + assert.throws( + () => CIDRBlock._parseCIDR("256.256.256.256/24"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("192.168.1.300/24"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("not-an-ip/24"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("999.999.999.999"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("gggg::1/64"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + + // Parse - invalid prefix format + assert.throws( + () => CIDRBlock._parseCIDR("192.168.1.0/abc"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("192.168.1.0/24.5"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("192.168.1.0/"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("/24"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("192.168.1.0/+24"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("127.0.0.0/024"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + + // Parse - invalid prefix length + assert.throws( + () => CIDRBlock._parseCIDR("192.168.1.0/33"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("192.168.1.0/-1"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("192.168.1.0/255"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("2001:db8::/129"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("2001:db8::/-5"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + }); + + it("contains ipv4 returns expected result", () => { + // Standard decimal format + const block1 = CIDRBlock._parseCIDR("192.168.1.0/24"); + const denyList1 = new BlockList(); + denyList1.addSubnet(block1.getAddress(), block1.getPrefix(), "ipv6"); + assert.equal(denyList1.check(toParsedAddress("192.168.1.1"), "ipv6"), true); + assert.equal(denyList1.check(toParsedAddress("::ffff:192.168.1.255"), "ipv6"), true); + assert.equal(denyList1.check(toParsedAddress("192.168.2.1"), "ipv6"), false); + + // Test without prefix length (defaults to /32) + const block2 = CIDRBlock._parseCIDR("127.0.0.1"); + const denyList2 = new BlockList(); + denyList2.addSubnet(block2.getAddress(), block2.getPrefix(), "ipv6"); + assert.equal(denyList2.check(toParsedAddress("127.0.0.1"), "ipv6"), true); + assert.equal(denyList2.check(toParsedAddress("::ffff:127.0.0.2"), "ipv6"), false); + + // Node.js parsing is strict dotted-quad for IPv4, so other C# formats are invalid. + assert.throws( + () => CIDRBlock._parseCIDR("0300.0250.001.000/24"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("0xC0.0xA8.0x1.0x0/24"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("192.0250.1.0x0/24"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("192.168.256/24"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("192.11010304/24"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("3232235776/24"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("0xC0A80101"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + assert.throws( + () => CIDRBlock._parseCIDR("192.168.257"), + (err: AntiSSRFError) => err.message.includes("Invalid CIDR block") + ); + }); + + it("contains ipv6 returns expected result", () => { + // Standard full format + const block1 = CIDRBlock._parseCIDR("2001:0db8:0000:0000:0000:0000:0000:0000/32"); + const denyList1 = new BlockList(); + denyList1.addSubnet(block1.getAddress(), block1.getPrefix(), "ipv6"); + assert.equal(denyList1.check(toParsedAddress("2001:db8::1"), "ipv6"), true); + assert.equal(denyList1.check(toParsedAddress("2001:db8:ffff::1"), "ipv6"), true); + assert.equal(denyList1.check(toParsedAddress("2001:db9::1"), "ipv6"), false); + + // Leading compression + const block3 = CIDRBlock._parseCIDR("::1/128"); + const denyList3 = new BlockList(); + denyList3.addSubnet(block3.getAddress(), block3.getPrefix(), "ipv6"); + assert.equal(denyList3.check(toParsedAddress("::1"), "ipv6"), true); + assert.equal(denyList3.check(toParsedAddress("::2"), "ipv6"), false); + + // Trailing compression + const block4 = CIDRBlock._parseCIDR("2001:db8:1::/48"); + const denyList4 = new BlockList(); + denyList4.addSubnet(block4.getAddress(), block4.getPrefix(), "ipv6"); + assert.equal(denyList4.check(toParsedAddress("2001:db8:1::1"), "ipv6"), true); + assert.equal(denyList4.check(toParsedAddress("2001:db8:1:ffff::1"), "ipv6"), true); + assert.equal(denyList4.check(toParsedAddress("2001:db8:2::1"), "ipv6"), false); + + // Middle compression + const block5 = CIDRBlock._parseCIDR("2001:db8::1:0:0:1/64"); + const denyList5 = new BlockList(); + denyList5.addSubnet(block5.getAddress(), block5.getPrefix(), "ipv6"); + assert.equal(denyList5.check(toParsedAddress("2001:db8::1"), "ipv6"), true); + assert.equal(denyList5.check(toParsedAddress("2001:db8:0:0:ffff::"), "ipv6"), true); + assert.equal(denyList5.check(toParsedAddress("2001:db9::1"), "ipv6"), false); + + // ::<ipv4> format + const block6 = CIDRBlock._parseCIDR("::ffff:192.168.1.0/120"); + const denyList6 = new BlockList(); + denyList6.addSubnet(block6.getAddress(), block6.getPrefix(), "ipv6"); + assert.equal(denyList6.check(toParsedAddress("192.168.1.1"), "ipv6"), true); + assert.equal(denyList6.check(toParsedAddress("::ffff:192.168.1.255"), "ipv6"), true); + assert.equal(denyList6.check(toParsedAddress("::ffff:192.168.2.1"), "ipv6"), false); + + // Mixed case hex digits + const block8 = CIDRBlock._parseCIDR("2001:DB8:aBCD:Ef01::/64"); + const denyList8 = new BlockList(); + denyList8.addSubnet(block8.getAddress(), block8.getPrefix(), "ipv6"); + assert.equal(denyList8.check(toParsedAddress("2001:db8:abcd:ef01::1"), "ipv6"), true); + assert.equal(denyList8.check(toParsedAddress("2001:db8:abcd:ef02::1"), "ipv6"), false); + + // Test without prefix length (defaults to /128) + const block9 = CIDRBlock._parseCIDR("2001:db8::1"); + const denyList9 = new BlockList(); + denyList9.addSubnet(block9.getAddress(), block9.getPrefix(), "ipv6"); + assert.equal(denyList9.check(toParsedAddress("2001:db8::1"), "ipv6"), true); + assert.equal(denyList9.check(toParsedAddress("2001:db8::2"), "ipv6"), false); + + const block10 = CIDRBlock._parseCIDR("::1"); + const denyList10 = new BlockList(); + denyList10.addSubnet(block10.getAddress(), block10.getPrefix(), "ipv6"); + assert.equal(denyList10.check(toParsedAddress("::1"), "ipv6"), true); + assert.equal(denyList10.check(toParsedAddress("::2"), "ipv6"), false); + + const block11 = CIDRBlock._parseCIDR("::ffff:192.168.1.1"); + const denyList11 = new BlockList(); + denyList11.addSubnet(block11.getAddress(), block11.getPrefix(), "ipv6"); + assert.equal(denyList11.check(toParsedAddress("192.168.1.1"), "ipv6"), true); + assert.equal(denyList11.check(toParsedAddress("::ffff:192.168.1.2"), "ipv6"), false); + }); + + it("contains ipv6 with scope returns expected result", () => { + // Scoped addresses should have their scope stripped and still match if the address is in the block. + const block1 = CIDRBlock._parseCIDR("fe80::/10"); + const denyList1 = new BlockList(); + denyList1.addSubnet(block1.getAddress(), block1.getPrefix(), "ipv6"); + assert.equal(denyList1.check(toParsedAddress("fe80::1%eth0"), "ipv6"), true); + assert.equal(denyList1.check(toParsedAddress("fe80::1%1"), "ipv6"), true); + assert.equal(denyList1.check(toParsedAddress("2001:db8::1%eth0"), "ipv6"), false); + }); +}); diff --git a/nodejs/tests/UnitTests/InAzureKeyVaultDomain.test.ts b/nodejs/tests/UnitTests/InAzureKeyVaultDomain.test.ts new file mode 100644 index 0000000..82f3b2e --- /dev/null +++ b/nodejs/tests/UnitTests/InAzureKeyVaultDomain.test.ts @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "assert"; + +import { URIValidator } from "../../src"; + +describe("InAzureKeyVaultDomain Tests", () => { + it("should return false for null and empty inputs", () => { + assert.strictEqual(URIValidator.inAzureKeyVaultDomain(null as unknown as string), false); + assert.strictEqual(URIValidator.inAzureKeyVaultDomain(null as unknown as URL), false); + assert.strictEqual(URIValidator.inAzureKeyVaultDomain(""), false); + }); + + it("accepts URLs in key vault domains", () => { + const urls = [ + "https://contoso.vault.azure.net/", + "https://fabrikam42.managedhsm.azure.net", + "https://corp-hsm.vault.azure.cn", + "https://devkeys99.managedhsm.azure.cn/", + "https://govvault1.vault.usgovcloudapi.net", + "https://securehsm.managedhsm.usgovcloudapi.net/" + ]; + + for (const url of urls) { + assert.strictEqual( + URIValidator.inAzureKeyVaultDomain(url), + true, + `Expected true for ${url}` + ); + assert.strictEqual( + URIValidator.inAzureKeyVaultDomain(new URL(url)), + true, + `Expected true for URL object: ${url}` + ); + } + }); + + it("supports private link key vault domains", () => { + const urls = [ + "https://contoso.privatelink.vault.azure.net", + "https://fabrikam42.privatelink.managedhsm.azure.net", + "https://corp-hsm.privatelink.vault.azure.cn", + "https://devkeys99.privatelink.managedhsm.azure.cn", + "https://govvault1.privatelink.vault.usgovcloudapi.net", + "https://securehsm.privatelink.managedhsm.usgovcloudapi.net" + ]; + + for (const url of urls) { + assert.strictEqual( + URIValidator.inAzureKeyVaultDomain(url), + true, + `Expected true for ${url}` + ); + assert.strictEqual( + URIValidator.inAzureKeyVaultDomain(new URL(url)), + true, + `Expected true for URL object: ${url}` + ); + } + }); + + it("rejects URLs not in key vault domains", () => { + const urls = [ + "https://my--vault.vault.azure.net", + "https://contoso.managedhsm.azure.net.evil.com", + "https://fabrikam.vault.azure.net.attacker.org", + "https://corp.vault.azuree.net", + "https://devkeys.vault.azure.nett", + "https://contoso.vault.azure.netmalicious", + "https://contoso.managedhsm.azure.usgovcloudapi.net", + "https://contoso.azure.vault.net", + "https://contoso.vault.azure.cn.fake", + "https://securehsm.managedhsm.usgovcloudapi.net.phishing", + "https://corp.managedhsm.azure.cnn", + "https://contoso.vaultazure.net", + "https://contoso.vault.azure" + ]; + + for (const url of urls) { + assert.strictEqual( + URIValidator.inAzureKeyVaultDomain(url), + false, + `Expected false for ${url}` + ); + assert.strictEqual( + URIValidator.inAzureKeyVaultDomain(new URL(url)), + false, + `Expected false for URL object: ${url}` + ); + } + }); + + it("correctly parses various URL components", () => { + const testCases: Array<[string, boolean]> = [ + ["http://accountname.vault.azure.net/some/path", true], + ["http://accountname.vault.azure.net#fragment", true], + ["http://accountname.vault.azure.net/?query=hi", true], + ["http://accountname.vault.azure.net:45", true], + ["https://username@accountname.vault.azure.net", true], + ["https://username:password@accountname.vault.azure.net", true], + ["https:accountname.vault.azure.net", true], // NodeJS parses protocols different + ["http:/accountname.vault.azure.net", true], // NodeJS parses protocols different + ["http:/\\accountname.vault.azure.net", true], + ["http:\\/accountname.vault.azure.net", true], + ["http://accountname.vault.azure.net:badPort", false], + ["http://:accountname.vault.azure.net", false] + ]; + + for (const [url, expectedResult] of testCases) { + assert.strictEqual( + URIValidator.inAzureKeyVaultDomain(url), + expectedResult, + `Expected ${expectedResult} for ${url}` + ); + + // Only test URL overload for valid URI formats + try { + const parsedUrl = new URL(url); + assert.strictEqual( + URIValidator.inAzureKeyVaultDomain(parsedUrl), + expectedResult, + `Expected ${expectedResult} for URL object: ${url}` + ); + } catch { + // Ignore exceptions for invalid URI formats + } + } + }); + + it("correctly rejects unicode", () => { + const urls = [ + "http://ñame.vault.azure.net/", + "https://contøso.managedhsm.azure.net", + "https://fabrikäm.vault.azure.cn/", + "https://corp.vàult.azure.net", + "https://devkeys.vault.àzure.net", + "https://contoso.vault.azure.cñ", + "https://сontoso.vault.usgovcloudapi.net", + "https://myapp.mànagedhsm.azure.cn", + "http://evil.c℁.vault.azure.net", + "https://データ.vault.usgovcloudapi.net", + "https://файлы.managedhsm.usgovcloudapi.net" + ]; + + for (const url of urls) { + assert.strictEqual( + URIValidator.inAzureKeyVaultDomain(url), + false, + `Expected false for ${url}` + ); + + // Test URL overload if the string can be parsed as a URL + try { + const parsedUrl = new URL(url); + assert.strictEqual( + URIValidator.inAzureKeyVaultDomain(parsedUrl), + false, + `Expected false for URL object: ${url}` + ); + } catch { + // Ignore exceptions for invalid URI formats + } + } + }); + + it("correctly handles upper/lowercase", () => { + const urls = [ + "http://CONTOSO.vault.azure.net", + "https://fabrikam42.VAULT.azure.net", + "https://corp-hsm.vault.AZURE.net", + "https://DEVKEYS99.managedhsm.azure.cn/", + "https://govvault1.MANAGEDHSM.azure.cn", + "https://SECUREHSM.vault.USGOVCLOUDAPI.net", + "https://contoso.managedhsm.USGOVCLOUDAPI.NET", + "HTTPS://fabrikam42.vault.azure.net", + "hTtPs://corp-hsm.managedhsm.azure.net", + "https://CONTOSO.VAULT.AZURE.NET", + "HtTpS://devkeys99.vault.azure.cn", + "https://GovVault1.Vault.Azure.Net" + ]; + + for (const url of urls) { + assert.strictEqual( + URIValidator.inAzureKeyVaultDomain(url), + true, + `Expected true for ${url}` + ); + assert.strictEqual( + URIValidator.inAzureKeyVaultDomain(new URL(url)), + true, + `Expected true for URL object: ${url}` + ); + } + }); + + it("correctly enforces allowed protocols", () => { + const testCases: Array<[string, boolean]> = [ + ["http://accountname.vault.azure.net", true], + ["https://accountname.vault.azure.net", true], + ["ws://accountname.vault.azure.net", false], + ["wss://accountname.vault.azure.net", false], + ["ftp://accountname.vault.azure.net", false], + ["file://accountname.vault.azure.net", false], + ["gopher://accountname.vault.azure.net", false], + ["mailto:accountname.vault.azure.net", false], + ["data://accountname.vault.azure.net", false], + ["javascript:alert('XSS')", false], + ["evil.com://accountname.vault.azure.net", false] + ]; + + for (const [url, expectedResult] of testCases) { + assert.strictEqual( + URIValidator.inAzureKeyVaultDomain(url), + expectedResult, + `Expected ${expectedResult} for ${url}` + ); + + // Only test URL overload for schemes that can create valid URLs + try { + const parsedUrl = new URL(url); + assert.strictEqual( + URIValidator.inAzureKeyVaultDomain(parsedUrl), + expectedResult, + `Expected ${expectedResult} for URL object: ${url}` + ); + } catch { + // Ignore exceptions from invalid URI formats + } + } + }); +}); diff --git a/nodejs/tests/UnitTests/InAzureStorageDomain.test.ts b/nodejs/tests/UnitTests/InAzureStorageDomain.test.ts new file mode 100644 index 0000000..72566e9 --- /dev/null +++ b/nodejs/tests/UnitTests/InAzureStorageDomain.test.ts @@ -0,0 +1,299 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "assert"; + +import { URIValidator } from "../../src"; + +describe("InAzureStorageDomain Tests", () => { + it("should return false for null and empty inputs", () => { + assert.strictEqual(URIValidator.inAzureStorageDomain(null!), false); + assert.strictEqual(URIValidator.inAzureStorageDomain(""), false); + }); + + it("accepts URLs in storage domains", () => { + const urls = [ + "https://myapp.blob.core.windows.net", + "https://frontend3.web.core.windows.net", + "https://data-lake.dfs.core.windows.net", + "https://files123.file.core.windows.net", + "https://queue-svc.queue.core.windows.net", + "https://tables01.table.core.windows.net", + "https://secure.blob.storage.azure.net", + "https://internal9.web.storage.azure.net", + "https://private-ep.dfs.storage.azure.net", + "https://corp-files.file.storage.azure.net", + "https://company2.queue.storage.azure.net", + "https://enterprise.table.storage.azure.net", + "https://gov-data.blob.core.usgovcloudapi.net", + "https://portal456.web.core.usgovcloudapi.net", + "https://analytics.dfs.core.usgovcloudapi.net", + "https://docs-gov.file.core.usgovcloudapi.net", + "https://notify123.queue.core.usgovcloudapi.net", + "https://records.table.core.usgovcloudapi.net", + "https://china-app.blob.core.chinacloudapi.cn", + "https://website7.web.core.chinacloudapi.cn", + "https://bigdata99.dfs.core.chinacloudapi.cn", + "https://storage.file.core.chinacloudapi.cn", + "https://events-cn.queue.core.chinacloudapi.cn", + "https://metadata2.table.core.chinacloudapi.cn" + ]; + + for (const url of urls) { + assert.strictEqual( + URIValidator.inAzureStorageDomain(url), + true, + `Expected true for ${url}` + ); + assert.strictEqual( + URIValidator.inAzureStorageDomain(new URL(url)), + true, + `Expected true for URL object: ${url}` + ); + } + }); + + it("supports secondary storage endpoints", () => { + const urls = [ + "https://myapp-secondary.blob.storage.azure.net", + "https://website-secondary.web.core.windows.net", + "https://files5-secondary.dfs.core.usgovcloudapi.net", + "https://messages-secondary.queue.core.chinacloudapi.cn", + "https://corp99-secondary.table.storage.azure.net", + "https://backup-secondary.file.core.windows.net" + ]; + + for (const url of urls) { + assert.strictEqual( + URIValidator.inAzureStorageDomain(url), + true, + `Expected true for ${url}` + ); + assert.strictEqual( + URIValidator.inAzureStorageDomain(new URL(url)), + true, + `Expected true for URL object: ${url}` + ); + } + }); + + it("supports private link storage domains", () => { + const urls = [ + "https://acct.privatelink.blob.storage.azure.net", + "https://web12.privatelink.web.core.windows.net", + "https://data.privatelink.dfs.storage.azure.net", + "https://files99.privatelink.file.core.usgovcloudapi.net", + "https://queue.privatelink.queue.core.chinacloudapi.cn", + "https://tables5-secondary.privatelink.table.storage.azure.net" + ]; + + for (const url of urls) { + assert.strictEqual( + URIValidator.inAzureStorageDomain(url), + true, + `Expected true for ${url}` + ); + assert.strictEqual( + URIValidator.inAzureStorageDomain(new URL(url)), + true, + `Expected true for URL object: ${url}` + ); + } + }); + + it("supports static sites and DNS zones", () => { + const urls = [ + "https://contosostaticsite.z22.web.core.windows.net", + "https://webapp5.z03.web.storage.azure.net", + "https://frontend.z45.blob.core.usgovcloudapi.net", + "https://portal99.z01.web.core.chinacloudapi.cn", + "https://static-site.privatelink.z88.dfs.storage.azure.net", + "https://demo-secondary.z0.web.core.windows.net" + ]; + + for (const url of urls) { + assert.strictEqual( + URIValidator.inAzureStorageDomain(url), + true, + `Expected true for ${url}` + ); + assert.strictEqual( + URIValidator.inAzureStorageDomain(new URL(url)), + true, + `Expected true for URL object: ${url}` + ); + } + }); + + it("rejects URLs not in storage domains", () => { + const urls = [ + "https://my--app.blob.core.windows.net", + "https://data--lake.dfs.storage.azure.net", + "https://web--site.web.core.usgovcloudapi.net", + "https://myapp.blob.core.windwos.net", + "https://storage.table.core.windoes.net", + "https://files.dfs.core.chinacloudapi.com", + "https://queue.queue.stoarge.azure.net", + "https://myapp.database.core.windows.net", + "https://storage.cache.storage.azure.net", + "https://files.storage.core.usgovcloudapi.net", + "https://myapp.core.blob.windows.net", + "https://storage.azure.storage.net", + "https://files.windows.core.net", + "https://myapp.blob.core.blob.windows.net", + "https://storage.web.core.windows.net.storage.azure.net", + "https://myapp.blob.core.windows.net.evil.com", + "https://storage.table.core.windows.netmalicious", + "https://files.dfs.storage.azure.net.attacker.org", + "https://queue.queue.core.chinacloudapi.cnbad", + "https://secure.web.storage.azure.netphishing", + "https://corp.file.core.usgovcloudapi.net.fake" + ]; + + for (const url of urls) { + assert.strictEqual( + URIValidator.inAzureStorageDomain(url), + false, + `Expected false for ${url}` + ); + assert.strictEqual( + URIValidator.inAzureStorageDomain(new URL(url)), + false, + `Expected false for URL object: ${url}` + ); + } + }); + + it("correctly parses various URL components", () => { + const testCases: Array<[string, boolean]> = [ + ["http://accountname.blob.core.windows.net/some/path", true], + ["http://accountname.blob.core.windows.net#fragment", true], + ["http://accountname.blob.core.windows.net/?query=hi", true], + ["http://accountname.blob.core.windows.net:45", true], + ["https://username@accountname.blob.core.windows.net", true], + ["https://username:password@accountname.blob.core.windows.net", true], + ["https:accountname.blob.core.windows.net", true], // NodeJS parses protocols different + ["http:/accountname.blob.core.windows.net", true], // NodeJS parses protocols different + ["http:/\\accountname.blob.core.windows.net", true], + ["http:\\/accountname.blob.core.windows.net", true], + ["http://accountname.blob.core.windows.net:badPort", false], + ["http://:accountname.blob.core.windows.net", false] + ]; + + for (const [url, expectedResult] of testCases) { + assert.strictEqual( + URIValidator.inAzureStorageDomain(url), + expectedResult, + `Expected ${expectedResult} for ${url}` + ); + + // Only test URL overload for valid URI formats + try { + const parsedUrl = new URL(url); + assert.strictEqual( + URIValidator.inAzureStorageDomain(parsedUrl), + expectedResult, + `Expected ${expectedResult} for URL object: ${url}` + ); + } catch { + // Ignore exceptions for invalid URI formats + } + } + }); + + it("correctly rejects unicode", () => { + const urls = [ + "http://ñame.blob.core.windows.net/", + "http://name.blob.core.wiñdows.net/", + "http://evil.c℁.blob.core.windows.net", + "https://tëst.web.storage.azure.net", + "https://app.blob.core.windöws.net", + "https://файлы.file.core.chinacloudapi.cn", + "https://データ.dfs.core.usgovcloudapi.net", + "https://myapp.bløb.core.windows.net" + ]; + + for (const url of urls) { + assert.strictEqual( + URIValidator.inAzureStorageDomain(url), + false, + `Expected false for ${url}` + ); + + // Test URL overload if the string can be parsed as a URL + try { + const parsedUrl = new URL(url); + assert.strictEqual( + URIValidator.inAzureStorageDomain(parsedUrl), + false, + `Expected false for URL object: ${url}` + ); + } catch { + // Ignore exceptions for invalid URI formats + } + } + }); + + it("correctly handles upper/lowercase", () => { + const urls = [ + "http://ACCOUNTNAME.blob.core.windows.net", + "http://accountname.BLOB.core.windows.net", + "http://ACCOUNTNAME.BLOB.CORE.WINDOWS.NET", + "hTtP://test.blob.core.windows.net/", + "HTTPS://myapp.WEB.storage.azure.net", + "https://DATA.dfs.STORAGE.AZURE.NET", + "HtTpS://files.FILE.core.usgovcloudapi.net", + "http://QUEUE.queue.CORE.chinacloudapi.cn", + "https://TABLES.table.core.WINDOWS.net" + ]; + + for (const url of urls) { + assert.strictEqual( + URIValidator.inAzureStorageDomain(url), + true, + `Expected true for ${url}` + ); + assert.strictEqual( + URIValidator.inAzureStorageDomain(new URL(url)), + true, + `Expected true for URL object: ${url}` + ); + } + }); + + it("correctly enforces allowed protocols", () => { + const testCases: Array<[string, boolean]> = [ + ["http://accountname.blob.core.windows.net", true], + ["https://accountname.blob.core.windows.net", true], + ["ws://accountname.blob.core.windows.net", false], + ["wss://accountname.blob.core.windows.net", false], + ["ftp://accountname.blob.core.windows.net", false], + ["file://accountname.blob.core.windows.net", false], + ["gopher://accountname.blob.core.windows.net", false], + ["mailto:accountname.blob.core.windows.net", false], + ["data://accountname.blob.core.windows.net", false], + ["javascript:alert('XSS')", false], + ["evil.com://accountname.blob.core.windows.net", false] + ]; + + for (const [url, expectedResult] of testCases) { + assert.strictEqual( + URIValidator.inAzureStorageDomain(url), + expectedResult, + `Expected ${expectedResult} for ${url}` + ); + + // Only test URL overload for schemes that can create valid URLs + try { + const parsedUrl = new URL(url); + assert.strictEqual( + URIValidator.inAzureStorageDomain(parsedUrl), + expectedResult, + `Expected ${expectedResult} for URL object: ${url}` + ); + } catch { + // Ignore exceptions from invalid URI formats + } + } + }); +}); diff --git a/nodejs/tests/UnitTests/InDomain.test.ts b/nodejs/tests/UnitTests/InDomain.test.ts new file mode 100644 index 0000000..73fc2b9 --- /dev/null +++ b/nodejs/tests/UnitTests/InDomain.test.ts @@ -0,0 +1,383 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "assert"; + +import { URIValidator } from "../../src"; + +describe("InDomain Tests", () => { + it("should return false for null and empty inputs", () => { + assert.strictEqual(URIValidator.inDomain(null as unknown as string, "bing.com"), false); + assert.strictEqual(URIValidator.inDomain(null as unknown as URL, "bing.com"), false); + assert.strictEqual(URIValidator.inDomain(null as unknown as string, ["bing.com"]), false); + assert.strictEqual(URIValidator.inDomain(null as unknown as URL, ["bing.com"]), false); + + assert.strictEqual(URIValidator.inDomain("http://bing.com", null as unknown as string), false); + assert.strictEqual(URIValidator.inDomain("http://bing.com", ""), false); + assert.strictEqual(URIValidator.inDomain(new URL("http://bing.com"), null as unknown as string), false); + assert.strictEqual(URIValidator.inDomain(new URL("http://bing.com"), ""), false); + + assert.strictEqual(URIValidator.inDomain("http://bing.com", null as unknown as string[]), false); + assert.strictEqual(URIValidator.inDomain("http://bing.com", []), false); + assert.strictEqual(URIValidator.inDomain(new URL("http://bing.com"), null as unknown as string[]), false); + assert.strictEqual(URIValidator.inDomain(new URL("http://bing.com"), []), false); + }); + + it("accepts URLs in trusted single domains", () => { + const testCases: Array<[string, string]> = [ + ["http://office.com", "office.com"], + ["https://office.com", "office.com"], + ["https://azure.com", ".azure.com"], + ["https://subdomain.microsoft.com", "microsoft.com"], + ["https://subdomain.microsoft.com", ".microsoft.com"] + ]; + + for (const [url, trustedDomain] of testCases) { + assert.strictEqual( + URIValidator.inDomain(url, trustedDomain), + true, + `Expected true for ${url} with domain ${trustedDomain}` + ); + assert.strictEqual( + URIValidator.inDomain(new URL(url), trustedDomain), + true, + `Expected true for URL object ${url} with domain ${trustedDomain}` + ); + } + }); + + it("accepts URLs in trusted domain arrays", () => { + const testCases: Array<[string, string[]]> = [ + ["https://subdomain.one.com", ["one.com", ".two.com"]], + ["http://subdomain.two.com", ["one.com", ".two.com"]], + ["https://one.com", ["one.com", ".two.com"]], + ["https://two.net", ["one.com", ".two.net"]] + ]; + + for (const [url, trustedDomains] of testCases) { + assert.strictEqual( + URIValidator.inDomain(url, trustedDomains), + true, + `Expected true for ${url} with domains ${trustedDomains.join(", ")}` + ); + assert.strictEqual( + URIValidator.inDomain(new URL(url), trustedDomains), + true, + `Expected true for URL object ${url} with domains ${trustedDomains.join(", ")}` + ); + } + }); + + it("rejects URLs not in trusted single domains", () => { + const testCases: Array<[string, string]> = [ + ["http://azure.com", "office.com"], + ["https://office.com", "subdomain.office.com"], + ["https://azure.com", ".office.com"], + ["https://subdomain.microsoft.com", "differentsubdomain.microsoft.com"] + ]; + + for (const [url, trustedDomain] of testCases) { + assert.strictEqual( + URIValidator.inDomain(url, trustedDomain), + false, + `Expected false for ${url} with domain ${trustedDomain}` + ); + assert.strictEqual( + URIValidator.inDomain(new URL(url), trustedDomain), + false, + `Expected false for URL object ${url} with domain ${trustedDomain}` + ); + } + }); + + it("rejects URLs not in trusted domain arrays", () => { + const testCases: Array<[string, string[]]> = [ + ["http://azure.com", ["one.com", "office.com"]], + ["https://office.com", ["subdomain.office.com"]], + ["https://azure.com", [".office.com", "two.com"]], + ["https://subdomain.microsoft.com", ["differentsubdomain.microsoft.com"]] + ]; + + for (const [url, trustedDomains] of testCases) { + assert.strictEqual( + URIValidator.inDomain(url, trustedDomains), + false, + `Expected false for ${url} with domains ${trustedDomains.join(", ")}` + ); + assert.strictEqual( + URIValidator.inDomain(new URL(url), trustedDomains), + false, + `Expected false for URL object ${url} with domains ${trustedDomains.join(", ")}` + ); + } + }); + + it("correctly parses various URL components", () => { + const urls = [ + "http://username@bing.com:/", + "http://username:password@bing.com", + "http://bing.com:45", + "http://bing.com/some/path", + "http://bing.com#fragment", + "http://bing.com/?query=hi", + "http:/\\bing.com", + "http:\\/bing.com" + ]; + + for (const url of urls) { + assert.strictEqual( + URIValidator.inDomain(url, "bing.com"), + true, + `Expected true for ${url} with single domain` + ); + assert.strictEqual( + URIValidator.inDomain(url, ["bing.com"]), + true, + `Expected true for ${url} with domain array` + ); + assert.strictEqual( + URIValidator.inDomain(new URL(url), "bing.com"), + true, + `Expected true for URL object ${url} with single domain` + ); + assert.strictEqual( + URIValidator.inDomain(new URL(url), ["bing.com"]), + true, + `Expected true for URL object ${url} with domain array` + ); + } + }); + + it("correctly rejects invalid URL components", () => { + const urls = [ + "http://bing.com:badPort", + "http://:bing.com" + ]; + + for (const url of urls) { + assert.strictEqual( + URIValidator.inDomain(url, "bing.com"), + false, + `Expected false for ${url} with single domain` + ); + assert.strictEqual( + URIValidator.inDomain(url, ["bing.com"]), + false, + `Expected false for ${url} with domain array` + ); + } + }); + + it("correctly handles unicode in single domains", () => { + const testCases: Array<[string, string]> = [ + ["http://español.test.net/", "test.net"], + ["http://español.test.net/", "xn--espaol-zwa.test.net"], + ["http://你好/", "xn--6qq79v"], + ["http://test.你好/", "你好"], + ["http://bing.hi.com/", "hi.com"], + ["http://bing.hı.com/", "hı.com"], + ["http://bing.hí.com/", "hí.com"], + ["http://😉", "😉"] + ]; + + for (const [url, trustedDomain] of testCases) { + assert.strictEqual( + URIValidator.inDomain(url, trustedDomain), + true, + `Expected true for ${url} with domain ${trustedDomain}` + ); + assert.strictEqual( + URIValidator.inDomain(new URL(url), trustedDomain), + true, + `Expected true for URL object ${url} with domain ${trustedDomain}` + ); + } + }); + + it("correctly handles unicode in domain arrays", () => { + const testCases: Array<[string, string[]]> = [ + ["http://español.test.net/", ["notempty", "test.net"]], + ["http://español.test.net/", ["hello", "xn--espaol-zwa.test.net"]], + ["http://你好/", ["xn--6qq79v", "not_the_domain.com"]], + ["http://bing.hı.com/", ["hi.com", "hı.com"]], + ["http://bing.hí.com/", ["hí.com", "notempty"]], + ["http://test.你好/", ["你好", "bing.com"]] + ]; + + for (const [url, trustedDomains] of testCases) { + assert.strictEqual( + URIValidator.inDomain(url, trustedDomains), + true, + `Expected true for ${url} with domains ${trustedDomains.join(", ")}` + ); + assert.strictEqual( + URIValidator.inDomain(new URL(url), trustedDomains), + true, + `Expected true for URL object ${url} with domains ${trustedDomains.join(", ")}` + ); + } + }); + + it("correctly rejects invalid unicode single domains", () => { + const testCases: Array<[string, string]> = [ + ["http://bing.hı.com/", "hi.com"], + ["http://bing.hí.com/", "hi.com"], + ["http://😉", "🔨"] + ]; + + for (const [url, trustedDomain] of testCases) { + assert.strictEqual( + URIValidator.inDomain(url, trustedDomain), + false, + `Expected false for ${url} with domain ${trustedDomain}` + ); + assert.strictEqual( + URIValidator.inDomain(new URL(url), trustedDomain), + false, + `Expected false for URL object ${url} with domain ${trustedDomain}` + ); + } + }); + + it("correctly rejects invalid unicode domain arrays", () => { + const testCases: Array<[string, string[]]> = [ + ["http://bing.hı.com/", ["hi.com", "hí.com"]], + ["http://bing.hí.com/", ["hi.com", "hı.com"]], + ["http://😉", ["🔨", ""]] + ]; + + for (const [url, trustedDomains] of testCases) { + assert.strictEqual( + URIValidator.inDomain(url, trustedDomains), + false, + `Expected false for ${url} with domains ${trustedDomains.join(", ")}` + ); + assert.strictEqual( + URIValidator.inDomain(new URL(url), trustedDomains), + false, + `Expected false for URL object ${url} with domains ${trustedDomains.join(", ")}` + ); + } + }); + + it("correctly rejects invalid unicode strings", () => { + assert.strictEqual( + URIValidator.inDomain("http://evil.c℁.core.azure.net", "azure.net"), + false, + "Expected false for invalid unicode domain" + ); + }); + + it("correctly handles upper/lowercase", () => { + const testCases: Array<[string, string]> = [ + ["hTtP://test.net", "test.net"], + ["wSS://test.net", "test.net"], + ["http://HELLO.com", "hello.com"], + ["http://Hello.你好/", "xn--6qq79v"], + ["http://español.test.net/", "TeSt.net"], + ["http://hello.COM", "HELLO.com"] + ]; + + for (const [url, trustedDomain] of testCases) { + assert.strictEqual( + URIValidator.inDomain(url, trustedDomain), + true, + `Expected true for ${url} with domain ${trustedDomain}` + ); + assert.strictEqual( + URIValidator.inDomain(new URL(url), trustedDomain), + true, + `Expected true for URL object ${url} with domain ${trustedDomain}` + ); + } + }); + + it("correctly handles upper/lowercase in domain arrays", () => { + const testCases: Array<[string, string[]]> = [ + ["http://HELLO.com", ["notempty", "hello.com"]], + ["http://Hello.你好/", ["xn--6qq79v", "not_the_domain.com"]], + ["http://español.test.net/", ["TeSt.net", "asdf"]], + ["http://hello.COM", ["HELLO.com"]] + ]; + + for (const [url, trustedDomains] of testCases) { + assert.strictEqual( + URIValidator.inDomain(url, trustedDomains), + true, + `Expected true for ${url} with domains ${trustedDomains.join(", ")}` + ); + assert.strictEqual( + URIValidator.inDomain(new URL(url), trustedDomains), + true, + `Expected true for URL object ${url} with domains ${trustedDomains.join(", ")}` + ); + } + }); + + it("correctly enforces allowed protocols", () => { + const urls = [ + "http://bing.com", + "https://bing.com", + "ws://bing.com", + "wss://bing.com" + ]; + + for (const url of urls) { + assert.strictEqual( + URIValidator.inDomain(url, "bing.com"), + true, + `Expected true for ${url}` + ); + assert.strictEqual( + URIValidator.inDomain(new URL(url), "bing.com"), + true, + `Expected true for URL object ${url}` + ); + } + }); + + it("correctly rejects disallowed protocols", () => { + const urls = [ + "ftp://bing.com", + "file://bing.com", + "gopher://bing.com", + "mailto:bing.com", + "data://bing.com", + "javascript:alert('XSS')", + "evil.com://bing.com" + ]; + + for (const url of urls) { + assert.strictEqual( + URIValidator.inDomain(url, "bing.com"), + false, + `Expected false for ${url}` + ); + assert.strictEqual( + URIValidator.inDomain(new URL(url), "bing.com"), + false, + `Expected false for URL object ${url}` + ); + } + }); + + it("correctly rejects file path strings", () => { + const testCases: Array<[string, string]> = [ + ["c:\\foo\\bar", "somedomain.com"], + ["file:///filepath", "somedomain.com"], + ["CCCCCCCCCCCCCCCCCCCCCCC:\\\\\\\\\\\\foo\\bar", "somedomain.com"], + ["/foo/bar", "somedomain.com"], + ["//////////////////////////////////////", "somedomain.com"], + ["\\\\.\\a\\a\\a\\", "somedomain.com"], + ["\\\\\\.\\a\\a\\a\\", "somedomain.com"] + ]; + + for (const [url, trustedDomain] of testCases) { + assert.strictEqual( + URIValidator.inDomain(url, trustedDomain), + false, + `Expected false for file path ${url}` + ); + } + }); +}); diff --git a/nodejs/tsconfig.json b/nodejs/tsconfig.json new file mode 100644 index 0000000..ed79c1c --- /dev/null +++ b/nodejs/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ESNext", + "outDir": "out", + "esModuleInterop": true, + "declaration": true, + "removeComments": true, + "types": ["node", "mocha"] + }, + "include": ["src", "tests"], + "exclude": ["out"] +} diff --git a/scripts/build-domains-dotnet.sh b/scripts/build-domains-dotnet.sh new file mode 100755 index 0000000..7e5d911 --- /dev/null +++ b/scripts/build-domains-dotnet.sh @@ -0,0 +1,85 @@ +#!/bin/bash + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Generates csharp/src/Domains.cs from config/Domains.json + +set -e + +# Path to the domains configuration file +DOMAINS_JSON="config/Domains.json" +OUTPUT_FILE="dotnet/src/Helpers/Domains.cs" + +# Ensure the output directory exists +mkdir -p "$(dirname "$OUTPUT_FILE")" + +# Check if jq is available +if ! command -v jq &> /dev/null; then + echo "Error: jq is required but not installed. Please install jq to continue." + exit 1 +fi + +# Check if domains.json exists +if [[ ! -f "$DOMAINS_JSON" ]]; then + echo "Error: $DOMAINS_JSON not found" + exit 1 +fi + +# Extract domains from JSON +AZURE_KEYVAULT_DOMAINS=$(jq -r '.azureKeyVault.domains[]' "$DOMAINS_JSON" | sed 's/^/ "/' | sed 's/$/"/' | tr '\n' ',' | sed 's/,$//') +AZURE_STORAGE_DOMAINS=$(jq -r '.azureStorage.domains[]' "$DOMAINS_JSON" | sed 's/^/ "/' | sed 's/$/"/' | tr '\n' ',' | sed 's/,$//') + +# Generate C# file +cat > "$OUTPUT_FILE" << 'EOF' +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This file is auto-generated from config/Domains.json +// Do not edit this file directly. + +namespace Microsoft.Security.AntiSSRF +{ + /// <summary> + /// Well-known Azure service domains for internal use. + /// </summary> + internal static class Domains + { + /// <summary> + /// Azure Key Vault service domains across all public Azure environments. + /// </summary> + internal static readonly string[] AzureKeyVaultDomains = new string[] + { +EOF + +# Add Azure Key Vault domains +echo "$AZURE_KEYVAULT_DOMAINS" | tr ',' '\n' | while IFS= read -r line; do + if [[ -n "$line" ]]; then + echo "$line," >> "$OUTPUT_FILE" + fi +done + +cat >> "$OUTPUT_FILE" << 'EOF' + }; + + /// <summary> + /// Azure Storage service domains across all public Azure environments. + /// </summary> + internal static readonly string[] AzureStorageDomains = new string[] + { +EOF + +# Add Azure Storage domains +echo "$AZURE_STORAGE_DOMAINS" | tr ',' '\n' | while IFS= read -r line; do + if [[ -n "$line" ]]; then + echo "$line," >> "$OUTPUT_FILE" + fi +done + +cat >> "$OUTPUT_FILE" << 'EOF' + }; + } +} +EOF + +echo "Generated $OUTPUT_FILE successfully" \ No newline at end of file diff --git a/scripts/build-domains-nodejs.sh b/scripts/build-domains-nodejs.sh new file mode 100755 index 0000000..5411463 --- /dev/null +++ b/scripts/build-domains-nodejs.sh @@ -0,0 +1,78 @@ +#!/bin/bash + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Generates nodejs/src/Helpers/Domains.ts from config/Domains.json + +set -e + +# Path to the domains configuration file +DOMAINS_JSON="config/Domains.json" +OUTPUT_FILE="nodejs/src/Helpers/Domains.ts" + +# Ensure the output directory exists +mkdir -p "$(dirname "$OUTPUT_FILE")" + +# Check if jq is available +if ! command -v jq &> /dev/null; then + echo "Error: jq is required but not installed. Please install jq to continue." + exit 1 +fi + +# Check if domains.json exists +if [[ ! -f "$DOMAINS_JSON" ]]; then + echo "Error: $DOMAINS_JSON not found" + exit 1 +fi + +# Extract domains from JSON for TypeScript/JavaScript format +AZURE_KEYVAULT_DOMAINS=$(jq -r '.azureKeyVault.domains[]' "$DOMAINS_JSON" | sed 's/^/ "/' | sed 's/$/"/' | tr '\n' ',' | sed 's/,$//') +AZURE_STORAGE_DOMAINS=$(jq -r '.azureStorage.domains[]' "$DOMAINS_JSON" | sed 's/^/ "/' | sed 's/$/"/' | tr '\n' ',' | sed 's/,$//') + +# Generate TypeScript file +cat > "$OUTPUT_FILE" << 'EOF' +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This file is auto-generated from config/Domains.json +// Do not edit this file directly. + +/** + * Well-known Azure service domains for internal use. + */ + +/** + * Azure Key Vault service domains across all public Azure environments. + */ +export const _azureKeyVaultDomains: readonly string[] = [ +EOF + +# Add Azure Key Vault domains +echo "$AZURE_KEYVAULT_DOMAINS" | tr ',' '\n' | while IFS= read -r line; do + if [[ -n "$line" ]]; then + echo "$line," >> "$OUTPUT_FILE" + fi +done + +cat >> "$OUTPUT_FILE" << 'EOF' +]; + +/** + * Azure Storage service domains across all public Azure environments. + */ +export const _azureStorageDomains: readonly string[] = [ +EOF + +# Add Azure Storage domains +echo "$AZURE_STORAGE_DOMAINS" | tr ',' '\n' | while IFS= read -r line; do + if [[ -n "$line" ]]; then + echo "$line," >> "$OUTPUT_FILE" + fi +done + +cat >> "$OUTPUT_FILE" << 'EOF' +]; +EOF + +echo "Generated $OUTPUT_FILE successfully" \ No newline at end of file diff --git a/scripts/build-ip-ranges-dotnet.sh b/scripts/build-ip-ranges-dotnet.sh new file mode 100755 index 0000000..87b948c --- /dev/null +++ b/scripts/build-ip-ranges-dotnet.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Script to build IPAddressRanges.cs from IPAddressRanges.json +# Only includes cidr ranges as C# static readonly arrays + +set -e + +# Get the directory where this script is located +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +JSON_FILE="$SCRIPT_DIR/../config/IPAddressRanges.json" +CS_FILE="$SCRIPT_DIR/../dotnet/src/IPAddressRanges.cs" + +# Check if jq is available +if ! command -v jq &> /dev/null; then + echo "Error: jq is required but not installed. Install with: brew install jq" + exit 1 +fi + +# Check if JSON file exists +if [[ ! -f "$JSON_FILE" ]]; then + echo "Error: IPAddressRanges.json not found in $SCRIPT_DIR" + exit 1 +fi + +# Create directory if it doesn't exist +mkdir -p "$(dirname "$CS_FILE")" + +# Generate C# file +cat > "$CS_FILE" << 'EOF' +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Auto-generated from IPAddressRanges.json +// Do not edit this file manually + +namespace Microsoft.Security.AntiSSRF +{ + /// <summary> + /// Static IP address ranges for AntiSSRF protection + /// </summary> + public static class IPAddressRanges + { +EOF + +# Generate individual field declarations +jq -r ' + to_entries + | map(select(.key != "_sources" and .value.standaloneVariable == true)) + | .[] + | " public static readonly string[] " + .key + " = { " + (.value.cidr | map("\"" + . + "\"") | join(", ")) + " };" +' "$JSON_FILE" >> "$CS_FILE" + +# Add recommendedLatest property that matches recommendedV1 +cat >> "$CS_FILE" << 'EOF' + + /// <summary> + /// recommendedLatest always points to the latest version + /// </summary> + public static readonly string[] recommendedLatest = recommendedV1; +EOF + + + +# Close the class and namespace +cat >> "$CS_FILE" << 'EOF' + } +} +EOF + +echo "Successfully generated $CS_FILE" \ No newline at end of file diff --git a/scripts/build-ip-ranges-nodejs.sh b/scripts/build-ip-ranges-nodejs.sh new file mode 100755 index 0000000..a58d2c0 --- /dev/null +++ b/scripts/build-ip-ranges-nodejs.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Script to build IPAddressRanges.ts from IPAddressRanges.json +# Generates a TypeScript class with static readonly properties + +set -e + +# Get the directory where this script is located +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +JSON_FILE="$SCRIPT_DIR/../config/IPAddressRanges.json" +TS_FILE="$SCRIPT_DIR/../nodejs/src/IPAddressRanges.ts" + +# Check if jq is available +if ! command -v jq &> /dev/null; then + echo "Error: jq is required but not installed." + echo "Install on WSL/Ubuntu: sudo apt-get update && sudo apt-get install -y jq" + echo "Install on macOS: brew install jq" + exit 1 +fi + +# Check if JSON file exists +if [[ ! -f "$JSON_FILE" ]]; then + echo "Error: IPAddressRanges.json not found in $SCRIPT_DIR" + exit 1 +fi + +# Generate TypeScript file +echo "// Copyright (c) Microsoft Corporation." > "$TS_FILE" +echo "// Licensed under the MIT License." >> "$TS_FILE" +echo "" >> "$TS_FILE" +echo "// Auto-generated from IPAddressRanges.json" >> "$TS_FILE" +echo "// Do not edit this file manually" >> "$TS_FILE" +echo "" >> "$TS_FILE" +echo "/**" >> "$TS_FILE" +echo " * Static IP address ranges for AntiSSRF protection" >> "$TS_FILE" +echo " */" >> "$TS_FILE" +echo "export class IPAddressRanges {" >> "$TS_FILE" + +# Generate individual property declarations +jq -r ' + to_entries + | map(select(.key != "_sources" and .value.standaloneVariable == true)) + | .[] + | " public static readonly " + .key + ": string[] = [" + (.value.cidr | map("\"" + . + "\"") | join(", ")) + "];" +' "$JSON_FILE" >> "$TS_FILE" + +# Add recommendedLatest property that matches recommendedV1 +echo "" >> "$TS_FILE" +echo " /**" >> "$TS_FILE" +echo " * recommendedLatest always points to the latest version" >> "$TS_FILE" +echo " */" >> "$TS_FILE" +echo " public static readonly recommendedLatest: string[] = IPAddressRanges.recommendedV1;" >> "$TS_FILE" + +# Close the class +echo "}" >> "$TS_FILE" + +echo "Successfully generated $TS_FILE" \ No newline at end of file