diff --git a/.github/workflows/check-license-headers.yml b/.github/workflows/check-license-headers.yml index befda88..7ee6592 100644 --- a/.github/workflows/check-license-headers.yml +++ b/.github/workflows/check-license-headers.yml @@ -35,6 +35,7 @@ jobs: -name "*.cs" -o \ -name "*.ts" -o \ -name "*.js" -o \ + -name "*.rs" -o \ -name "*.sh" -o \ -name "*.yml" -o \ -name "*.yaml" \ @@ -57,7 +58,7 @@ jobs: missing_headers+=("$file") fi ;; - *.cs|*.ts|*.js) + *.cs|*.ts|*.js|*.rs) if ! head -5 "$file" | grep -q "// Copyright (c) Microsoft Corporation."; then missing_headers+=("$file") fi @@ -77,7 +78,7 @@ jobs: echo " # Copyright (c) Microsoft Corporation." echo " # Licensed under the MIT License." echo "" - echo "For .cs/.ts/.js files:" + echo "For .cs/.ts/.js/.rs files:" echo " // Copyright (c) Microsoft Corporation." echo " // Licensed under the MIT License." exit 1 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b298fe8..4c1d825 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,6 +41,8 @@ jobs: build-mode: none - language: javascript-typescript build-mode: none + - language: rust + build-mode: manual # 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 @@ -70,6 +72,7 @@ jobs: - 'dotnet/FunctionalTests/**' - 'dotnet/UnitTests/**' - 'nodejs/tests/**' + - 'rust/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. @@ -83,16 +86,14 @@ jobs: # 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: Setup Rust + if: matrix.language == 'rust' + uses: dtolnay/rust-toolchain@stable + + - name: Build Rust for CodeQL + if: matrix.language == 'rust' + working-directory: rust + run: cargo build --verbose - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml new file mode 100644 index 0000000..b2fda21 --- /dev/null +++ b/.github/workflows/test-rust.yml @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +name: Run Rust Tests + +on: + pull_request: + branches: + - main + paths: + - "rust/**" + - ".github/workflows/test-rust.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + toolchain: [stable, beta] + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup Rust ${{ matrix.toolchain }} + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ matrix.toolchain }} + + - name: Cache cargo dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: "rust" + + - name: Build + working-directory: rust + run: cargo build --verbose + + - name: Run lib tests (no network features) + working-directory: rust + run: cargo test --lib --verbose + + - name: Run all tests (with reqwest integration) + working-directory: rust + run: cargo test --all-features --verbose diff --git a/.github/workflows/update-domains.yml b/.github/workflows/update-domains.yml index e07058e..b522e4d 100644 --- a/.github/workflows/update-domains.yml +++ b/.github/workflows/update-domains.yml @@ -11,6 +11,7 @@ on: - 'config/Domains.json' - 'nodejs/src/Helpers/Domains.ts' - 'dotnet/src/Helpers/Domains.cs' + - 'rust/src/domains.rs' workflow_dispatch: # Allow manual triggering permissions: @@ -38,17 +39,21 @@ jobs: - name: Build C# version run: ./scripts/build-domains-dotnet.sh + - name: Build Rust version + run: ./scripts/build-domains-rust.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 + if ! git diff --exit-code nodejs/src/Helpers/Domains.ts dotnet/src/Helpers/Domains.cs rust/src/domains.rs; 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 + git diff --name-only nodejs/src/Helpers/Domains.ts dotnet/src/Helpers/Domains.cs rust/src/domains.rs echo "" echo "Please run the following commands locally and commit the results:" echo " ./scripts/build-domains-nodejs.sh" echo " ./scripts/build-domains-dotnet.sh" + echo " ./scripts/build-domains-rust.sh" exit 1 else echo "βœ… All generated files are up-to-date" - fi \ No newline at end of file + fi diff --git a/.github/workflows/update-ip-ranges.yml b/.github/workflows/update-ip-ranges.yml index 08f148e..f9d7eab 100644 --- a/.github/workflows/update-ip-ranges.yml +++ b/.github/workflows/update-ip-ranges.yml @@ -11,6 +11,7 @@ on: - 'config/IPAddressRanges.json' - 'nodejs/src/IPAddressRanges.ts' - 'dotnet/src/IPAddressRanges.cs' + - 'rust/src/ip_address_ranges.rs' workflow_dispatch: # Allow manual triggering permissions: @@ -38,16 +39,20 @@ jobs: - name: Build C# version run: ./scripts/build-ip-ranges-dotnet.sh + - name: Build Rust version + run: ./scripts/build-ip-ranges-rust.sh + - name: Verify generated files are up-to-date run: | - if ! git diff --exit-code nodejs/src/IPAddressRanges.ts dotnet/src/IPAddressRanges.cs; then + if ! git diff --exit-code nodejs/src/IPAddressRanges.ts dotnet/src/IPAddressRanges.cs rust/src/ip_address_ranges.rs; 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 + git diff --name-only nodejs/src/IPAddressRanges.ts dotnet/src/IPAddressRanges.cs rust/src/ip_address_ranges.rs 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" + echo " ./scripts/build-ip-ranges-rust.sh" exit 1 else echo "βœ… All generated files are up-to-date" diff --git a/README.md b/README.md index bc1eecc..1954ab3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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. +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 .NET, Node.js, and Rust applications. ## What is Server-Side Request Forgery (SSRF)? @@ -47,6 +47,13 @@ AntiSSRF helps mitigate these risks by: - **Quick Start**: [Getting Started Guide](https://microsoft.github.io/AntiSSRF/getting-started) - **Library README**: [Node.js README](nodejs/README.md) +### Rust + +- **crates.io Package**: [antissrf](https://crates.io/crates/antissrf) +- **Documentation**: [AntiSSRF Rust API Documentation](https://microsoft.github.io/AntiSSRF/rust-api/) +- **Quick Start**: [Getting Started Guide](https://microsoft.github.io/AntiSSRF/getting-started) +- **Library README**: [Rust README](rust/README.md) + ## Contributing We welcome contributions! Please see our contribution resources: diff --git a/docs/getting-started.md b/docs/getting-started.md index 9cce982..daa8abd 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -27,6 +27,14 @@ Install the npm package: npm install @microsoft/antissrf ``` +### Rust + +Install the crate: + +```bash +cargo add antissrf +``` + ## Quick Start Examples ### .NET Usage @@ -72,16 +80,35 @@ https.get(options, (res) => { }); ``` +### Rust Usage + +```rust +use antissrf::{AntiSSRFPolicy, PolicyConfigOptions}; + +// Create a policy for external-only requests +let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + +// Validate a request URL and headers +let mut headers = vec![]; +let allowed = policy.validate_request("https://api.example.com/data", &mut headers)?; + +// With reqwest middleware (DNS-level IP blocking) +use antissrf::network::reqwest_integration::AntiSSRFClientBuilder; + +let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware()?; +``` + ## 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) | +| **General Case** | The untrusted URL can belong to **any domain** or an **untrusted domain**. | [.NET](dotnet-api/antissrfpolicy/) \| [Node.js](nodejs-api/antissrfpolicy/) \| [Rust](rust-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) \| [Rust](rust-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) \| [Rust](rust-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) \| [Rust](rust-api/urivalidator/indomain) | ## Best Practices @@ -105,7 +132,7 @@ The AntiSSRF library provides validation for different scenarios based on your t ## Next Steps ### Learn More -- πŸ“– **API Documentation**: [.NET API](dotnet-api/) \| [Node.js API](nodejs-api/) +- πŸ“– **API Documentation**: [.NET API](dotnet-api/) \| [Node.js API](nodejs-api/) \| [Rust API](rust-api/) - ❓ **Common Questions**: [FAQ](faq) ### Get Support diff --git a/docs/index.md b/docs/index.md index 6ca0fb3..3db741e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -44,20 +44,18 @@ AntiSSRF helps mitigate these risks by: ## 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. +| Language | Documentation | Package | Notes | +| --- | --- | --- | --- | +| C# | [AntiSSRF .NET Library](dotnet-api/) | [NuGet](https://www.nuget.org/packages/Microsoft.Security.AntiSSRF/) | For web clients using `HttpClient` objects | +| JavaScript/TypeScript | [AntiSSRF Node.js Library](nodejs-api/) | [npm](https://www.npmjs.com/package/@microsoft/antissrf) | For requests using NodeJS HTTP(S) Agents | +| Rust | [AntiSSRF Rust Library](rust-api/) | [crates.io](https://crates.io/crates/antissrf) | For reqwest clients with middleware support | ## Next Steps ### Learn More - πŸš€ **Getting Started**: [Installation and Quick Start Guide](getting-started) -- πŸ“– **API Documentation**: [.NET API](dotnet-api) \| [Node.js API](nodejs-api) +- πŸ“– **API Documentation**: [.NET API](dotnet-api) \| [Node.js API](nodejs-api) \| [Rust API](rust-api) - ❓ **Common Questions**: [FAQ](faq) ### Get Support diff --git a/docs/rust-api/index.md b/docs/rust-api/index.md new file mode 100644 index 0000000..bce9070 --- /dev/null +++ b/docs/rust-api/index.md @@ -0,0 +1,80 @@ +--- +layout: default +title: Rust API Reference +nav_order: 5 +description: "Complete API documentation for the AntiSSRF Rust Library" +has_children: true +has_toc: false +--- + +# API Documentation + +## AntiSSRF Rust Library + +The **AntiSSRF Rust Library** (`antissrf`) is a crate for Rust applications that provides robust URL validation and HTTP request protection to prevent SSRF vulnerabilities. It integrates with `reqwest` via `reqwest-middleware` for DNS-level IP blocking, header validation, protocol enforcement, and redirect-chain re-validation. + +## Usage Instructions + +The AntiSSRF library provides validation for different scenarios based on your trust requirements: + +| Use Case | Description | Documentation | +| --- | --- | --- | +| **General Case** | Block internal/sensitive IP addresses, enforce headers, validate protocols. | [`AntiSSRFPolicy`](antissrfpolicy) | +| **Azure Key Vault Domain** | Validate URL belongs to an Azure Key Vault domain. | [`URIValidator::in_azure_key_vault_domain`](urivalidator/inazurekeyvaultdomain) | +| **Azure Storage Domain** | Validate URL belongs to an Azure Storage domain. | [`URIValidator::in_azure_storage_domain`](urivalidator/inazurestoragedomain) | +| **Trusted Domain Allowlist** | Validate URL belongs to a specific, trusted domain. | [`URIValidator::in_domain`](urivalidator/indomain) | + +## Modules + +| Module | Description | +| --- | --- | +| [`policy`](antissrfpolicy) | [`AntiSSRFPolicy`](antissrfpolicy) β€” central configuration object with allowlist, denylist, and header enforcement | +| [`error`](error) | [`AntiSSRFError`](error) β€” typed error variants with clear security semantics | +| [`cidr`](cidr) | [`CIDRBlock`](cidr) β€” CIDR block parsing and IP containment with IPv6 normalization | +| [`uri_validator`](urivalidator) | [`URIValidator`](urivalidator) β€” domain and Azure service URL validation | +| [`network`](network) | reqwest middleware integration with DNS-level IP blocking and redirect re-validation | + +## Installation + +Add to your `Cargo.toml`: + +```toml +[dependencies] +antissrf = "0.1.1" +``` + +Or with explicit feature control: + +```toml +# Core only (no HTTP client dependencies) +antissrf = { version = "0.1.1", default-features = false } + +# Full reqwest integration (default) +antissrf = { version = "0.1.1", features = ["reqwest-integration"] } +``` + +## Quick Start + +```rust +use antissrf::{AntiSSRFPolicy, PolicyConfigOptions}; + +// Block all known dangerous IPs +let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + +// Validate a request +let mut headers = vec![]; +let allowed = policy.validate_request("https://api.example.com", &mut headers)?; +``` + +## Feature Flags + +| Flag | Default | Description | +| --- | --- | --- | +| `reqwest-integration` | Yes | Enables `network` module with reqwest / reqwest-middleware support | + +## References + +- [crates.io](https://crates.io/crates/antissrf) +- [docs.rs](https://docs.rs/antissrf) +- [Standalone Repository](https://github.com/finn79426/AntiSSRF-rs) +- [Microsoft AntiSSRF Documentation](https://microsoft.github.io/AntiSSRF/) diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..1cc950b --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1,5 @@ +# Rust build output +target/ + +# Local development artifacts +*.local.* diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..665103b --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,1885 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "antissrf" +version = "0.1.1" +dependencies = [ + "async-trait", + "http", + "ipnetwork", + "mockito", + "reqwest", + "reqwest-middleware", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "ipnetwork" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "pin-project-lite", + "rand", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "reqwest-middleware" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" +dependencies = [ + "anyhow", + "async-trait", + "http", + "reqwest", + "thiserror", + "tower-service", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..2fc19d3 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "antissrf" +version = "0.1.1" +edition = "2024" +authors = ["whiteberets.eth "] +description = "Rust implementation of Microsoft AntiSSRF" +license = "MIT" +repository = "https://github.com/microsoft/AntiSSRF" + +[dependencies] +thiserror = "2.0.18" +ipnetwork = "0.21.1" +url = "2.5.8" +reqwest = { version = "0.13.4", optional = true } +reqwest-middleware = { version = "0.5.2", optional = true } +async-trait = { version = "0.1.89", optional = true } +tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros"], optional = true } +http = { version = "1.4.1", optional = true } + +[dev-dependencies] +mockito = "1.7.2" +tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros"] } + +[features] +default = ["reqwest-integration"] +reqwest-integration = ["dep:reqwest", "dep:reqwest-middleware", "dep:async-trait", "dep:tokio", "dep:http"] diff --git a/rust/src/cidr.rs b/rust/src/cidr.rs new file mode 100644 index 0000000..c2c847c --- /dev/null +++ b/rust/src/cidr.rs @@ -0,0 +1,234 @@ +//! CIDR block parsing and IP range containment for AntiSSRF protection. +//! +//! [`CIDRBlock`] is the foundational primitive for all IP-based policy checks. +//! It normalizes every IPv4 address and network to IPv6-mapped form +//! (`::ffff:`), so a single comparison path handles both families. +//! +//! # IPv6 Normalization +//! +//! When you parse `"10.0.0.0/8"`, the internal representation becomes +//! `"::ffff:10.0.0.0/104"` (prefix + 96). This means: +//! +//! - `contains("10.1.2.3")` β†’ true +//! - `contains("::ffff:10.1.2.3")` β†’ true (IPv4-mapped IPv6) +//! - `contains("2001:db8::1")` β†’ false +//! +//! See [`CIDRBlock::parse`] and [`CIDRBlock::contains`] for details. + +use crate::error::AntiSSRFError; +use ipnetwork::{IpNetwork, Ipv6Network}; +use std::fmt; +use std::net::IpAddr; +use std::str::FromStr; + +/// Represents a CIDR block for IP range checking. +/// +/// Internally, all networks are normalized to IPv6-mapped form so that +/// a single comparison logic works regardless of address family. +/// +/// # Example +/// +/// ```rust +/// use antissrf::CIDRBlock; +/// use std::net::IpAddr; +/// +/// let block = CIDRBlock::parse("10.0.0.0/8").unwrap(); +/// assert!(block.contains("10.1.2.3".parse::().unwrap())); +/// assert!(!block.contains("11.0.0.1".parse::().unwrap())); +/// ``` +#[derive(Debug, Clone, PartialEq)] +pub struct CIDRBlock { + /// Normalized IPv6 network (IPv4 CIDRs are mapped to IPv6-mapped form). + network: Ipv6Network, + /// Original CIDR string, preserved for Display. + original: String, +} + +impl CIDRBlock { + /// Parse a CIDR notation string into a `CIDRBlock`. + /// + /// Supports both IPv4 (`10.0.0.0/8`) and IPv6 (`fe80::/10`) notation. + /// IPv4 CIDRs are internally normalized to IPv6-mapped form (`::ffff:10.0.0.0/104`). + /// + /// # Errors + /// + /// Returns `AntiSSRFError::InvalidCIDR` if the string is not valid CIDR notation. + /// + /// # Examples + /// + /// ```rust + /// use antissrf::CIDRBlock; + /// + /// let block = CIDRBlock::parse("192.168.0.0/16").unwrap(); + /// let block_v6 = CIDRBlock::parse("fe80::/10").unwrap(); + /// ``` + pub fn parse(cidr: &str) -> Result { + let original = cidr.to_string(); + let network = + IpNetwork::from_str(cidr).map_err(|e| AntiSSRFError::InvalidCIDR(e.to_string()))?; + + let ipv6_network = match network { + IpNetwork::V4(v4) => { + let mapped = v4.network().to_ipv6_mapped(); + let prefix = v4.prefix() + 96; + Ipv6Network::new(mapped, prefix) + .map_err(|e| AntiSSRFError::InvalidCIDR(e.to_string()))? + } + IpNetwork::V6(v6) => v6, + }; + + Ok(Self { + network: ipv6_network, + original, + }) + } + + /// Check whether an IP address is contained within this CIDR block. + /// + /// IPv4 addresses are automatically mapped to IPv6-mapped form before checking. + /// + /// # Examples + /// + /// ```rust + /// use antissrf::CIDRBlock; + /// use std::net::IpAddr; + /// + /// let block = CIDRBlock::parse("10.0.0.0/8").unwrap(); + /// assert!(block.contains("10.1.2.3".parse::().unwrap())); + /// assert!(!block.contains("11.0.0.1".parse::().unwrap())); + /// + /// // IPv4-mapped IPv6 address also matches IPv4 CIDR + /// assert!(block.contains("::ffff:10.1.2.3".parse::().unwrap())); + /// ``` + pub fn contains(&self, addr: IpAddr) -> bool { + let ipv6_addr = match addr { + IpAddr::V4(v4) => v4.to_ipv6_mapped(), + IpAddr::V6(v6) => v6, + }; + self.network.contains(ipv6_addr) + } + + /// Check whether another CIDR block is fully contained within this block. + /// + /// Returns `true` if `other` represents a subnet of `self`. + /// + /// # Examples + /// + /// ```rust + /// use antissrf::CIDRBlock; + /// + /// let parent = CIDRBlock::parse("10.0.0.0/8").unwrap(); + /// let child = CIDRBlock::parse("10.1.0.0/16").unwrap(); + /// assert!(parent.contains_cidr(&child)); + /// + /// let non_child = CIDRBlock::parse("11.0.0.0/8").unwrap(); + /// assert!(!parent.contains_cidr(&non_child)); + /// ``` + pub fn contains_cidr(&self, other: &CIDRBlock) -> bool { + if other.network.prefix() < self.network.prefix() { + return false; + } + self.network.contains(other.network.network()) + } +} + +impl fmt::Display for CIDRBlock { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.original) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{Ipv4Addr, Ipv6Addr}; + + #[test] + fn parse_valid_ipv4() { + let block = CIDRBlock::parse("10.0.0.0/8").unwrap(); + assert_eq!(block.to_string(), "10.0.0.0/8"); + } + + #[test] + fn parse_valid_ipv6() { + let block = CIDRBlock::parse("fe80::/10").unwrap(); + assert_eq!(block.to_string(), "fe80::/10"); + } + + #[test] + fn parse_invalid_cidr() { + assert!(matches!( + CIDRBlock::parse("invalid"), + Err(AntiSSRFError::InvalidCIDR(_)) + )); + } + + #[test] + fn parse_invalid_prefix() { + assert!(matches!( + CIDRBlock::parse("10.0.0.0/33"), + Err(AntiSSRFError::InvalidCIDR(_)) + )); + } + + #[test] + fn contains_ipv4_in_ipv4_cidr() { + let block = CIDRBlock::parse("10.0.0.0/8").unwrap(); + assert!(block.contains(IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3)))); + assert!(!block.contains(IpAddr::V4(Ipv4Addr::new(11, 0, 0, 1)))); + } + + #[test] + fn contains_ipv4_mapped_in_ipv4_cidr() { + let block = CIDRBlock::parse("10.0.0.0/8").unwrap(); + let mapped = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xFFFF, 0x0a01, 0x0203)); + assert!(block.contains(mapped)); + } + + #[test] + fn contains_ipv6_in_ipv6_cidr() { + let block = CIDRBlock::parse("fe80::/10").unwrap(); + assert!(block.contains(IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)))); + assert!(!block.contains(IpAddr::V6(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1)))); + } + + #[test] + fn contains_ipv4_in_ipv6_mapped_cidr() { + // ::ffff:10.0.0.0/104 is the IPv6-mapped form of 10.0.0.0/8 + let block = CIDRBlock::parse("::ffff:10.0.0.0/104").unwrap(); + assert!(block.contains(IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3)))); + } + + #[test] + fn contains_cidr_ipv4() { + let parent = CIDRBlock::parse("10.0.0.0/8").unwrap(); + let child = CIDRBlock::parse("10.1.0.0/16").unwrap(); + assert!(parent.contains_cidr(&child)); + + let non_child = CIDRBlock::parse("11.0.0.0/8").unwrap(); + assert!(!parent.contains_cidr(&non_child)); + + let same_prefix = CIDRBlock::parse("10.0.0.0/8").unwrap(); + assert!(parent.contains_cidr(&same_prefix)); + } + + #[test] + fn contains_cidr_larger_prefix_fails() { + let small = CIDRBlock::parse("10.1.0.0/16").unwrap(); + let large = CIDRBlock::parse("10.0.0.0/8").unwrap(); + assert!(!small.contains_cidr(&large)); + } + + #[test] + fn display_preserves_original() { + let block = CIDRBlock::parse("192.168.0.0/16").unwrap(); + assert_eq!(format!("{}", block), "192.168.0.0/16"); + } + + #[test] + fn clone_and_eq() { + let a = CIDRBlock::parse("10.0.0.0/8").unwrap(); + let b = a.clone(); + assert_eq!(a, b); + } +} diff --git a/rust/src/domains.rs b/rust/src/domains.rs new file mode 100644 index 0000000..20c87e4 --- /dev/null +++ b/rust/src/domains.rs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Well-known Azure service domains for URL validation. +//! +//! This module provides static domain lists used by [`crate::URIValidator`] to +//! recognise Azure Key Vault and Azure Storage endpoints. The lists cover +//! all public Azure sovereign clouds (global Azure, China, US Government). +//! +//! # Usage +//! +//! These constants are typically consumed indirectly through +//! [`crate::URIValidator::in_azure_key_vault_domain`] and +//! [`crate::URIValidator::in_azure_storage_domain`], but they are also public +//! in case you need to build custom domain checks. +//! +//! This file is auto-generated from `config/Domains.json`. +//! Do not edit manually; run `scripts/build-domains-rust.sh` to regenerate. + +/// Azure Key Vault service domains across all public Azure environments. +/// +/// Covers vault and managed-HSM endpoints for: +/// +/// | Suffix | Cloud | +/// |--------|-------| +/// | `vault.azure.net` | Global Azure | +/// | `vault.azure.cn` | Azure China | +/// | `vault.usgovcloudapi.net` | Azure US Government | +/// +/// Hostnames containing `--` are rejected by [`crate::URIValidator::in_azure_key_vault_domain`] +/// per Azure naming restrictions. +pub const AZURE_KEY_VAULT_DOMAINS: &[&str] = &[ + "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. +/// +/// Covers Blob, Web, Data Lake Storage Gen2 (DFS), File, Queue, and Table +/// endpoints for: +/// +/// | Suffix | Cloud | +/// |--------|-------| +/// | `*.core.windows.net` | Global Azure | +/// | `*.storage.azure.net` | Global Azure (alternative) | +/// | `*.core.usgovcloudapi.net` | Azure US Government | +/// | `*.core.chinacloudapi.cn` | Azure China | +/// +/// Like [`AZURE_KEY_VAULT_DOMAINS`], hostnames containing `--` are rejected +/// by [`crate::URIValidator::in_azure_storage_domain`] per Azure naming restrictions. +pub const AZURE_STORAGE_DOMAINS: &[&str] = &[ + "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/rust/src/error.rs b/rust/src/error.rs new file mode 100644 index 0000000..11fbd17 --- /dev/null +++ b/rust/src/error.rs @@ -0,0 +1,221 @@ +//! Error types for AntiSSRF operations. +//! +//! Every fallible operation in this crate returns [`AntiSSRFError`], a single +//! enum that covers validation failures, configuration mistakes, and runtime +//! policy violations. The error type implements [`std::error::Error`] via +//! `thiserror`, and is [`Clone`] + [`PartialEq`] so it can be cheaply passed +//! around and compared in tests. +//! +//! # Error categories +//! +//! | Variant | Triggered by | Typical cause | +//! |---------|--------------|---------------| +//! | [`IPDisallowed`](AntiSSRFError::IPDisallowed) | [`AntiSSRFPolicy::validate_request`](crate::policy::AntiSSRFPolicy::validate_request), DNS resolution, redirects | Target IP is in a denylist or not in the allowlist | +//! | [`SchemeDisallowed`](AntiSSRFError::SchemeDisallowed) | [`AntiSSRFPolicy::validate_request`](crate::policy::AntiSSRFPolicy::validate_request) | `http://` used when [`set_allow_plaintext_http`](crate::policy::AntiSSRFPolicy::set_allow_plaintext_http) is `false` | +//! | [`HeaderDenied`](AntiSSRFError::HeaderDenied) | [`AntiSSRFPolicy::validate_request`](crate::policy::AntiSSRFPolicy::validate_request) | Request contains a header in the denylist | +//! | [`HeaderRequired`](AntiSSRFError::HeaderRequired) | [`AntiSSRFPolicy::validate_request`](crate::policy::AntiSSRFPolicy::validate_request) | Request is missing a header in the required list | +//! | [`InvalidHeader`](AntiSSRFError::InvalidHeader) | [`AntiSSRFPolicy`](crate::policy::AntiSSRFPolicy) configuration methods | Empty or malformed header name supplied at build time | +//! | [`PolicyLocked`](AntiSSRFError::PolicyLocked) | Mutating a locked [`AntiSSRFPolicy`](crate::policy::AntiSSRFPolicy) | Attempting to modify a policy after it has been used | +//! | [`ConflictingConfiguration`](AntiSSRFError::ConflictingConfiguration) | [`AntiSSRFPolicy`](crate::policy::AntiSSRFPolicy) configuration methods | Logically incompatible options (e.g. denylist + `deny_all_unspecified_ips`) | +//! | [`InvalidCIDR`](AntiSSRFError::InvalidCIDR) | [`CIDRBlock::parse`](crate::CIDRBlock::parse) | Malformed CIDR string such as `10.0.0.0/33` | +//! | [`InvalidIP`](AntiSSRFError::InvalidIP) | IP parsing helpers | String that does not represent a valid IPv4 or IPv6 address | +//! | [`InvalidURL`](AntiSSRFError::InvalidURL) | URL parsing helpers | String that is not a valid URL | +//! | [`RedirectValidationFailed`](AntiSSRFError::RedirectValidationFailed) | [`network`](crate::network) middleware | A redirect Location failed re-validation against the active policy | +//! +//! # Example +//! +//! ``` +//! use antissrf::{AntiSSRFPolicy, PolicyConfigOptions, AntiSSRFError}; +//! +//! # fn main() -> Result<(), AntiSSRFError> { +//! let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); +//! policy.set_allow_plaintext_http(false)?; +//! +//! let mut headers = vec![]; +//! match policy.validate_request("http:", &mut headers) { +//! Err(AntiSSRFError::SchemeDisallowed) => { +//! // plaintext HTTP rejected +//! } +//! Err(AntiSSRFError::IPDisallowed) => { +//! // IMDS address blocked +//! } +//! Ok(_) => {} +//! Err(e) => panic!("unexpected error: {}", e), +//! } +//! # Ok(()) +//! # } +//! ``` + +use thiserror::Error; + +/// Error types for AntiSSRF operations. +/// +/// See the [module-level documentation](self) for a mapping of each variant to +/// the policy setting or API operation that triggers it. +#[derive(Error, Debug, Clone, PartialEq)] +pub enum AntiSSRFError { + /// The target IP address is blocked by the active policy. + /// + /// Returned by [`AntiSSRFPolicy::validate_request`](crate::policy::AntiSSRFPolicy::validate_request) + /// and the network middleware when the resolved IP falls inside a denylist + /// (or outside the allowlist when [`deny_all_unspecified_ips`](crate::policy::AntiSSRFPolicy::deny_all_unspecified_ips) + /// is enabled). + #[error("IP address disallowed by policy")] + IPDisallowed, + + /// The request scheme is blocked by the active policy. + /// + /// Common causes: + /// - Plaintext `http://` is used while [`set_allow_plaintext_http`](crate::policy::AntiSSRFPolicy::set_allow_plaintext_http) + /// is `false`. + /// - A non-HTTP scheme such as `ftp:` or `file:` is requested. + #[error("Request scheme disallowed by policy")] + SchemeDisallowed, + + /// A denied HTTP header was present in the request. + /// + /// Set the denied header list via + /// [`add_denied_headers`](crate::policy::AntiSSRFPolicy::add_denied_headers). + /// Comparison is case-insensitive for header names. + #[error("Request header disallowed by policy")] + HeaderDenied, + + /// A required HTTP header was missing from the request. + /// + /// Set the required header list via + /// [`add_required_headers`](crate::policy::AntiSSRFPolicy::add_required_headers). + /// Comparison is case-insensitive for header names. + #[error("Required request header missing")] + HeaderRequired, + + /// An invalid or empty header name was provided during policy configuration. + /// + /// Header names must be non-empty and contain only printable ASCII characters + /// excluding the colon (`:`). + #[error("Invalid header name")] + InvalidHeader, + + /// Custom DNS lookup functions are not permitted for security reasons. + /// + /// The network middleware must control name resolution to ensure IP validation + /// happens after resolution and before the TCP handshake. + #[error("Cannot use AntiSSRF with custom lookup function")] + CustomLookupNotAllowed, + + /// The policy has been locked and can no longer be modified. + /// + /// Policies are automatically locked on first use (e.g. the first call to + /// [`validate_request`](crate::policy::AntiSSRFPolicy::validate_request) or + /// the first request made through the network middleware). Create a new + /// [`AntiSSRFPolicy`](crate::policy::AntiSSRFPolicy) if you + /// need different settings. + #[error("Policy is locked and cannot be modified")] + PolicyLocked, + + /// A logically incompatible combination of policy options was requested. + /// + /// For example, adding explicit denied addresses while + /// [`deny_all_unspecified_ips`](crate::policy::AntiSSRFPolicy::deny_all_unspecified_ips) + /// is already enabled is redundant and therefore rejected. + #[error("Conflicting policy configuration")] + ConflictingConfiguration, + + /// The supplied string is not a valid CIDR block. + /// + /// Valid examples: `192.168.0.0/24`, `::1/128`, `10.0.0.0/8`. + /// Invalid examples: `10.0.0.0/33` (prefix too large), `not-an-ip/24`. + #[error("Invalid CIDR block: {0}")] + InvalidCIDR(String), + + /// The supplied string is not a valid IP address. + /// + /// Valid examples: `192.168.1.1`, `::1`, `::ffff:192.168.1.1`. + #[error("Invalid IP address: {0}")] + InvalidIP(String), + + /// The supplied string is not a valid URL. + /// + /// URLs must include a scheme and host. Valid examples: + /// `https://example.com/path`, `http://10.0.0.1:8080/`. + #[error("Invalid URL: {0}")] + InvalidURL(String), + + /// A redirect in the HTTP response chain failed re-validation. + /// + /// The network middleware re-runs the active policy against every + /// `Location` header in a 3xx redirect. If the new URL is blocked, + /// this error is returned and the redirect is aborted. + #[error("Redirect validation failed: {0}")] + RedirectValidationFailed(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_display_messages() { + assert_eq!( + AntiSSRFError::IPDisallowed.to_string(), + "IP address disallowed by policy" + ); + assert_eq!( + AntiSSRFError::SchemeDisallowed.to_string(), + "Request scheme disallowed by policy" + ); + assert_eq!( + AntiSSRFError::HeaderDenied.to_string(), + "Request header disallowed by policy" + ); + assert_eq!( + AntiSSRFError::HeaderRequired.to_string(), + "Required request header missing" + ); + assert_eq!( + AntiSSRFError::InvalidHeader.to_string(), + "Invalid header name" + ); + assert_eq!( + AntiSSRFError::CustomLookupNotAllowed.to_string(), + "Cannot use AntiSSRF with custom lookup function" + ); + assert_eq!( + AntiSSRFError::PolicyLocked.to_string(), + "Policy is locked and cannot be modified" + ); + assert_eq!( + AntiSSRFError::ConflictingConfiguration.to_string(), + "Conflicting policy configuration" + ); + assert_eq!( + AntiSSRFError::InvalidCIDR("10.0.0.0/33".to_string()).to_string(), + "Invalid CIDR block: 10.0.0.0/33" + ); + assert_eq!( + AntiSSRFError::InvalidIP("not-an-ip".to_string()).to_string(), + "Invalid IP address: not-an-ip" + ); + assert_eq!( + AntiSSRFError::InvalidURL("not-a-url".to_string()).to_string(), + "Invalid URL: not-a-url" + ); + assert_eq!( + AntiSSRFError::RedirectValidationFailed("http://127.0.0.1/secret".to_string()) + .to_string(), + "Redirect validation failed: http://127.0.0.1/secret" + ); + } + + #[test] + fn error_clonable() { + let err = AntiSSRFError::IPDisallowed; + let cloned = err.clone(); + assert_eq!(err, cloned); + } + + #[test] + fn error_partial_eq() { + assert_eq!(AntiSSRFError::PolicyLocked, AntiSSRFError::PolicyLocked); + assert_ne!(AntiSSRFError::IPDisallowed, AntiSSRFError::SchemeDisallowed); + } +} diff --git a/rust/src/ip_address_ranges.rs b/rust/src/ip_address_ranges.rs new file mode 100644 index 0000000..9bbe0ac --- /dev/null +++ b/rust/src/ip_address_ranges.rs @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Static IP address ranges for AntiSSRF protection. +//! +//! Each constant in this module represents a category of special-purpose IP +//! addresses defined by IANA / IETF RFCs. They are used by +//! [`AntiSSRFPolicy`](crate::AntiSSRFPolicy) +//! to build deny/allow lists. +//! +//! # Usage +//! +//! ```rust +//! use antissrf::ip_address_ranges; +//! +//! // Block the Azure Instance Metadata Service endpoint +//! let imds = ip_address_ranges::IMDS; +//! assert!(imds.contains(&"169.254.169.254/32")); +//! ``` +//! +//! # Source +//! +//! This file is auto-generated from `config/IPAddressRanges.json`. +//! Do not edit manually; run `scripts/build-ip-ranges-rust.sh` to regenerate. +//! +//! # References +//! +//! - [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) +//! - [RFC 6890](https://tools.ietf.org/html/rfc6890) β€” Special-Purpose IP Address Registries + +/// Address Management Transitions (AMT) relay addresses. +/// +/// Defined in [RFC 7450](https://tools.ietf.org/html/rfc7450). +/// Not typically relevant for SSRF unless your application explicitly uses AMT. +pub const AMT: &[&str] = &["192.52.193.0/24", "2001:3::/32"]; + +/// AS112 DNS server addresses for reverse DNS of private-use space. +/// +/// Defined in [RFC 7535](https://tools.ietf.org/html/rfc7535). +pub const AS112: &[&str] = &[ + "192.31.196.0/24", + "192.175.48.0/24", + "2001:4:112::/48", + "2620:4f:8000::/48", +]; + +/// Benchmarking addresses for network interconnection devices. +/// +/// Defined in [RFC 2544](https://tools.ietf.org/html/rfc2544) and +/// [RFC 5180](https://tools.ietf.org/html/rfc5180). +pub const BENCHMARKING: &[&str] = &["198.18.0.0/15", "2001:2::/48"]; + +/// Deprecated 6to4 anycast relay addresses. +/// +/// Formerly used for 6to4 transition; now deprecated per +/// [RFC 7526](https://tools.ietf.org/html/rfc7526). +pub const DEPRECATED: &[&str] = &["192.88.99.0/24", "2001:10::/28"]; + +/// DetNet Service Prefixes for Deterministic Networking. +/// +/// Defined in [RFC 9023](https://tools.ietf.org/html/rfc9023). +pub const DETSPREFIX: &[&str] = &["2001:30::/28"]; + +/// Discard-only prefix for IPv6 documentation. +/// +/// Defined in [RFC 6666](https://tools.ietf.org/html/rfc6666). +pub const DISCARDONLY: &[&str] = &["100::/64"]; + +/// Documentation and example addresses. +/// +/// These ranges are reserved for use in documentation and examples +/// ([RFC 5737](https://tools.ietf.org/html/rfc5737), [RFC 3849](https://tools.ietf.org/html/rfc3849)). +/// They should never appear in legitimate traffic. +pub const DOCUMENTATION: &[&str] = &[ + "192.0.2.0/24", + "198.51.100.0/24", + "203.0.113.0/24", + "2001:db8::/32", + "3fff::/20", +]; + +/// Dummy / test addresses for protocol experiments. +/// +/// Defined in [RFC 4727](https://tools.ietf.org/html/rfc4727). +pub const DUMMY: &[&str] = &["192.0.0.8/32", "100:0:0:1::/64"]; + +/// IETF protocol assignment addresses. +/// +/// Used for protocol number assignments and other IETF purposes +/// ([RFC 6890](https://tools.ietf.org/html/rfc6890)). +pub const IETFPROTOCOL: &[&str] = &["192.0.0.0/24", "2001::/23"]; + +/// Azure Instance Metadata Service (IMDS) endpoint. +/// +/// `169.254.169.254` is the link-local address used by Azure VMs to retrieve +/// instance metadata, tokens, and secrets. **Blocking this is critical** +/// for SSRF prevention in Azure environments. +pub const IMDS: &[&str] = &["169.254.169.254/32"]; + +/// IPv4/IPv6 translation well-known prefixes. +/// +/// Defined in [RFC 6052](https://tools.ietf.org/html/rfc6052) and +/// [RFC 8215](https://tools.ietf.org/html/rfc8215). +pub const IPV4IPV6TRANSLAT: &[&str] = &["64:ff9b::/96", "64:ff9b:1::/48"]; + +/// IPv4 Service Continuity Prefix for DS-Lite. +/// +/// Defined in [RFC 6333](https://tools.ietf.org/html/rfc6333). +pub const IPV4SERVICECONTINUITY: &[&str] = &["192.0.0.0/29"]; + +/// Limited broadcast address. +/// +/// `255.255.255.255` is the IPv4 limited broadcast destination +/// ([RFC 919](https://tools.ietf.org/html/rfc919)). +pub const BROADCAST: &[&str] = &["255.255.255.255/32"]; + +/// Link-local addresses (auto-configured, non-routable). +/// +/// `169.254.0.0/16` (IPv4) and `fe80::/10` (IPv6) are used for local network +/// communication without a DHCP server. Commonly exploited in SSRF attacks. +pub const LINKLOCAL: &[&str] = &["169.254.0.0/16", "fe80::/10"]; + +/// Loopback addresses. +/// +/// `127.0.0.0/8` (IPv4) and `::1/128` (IPv6) refer to the local host. +/// **Always block in ExternalOnly policies** to prevent connections to +/// local services. +pub const LOOPBACK: &[&str] = &["127.0.0.0/8", "::1/128"]; + +/// Multicast addresses. +/// +/// `224.0.0.0/4` (IPv4) and `ff00::/8` (IPv6) are reserved for multicast +/// traffic ([RFC 1112](https://tools.ietf.org/html/rfc1112), [RFC 4291](https://tools.ietf.org/html/rfc4291)). +pub const MULTICAST: &[&str] = &["224.0.0.0/4", "ff00::/8"]; + +/// ORCHIDv2 overlay routable cryptographic hash identifiers. +/// +/// Defined in [RFC 7343](https://tools.ietf.org/html/rfc7343). +pub const ORCHIDV2: &[&str] = &["2001:20::/28"]; + +/// Private-use (RFC 1918) addresses. +/// +/// `10.0.0.0/8`, `172.16.0.0/12`, and `192.168.0.0/16` are the standard +/// private IPv4 ranges. These are the **most common targets** for SSRF +/// attacks against internal infrastructure. +pub const PRIVATEUSE: &[&str] = &["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]; + +/// Reserved IPv4 addresses (future use). +/// +/// `240.0.0.0/4` is reserved by [RFC 1112](https://tools.ietf.org/html/rfc1112) +/// for future use. +pub const RESERVED: &[&str] = &["240.0.0.0/4"]; + +/// Carrier-grade NAT (CGNAT) shared address space. +/// +/// `100.64.0.0/10` is reserved for ISP-level NAT per +/// [RFC 6598](https://tools.ietf.org/html/rfc6598). Should be treated as +/// internal for SSRF purposes. +pub const SHAREDADDRESSSPACE: &[&str] = &["100.64.0.0/10"]; + +/// Deprecated site-local IPv6 addresses. +/// +/// `fec0::/10` was deprecated by [RFC 3879](https://tools.ietf.org/html/rfc3879). +pub const SITELOCAL: &[&str] = &["fec0::/10"]; + +/// 6to4 transition anycast addresses. +/// +/// Defined in [RFC 3056](https://tools.ietf.org/html/rfc3056). +pub const SIXTO4: &[&str] = &["2002::/16"]; + +/// SRv6 Segment Identifier (SID) prefix. +/// +/// Defined in [RFC 9602](https://tools.ietf.org/html/rfc9602). +pub const SRV6SID: &[&str] = &["5f00::/16"]; + +/// Teredo transition tunneling addresses. +/// +/// Defined in [RFC 4380](https://tools.ietf.org/html/rfc4380). +pub const TEREDO: &[&str] = &["2001::/32"]; + +/// Unique local IPv6 unicast addresses (ULA). +/// +/// `fc00::/7` is the IPv6 equivalent of RFC 1918 private addresses +/// ([RFC 4193](https://tools.ietf.org/html/rfc4193)). +pub const UNIQUELOCAL: &[&str] = &["fc00::/7"]; + +/// Unspecified addresses. +/// +/// `0.0.0.0/8` (IPv4) and `::/128` (IPv6) represent "this host on this network" +/// ([RFC 1122](https://tools.ietf.org/html/rfc1122)). +pub const UNSPECIFIED: &[&str] = &["0.0.0.0/8", "::/128"]; + +/// Azure Wire Server endpoint. +/// +/// `168.63.129.16` is used by Azure for VM Agent communication and +/// DHCP-like functionality. **Block in ExternalOnly policies** to prevent +/// SSRF-based metadata exfiltration. +pub const WIRESERVER: &[&str] = &["168.63.129.16/32"]; + +/// Recommended deny list (version 1) β€” comprehensive combination of all +/// dangerous special-purpose ranges. +/// +/// This list includes: +/// +/// - Loopback, link-local, multicast, broadcast +/// - Private-use (RFC 1918) and CGNAT (RFC 6598) +/// - Documentation / example ranges +/// - Transition / deprecated ranges +/// - Azure-specific endpoints ([`IMDS`], [`WIRESERVER`]) +/// +/// Used by [`PolicyConfigOptions::ExternalOnlyLatest`](crate::PolicyConfigOptions::ExternalOnlyLatest). +/// This is the default comprehensive blocklist for production use. +pub const RECOMMENDEDV1: &[&str] = &[ + "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", +]; + +/// Alias for the current recommended deny list. +/// +/// Always points to [`RECOMMENDEDV1`]. When a new version is introduced, +/// this alias will be updated to reference it. +pub const RECOMMENDED_LATEST: &[&str] = RECOMMENDEDV1; diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..625e3c5 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,167 @@ +//! AntiSSRF β€” Microsoft's SSRF Prevention Library for Rust +//! +//! A Rust implementation of [Microsoft's AntiSSRF](https://github.com/microsoft/AntiSSRF) +//! library for preventing **Server-Side Request Forgery (SSRF)** attacks. +//! +//! SSRF occurs when an attacker tricks a server into making requests to +//! unintended destinations β€” typically internal services, cloud metadata +//! endpoints, or restricted networks. This crate provides layered defenses +//! against such attacks by validating URLs, IP addresses, headers, and +//! redirect chains **before** any network request is issued. +//! +//! # Architecture +//! +//! The library is organized into three layers: +//! +//! | Layer | Module | Purpose | +//! |-------|--------|---------| +//! | **Policy** | [`policy`] | Configure blocking rules, allowlists, and required headers | +//! | **Validation** | [`uri_validator`], [`cidr`] | Static checks for domains, CIDR ranges, and IP addresses | +//! | **Network** | [`network`] (reqwest feature) | `reqwest` middleware that enforces policy on every request and redirect | +//! +//! # Quick Start +//! +//! Build a policy that blocks all internal/sensitive IP ranges and then +//! wrap a `reqwest` client with enforcement middleware: +//! +//! ```no_run +//! # #[cfg(feature = "reqwest-integration")] +//! # { +//! use antissrf::{AntiSSRFPolicy, PolicyConfigOptions}; +//! use antissrf::network::reqwest_integration::AntiSSRFClientBuilder; +//! +//! # fn main() -> Result<(), Box> { +//! let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); +//! +//! let client = AntiSSRFClientBuilder::new(policy) +//! .build_with_middleware()?; +//! +//! // Any request to a forbidden IP (e.g. 169.254.169.254) is rejected +//! // before a TCP connection is opened. +//! # Ok(()) +//! # } +//! # } +//! ``` +//! +//! # Feature Flags +//! +//! | Flag | Default | Description | +//! |------|---------|-------------| +//! | `reqwest-integration` | **Yes** | Enables [`network`] module with `reqwest` / `reqwest-middleware` / `tower` support | +//! +//! Disable the default features if you only need static validation: +//! +//! ```toml +//! [dependencies] +//! antissrf = { version = "0.1.1", default-features = false } +//! ``` +//! +//! # Usage Patterns +//! +//! ## Static IP / CIDR validation +//! +//! Use [`CIDRBlock`] and the constants in [`ip_address_ranges`] +//! directly when you don't need a full policy: +//! +//! ``` +//! use antissrf::CIDRBlock; +//! use antissrf::ip_address_ranges::RECOMMENDEDV1; +//! +//! # fn main() -> Result<(), Box> { +//! let imds = CIDRBlock::parse("169.254.169.254/32")?; +//! assert!(imds.contains("169.254.169.254".parse()?)); +//! +//! // Recommended set covers IMDS, WireServer, loopback, RFC 1918, etc. +//! let recommended: Vec = RECOMMENDEDV1 +//! .iter() +//! .map(|s| CIDRBlock::parse(s).unwrap()) +//! .collect(); +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Domain allowlisting +//! +//! [`URIValidator`] checks whether a URL belongs to a +//! trusted domain or an Azure service domain: +//! +//! ``` +//! use antissrf::URIValidator; +//! +//! assert!(URIValidator::in_domain("https://api.trusted.com/v1", &["trusted.com"])); +//! assert!(!URIValidator::in_azure_key_vault_domain("https://evil.com")); +//! ``` +//! +//! ## Fine-grained policy configuration +//! +//! [`AntiSSRFPolicy`] supports allowlists, +//! custom denylists, header enforcement, and protocol restrictions: +//! +//! ``` +//! use antissrf::{AntiSSRFPolicy, PolicyConfigOptions, AntiSSRFError}; +//! +//! # fn main() -> Result<(), AntiSSRFError> { +//! let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); +//! policy.set_allow_plaintext_http(false)?; // deny http:// +//! policy.add_required_headers(&["X-Request-ID"])?; +//! policy.add_denied_headers(&["X-Internal-Auth"])?; +//! +//! let mut headers = vec![ +//! ("X-Request-ID".to_string(), "abc123".to_string()), +//! ]; +//! assert!(policy.validate_request("https:", &mut headers)?); +//! # Ok(()) +//! # } +//! ``` +//! +//! # Security Considerations +//! +//! 1. **Validate after DNS resolution** β€” IP checks must be performed on +//! resolved addresses, not on the original hostname, to catch DNS rebinding +//! attacks. +//! 2. **Re-validate every redirect** β€” A benign initial URL may redirect to a +//! forbidden internal endpoint. The [`network`] middleware +//! enforces this automatically. +//! 3. **IPv6 normalization** β€” All IPv4 addresses are mapped to the IPv6-mapped +//! form (`::ffff:x.x.x.x`) before CIDR checks. Ensure your allow/deny +//! lists account for this. +//! 4. **Header case-insensitivity** β€” Header names are compared +//! case-insensitively per RFC 7230, but header values are compared +//! exactly. +//! 5. **Policy immutability** β€” Once built, a policy cannot be modified +//! (edit-lock). Create a new policy if requirements change. +//! +//! # References +//! +//! - [Microsoft AntiSSRF Documentation](https://microsoft.github.io/AntiSSRF/) +//! - [OWASP SSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html) +//! - [CWE-918](https://cwe.mitre.org/data/definitions/918.html) +//! +//! # Crate Map +//! +//! | Module | Description | +//! |--------|-------------| +//! | [`policy`] | [`AntiSSRFPolicy`] β€” central configuration object | +//! | [`error`] | [`AntiSSRFError`] β€” error variants with clear security semantics | +//! | [`cidr`] | [`CIDRBlock`] β€” parsing and IP containment with IPv6 normalization | +//! | [`ip_address_ranges`] | Static constants for RFC special-purpose IP ranges | +//! | [`domains`] | Azure cloud domain suffixes | +//! | [`uri_validator`] | [`URIValidator`] β€” domain and Azure service validation | +//! | [`network`] | `reqwest` middleware integration | + +pub mod cidr; +pub mod domains; +pub mod error; +pub mod ip_address_ranges; +#[cfg(feature = "reqwest-integration")] +pub mod network; +pub mod policy; +pub mod uri_validator; + +pub use cidr::CIDRBlock; +pub use error::AntiSSRFError; +pub use policy::{AntiSSRFPolicy, PolicyConfigOptions}; +pub use uri_validator::URIValidator; + +/// Result type alias for AntiSSRF operations. +pub type Result = std::result::Result; diff --git a/rust/src/network.rs b/rust/src/network.rs new file mode 100644 index 0000000..5fbc74a --- /dev/null +++ b/rust/src/network.rs @@ -0,0 +1,885 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! reqwest integration for AntiSSRF protection. +//! +//! This module provides three integration points for [`reqwest`](https://docs.rs/reqwest): +//! +//! | Type | Purpose | Used with | +//! |------|---------|-----------| +//! | [`AntiSSRFResolver`](crate::network::reqwest_integration::AntiSSRFResolver) | Custom DNS resolver that blocks disallowed IPs before connection | [`reqwest::ClientBuilder::dns_resolver`](reqwest::ClientBuilder) | +//! | [`AntiSSRFMiddleware`](crate::network::reqwest_integration::AntiSSRFMiddleware) | Tower-compatible middleware that validates headers, protocol, and redirects | [`reqwest_middleware`](https://docs.rs/reqwest-middleware) | +//! | [`AntiSSRFClientBuilder`](crate::network::reqwest_integration::AntiSSRFClientBuilder) | Convenience builder that wires resolver + middleware automatically | Direct construction | +//! +//! # Architecture +//! +//! DNS resolution is the **first** line of defence: [`AntiSSRFResolver`](crate::network::reqwest_integration::AntiSSRFResolver) intercepts +//! every hostname lookup and rejects resolved IPs that violate the policy. +//! This happens before any TCP connection is established. +//! +//! The [`AntiSSRFMiddleware`](crate::network::reqwest_integration::AntiSSRFMiddleware) layer is the **second** line of defence: +//! it validates request headers and protocol on the way out, and re-validates +//! every redirect URL on the way back. This protects against DNS rebinding +//! (where a malicious host changes its IP after the initial lookup) and +//! open-redirect attacks that pivot to internal services. +//! +//! # Minimal Example +//! +//! ```rust,no_run +//! use antissrf::{AntiSSRFPolicy, PolicyConfigOptions}; +//! use antissrf::network::reqwest_integration::AntiSSRFClientBuilder; +//! +//! # async fn example() -> Result<(), Box> { +//! let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); +//! let client = AntiSSRFClientBuilder::new(policy).build_with_middleware()?; +//! +//! let response = client +//! .get("https://example.com") +//! .send() +//! .await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! # Redirect Handling +//! +//! [`AntiSSRFClientBuilder::build_with_middleware`](crate::network::reqwest_integration::AntiSSRFClientBuilder::build_with_middleware) disables reqwest's native +//! redirect policy and replaces it with a custom implementation inside +//! [`AntiSSRFMiddleware`](crate::network::reqwest_integration::AntiSSRFMiddleware). The custom implementation: +//! +//! 1. Re-validates the redirect URL against the policy on every hop. +//! 2. Strips `Authorization` headers to prevent credential leakage. +//! 3. Converts POST β†’ GET on 301/302/303 per RFC 7231. +//! 4. Preserves POST on 307/308 per RFC 7538. +//! 5. Caps at 50 hops to prevent infinite loops. +//! +//! # Feature Flag +//! +//! This module is only available when the **`reqwest-integration`** feature is enabled. + +#[cfg(feature = "reqwest-integration")] +pub mod reqwest_integration { + use crate::{AntiSSRFError, AntiSSRFPolicy}; + use http::Extensions; + use reqwest::dns::{Name, Resolve, Resolving}; + use reqwest::{Request, Response}; + use reqwest_middleware::Error as MiddlewareError; + use reqwest_middleware::{Middleware, Next, Result as MiddlewareResult}; + use std::net::SocketAddr; + use std::sync::Arc; + use tokio::net::lookup_host; + + /// Custom DNS resolver that validates resolved IPs against AntiSSRF policy. + /// + /// Wraps tokio's DNS lookup with IP address filtering. Any resolved IP + /// that violates the policy causes the lookup to fail with [`AntiSSRFError::IPDisallowed`]. + /// + /// Because DNS happens **before** TCP connection, this blocks SSRF at the + /// earliest possible point in the network stack. + /// + /// # Arguments + /// + /// * `policy` β€” An [`AntiSSRFPolicy`] (cloned internally, so no external + /// lifetime requirements). + /// + /// # Returns + /// + /// A boxed future that yields an iterator of [`SocketAddr`]s, or an error + /// if every resolved IP is disallowed. + /// + /// # Examples + /// + /// ```rust,no_run + /// use std::sync::Arc; + /// use antissrf::{AntiSSRFPolicy, PolicyConfigOptions}; + /// use antissrf::network::reqwest_integration::AntiSSRFResolver; + /// + /// # async fn example() -> Result<(), Box> { + /// let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + /// let resolver = AntiSSRFResolver::new(policy); + /// + /// let client = reqwest::Client::builder() + /// .dns_resolver(Arc::new(resolver)) + /// .build()?; + /// # Ok(()) + /// # } + /// ``` + #[derive(Debug, Clone)] + pub struct AntiSSRFResolver { + policy: AntiSSRFPolicy, + } + + impl AntiSSRFResolver { + /// Creates a new resolver wrapping the given policy. + pub fn new(policy: AntiSSRFPolicy) -> Self { + Self { policy } + } + } + + impl Resolve for AntiSSRFResolver { + fn resolve(&self, name: Name) -> Resolving { + let mut policy = self.policy.clone(); + let host = name.as_str().to_string(); + + Box::pin(async move { + // For bare IP addresses, skip DNS lookup and use the IP directly. + // lookup_host requires host:port format, so we bypass it for IPs. + let addrs: Vec = if let Ok(ip) = host.parse::() { + vec![SocketAddr::new(ip, 0)] // bare IPs are treated as `host:0` for policy checking + } else { + // Perform DNS lookup for non-IP hosts. + // We append ":0" to satisfy lookup_host's requirement for a port, but the port is ignored in policy checks. + lookup_host(format!("{}:0", host)) + .await + .map_err(Box::new)? + .collect() + }; + + let ip_strings: Vec = addrs + .iter() + .map(|a: &SocketAddr| a.ip().to_string()) + .collect(); + let ip_refs: Vec<&str> = ip_strings.iter().map(|s: &String| s.as_str()).collect(); + + let allowed: bool = policy + .is_network_connection_allowed(&ip_refs) + .map_err(Box::new)?; + if !allowed { + Err(Box::new(AntiSSRFError::IPDisallowed))? + } + + let addrs: Box + Send> = + Box::new(addrs.into_iter()); + Ok(addrs) + }) + } + } + + /// Convenience builder for creating a reqwest Client pre-configured with AntiSSRF. + /// + /// Provides a fluent API to build a [`reqwest::Client`] that uses [`AntiSSRFResolver`] + /// for DNS resolution. Redirects are disabled at the reqwest level; use + /// [`Self::build_with_middleware`] to get full redirect re-validation. + /// + /// # Example + /// + /// ```rust,no_run + /// use antissrf::{AntiSSRFPolicy, PolicyConfigOptions}; + /// use antissrf::network::reqwest_integration::AntiSSRFClientBuilder; + /// + /// # async fn example() -> Result<(), Box> { + /// let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + /// let client = AntiSSRFClientBuilder::new(policy) + /// .timeout(std::time::Duration::from_secs(30)) + /// .build()?; + /// # Ok(()) + /// # } + /// ``` + #[derive(Debug)] + pub struct AntiSSRFClientBuilder { + policy: AntiSSRFPolicy, + builder: reqwest::ClientBuilder, + } + + impl AntiSSRFClientBuilder { + /// Creates a new builder with the given policy. + /// + /// Automatically registers an [`AntiSSRFResolver`] and disables reqwest's + /// native redirect handling. + pub fn new(policy: AntiSSRFPolicy) -> Self { + let resolver = Arc::new(AntiSSRFResolver::new(policy.clone())); + let builder = reqwest::Client::builder() + .dns_resolver(resolver) + .redirect(reqwest::redirect::Policy::none()); + + Self { policy, builder } + } + + /// Sets a global timeout for the entire request (including all redirects). + /// + /// See [`reqwest::ClientBuilder::timeout`]. + /// + /// # Example + /// + /// ```rust,no_run + /// use antissrf::{AntiSSRFPolicy, PolicyConfigOptions}; + /// use antissrf::network::reqwest_integration::AntiSSRFClientBuilder; + /// + /// # async fn example() -> Result<(), Box> { + /// let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + /// let client = AntiSSRFClientBuilder::new(policy) + /// .timeout(std::time::Duration::from_secs(30)) + /// .build()?; + /// # Ok(()) + /// # } + /// ``` + pub fn timeout(mut self, timeout: std::time::Duration) -> Self { + self.builder = self.builder.timeout(timeout); + self + } + + /// Sets a timeout for establishing a TCP connection. + /// + /// See [`reqwest::ClientBuilder::connect_timeout`]. + pub fn connect_timeout(mut self, timeout: std::time::Duration) -> Self { + self.builder = self.builder.connect_timeout(timeout); + self + } + + /// Sets the maximum number of idle connections per host. + /// + /// See [`reqwest::ClientBuilder::pool_max_idle_per_host`]. + pub fn pool_max_idle_per_host(mut self, max: usize) -> Self { + self.builder = self.builder.pool_max_idle_per_host(max); + self + } + + /// Builds the configured [`reqwest::Client`]. + /// + /// Redirects are disabled on the returned client; redirect handling + /// is performed by [`AntiSSRFMiddleware`] when used via + /// [`Self::build_with_middleware`]. + /// + /// # Errors + /// + /// Returns an error if the underlying reqwest client fails to build. + pub fn build(self) -> Result { + self.builder.build() + } + + /// Builds a [`reqwest_middleware::ClientWithMiddleware`] with redirect + /// re-validation enabled. + /// + /// This is the **preferred** method when you need full AntiSSRF protection + /// including redirect chain validation. Returns a client that runs + /// [`AntiSSRFMiddleware`] on every request and re-validates each + /// redirect hop against the policy. + /// + /// # Errors + /// + /// Returns an error if the underlying reqwest client fails to build. + /// + /// # Example + /// + /// ```rust,no_run + /// use antissrf::{AntiSSRFPolicy, PolicyConfigOptions}; + /// use antissrf::network::reqwest_integration::AntiSSRFClientBuilder; + /// + /// # async fn example() -> Result<(), Box> { + /// let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + /// let client = AntiSSRFClientBuilder::new(policy).build_with_middleware()?; + /// + /// let response = client + /// .get("https://example.com") + /// .send() + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub fn build_with_middleware( + self, + ) -> Result { + let client = self.builder.build()?; + let middleware = AntiSSRFMiddleware::new(self.policy).with_client(client.clone()); + Ok(reqwest_middleware::ClientBuilder::new(client) + .with(middleware) + .build()) + } + } + + /// Middleware that validates HTTP request headers and protocol against AntiSSRF policy. + /// + /// Intercepts outgoing requests to ensure: + /// - Protocol is allowed (`https` always ok, `http` only if `allow_plaintext_http`) + /// - Denied headers are not present + /// - Required headers are present + /// - Injects `X-Forwarded-For` if configured + /// - Re-validates redirect URLs on every hop + /// + /// # Example + /// + /// ```rust,no_run + /// use antissrf::{AntiSSRFPolicy, PolicyConfigOptions}; + /// use antissrf::network::reqwest_integration::AntiSSRFMiddleware; + /// use reqwest_middleware::ClientBuilder; + /// + /// # async fn example() -> Result<(), Box> { + /// let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + /// let middleware = AntiSSRFMiddleware::new(policy); + /// + /// let client = ClientBuilder::new(reqwest::Client::new()) + /// .with(middleware) + /// .build(); + /// # Ok(()) + /// # } + /// ``` + #[derive(Debug)] + pub struct AntiSSRFMiddleware { + policy: AntiSSRFPolicy, + client: Option, + } + + impl AntiSSRFMiddleware { + /// Creates a new middleware instance with the given policy. + /// + /// Redirect re-validation requires a client to be supplied via + /// [`Self::with_client`]. + pub fn new(policy: AntiSSRFPolicy) -> Self { + Self { + policy, + client: None, + } + } + + /// Provide a [`reqwest::Client`] for handling redirect requests. + /// + /// Without a stored client, redirects cannot be re-validated and + /// the middleware returns the redirect response as-is (still safe + /// because DNS was already filtered by [`AntiSSRFResolver`]). + pub fn with_client(mut self, client: reqwest::Client) -> Self { + self.client = Some(client); + self + } + } + + #[async_trait::async_trait] + impl Middleware for AntiSSRFMiddleware { + async fn handle( + &self, + mut req: Request, + _extensions: &mut Extensions, + next: Next<'_>, + ) -> MiddlewareResult { + let mut policy = self.policy.clone(); + + let mut current_url = req.url().clone(); + let mut current_method = req.method().clone(); + let mut current_headers = req.headers().clone(); + + Self::validate_and_prepare(&mut policy, &mut req)?; + + let mut response = next.run(req, _extensions).await?; + let mut redirect_count = 0; + const MAX_REDIRECTS: usize = 50; + + while redirect_count < MAX_REDIRECTS { + let status = response.status(); + + let is_redirect = matches!(status.as_u16(), 300 | 301 | 302 | 303 | 307 | 308); + let location = response.headers().get(reqwest::header::LOCATION).cloned(); + + if !is_redirect || location.is_none() { + return Ok(response); + } + + let location = location.unwrap(); + let location_str = location.to_str().map_err(|e| { + MiddlewareError::Middleware( + AntiSSRFError::InvalidURL(format!("Invalid location header: {}", e)).into(), + ) + })?; + + // RFC 7231 Β§7.1.2: Location may be relative or absolute. + // `Url::join` handles both β€” absolute URL replaces host, + // relative path is resolved against current URL. + let mut redirect_url = current_url.join(location_str).map_err(|e| { + MiddlewareError::Middleware( + AntiSSRFError::InvalidURL(format!("Invalid redirect URL: {}", e)).into(), + ) + })?; + + if redirect_url.fragment().is_none() { + // RFC 7231 Β§7.1.2: Preserve original fragment when redirect lacks one. + // e.g. GET /page#sec β†’ 301 Location: /other β†’ redirect to /other#sec + if let Some(fragment) = current_url.fragment() { + redirect_url.set_fragment(Some(fragment)); + } + } + + let new_method = Self::redirect_method(¤t_method, status); + let mut new_req = Request::new(new_method.clone(), redirect_url.clone()); + + // RFC 7235 Β§4.1 Strip Authorization on redirect: prevents cross-origin credential leak. + for (name, value) in current_headers.iter() { + if !name.as_str().eq_ignore_ascii_case("authorization") { + new_req.headers_mut().insert(name.clone(), value.clone()); + } + } + + // Clear body-related headers when method changes (e.g. POSTβ†’GET on 301/302/303). + // These headers reference a body that no longer exists and can confuse servers. + if new_method != current_method { + new_req.headers_mut().remove(reqwest::header::CONTENT_TYPE); + new_req + .headers_mut() + .remove(reqwest::header::CONTENT_LENGTH); + new_req + .headers_mut() + .remove(reqwest::header::TRANSFER_ENCODING); + new_req + .headers_mut() + .remove(reqwest::header::CONTENT_ENCODING); + new_req.headers_mut().remove(reqwest::header::EXPECT); + } + + Self::validate_and_prepare(&mut policy, &mut new_req)?; + + current_url = redirect_url; + current_method = new_method; + current_headers = new_req.headers().clone(); + + let client = match &self.client { + Some(c) => c, + None => return Ok(response), + }; + + response = client + .execute(new_req) + .await + .map_err(MiddlewareError::Reqwest)?; + redirect_count += 1; + } + + Ok(response) + } + } + + impl AntiSSRFMiddleware { + /// Validates scheme, headers, and protocol against the policy. + /// + /// Also applies any header mutations requested by the policy (e.g. + /// injecting `X-Forwarded-For`, removing denied headers). + /// + /// # Errors + /// + /// Returns [`MiddlewareError::Middleware`] wrapping [`AntiSSRFError`] if + /// the request violates the policy. + fn validate_and_prepare( + policy: &mut AntiSSRFPolicy, + req: &mut Request, + ) -> Result<(), MiddlewareError> { + let scheme = req.url().scheme(); + let protocol = format!("{}:", scheme); + + let mut headers: Vec<(String, String)> = req + .headers() + .iter() + .filter_map(|(k, v)| { + let key = k.to_string(); + let value = v.to_str().ok()?.to_string(); + Some((key, value)) + }) + .collect(); + + policy + .validate_request(&protocol, &mut headers) + .map_err(|e| MiddlewareError::Middleware(e.into()))?; + + // Overwrite all headers with validated set. + // validate_request may have injected XFF/filtered denied headers; + // the returned Vec is the authoritative header list. + req.headers_mut().clear(); + for (key, value) in headers { + let name = + reqwest::header::HeaderName::from_bytes(key.as_bytes()).map_err(|e| { + MiddlewareError::Middleware( + AntiSSRFError::InvalidURL(format!("Invalid header name: {}", e)).into(), + ) + })?; + let val = reqwest::header::HeaderValue::from_str(&value).map_err(|e| { + MiddlewareError::Middleware( + AntiSSRFError::InvalidURL(format!("Invalid header value: {}", e)).into(), + ) + })?; + req.headers_mut().insert(name, val); + } + + Ok(()) + } + + /// Determines the HTTP method to use after a redirect. + /// + /// Follows RFC 7231 and RFC 7538: + /// - 301/302: POST β†’ GET + /// - 303: any non-idempotent β†’ GET + /// - 307/308: preserve method + fn redirect_method( + method: &reqwest::Method, + status: reqwest::StatusCode, + ) -> reqwest::Method { + match status.as_u16() { + 300..=302 => { + if *method == reqwest::Method::POST { + reqwest::Method::GET + } else { + method.clone() + } + } + 303 => { + if *method != reqwest::Method::GET && *method != reqwest::Method::HEAD { + reqwest::Method::GET + } else { + method.clone() + } + } + 307 | 308 => method.clone(), + _ => method.clone(), + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + use crate::PolicyConfigOptions; + use std::str::FromStr; + + #[tokio::test] + async fn resolver_allows_public_ip() { + let policy = AntiSSRFPolicy::new(PolicyConfigOptions::None); + let resolver = AntiSSRFResolver::new(policy); + // Use direct IP to avoid DNS dependency in test environment + let name: Name = "8.8.8.8".parse().unwrap(); + + let result = resolver.resolve(name).await; + + if let Err(ref e) = result { + eprintln!("Resolver error: {}", e); + } + assert!( + result.is_ok(), + "Public IP with None policy should be allowed" + ); + } + + #[tokio::test] + async fn resolver_blocks_localhost() { + let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + let resolver = AntiSSRFResolver::new(policy); + let name: Name = "127.0.0.1".parse().unwrap(); + + let result = resolver.resolve(name).await; + assert!( + result.is_err(), + "127.0.0.1 with ExternalOnlyLatest should be blocked" + ); + } + + #[tokio::test] + async fn resolver_blocks_imds() { + let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + let resolver = AntiSSRFResolver::new(policy); + let name: Name = "169.254.169.254".parse().unwrap(); + + let result = resolver.resolve(name).await; + assert!( + result.is_err(), + "IMDS IP with ExternalOnlyLatest should be blocked" + ); + } + + #[tokio::test] + async fn test_ipv6_bracketed_imds_blocked() { + let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + let resolver = AntiSSRFResolver::new(policy); + let name = Name::from_str("[::ffff:a9fe:a9fe]").unwrap(); + let result = resolver.resolve(name).await; + assert!(result.is_err(), "Should block IPv6 mapped IMDS"); + } + + #[tokio::test] + async fn test_ipv6_unbracketed_imds_blocked() { + let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + let resolver = AntiSSRFResolver::new(policy); + let name = Name::from_str("::ffff:a9fe:a9fe").unwrap(); + let result = resolver.resolve(name).await; + assert!(result.is_err(), "Should block IPv6 mapped IMDS"); + } + + #[tokio::test] + async fn test_ipv6_bracketed_imds_error_type() { + let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + let resolver = AntiSSRFResolver::new(policy); + let name = Name::from_str("[::ffff:a9fe:a9fe]").unwrap(); + let result = resolver.resolve(name).await; + + let err_str = match result { + Err(e) => format!("{}", e), + Ok(_) => panic!("Expected error, got success"), + }; + // Bracketed IPv6 bypasses direct IpAddr parse, so it may hit DNS lookup path + // and produce a DNS error rather than IPDisallowed β€” known limitation + assert!( + err_str.contains("IP address disallowed") + || err_str.contains("DNS") + || err_str.contains("failed to lookup address"), + "Unexpected error: {}", + err_str + ); + } + + #[tokio::test] + async fn test_ipv6_unbracketed_imds_error_type() { + let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + let resolver = AntiSSRFResolver::new(policy); + let name = Name::from_str("::ffff:a9fe:a9fe").unwrap(); + let result = resolver.resolve(name).await; + + let err_str = match result { + Err(e) => format!("{}", e), + Ok(_) => panic!("Expected error, got success"), + }; + assert!( + err_str.contains("IP address disallowed"), + "Expected IPDisallowed, got: {}", + err_str + ); + } + + #[tokio::test] + async fn hex_ip_is_normalized_and_blocked() { + // URL parser normalizes 0xA9.0xFE.0xA9.0xFE -> 169.254.169.254 + // Middleware blocks before connecting, so no mock server needed + let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware() + .expect("should build"); + + let result = client.get("http://0xA9.0xFE.0xA9.0xFE/").send().await; + + assert!( + result.is_err(), + "Hex-encoded IMDS IP (0xA9.0xFE.0xA9.0xFE) should be blocked after URL normalization" + ); + } + + #[tokio::test] + async fn client_builder_works() { + let policy = AntiSSRFPolicy::new(PolicyConfigOptions::None); + let client = AntiSSRFClientBuilder::new(policy) + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Client should build"); + + // Just verify it doesn't panic + drop(client); + } + + #[tokio::test] + async fn client_builder_with_middleware_works() { + let policy = AntiSSRFPolicy::new(PolicyConfigOptions::None); + let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware() + .expect("ClientWithMiddleware should build"); + + drop(client); + } + + fn localhost_allowlist_policy() -> AntiSSRFPolicy { + let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + policy.add_allowed_addresses(&["127.0.0.1/32"]).unwrap(); + policy.set_allow_plaintext_http(true).unwrap(); + policy + } + + #[tokio::test] + async fn redirect_blocks_disallowed_ip() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/redirect") + .with_status(301) + .with_header("Location", "http://169.254.169.254/target") + .create_async() + .await; + + let policy = localhost_allowlist_policy(); + let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware() + .expect("should build"); + + let result = client + .get(format!("{}/redirect", server.url())) + .send() + .await; + + assert!( + result.is_err(), + "Redirect to disallowed IMDS IP should be blocked" + ); + } + + #[tokio::test] + async fn redirect_allows_allowed_target() { + let mut server = mockito::Server::new_async().await; + let _redirect_mock = server + .mock("GET", "/redirect") + .with_status(301) + .with_header("Location", format!("{}/target", server.url()).as_str()) + .create_async() + .await; + + let _target_mock = server + .mock("GET", "/target") + .with_status(200) + .with_body("ok") + .create_async() + .await; + + let policy = localhost_allowlist_policy(); + let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware() + .expect("should build"); + + let response = client + .get(format!("{}/redirect", server.url())) + .send() + .await + .expect("request should succeed"); + + assert_eq!( + response.status(), + 200, + "Allowed redirect target should succeed" + ); + let body = response.text().await.expect("should have body"); + assert_eq!(body, "ok"); + } + + #[tokio::test] + async fn redirect_strips_authorization() { + let mut server = mockito::Server::new_async().await; + let _redirect_mock = server + .mock("GET", "/redirect") + .with_status(301) + .with_header("Location", format!("{}/target", server.url()).as_str()) + .create_async() + .await; + + let _target_mock = server + .mock("GET", "/target") + .with_status(200) + .create_async() + .await; + + let policy = localhost_allowlist_policy(); + let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware() + .expect("should build"); + + let response = client + .get(format!("{}/redirect", server.url())) + .header("Authorization", "Bearer secret") + .send() + .await + .expect("request should succeed"); + + assert_eq!( + response.status(), + 200, + "Redirect after stripping auth should succeed" + ); + } + + #[tokio::test] + async fn redirect_converts_post_to_get_on_301() { + let mut server = mockito::Server::new_async().await; + let _redirect_mock = server + .mock("POST", "/redirect") + .with_status(301) + .with_header("Location", format!("{}/target", server.url()).as_str()) + .create_async() + .await; + + // Only match GET on target β€” if POST is preserved, this won't match + let _target_mock = server + .mock("GET", "/target") + .with_status(200) + .create_async() + .await; + + let policy = localhost_allowlist_policy(); + let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware() + .expect("should build"); + + let response = client + .post(format!("{}/redirect", server.url())) + .body("test body") + .send() + .await + .expect("request should succeed"); + + assert_eq!( + response.status(), + 200, + "POST should be converted to GET on 301 redirect" + ); + } + + #[tokio::test] + async fn redirect_preserves_post_on_307() { + let mut server = mockito::Server::new_async().await; + let _redirect_mock = server + .mock("POST", "/redirect") + .with_status(307) + .with_header("Location", format!("{}/target", server.url()).as_str()) + .create_async() + .await; + + // Only match POST on target β€” if GET conversion happens, this won't match + let _target_mock = server + .mock("POST", "/target") + .with_status(200) + .create_async() + .await; + + let policy = localhost_allowlist_policy(); + let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware() + .expect("should build"); + + let response = client + .post(format!("{}/redirect", server.url())) + .body("test body") + .send() + .await + .expect("request should succeed"); + + assert_eq!( + response.status(), + 200, + "POST should be preserved on 307 redirect" + ); + } + + #[tokio::test] + async fn redirect_caps_at_50() { + let mut server = mockito::Server::new_async().await; + let server_url = server.url(); + + let _redirect_mock = server + .mock("GET", "/loop") + .with_status(301) + .with_header("Location", format!("{}/loop", server_url).as_str()) + .create_async() + .await; + + let policy = localhost_allowlist_policy(); + let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware() + .expect("should build"); + + let response = client + .get(format!("{}/loop", server_url)) + .send() + .await + .expect("request should not error"); + + assert_eq!( + response.status(), + 301, + "After 50 redirects, the 51st redirect response should be returned" + ); + } + } +} diff --git a/rust/src/policy.rs b/rust/src/policy.rs new file mode 100644 index 0000000..976da51 --- /dev/null +++ b/rust/src/policy.rs @@ -0,0 +1,1107 @@ +//! Policy configuration and request validation. +//! +//! [`AntiSSRFPolicy`] is the central configuration object. It is created once, +//! optionally mutated through builder-style methods, and then locked on first use. +//! Once locked it becomes immutable, ensuring that validation rules cannot change +//! mid-request. +//! +//! # Evaluation order +//! +//! When [`is_network_connection_allowed`](AntiSSRFPolicy::is_network_connection_allowed) +//! checks a list of IP addresses, the following precedence is used for **each** IP: +//! +//! 1. **Allowlist** β€” if the IP is inside any [`CIDRBlock`] in +//! [`allowed_addresses`](AntiSSRFPolicy::allowed_addresses), the IP is **allowed**. +//! 2. **`deny_all_unspecified_ips`** β€” if this flag is `true` and the IP was not +//! in the allowlist, the IP is **denied**. +//! 3. **Denylist** β€” if the IP is inside any [`CIDRBlock`] in +//! [`denied_addresses`](AntiSSRFPolicy::denied_addresses), the IP is **denied**. +//! 4. **Default** β€” if none of the above matched, the IP is **allowed**. +//! +//! This means the allowlist always wins, even over `deny_all_unspecified_ips`. +//! +//! # Edit locking +//! +//! A policy is automatically locked the first time a validation method +//! ([`is_network_connection_allowed`](AntiSSRFPolicy::is_network_connection_allowed) +//! or [`validate_request`](AntiSSRFPolicy::validate_request)) is called. +//! After that point all mutating methods return [`PolicyLocked`](crate::AntiSSRFError::PolicyLocked). +//! This prevents accidental or malicious runtime changes to security rules. +//! +//! # Example +//! +//! ``` +//! use antissrf::{AntiSSRFPolicy, PolicyConfigOptions, AntiSSRFError}; +//! +//! # fn main() -> Result<(), AntiSSRFError> { +//! let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); +//! +//! // Allow a specific internal range (allowlist wins over denylist) +//! policy.add_allowed_addresses(&["10.0.0.0/8"])?; +//! +//! // Require a tracing header +//! policy.add_required_headers(&["x-request-id"])?; +//! +//! // Lock occurs here +//! assert!(policy.is_network_connection_allowed(&["8.8.8.8"])?); +//! assert!(policy.is_network_connection_allowed(&["10.0.0.1"])?); +//! assert!(!policy.is_network_connection_allowed(&["169.254.169.254"])?); +//! # Ok(()) +//! # } +//! ``` + +use crate::cidr::CIDRBlock; +use crate::error::AntiSSRFError; +use crate::ip_address_ranges; +use std::net::IpAddr; + +/// Preset blocking strategies used when constructing an [`AntiSSRFPolicy`]. +/// +/// These options configure sensible defaults. You can further customize the +/// policy after creation via [`AntiSSRFPolicy`] methods. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PolicyConfigOptions { + /// No restrictions. All IP addresses, protocols, and headers are allowed. + /// + /// Use this variant when you intend to configure every rule manually or + /// when you only need the library for CIDR parsing and domain validation. + None, + + /// Block all external IPs unless explicitly allowlisted. + /// + /// Sets [`deny_all_unspecified_ips`](AntiSSRFPolicy::deny_all_unspecified_ips) + /// to `true`. Any IP address that is **not** in the allowlist is denied. + /// This is suitable for services that should only talk to a known set of + /// internal or partner endpoints. + InternalOnly, + + /// Block known dangerous IP ranges (V1 list). + /// + /// Populates the denylist with [`crate::ip_address_ranges::RECOMMENDEDV1`], + /// which includes: + /// - Azure IMDS (`169.254.169.254/32`) + /// - Azure WireServer (`168.63.129.16/32`) + /// - Loopback, link-local, multicast, RFC 1918 private ranges, CGNAT, and more + /// + /// Also enables [`add_xff_header`](AntiSSRFPolicy::add_xff_header). + /// External public IPs (e.g. `8.8.8.8`) remain allowed. + ExternalOnlyV1, + + /// Alias for [`ExternalOnlyV1`](PolicyConfigOptions::ExternalOnlyV1). + /// + /// Reserved for forward compatibility. When a future version introduces a + /// new recommended blocklist, this variant will automatically map to it. + ExternalOnlyLatest, +} + +/// Central configuration object for SSRF prevention. +/// +/// `AntiSSRFPolicy` is created via [`new`](AntiSSRFPolicy::new) with a +/// [`PolicyConfigOptions`] preset and then customised through `add_*` / `set_*` +/// methods. Once a validation method is called the policy is **locked** and +/// can no longer be changed. +/// +/// # Thread safety +/// +/// The type is `Clone` but not `Sync`. If you need to share a policy across +/// threads, clone it before locking or wrap it in an `Arc`. +/// +/// # Security behaviour summary +/// +/// | Setting | Default | Purpose | +/// |---------|---------|---------| +/// | `allowed_addresses` | empty | IPs/CIDRs that are always permitted | +/// | `denied_addresses` | empty | IPs/CIDRs that are always blocked | +/// | `deny_all_unspecified_ips` | `false` | Reject any IP not in `allowed_addresses` | +/// | `required_headers` | empty | Headers that must be present (case-insensitive names) | +/// | `denied_headers` | empty | Headers that must not be present (case-insensitive names) | +/// | `add_xff_header` | `false` | Inject `X-Forwarded-For: true` if missing | +/// | `allow_plaintext_http` | `false` | Permit `http://` requests | +#[derive(Debug, Clone, PartialEq)] +pub struct AntiSSRFPolicy { + allowed_addresses: Vec, + denied_addresses: Vec, + deny_all_unspecified_ips: bool, + required_headers: Vec, + denied_headers: Vec, + add_xff_header: bool, + allow_plaintext_http: bool, + locked: bool, +} + +impl AntiSSRFPolicy { + /// Create a new policy using a preset configuration. + /// + /// # Examples + /// + /// ``` + /// use antissrf::{AntiSSRFPolicy, PolicyConfigOptions}; + /// + /// let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + /// ``` + pub fn new(config: PolicyConfigOptions) -> Self { + let mut policy = Self { + allowed_addresses: Vec::new(), + denied_addresses: Vec::new(), + deny_all_unspecified_ips: false, + required_headers: Vec::new(), + denied_headers: Vec::new(), + add_xff_header: false, + allow_plaintext_http: false, + locked: false, + }; + + match config { + PolicyConfigOptions::None => {} + PolicyConfigOptions::InternalOnly => { + policy.deny_all_unspecified_ips = true; + } + PolicyConfigOptions::ExternalOnlyV1 | PolicyConfigOptions::ExternalOnlyLatest => { + policy.add_denied_addresses_from_slice(ip_address_ranges::RECOMMENDEDV1); + policy.add_xff_header = true; + } + } + + policy + } + + // ========================================================================= + // Getters + // ========================================================================= + + /// Return the list of allowed IP address CIDR blocks. + /// + /// The returned slice is ordered by insertion order. + pub fn allowed_addresses(&self) -> &[CIDRBlock] { + &self.allowed_addresses + } + + /// Return the list of denied IP address CIDR blocks. + /// + /// The returned slice is ordered by insertion order. + pub fn denied_addresses(&self) -> &[CIDRBlock] { + &self.denied_addresses + } + + /// Return whether every IP not explicitly allowlisted is denied. + pub fn deny_all_unspecified_ips(&self) -> bool { + self.deny_all_unspecified_ips + } + + /// Return the list of required HTTP header names (all lowercased). + pub fn required_headers(&self) -> &[String] { + &self.required_headers + } + + /// Return the list of denied HTTP header names (all lowercased). + pub fn denied_headers(&self) -> &[String] { + &self.denied_headers + } + + /// Return whether the policy will automatically inject `X-Forwarded-For`. + pub fn add_xff_header(&self) -> bool { + self.add_xff_header + } + + /// Return whether plaintext `http://` requests are permitted. + pub fn allow_plaintext_http(&self) -> bool { + self.allow_plaintext_http + } + + /// Return whether the policy has been locked. + /// + /// A policy becomes locked on the first call to + /// [`is_network_connection_allowed`](AntiSSRFPolicy::is_network_connection_allowed) + /// or [`validate_request`](AntiSSRFPolicy::validate_request). + pub fn is_locked(&self) -> bool { + self.locked + } + + // ========================================================================= + // Locking mechanism + // ========================================================================= + + fn lock(&mut self) { + self.locked = true; + } + + fn assert_not_locked(&self) -> Result<(), AntiSSRFError> { + if self.locked { + Err(AntiSSRFError::PolicyLocked) + } else { + Ok(()) + } + } + + // ========================================================================= + // IP address management + // ========================================================================= + + /// Add IP addresses or CIDR blocks to the allowlist. + /// + /// Strings are normalised before parsing: single IPs receive the correct + /// host-prefix length (`/32` for IPv4, `/128` for IPv6). + /// + /// # Errors + /// + /// Returns [`PolicyLocked`](crate::AntiSSRFError::PolicyLocked) if the + /// policy has already been used, or [`InvalidCIDR`](crate::AntiSSRFError::InvalidCIDR) + /// if a string cannot be parsed. + /// + /// # Examples + /// + /// ``` + /// use antissrf::{AntiSSRFPolicy, PolicyConfigOptions, AntiSSRFError}; + /// + /// # fn main() -> Result<(), AntiSSRFError> { + /// let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::None); + /// policy.add_allowed_addresses(&["10.0.0.0/8", "::1"])?; + /// assert_eq!(policy.allowed_addresses().len(), 2); + /// # Ok(()) + /// # } + /// ``` + pub fn add_allowed_addresses(&mut self, addresses: &[&str]) -> Result<(), AntiSSRFError> { + self.assert_not_locked()?; + for addr in addresses { + let normalized = Self::normalize_address(addr); + let block = CIDRBlock::parse(&normalized)?; + self.allowed_addresses.push(block); + } + Ok(()) + } + + /// Add IP addresses or CIDR blocks to the denylist. + /// + /// Strings are normalised before parsing (see [`add_allowed_addresses`](AntiSSRFPolicy::add_allowed_addresses)). + /// + /// # Errors + /// + /// Returns [`PolicyLocked`](crate::AntiSSRFError::PolicyLocked) if the + /// policy has already been used, [`ConflictingConfiguration`](crate::AntiSSRFError::ConflictingConfiguration) + /// if [`deny_all_unspecified_ips`](AntiSSRFPolicy::deny_all_unspecified_ips) is already + /// `true`, or [`InvalidCIDR`](crate::AntiSSRFError::InvalidCIDR) on parse failure. + /// + /// # Examples + /// + /// ``` + /// use antissrf::{AntiSSRFPolicy, PolicyConfigOptions, AntiSSRFError}; + /// + /// # fn main() -> Result<(), AntiSSRFError> { + /// let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::None); + /// policy.add_denied_addresses(&["169.254.169.254/32"])?; + /// assert_eq!(policy.denied_addresses().len(), 1); + /// # Ok(()) + /// # } + /// ``` + pub fn add_denied_addresses(&mut self, addresses: &[&str]) -> Result<(), AntiSSRFError> { + self.assert_not_locked()?; + if self.deny_all_unspecified_ips { + return Err(AntiSSRFError::ConflictingConfiguration); + } + for addr in addresses { + let normalized = Self::normalize_address(addr); + let block = CIDRBlock::parse(&normalized)?; + self.denied_addresses.push(block); + } + Ok(()) + } + + /// Check whether every IP in the supplied list is allowed by this policy. + /// + /// This method **locks** the policy. After it returns (successfully or not) + /// the policy can no longer be modified. + /// + /// # Arguments + /// + /// * `ipaddresses` β€” Slice of IP address strings (e.g. `["8.8.8.8", "::1"]`). + /// Each string is parsed with [`IpAddr`] before checking. + /// + /// # Returns + /// + /// - `Ok(true)` β€” **all** IPs are allowed. + /// - `Ok(false)` β€” at least one IP is blocked. + /// - `Err(AntiSSRFError::InvalidIP)` β€” one of the strings is not a valid IP address. + /// + /// # Examples + /// + /// ``` + /// use antissrf::{AntiSSRFPolicy, PolicyConfigOptions, AntiSSRFError}; + /// + /// # fn main() -> Result<(), AntiSSRFError> { + /// let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + /// + // Allowlist wins over denylist + /// policy.add_allowed_addresses(&["169.254.169.254/32"])?; + /// assert!(policy.is_network_connection_allowed(&["169.254.169.254"])?); + /// + /// // But 127.0.0.1 is still blocked (loopback is in the V1 denylist) + /// assert!(!policy.is_network_connection_allowed(&["127.0.0.1"])?); + /// # Ok(()) + /// # } + /// ``` + pub fn is_network_connection_allowed( + &mut self, + ipaddresses: &[&str], + ) -> Result { + self.lock(); + + for ip_str in ipaddresses { + let ip: IpAddr = ip_str + .parse() + .map_err(|_| AntiSSRFError::InvalidIP(ip_str.to_string()))?; + + // If this IP is in the allowlist it is safe – move on to the next IP + if self.allowed_addresses.iter().any(|a| a.contains(ip)) { + continue; + } + + // Reject when every unspecified IP is denied or this IP is in the denylist + if self.deny_all_unspecified_ips || self.denied_addresses.iter().any(|d| d.contains(ip)) + { + return Ok(false); + } + } + + Ok(true) + } + + /// Set the `deny_all_unspecified_ips` flag. + /// + /// When `true`, any IP that is **not** in the allowlist is rejected. + /// + /// # Errors + /// + /// Returns [`PolicyLocked`](crate::AntiSSRFError::PolicyLocked) if the + /// policy has already been used. + pub fn set_deny_all_unspecified_ips(&mut self, value: bool) -> Result<(), AntiSSRFError> { + self.assert_not_locked()?; + self.deny_all_unspecified_ips = value; + Ok(()) + } + + // ========================================================================= + // Header management + // ========================================================================= + + /// Add required HTTP header names. + /// + /// Names are automatically trimmed and lowercased. A request that does + /// not contain **all** required headers (case-insensitive name match) is + /// rejected with [`HeaderRequired`](crate::AntiSSRFError::HeaderRequired). + /// + /// # Errors + /// + /// Returns [`PolicyLocked`](crate::AntiSSRFError::PolicyLocked) or + /// [`InvalidHeader`](crate::AntiSSRFError::InvalidHeader) if an empty + /// string is supplied. + /// + /// # Examples + /// + /// ``` + /// use antissrf::{AntiSSRFPolicy, PolicyConfigOptions, AntiSSRFError}; + /// + /// # fn main() -> Result<(), AntiSSRFError> { + /// let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::None); + /// policy.add_required_headers(&["Authorization", "X-Request-ID"])?; + /// assert_eq!(policy.required_headers(), &["authorization", "x-request-id"]); + /// # Ok(()) + /// # } + /// ``` + pub fn add_required_headers(&mut self, headers: &[&str]) -> Result<(), AntiSSRFError> { + self.assert_not_locked()?; + for header in headers { + let h = header.trim().to_ascii_lowercase(); + if h.is_empty() { + return Err(AntiSSRFError::InvalidHeader); + } + self.required_headers.push(h); + } + Ok(()) + } + + /// Add denied HTTP header names. + /// + /// Names are automatically trimmed and lowercased. A request that contains + /// **any** denied header (case-insensitive name match) is rejected with + /// [`HeaderDenied`](crate::AntiSSRFError::HeaderDenied). + /// + /// # Errors + /// + /// Returns [`PolicyLocked`](crate::AntiSSRFError::PolicyLocked) or + /// [`InvalidHeader`](crate::AntiSSRFError::InvalidHeader) if an empty + /// string is supplied. + /// + /// # Examples + /// + /// ``` + /// use antissrf::{AntiSSRFPolicy, PolicyConfigOptions, AntiSSRFError}; + /// + /// # fn main() -> Result<(), AntiSSRFError> { + /// let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::None); + /// policy.add_denied_headers(&["X-Internal-Auth", "X-Debug"])?; + /// assert_eq!(policy.denied_headers(), &["x-internal-auth", "x-debug"]); + /// # Ok(()) + /// # } + /// ``` + pub fn add_denied_headers(&mut self, headers: &[&str]) -> Result<(), AntiSSRFError> { + self.assert_not_locked()?; + for header in headers { + let h = header.trim().to_ascii_lowercase(); + if h.is_empty() { + return Err(AntiSSRFError::InvalidHeader); + } + self.denied_headers.push(h); + } + Ok(()) + } + + /// Set whether to automatically inject an `X-Forwarded-For` header. + /// + /// When enabled, [`validate_request`](AntiSSRFPolicy::validate_request) + /// appends `X-Forwarded-For: true` to the header list if the header is not + /// already present. The comparison is case-insensitive. + /// + /// # Errors + /// + /// Returns [`PolicyLocked`](crate::AntiSSRFError::PolicyLocked) if the + /// policy has already been used. + pub fn set_add_xff_header(&mut self, value: bool) -> Result<(), AntiSSRFError> { + self.assert_not_locked()?; + self.add_xff_header = value; + Ok(()) + } + + /// Set whether plaintext `http://` requests are permitted. + /// + /// When `false` (the default), only `https:` is accepted by + /// [`validate_request`](AntiSSRFPolicy::validate_request). + /// + /// # Errors + /// + /// Returns [`PolicyLocked`](crate::AntiSSRFError::PolicyLocked) if the + /// policy has already been used. + pub fn set_allow_plaintext_http(&mut self, value: bool) -> Result<(), AntiSSRFError> { + self.assert_not_locked()?; + self.allow_plaintext_http = value; + Ok(()) + } + + // ========================================================================= + // HTTP request validation + // ========================================================================= + + /// Validate the protocol scheme and headers of an HTTP request. + /// + /// This method **locks** the policy. After it returns (successfully or not) + /// the policy can no longer be modified. + /// + /// # Arguments + /// + /// * `protocol` β€” Scheme string such as `"http:"` or `"https:"`. + /// * `headers` β€” Mutable list of header key-value pairs. If + /// [`add_xff_header`](AntiSSRFPolicy::add_xff_header) is enabled and + /// `X-Forwarded-For` is missing, the header is appended in-place. + /// + /// # Returns + /// + /// - `Ok(true)` β€” the request complies with the policy. + /// - `Err(AntiSSRFError::SchemeDisallowed)` β€” the scheme is not `https:` + /// (or `http:` when [`allow_plaintext_http`](AntiSSRFPolicy::allow_plaintext_http) is `true`). + /// - `Err(AntiSSRFError::HeaderDenied)` β€” a denied header was found. + /// - `Err(AntiSSRFError::HeaderRequired)` β€” a required header is missing. + /// + /// # Examples + /// + /// ``` + /// use antissrf::{AntiSSRFPolicy, PolicyConfigOptions, AntiSSRFError}; + /// + /// # fn main() -> Result<(), AntiSSRFError> { + /// let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::None); + /// policy.set_allow_plaintext_http(true)?; + /// policy.add_required_headers(&["x-request-id"])?; + /// + /// let mut headers = vec![ + /// ("X-Request-ID".to_string(), "abc".to_string()), + /// ]; + /// assert!(policy.validate_request("http:", &mut headers)?); + /// # Ok(()) + /// # } + /// ``` + pub fn validate_request( + &mut self, + protocol: &str, + headers: &mut Vec<(String, String)>, + ) -> Result { + self.lock(); + + // Protocol check + if protocol != "https:" { + if protocol == "http:" && self.allow_plaintext_http { + // OK + } else { + return Err(AntiSSRFError::SchemeDisallowed); + } + } + + // Only allow HTTP and HTTPS + if protocol != "http:" && protocol != "https:" { + return Err(AntiSSRFError::SchemeDisallowed); + } + + // Denied header check + for denied in &self.denied_headers { + if headers.iter().any(|(k, _)| k.eq_ignore_ascii_case(denied)) { + return Err(AntiSSRFError::HeaderDenied); + } + } + + // Required header check + for required in &self.required_headers { + if !headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case(required)) + { + return Err(AntiSSRFError::HeaderRequired); + } + } + + // Inject `X-Forwarded-For` if enabled and not already present + if self.add_xff_header + && !headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("x-forwarded-for")) + { + headers.push(("X-Forwarded-For".to_string(), "true".to_string())); + } + + Ok(true) + } + + /// Determine whether this request needs an injected `X-Forwarded-For` header. + /// + /// Returns `true` when [`add_xff_header`](AntiSSRFPolicy::add_xff_header) + /// is enabled **and** no `X-Forwarded-For` header (case-insensitive) is + /// already present in `headers`. + /// + /// This is a pure query; it does **not** lock the policy. + /// + /// # Examples + /// + /// ``` + /// use antissrf::{AntiSSRFPolicy, PolicyConfigOptions, AntiSSRFError}; + /// + /// # fn main() -> Result<(), AntiSSRFError> { + /// let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::None); + /// policy.set_add_xff_header(true)?; + /// + /// assert!(policy.needs_xff_header(&[])); + /// + /// let headers = vec![("x-forwarded-for".to_string(), "1.2.3.4".to_string())]; + /// assert!(!policy.needs_xff_header(&headers)); + /// # Ok(()) + /// # } + /// ``` + pub fn needs_xff_header(&self, headers: &[(String, String)]) -> bool { + self.add_xff_header + && !headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("x-forwarded-for")) + } + + // ========================================================================= + // Internal helpers + // ========================================================================= + + fn add_denied_addresses_from_slice(&mut self, addresses: &[&str]) { + for addr in addresses { + let normalized = Self::normalize_address(addr); + if let Ok(block) = CIDRBlock::parse(&normalized) { + self.denied_addresses.push(block); + } + } + } + + /// Normalise an IP address string by trimming whitespace and appending the + /// host-prefix length if a CIDR mask is missing. + fn normalize_address(addr: &str) -> String { + let trimmed = addr.trim(); + if !trimmed.contains('/') { + if trimmed.contains(':') { + format!("{}/128", trimmed) + } else { + format!("{}/32", trimmed) + } + } else { + trimmed.to_string() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ========================================================================= + // Constructor tests + // ========================================================================= + + #[test] + fn new_none_has_empty_lists() { + let p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + assert!(!p.deny_all_unspecified_ips); + assert!(!p.add_xff_header); + assert!(!p.allow_plaintext_http); + } + + #[test] + fn new_internal_only_sets_deny_all() { + let p = AntiSSRFPolicy::new(PolicyConfigOptions::InternalOnly); + assert!(p.deny_all_unspecified_ips); + } + + #[test] + fn new_external_v1_populates_denylist() { + let p = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyV1); + assert!(!p.denied_addresses.is_empty()); + assert!(p.add_xff_header); + } + + #[test] + fn new_external_latest_populates_denylist() { + let p = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + assert!(!p.denied_addresses.is_empty()); + assert!(p.add_xff_header); + } + + // ========================================================================= + // IP address tests + // ========================================================================= + + #[test] + fn add_allowed_addresses_works() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::InternalOnly); + p.add_allowed_addresses(&["10.0.0.0/8"]).unwrap(); + assert_eq!(p.allowed_addresses.len(), 1); + assert!(p.is_network_connection_allowed(&["10.0.0.0"]).unwrap()); + assert!(p.is_network_connection_allowed(&["10.0.0.1"]).unwrap()); + assert!(p.is_network_connection_allowed(&["10.1.2.3"]).unwrap()); + assert!( + p.is_network_connection_allowed(&["10.255.255.255"]) + .unwrap() + ); + assert!(!p.is_network_connection_allowed(&["11.0.0.0"]).unwrap()); + } + + #[test] + fn add_allowed_addresses_normalizes_single_ip() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::InternalOnly); + p.add_allowed_addresses(&["10.0.0.1"]).unwrap(); + assert_eq!(p.allowed_addresses.len(), 1); + assert!(p.is_network_connection_allowed(&["10.0.0.1"]).unwrap()); + assert!(!p.is_network_connection_allowed(&["10.0.0.2"]).unwrap()); + } + + #[test] + fn add_allowed_addresses_rejects_invalid() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + let result = p.add_allowed_addresses(&["not-an-ip"]); + assert!(matches!(result, Err(AntiSSRFError::InvalidCIDR(_)))); + } + + #[test] + fn add_denied_addresses_works() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.add_denied_addresses(&["169.254.169.254/32"]).unwrap(); + assert!( + p.is_network_connection_allowed(&["169.254.169.253"]) + .unwrap() + ); + assert!( + !p.is_network_connection_allowed(&["169.254.169.254"]) + .unwrap() + ); + assert_eq!(p.denied_addresses.len(), 1); + } + + #[test] + fn add_denied_addresses_fails_when_deny_all_set() { + // InternalOnly sets `deny_all_unspecified_ips = true`, + // so adding specific denied addresses should fail. + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::InternalOnly); + let result = p.add_denied_addresses(&["10.0.0.0/8"]); + assert!(matches!( + result, + Err(AntiSSRFError::ConflictingConfiguration) + )); + } + + #[test] + fn is_network_connection_allowed_basic() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + assert!(p.is_network_connection_allowed(&["8.8.8.8"]).unwrap()); + } + + #[test] + fn is_network_connection_allowed_blocks_denylist() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.add_denied_addresses(&["169.254.169.254/32"]).unwrap(); + assert!( + !p.is_network_connection_allowed(&["169.254.169.254"]) + .unwrap() + ); + } + + #[test] + fn is_network_connection_allowed_allows_allowlist() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.add_denied_addresses(&["10.0.0.0/8"]).unwrap(); + p.add_allowed_addresses(&["10.0.0.1/32"]).unwrap(); + assert!(p.is_network_connection_allowed(&["10.0.0.1"]).unwrap()); + assert!(!p.is_network_connection_allowed(&["10.0.0.2"]).unwrap()); + } + + #[test] + fn is_network_connection_allowed_deny_all_unspecified() { + // InternalOnly sets `deny_all_unspecified_ips = true`, + // so any IP not explicitly allowed should be denied. + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::InternalOnly); + assert!(!p.is_network_connection_allowed(&["8.8.8.8"]).unwrap()); + } + + #[test] + fn is_network_connection_allowed_deny_all_with_allowlist() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::InternalOnly); + p.add_allowed_addresses(&["8.8.8.8/32"]).unwrap(); + assert!(p.is_network_connection_allowed(&["8.8.8.8"]).unwrap()); + assert!(!p.is_network_connection_allowed(&["8.8.8.9"]).unwrap()); + } + + #[test] + fn is_network_connection_allowed_mixed_allowlist_and_denylist() { + // Verifies that a single blocked IP in a batch causes the entire check to fail, + // even when another IP in the same batch is in the allowlist. + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.add_denied_addresses(&["10.0.0.0/8"]).unwrap(); + p.add_allowed_addresses(&["127.0.0.1/32"]).unwrap(); + assert!( + !p.is_network_connection_allowed(&["127.0.0.1", "10.0.0.1"]) + .unwrap() + ); + } + + #[test] + fn is_network_connection_allowed_invalid_ip() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + let result = p.is_network_connection_allowed(&["not-an-ip"]); + assert!(matches!(result, Err(AntiSSRFError::InvalidIP(_)))); + } + + #[test] + fn is_network_connection_allowed_locks_policy() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.is_network_connection_allowed(&["8.8.8.8"]).unwrap(); + assert!(matches!( + p.add_allowed_addresses(&["10.0.0.0/8"]), + Err(AntiSSRFError::PolicyLocked) + )); + } + + #[test] + fn ipv6_single_ip_normalized() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.add_allowed_addresses(&["::1"]).unwrap(); + assert_eq!(p.allowed_addresses.len(), 1); + assert_eq!(p.allowed_addresses[0].to_string(), "::1/128"); + } + + #[test] + fn external_v1_blocks_imds() { + // ExternalOnlyV1 should block known dangerous IPs such as IMDS. + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyV1); + assert!( + !p.is_network_connection_allowed(&["169.254.169.254"]) + .unwrap() + ); + + assert!(!p.is_network_connection_allowed(&["168.63.129.16"]).unwrap()); + } + + #[test] + fn external_latest_blocks_imds_and_wireserver() { + // ExternalOnlyLatest should block known dangerous IPs such as IMDS and Wireserver. + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + assert!( + !p.is_network_connection_allowed(&["169.254.169.254"]) + .unwrap() + ); + assert!(!p.is_network_connection_allowed(&["168.63.129.16"]).unwrap()); + } + + #[test] + fn external_v1_allows_external_ips() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyV1); + assert!(p.is_network_connection_allowed(&["8.8.8.8"]).unwrap()); + assert!(p.is_network_connection_allowed(&["1.1.1.1"]).unwrap()); + } + + #[test] + fn external_latest_allows_external_ips() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + assert!(p.is_network_connection_allowed(&["8.8.8.8"]).unwrap()); + assert!(p.is_network_connection_allowed(&["1.1.1.1"]).unwrap()); + } + + #[test] + fn none_with_deny_all_blocks_unless_allowlisted() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.set_deny_all_unspecified_ips(true).unwrap(); + p.add_allowed_addresses(&["8.8.8.8/32"]).unwrap(); + assert!(p.is_network_connection_allowed(&["8.8.8.8"]).unwrap()); + assert!(!p.is_network_connection_allowed(&["1.1.1.1"]).unwrap()); + } + + // ========================================================================= + // HTTP header tests + // ========================================================================= + + #[test] + fn add_required_headers_works() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.add_required_headers(&["Authorization", "X-Custom"]) + .unwrap(); + assert_eq!(p.required_headers, vec!["authorization", "x-custom"]); + } + + #[test] + fn add_required_headers_rejects_empty() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + let result = p.add_required_headers(&[""]); + assert!(matches!(result, Err(AntiSSRFError::InvalidHeader))); + } + + #[test] + fn add_denied_headers_works() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.add_denied_headers(&["X-Secret"]).unwrap(); + assert_eq!(p.denied_headers, vec!["x-secret"]); + } + + #[test] + fn validate_request_https_always_allowed() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + assert!(p.validate_request("https:", &mut vec![]).unwrap()); + } + + #[test] + fn validate_request_http_denied_by_default() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + let result = p.validate_request("http:", &mut vec![]); + assert!(matches!(result, Err(AntiSSRFError::SchemeDisallowed))); + } + + #[test] + fn validate_request_http_allowed_when_configured() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.set_allow_plaintext_http(true).unwrap(); + assert!(p.validate_request("http:", &mut vec![]).unwrap()); + } + + #[test] + fn validate_request_unknown_protocol_denied() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + let result = p.validate_request("ftp:", &mut vec![]); + assert!(matches!(result, Err(AntiSSRFError::SchemeDisallowed))); + } + + #[test] + fn validate_request_denied_header_found() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.add_denied_headers(&["x-secret"]).unwrap(); + let mut headers = vec![("X-Secret".to_string(), "value".to_string())]; + let result = p.validate_request("https:", &mut headers); + assert!(matches!(result, Err(AntiSSRFError::HeaderDenied))); + } + + #[test] + fn validate_request_required_header_missing() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.add_required_headers(&["authorization"]).unwrap(); + let result = p.validate_request("https:", &mut vec![]); + assert!(matches!(result, Err(AntiSSRFError::HeaderRequired))); + } + + #[test] + fn validate_request_required_header_present() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.add_required_headers(&["authorization"]).unwrap(); + let mut headers = vec![("Authorization".to_string(), "Bearer token".to_string())]; + assert!(p.validate_request("https:", &mut headers).unwrap()); + } + + #[test] + fn validate_request_locks_policy() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.validate_request("https:", &mut vec![]).unwrap(); + assert!(matches!( + p.add_required_headers(&["X-Test"]), + Err(AntiSSRFError::PolicyLocked) + )); + } + + #[test] + fn is_locked_returns_true_after_validate_request() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + assert!(!p.is_locked()); + p.validate_request("https:", &mut vec![]).unwrap(); + assert!(p.is_locked()); + } + + // ========================================================================= + // Property tests + // ========================================================================= + + #[test] + fn set_deny_all_unspecified_ips_works() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.set_deny_all_unspecified_ips(true).unwrap(); + assert!(p.deny_all_unspecified_ips()); + } + + #[test] + fn set_add_xff_header_works() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.set_add_xff_header(true).unwrap(); + assert!(p.add_xff_header()); + } + + #[test] + fn set_allow_plaintext_http_works() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.set_allow_plaintext_http(true).unwrap(); + assert!(p.allow_plaintext_http()); + } + + #[test] + fn set_deny_all_unspecified_ips_locked() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::InternalOnly); + p.is_network_connection_allowed(&["8.8.8.8"]).unwrap(); + assert!(matches!( + p.set_deny_all_unspecified_ips(false), + Err(AntiSSRFError::PolicyLocked) + )); + } + + // ========================================================================= + // XFF header tests + // ========================================================================= + + #[test] + fn needs_xff_header_when_enabled_and_missing() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.set_add_xff_header(true).unwrap(); + assert!(p.needs_xff_header(&[])); + } + + #[test] + fn needs_xff_header_when_already_present() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.set_add_xff_header(true).unwrap(); + let headers = vec![("X-Forwarded-For".to_string(), "1.2.3.4".to_string())]; + assert!(!p.needs_xff_header(&headers)); + } + + #[test] + fn needs_xff_header_when_disabled() { + let p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + assert!(!p.needs_xff_header(&[])); + } + + #[test] + fn needs_xff_header_case_insensitive() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.set_add_xff_header(true).unwrap(); + let headers: Vec<(String, String)> = + vec![("x-forwarded-for".to_string(), "1.2.3.4".to_string())]; + assert!(!p.needs_xff_header(&headers)); + } + + // ========================================================================= + // Getter tests + // ========================================================================= + + #[test] + fn allowed_addresses_getter() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.add_allowed_addresses(&["10.0.0.0/8", "192.168.1.1"]) + .unwrap(); + let addrs = p.allowed_addresses(); + assert_eq!(addrs.len(), 2); + assert_eq!(addrs[0].to_string(), "10.0.0.0/8"); + assert_eq!(addrs[1].to_string(), "192.168.1.1/32"); + } + + #[test] + fn denied_addresses_getter() { + let p = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyV1); + let addrs = p.denied_addresses(); + assert!(!addrs.is_empty()); + // Verify that known dangerous IPs are in the denylist. + assert!(addrs.iter().any(|a| a.to_string() == "169.254.0.0/16")); + } + + #[test] + fn required_headers_getter() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.add_required_headers(&["Authorization", "X-Custom"]) + .unwrap(); + let headers = p.required_headers(); + assert_eq!(headers, vec!["authorization", "x-custom"]); + } + + #[test] + fn denied_headers_getter() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.add_denied_headers(&["X-Secret"]).unwrap(); + let headers = p.denied_headers(); + assert_eq!(headers, vec!["x-secret"]); + } + + // ========================================================================= + // XFF injection side-effect tests + // ========================================================================= + + #[test] + fn validate_request_injects_xff_when_missing() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.set_add_xff_header(true).unwrap(); + let mut headers = vec![]; + let result = p.validate_request("https:", &mut headers).unwrap(); + assert!(result); + assert!( + headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("x-forwarded-for")) + ); + assert_eq!(headers.len(), 1); + } + + #[test] + fn validate_request_does_not_inject_xff_when_present() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.set_add_xff_header(true).unwrap(); + let mut headers = vec![("X-Forwarded-For".to_string(), "1.2.3.4".to_string())]; + let result = p.validate_request("https:", &mut headers).unwrap(); + assert!(result); + assert_eq!(headers.len(), 1); + } + + #[test] + fn validate_request_does_not_inject_xff_when_disabled() { + let mut p = AntiSSRFPolicy::new(PolicyConfigOptions::None); + p.set_add_xff_header(false).unwrap(); + let mut headers = vec![]; + let result = p.validate_request("https:", &mut headers).unwrap(); + assert!(result); + assert!(headers.is_empty()); + } +} diff --git a/rust/src/uri_validator.rs b/rust/src/uri_validator.rs new file mode 100644 index 0000000..66dea69 --- /dev/null +++ b/rust/src/uri_validator.rs @@ -0,0 +1,467 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! URL and domain validation utilities for AntiSSRF protection. +//! +//! [`URIValidator`] provides static methods for checking whether a URL belongs +//! to a trusted domain. It is used independently of [`AntiSSRFPolicy`](crate::AntiSSRFPolicy) +//! for lightweight, non-network validation β€” for example, whitelisting a redirect +//! target or verifying that a user-supplied URL points to an approved service. +//! +//! # Supported Protocols +//! +//! | Method | Allowed protocols | +//! |--------|-------------------| +//! | [`in_domain`](URIValidator::in_domain) | `http:`, `https:`, `ws:`, `wss:` | +//! | [`in_azure_key_vault_domain`](URIValidator::in_azure_key_vault_domain) | `http:`, `https:` | +//! | [`in_azure_storage_domain`](URIValidator::in_azure_storage_domain) | `http:`, `https:` | +//! +//! # Subdomain Matching +//! +//! All three methods support subdomain matching. For example, +//! `"https://api.trusted.com"` matches `"trusted.com"`. +//! +//! # Punycode Normalisation +//! +//! Internationalised domain names (IDN) are normalised to ASCII (punycode) form +//! before comparison. Both the URL hostname and the trusted domain list are +//! converted, so `"mΓΌnchen.example"` matches `"xn--mnchen-3ya.example"`. + +use url::Url; + +/// Static utility for validating URLs against trusted domains. +/// +/// All methods are stateless; you do not need to instantiate this struct. +pub struct URIValidator; + +impl URIValidator { + const DOMAIN_PROTOCOLS: &[&str] = &["http:", "https:", "ws:", "wss:"]; + const AZURE_SDK_PROTOCOLS: &[&str] = &["http:", "https:"]; + + /// Verifies whether a URL's hostname is within any of the provided domains. + /// + /// Supports subdomain matching and punycode normalisation. Malformed URLs + /// or unsupported protocols return `false`. + /// + /// # Arguments + /// + /// * `url` β€” The URL to verify. Must include a scheme and hostname. + /// * `trusted_domains` β€” One or more domains to check against. Each domain + /// may be dotted (`.trusted.com`) or undotted (`trusted.com`). + /// + /// # Returns + /// + /// `true` if the URL's hostname is in any provided domain, `false` otherwise. + /// + /// # Examples + /// + /// ``` + /// use antissrf::URIValidator; + /// + /// // Subdomain match + /// assert!(URIValidator::in_domain("https://api.trusted.com", &["trusted.com"])); + /// + /// // Exact match + /// assert!(URIValidator::in_domain("https://trusted.com", &["trusted.com"])); + /// + /// // No match + /// assert!(!URIValidator::in_domain("https://evil.com", &["trusted.com"])); + /// + /// // Dotted domain syntax also works + /// assert!(URIValidator::in_domain("https://api.trusted.com", &[".trusted.com"])); + /// + /// // Malformed URL returns false + /// assert!(!URIValidator::in_domain("not-a-url", &["trusted.com"])); + /// ``` + pub fn in_domain(url: &str, trusted_domains: &[&str]) -> bool { + let hostname = match Self::get_valid_hostname(url, Self::DOMAIN_PROTOCOLS) { + Some(h) => h, + None => return false, + }; + + if trusted_domains.is_empty() { + return false; + } + + for domain in trusted_domains { + let ascii_domain = match Self::domain_to_ascii(domain) { + Some(d) => d, + None => continue, + }; + + if Self::hostname_in_single_domain(&hostname, &ascii_domain) { + return true; + } + } + + false + } + + /// Verifies whether a URL is in an Azure Key Vault domain. + /// + /// Checks against the well-known Key Vault endpoints listed in + /// [`AZURE_KEY_VAULT_DOMAINS`](crate::domains::AZURE_KEY_VAULT_DOMAINS). + /// Rejects hostnames containing `--`, which violates Azure naming restrictions. + /// + /// # Arguments + /// + /// * `url` β€” The URL to verify. Must use `http:` or `https:`. + /// + /// # Returns + /// + /// `true` if the URL is in any Azure Key Vault domain, `false` otherwise. + /// + /// # Examples + /// + /// ``` + /// use antissrf::URIValidator; + /// + /// assert!(URIValidator::in_azure_key_vault_domain("https://myvault.vault.azure.net")); + /// assert!(!URIValidator::in_azure_key_vault_domain("https://my--vault.vault.azure.net")); + /// assert!(!URIValidator::in_azure_key_vault_domain("ws://myvault.vault.azure.net")); + /// ``` + pub fn in_azure_key_vault_domain(url: &str) -> bool { + let hostname = match Self::get_valid_hostname(url, Self::AZURE_SDK_PROTOCOLS) { + Some(h) => h, + None => return false, + }; + + if hostname.contains("--") { + return false; + } + + for domain in crate::domains::AZURE_KEY_VAULT_DOMAINS { + if Self::hostname_in_single_domain(&hostname, domain) { + return true; + } + } + + false + } + + /// Verifies whether a URL is in an Azure Storage domain. + /// + /// Checks against the well-known Storage endpoints listed in + /// [`AZURE_STORAGE_DOMAINS`](crate::domains::AZURE_STORAGE_DOMAINS). + /// Rejects hostnames containing `--`, which violates Azure naming restrictions. + /// + /// # Arguments + /// + /// * `url` β€” The URL to verify. Must use `http:` or `https:`. + /// + /// # Returns + /// + /// `true` if the URL is in any Azure Storage domain, `false` otherwise. + /// + /// # Examples + /// + /// ``` + /// use antissrf::URIValidator; + /// + /// assert!(URIValidator::in_azure_storage_domain("https://mystorage.blob.core.windows.net")); + /// assert!(!URIValidator::in_azure_storage_domain("https://my--storage.blob.core.windows.net")); + /// ``` + pub fn in_azure_storage_domain(url: &str) -> bool { + let hostname = match Self::get_valid_hostname(url, Self::AZURE_SDK_PROTOCOLS) { + Some(h) => h, + None => return false, + }; + + if hostname.contains("--") { + return false; + } + + for domain in crate::domains::AZURE_STORAGE_DOMAINS { + if Self::hostname_in_single_domain(&hostname, domain) { + return true; + } + } + + false + } + + /// Extract and validate a hostname from a URL string. + /// + /// Returns `None` if the URL is malformed, has no hostname, or uses an + /// unsupported protocol. + fn get_valid_hostname(url_str: &str, allowed_protocols: &[&str]) -> Option { + let url = Url::parse(url_str).ok()?; + let hostname = url.host_str()?.to_string(); + if hostname.is_empty() { + return None; + } + + let protocol = format!("{}:", url.scheme()); + if !allowed_protocols.contains(&protocol.as_str()) { + return None; + } + + Some(hostname) + } + + /// Convert a domain string to ASCII (punycode) form using the `url` crate. + /// + /// Returns `None` if the domain cannot be parsed. + fn domain_to_ascii(domain: &str) -> Option { + let temp_url = format!("https://{}/", domain); + let url = Url::parse(&temp_url).ok()?; + url.host_str().map(|s| s.to_string()) + } + + /// Check whether `hostname` is within a single `domain`. + /// + /// Supports exact match and subdomain match. A dotted domain (`.trusted.com`) + /// matches both `trusted.com` and any subdomain. + fn hostname_in_single_domain(hostname: &str, domain: &str) -> bool { + let dotted = format!(".{}", hostname); + if dotted.ends_with(domain) { + if hostname.len() == domain.len() { + return true; + } + if domain.starts_with('.') { + return true; + } + if hostname.len() > domain.len() + && hostname.as_bytes()[hostname.len() - domain.len() - 1] == b'.' + { + return true; + } + } + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn in_domain_exact_match() { + assert!(URIValidator::in_domain( + "https://trusted.com", + &["trusted.com"] + )); + } + + #[test] + fn in_domain_subdomain_match() { + assert!(URIValidator::in_domain( + "https://api.trusted.com", + &["trusted.com"] + )); + assert!(URIValidator::in_domain( + "https://sub.api.trusted.com", + &["trusted.com"] + )); + } + + #[test] + fn in_domain_no_false_positive() { + assert!(!URIValidator::in_domain( + "https://nottrusted.com", + &["trusted.com"] + )); + assert!(!URIValidator::in_domain( + "https://trusted.com.evil.com", + &["trusted.com"] + )); + } + + #[test] + fn in_domain_dotted_domain() { + assert!(URIValidator::in_domain( + "https://api.trusted.com", + &[".trusted.com"] + )); + assert!(URIValidator::in_domain( + "https://trusted.com", + &[".trusted.com"] + )); + } + + #[test] + fn in_domain_multiple_domains() { + assert!(URIValidator::in_domain( + "https://api.first.com", + &["first.com", "second.com"] + )); + assert!(URIValidator::in_domain( + "https://api.second.com", + &["first.com", "second.com"] + )); + } + + #[test] + fn in_domain_unsupported_protocol() { + assert!(!URIValidator::in_domain( + "ftp://trusted.com", + &["trusted.com"] + )); + assert!(!URIValidator::in_domain( + "file:///trusted.com", + &["trusted.com"] + )); + } + + #[test] + fn in_domain_supported_protocols() { + assert!(URIValidator::in_domain( + "http://trusted.com", + &["trusted.com"] + )); + assert!(URIValidator::in_domain( + "https://trusted.com", + &["trusted.com"] + )); + assert!(URIValidator::in_domain( + "ws://trusted.com", + &["trusted.com"] + )); + assert!(URIValidator::in_domain( + "wss://trusted.com", + &["trusted.com"] + )); + } + + #[test] + fn in_domain_invalid_url() { + assert!(!URIValidator::in_domain("not-a-url", &["trusted.com"])); + } + + #[test] + fn in_domain_empty_hostname() { + assert!(!URIValidator::in_domain("https://", &["trusted.com"])); + } + + #[test] + fn in_domain_empty_trusted_domains() { + assert!(!URIValidator::in_domain("https://trusted.com", &[])); + } + + #[test] + fn in_domain_skips_malformed_domains() { + // Malformed domain in the list should be skipped, not abort the entire check. + assert!(URIValidator::in_domain( + "https://valid.com", + &["valid.com", "not a domain!"] + )); + assert!(URIValidator::in_domain( + "https://valid.com", + &["not a domain!", "valid.com"] + )); + assert!(!URIValidator::in_domain( + "https://valid.com", + &["not a domain!", "also bad"] + )); + } + + #[test] + fn in_domain_invalid_trusted_domain() { + assert!(!URIValidator::in_domain( + "https://trusted.com", + &["not a domain"] + )); + } + + #[test] + fn in_domain_punycode() { + // URL hostname is automatically punycoded by Url::parse + // Trusted domain must also be converted to match + assert!(URIValidator::in_domain( + "https://mΓΌnchen.example", + &["xn--mnchen-3ya.example"] + )); + } + + #[test] + fn in_azure_key_vault_domain_match() { + assert!(URIValidator::in_azure_key_vault_domain( + "https://myvault.vault.azure.net" + )); + } + + #[test] + fn in_azure_key_vault_domain_subdomain() { + assert!(URIValidator::in_azure_key_vault_domain( + "https://sub.myvault.vault.azure.net" + )); + } + + #[test] + fn in_azure_key_vault_domain_rejects_double_dash() { + assert!(!URIValidator::in_azure_key_vault_domain( + "https://my--vault.vault.azure.net" + )); + } + + #[test] + fn in_azure_key_vault_domain_rejects_unsupported_protocol() { + assert!(!URIValidator::in_azure_key_vault_domain( + "ws://myvault.vault.azure.net" + )); + } + + #[test] + fn in_azure_key_vault_domain_no_match() { + assert!(!URIValidator::in_azure_key_vault_domain("https://evil.com")); + } + + #[test] + fn in_azure_storage_domain_match() { + assert!(URIValidator::in_azure_storage_domain( + "https://mystorage.blob.core.windows.net" + )); + } + + #[test] + fn in_azure_storage_domain_rejects_double_dash() { + assert!(!URIValidator::in_azure_storage_domain( + "https://my--storage.blob.core.windows.net" + )); + } + + #[test] + fn in_azure_storage_domain_no_match() { + assert!(!URIValidator::in_azure_storage_domain("https://evil.com")); + } + + #[test] + fn hostname_in_single_domain_exact() { + assert!(URIValidator::hostname_in_single_domain( + "trusted.com", + "trusted.com" + )); + } + + #[test] + fn hostname_in_single_domain_subdomain() { + assert!(URIValidator::hostname_in_single_domain( + "api.trusted.com", + "trusted.com" + )); + } + + #[test] + fn hostname_in_single_domain_no_false_match() { + assert!(!URIValidator::hostname_in_single_domain( + "nottrusted.com", + "trusted.com" + )); + assert!(!URIValidator::hostname_in_single_domain( + "trusted.com.evil", + "trusted.com" + )); + } + + #[test] + fn hostname_in_single_domain_dotted() { + assert!(URIValidator::hostname_in_single_domain( + "api.trusted.com", + ".trusted.com" + )); + assert!(URIValidator::hostname_in_single_domain( + "trusted.com", + ".trusted.com" + )); + } +} diff --git a/rust/tests/integration_bypass_test.rs b/rust/tests/integration_bypass_test.rs new file mode 100644 index 0000000..ff71541 --- /dev/null +++ b/rust/tests/integration_bypass_test.rs @@ -0,0 +1,281 @@ +#[cfg(feature = "reqwest-integration")] +mod integration_tests { + use antissrf::network::reqwest_integration::AntiSSRFClientBuilder; + use antissrf::{AntiSSRFPolicy, PolicyConfigOptions}; + use reqwest_middleware::Error as MiddlewareError; + + fn localhost_allowlist_policy() -> AntiSSRFPolicy { + let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + policy.add_allowed_addresses(&["127.0.0.1/32"]).unwrap(); + policy.set_allow_plaintext_http(true).unwrap(); + policy + } + + #[tokio::test] + async fn test_redirect_to_localhost_blocked_without_allowlist() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/redirect") + .with_status(302) + .with_header("Location", "http://127.0.0.1:9999/") + .create_async() + .await; + + let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware() + .expect("Failed to build client"); + + let url = format!("{}/redirect", server.url()); + let result: Result = client.get(&url).send().await; + + assert!( + result.is_err(), + "Expected redirect to localhost to be blocked, got {:?}", + result + ); + } + + #[tokio::test] + async fn test_redirect_to_cloud_metadata_blocked() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/redirect") + .with_status(307) + .with_header("Location", "http://169.254.169.254/latest/meta-data/") + .create_async() + .await; + + let policy = localhost_allowlist_policy(); + let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware() + .expect("Failed to build client"); + + let url = format!("{}/redirect", server.url()); + let result: Result = client.get(&url).send().await; + + assert!( + result.is_err(), + "Expected redirect to cloud metadata to be blocked, got {:?}", + result + ); + } + + #[tokio::test] + async fn test_redirect_to_wireserver_blocked() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/redirect") + .with_status(302) + .with_header("Location", "http://168.63.129.16/") + .create_async() + .await; + + let policy = localhost_allowlist_policy(); + let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware() + .expect("Failed to build client"); + + let url = format!("{}/redirect", server.url()); + let result: Result = client.get(&url).send().await; + + assert!( + result.is_err(), + "Expected redirect to WireServer to be blocked, got {:?}", + result + ); + } + + #[tokio::test] + async fn test_redirect_chain_multiple_hops_blocked() { + let mut server = mockito::Server::new_async().await; + let server_url = server.url(); + + let _redirect1 = server + .mock("GET", "/step1") + .with_status(302) + .with_header("Location", format!("{}/step2", server_url).as_str()) + .create_async() + .await; + + let _redirect2 = server + .mock("GET", "/step2") + .with_status(302) + .with_header("Location", "http://169.254.169.254/") + .create_async() + .await; + + let policy = localhost_allowlist_policy(); + let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware() + .expect("Failed to build client"); + + let url = format!("{}/step1", server_url); + let result: Result = client.get(&url).send().await; + + assert!( + result.is_err(), + "Expected multi-hop redirect to blocked target to fail, got {:?}", + result + ); + } + + #[tokio::test] + async fn test_redirect_chain_allowed_all_hops() { + let mut server = mockito::Server::new_async().await; + let server_url = server.url(); + + let _redirect1 = server + .mock("GET", "/step1") + .with_status(302) + .with_header("Location", format!("{}/step2", server_url).as_str()) + .create_async() + .await; + + let _redirect2 = server + .mock("GET", "/step2") + .with_status(302) + .with_header("Location", format!("{}/target", server_url).as_str()) + .create_async() + .await; + + let _target = server + .mock("GET", "/target") + .with_status(200) + .with_body("ok") + .create_async() + .await; + + let policy = localhost_allowlist_policy(); + let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware() + .expect("Failed to build client"); + + let url = format!("{}/step1", server_url); + let response = client + .get(&url) + .send() + .await + .expect("request should succeed"); + + assert_eq!( + response.status(), + 200, + "All-allowed redirect chain should succeed" + ); + let body = response.text().await.expect("should have body"); + assert_eq!(body, "ok"); + } + + #[tokio::test] + async fn test_auth_stripped_on_redirect() { + let mut server = mockito::Server::new_async().await; + let server_url = server.url(); + + let _redirect_mock = server + .mock("GET", "/redirect") + .with_status(302) + .with_header("Location", format!("{}/target", server_url).as_str()) + .create_async() + .await; + + let _target_mock = server + .mock("GET", "/target") + .with_status(200) + .create_async() + .await; + + let policy = localhost_allowlist_policy(); + let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware() + .expect("Failed to build client"); + + let url = format!("{}/redirect", server_url); + let response = client + .get(&url) + .header("Authorization", "Bearer secret-token") + .send() + .await + .expect("request should succeed"); + + assert_eq!( + response.status(), + 200, + "Auth-stripped redirect should succeed" + ); + } + + #[tokio::test] + async fn test_plaintext_http_blocked_by_default() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/test") + .with_status(200) + .create_async() + .await; + + let policy = AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest); + let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware() + .expect("Failed to build client"); + + let url = format!("{}/test", server.url()); + let result: Result = client.get(&url).send().await; + + assert!( + result.is_err(), + "Expected HTTP request to blocked localhost to fail, got {:?}", + result + ); + } + + #[tokio::test] + async fn test_redirect_to_encoded_localhost_blocked() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/redirect") + .with_status(302) + .with_header("Location", "http://2130706433/") + .create_async() + .await; + + let policy = localhost_allowlist_policy(); + let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware() + .expect("Failed to build client"); + + let url = format!("{}/redirect", server.url()); + let result: Result = client.get(&url).send().await; + + assert!( + result.is_err(), + "Expected redirect to encoded localhost (2130706433) to be blocked, got {:?}", + result + ); + } + + #[tokio::test] + async fn test_redirect_to_hex_localhost_blocked() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/redirect") + .with_status(302) + .with_header("Location", "http://0x7f000001/") + .create_async() + .await; + + let policy = localhost_allowlist_policy(); + let client = AntiSSRFClientBuilder::new(policy) + .build_with_middleware() + .expect("Failed to build client"); + + let url = format!("{}/redirect", server.url()); + let result: Result = client.get(&url).send().await; + + assert!( + result.is_err(), + "Expected redirect to hex localhost (0x7f000001) to be blocked, got {:?}", + result + ); + } +} diff --git a/rust/tests/ssrf_payload_test.rs b/rust/tests/ssrf_payload_test.rs new file mode 100644 index 0000000..e6fbb2f --- /dev/null +++ b/rust/tests/ssrf_payload_test.rs @@ -0,0 +1,502 @@ +use antissrf::{AntiSSRFError, AntiSSRFPolicy, PolicyConfigOptions}; + +fn make_external_policy() -> AntiSSRFPolicy { + AntiSSRFPolicy::new(PolicyConfigOptions::ExternalOnlyLatest) +} + +#[test] +fn test_localhost_variants_blocked() { + let mut policy = make_external_policy(); + let blocked = vec![ + "127.0.0.1", + "127.0.0.2", + "127.1.0.1", + "0.0.0.0", + "0.0.0.1", + "::1", + "::ffff:127.0.0.1", + ]; + for ip_str in &blocked { + let allowed = policy.is_network_connection_allowed(&[ip_str]).unwrap(); + assert!(!allowed, "Expected {} to be blocked", ip_str); + } +} + +#[test] +fn test_cloud_metadata_blocked() { + let mut policy = make_external_policy(); + let blocked = vec![ + "169.254.169.254", + "168.63.129.16", + "192.0.0.192", + "100.100.100.200", + ]; + for ip_str in &blocked { + let allowed = policy.is_network_connection_allowed(&[ip_str]).unwrap(); + assert!(!allowed, "Expected {} to be blocked", ip_str); + } +} + +#[test] +fn test_ipv6_localhost_blocked() { + let mut policy = make_external_policy(); + let blocked = vec!["::1", "::ffff:127.0.0.1", "::ffff:7f00:1"]; + for ip_str in &blocked { + let allowed = policy.is_network_connection_allowed(&[ip_str]).unwrap(); + assert!(!allowed, "Expected {} to be blocked", ip_str); + } +} + +#[test] +fn test_ipv6_cloud_metadata_blocked() { + let mut policy = make_external_policy(); + let allowed = policy + .is_network_connection_allowed(&["::ffff:169.254.169.254"]) + .unwrap(); + assert!(!allowed, "Expected IPv6-mapped IMDS to be blocked"); +} + +#[test] +fn test_private_network_ranges_blocked() { + let mut policy = make_external_policy(); + let blocked = vec![ + "10.0.0.1", + "10.255.255.255", + "172.16.0.1", + "172.31.255.255", + "192.168.0.1", + "192.168.255.255", + "100.64.0.1", // CGNAT + "100.127.255.255", + "198.18.0.1", // Benchmarking + "198.19.255.255", + ]; + for ip_str in &blocked { + let allowed = policy.is_network_connection_allowed(&[ip_str]).unwrap(); + assert!(!allowed, "Expected {} to be blocked", ip_str); + } +} + +#[test] +fn test_link_local_blocked() { + let mut policy = make_external_policy(); + let blocked = vec!["169.254.0.1", "169.254.255.255", "fe80::1"]; + for ip_str in &blocked { + let allowed = policy.is_network_connection_allowed(&[ip_str]).unwrap(); + assert!(!allowed, "Expected {} to be blocked", ip_str); + } +} + +#[test] +fn test_multicast_blocked() { + let mut policy = make_external_policy(); + let blocked = vec!["224.0.0.1", "239.255.255.255", "ff02::1"]; + for ip_str in &blocked { + let allowed = policy.is_network_connection_allowed(&[ip_str]).unwrap(); + assert!(!allowed, "Expected {} to be blocked", ip_str); + } +} + +#[test] +fn test_allowlist_overrides_denylist() { + let mut policy = make_external_policy(); + policy.add_allowed_addresses(&["127.0.0.1/32"]).unwrap(); + + let allowed = policy + .is_network_connection_allowed(&["127.0.0.1"]) + .unwrap(); + assert!(allowed, "Expected allowlisted IP to pass"); +} + +#[test] +fn test_deny_all_unspecified_blocks_non_allowlisted() { + let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::None); + policy.set_deny_all_unspecified_ips(true).unwrap(); + policy.add_allowed_addresses(&["8.8.8.8/32"]).unwrap(); + + let blocked = policy.is_network_connection_allowed(&["1.1.1.1"]).unwrap(); + let allowed = policy.is_network_connection_allowed(&["8.8.8.8"]).unwrap(); + + assert!(!blocked, "Expected non-allowlisted IP to be blocked"); + assert!(allowed, "Expected allowlisted IP to pass"); +} + +#[test] +fn test_protocol_smuggling_blocked() { + let mut policy = make_external_policy(); + let blocked_protocols = vec![ + "file:", "gopher:", "dict:", "ftp:", "sftp:", "jar:", "ldap:", "tftp:", + ]; + for protocol in &blocked_protocols { + let mut headers = Vec::new(); + let result = policy.validate_request(protocol, &mut headers); + assert!( + result.is_err(), + "Expected protocol {} to be blocked, got {:?}", + protocol, + result + ); + } +} + +#[test] +fn test_http_allowed_when_plaintext_enabled() { + let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::None); + policy.set_allow_plaintext_http(true).unwrap(); + let mut headers = Vec::new(); + let result = policy.validate_request("http:", &mut headers); + assert!( + result.is_ok(), + "Expected HTTP to be allowed when plaintext enabled, got {:?}", + result + ); +} + +#[test] +fn test_http_blocked_when_plaintext_disabled() { + let mut policy = make_external_policy(); + let mut headers = Vec::new(); + let result = policy.validate_request("http:", &mut headers); + assert!( + result.is_err(), + "Expected HTTP to be blocked when plaintext disabled, got {:?}", + result + ); +} + +#[test] +fn test_https_always_allowed() { + let mut policy = make_external_policy(); + let mut headers = Vec::new(); + let result = policy.validate_request("https:", &mut headers); + assert!( + result.is_ok(), + "Expected HTTPS to be allowed, got {:?}", + result + ); +} + +#[test] +fn test_required_headers_enforced() { + let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::None); + policy.add_required_headers(&["X-Custom-Auth"]).unwrap(); + + // Missing required header + let mut headers = Vec::new(); + let result = policy.validate_request("https:", &mut headers); + assert!( + matches!(result, Err(AntiSSRFError::HeaderRequired)), + "Expected missing required header to fail, got {:?}", + result + ); + + // Required header present + let mut headers = vec![("X-Custom-Auth".to_string(), "token".to_string())]; + let result = policy.validate_request("https:", &mut headers); + assert!( + result.is_ok(), + "Expected required header to pass, got {:?}", + result + ); +} + +#[test] +fn test_denied_headers_enforced() { + let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::None); + policy.add_denied_headers(&["X-Internal-Token"]).unwrap(); + + // Denied header present + let mut headers = vec![("X-Internal-Token".to_string(), "secret".to_string())]; + let result = policy.validate_request("https:", &mut headers); + assert!( + matches!(result, Err(AntiSSRFError::HeaderDenied)), + "Expected denied header to fail, got {:?}", + result + ); + + // Denied header absent + let mut headers = Vec::new(); + let result = policy.validate_request("https:", &mut headers); + assert!( + result.is_ok(), + "Expected no denied headers to pass, got {:?}", + result + ); +} + +#[test] +fn test_header_case_insensitive() { + let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::None); + policy.add_denied_headers(&["x-internal-token"]).unwrap(); + + // Uppercase variant of denied header + let mut headers = vec![("X-Internal-Token".to_string(), "secret".to_string())]; + let result = policy.validate_request("https:", &mut headers); + assert!( + matches!(result, Err(AntiSSRFError::HeaderDenied)), + "Expected case-insensitive denied header to fail, got {:?}", + result + ); +} + +#[test] +fn test_xff_header_injected() { + let mut policy = make_external_policy(); + // ExternalOnlyLatest sets add_xff_header = true + let mut headers = Vec::new(); + let result = policy.validate_request("https:", &mut headers); + assert!(result.is_ok()); + + assert!( + headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("x-forwarded-for")), + "Expected X-Forwarded-For header to be injected" + ); +} + +#[test] +fn test_xff_header_not_duplicated() { + let mut policy = make_external_policy(); + let mut headers = vec![("X-Forwarded-For".to_string(), "1.2.3.4".to_string())]; + let result = policy.validate_request("https:", &mut headers); + assert!(result.is_ok()); + + let xff_count = headers + .iter() + .filter(|(k, _)| k.eq_ignore_ascii_case("x-forwarded-for")) + .count(); + assert_eq!(xff_count, 1, "Expected exactly one X-Forwarded-For header"); +} + +#[test] +fn test_public_ips_allowed() { + let mut policy = make_external_policy(); + let allowed_ips = vec!["8.8.8.8", "1.1.1.1", "208.67.222.222"]; + for ip_str in &allowed_ips { + let allowed = policy.is_network_connection_allowed(&[ip_str]).unwrap(); + assert!(allowed, "Expected {} to be allowed", ip_str); + } +} + +#[test] +fn test_dns_rebinding_simulation() { + let mut policy = make_external_policy(); + let allowed = policy.is_network_connection_allowed(&["8.8.8.8"]).unwrap(); + let blocked = policy + .is_network_connection_allowed(&["127.0.0.1"]) + .unwrap(); + + assert!(allowed, "Expected public IP to be allowed"); + assert!(!blocked, "Expected localhost to be blocked"); +} + +#[test] +fn test_cidr_contains_sub_ip() { + let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::None); + policy.add_denied_addresses(&["10.0.0.0/8"]).unwrap(); + + let blocked = policy.is_network_connection_allowed(&["10.0.0.1"]).unwrap(); + assert!(!blocked, "Expected IP in denied CIDR to be blocked"); + + let allowed = policy.is_network_connection_allowed(&["11.0.0.1"]).unwrap(); + assert!(allowed, "Expected IP outside denied CIDR to be allowed"); +} + +#[test] +fn test_invalid_ip_rejected() { + let mut policy = make_external_policy(); + let result = policy.is_network_connection_allowed(&["not-an-ip"]); + assert!( + matches!(result, Err(AntiSSRFError::InvalidIP(_))), + "Expected invalid IP to be rejected, got {:?}", + result + ); +} + +#[test] +fn test_policy_locks_after_use() { + let mut policy = make_external_policy(); + policy.is_network_connection_allowed(&["8.8.8.8"]).unwrap(); + + assert!(policy.is_locked()); + + let result = policy.add_allowed_addresses(&["1.2.3.4/32"]); + assert!( + matches!(result, Err(AntiSSRFError::PolicyLocked)), + "Expected locked policy to reject modifications, got {:?}", + result + ); +} + +#[test] +fn test_batch_ip_check_fails_if_any_blocked() { + let mut policy = make_external_policy(); + let allowed = policy + .is_network_connection_allowed(&["8.8.8.8", "127.0.0.1"]) + .unwrap(); + assert!(!allowed, "Expected batch with blocked IP to fail"); +} + +#[test] +fn test_batch_ip_check_succeeds_if_all_allowed() { + let mut policy = make_external_policy(); + let allowed = policy + .is_network_connection_allowed(&["8.8.8.8", "1.1.1.1"]) + .unwrap(); + assert!(allowed, "Expected batch with all allowed IPs to pass"); +} + +#[test] +fn test_conflicting_configuration_rejected() { + let mut policy = AntiSSRFPolicy::new(PolicyConfigOptions::InternalOnly); + let result = policy.add_denied_addresses(&["10.0.0.0/8"]); + assert!( + matches!(result, Err(AntiSSRFError::ConflictingConfiguration)), + "Expected conflicting configuration to be rejected, got {:?}", + result + ); +} + +#[test] +fn test_wireserver_blocked() { + let mut policy = make_external_policy(); + let allowed = policy + .is_network_connection_allowed(&["168.63.129.16"]) + .unwrap(); + assert!(!allowed, "Expected Azure WireServer to be blocked"); +} + +#[test] +fn test_imds_blocked() { + let mut policy = make_external_policy(); + let allowed = policy + .is_network_connection_allowed(&["169.254.169.254"]) + .unwrap(); + assert!(!allowed, "Expected AWS IMDS to be blocked"); +} + +#[test] +fn test_loopback_variants_blocked() { + let mut policy = make_external_policy(); + let blocked = vec!["127.0.0.1", "127.255.255.255", "127.0.0.0"]; + for ip_str in &blocked { + let allowed = policy.is_network_connection_allowed(&[ip_str]).unwrap(); + assert!(!allowed, "Expected {} to be blocked", ip_str); + } +} + +#[test] +fn test_unique_local_blocked() { + let mut policy = make_external_policy(); + let blocked = vec!["fc00::1", "fd00::1"]; + for ip_str in &blocked { + let allowed = policy.is_network_connection_allowed(&[ip_str]).unwrap(); + assert!(!allowed, "Expected {} to be blocked", ip_str); + } +} + +#[test] +fn test_site_local_blocked() { + let mut policy = make_external_policy(); + let blocked = vec!["fec0::1"]; + for ip_str in &blocked { + let allowed = policy.is_network_connection_allowed(&[ip_str]).unwrap(); + assert!(!allowed, "Expected {} to be blocked", ip_str); + } +} + +#[test] +fn test_documentation_network_blocked() { + let mut policy = make_external_policy(); + let blocked = vec!["192.0.2.1", "198.51.100.1", "203.0.113.1"]; + for ip_str in &blocked { + let allowed = policy.is_network_connection_allowed(&[ip_str]).unwrap(); + assert!(!allowed, "Expected {} to be blocked", ip_str); + } +} + +#[test] +fn test_shared_address_space_blocked() { + let mut policy = make_external_policy(); + let blocked = vec!["100.64.0.1", "100.100.100.100", "100.127.255.255"]; + for ip_str in &blocked { + let allowed = policy.is_network_connection_allowed(&[ip_str]).unwrap(); + assert!(!allowed, "Expected {} to be blocked", ip_str); + } +} + +#[test] +fn test_reserved_blocked() { + let mut policy = make_external_policy(); + let blocked = vec!["240.0.0.1", "255.255.255.255"]; + for ip_str in &blocked { + let allowed = policy.is_network_connection_allowed(&[ip_str]).unwrap(); + assert!(!allowed, "Expected {} to be blocked", ip_str); + } +} + +#[test] +fn test_benchmarking_blocked() { + let mut policy = make_external_policy(); + let blocked = vec!["198.18.0.1", "198.19.255.255"]; + for ip_str in &blocked { + let allowed = policy.is_network_connection_allowed(&[ip_str]).unwrap(); + assert!(!allowed, "Expected {} to be blocked", ip_str); + } +} + +#[test] +fn test_decimal_hex_octal_ip_rejected() { + let mut policy = make_external_policy(); + // Raw encoded IP bypass formats - rejected by IpAddr::from_str + // url crate normalizes these before resolver sees host + let invalid_ips = vec![ + "2130706433", // decimal 127.0.0.1 + "0x7f000001", // hex 127.0.0.1 + "0177.0.0.1", // octal 127.0.0.1 + "0xA9.0xFE.0xA9.0xFE", // hex 169.254.169.254 + "0xA9FEA9FE", // hex 169.254.169.254 + "0251.0376.0251.0376", // octal 169.254.169.254 + "0", // decimal 0.0.0.0 + ]; + for ip_str in &invalid_ips { + let result = policy.is_network_connection_allowed(&[ip_str]); + assert!( + matches!(result, Err(AntiSSRFError::InvalidIP(_))), + "Expected {} to be rejected as invalid IP, got {:?}", + ip_str, + result + ); + } +} + +#[test] +fn test_shortened_ip_blocked_or_rejected() { + let mut policy = make_external_policy(); + // 127.1 is shorthand for 127.0.0.1 in some parsers (inet_aton) + // Rust's IpAddr::from_str may accept or reject it + let result = policy.is_network_connection_allowed(&["127.1"]); + match result { + Ok(allowed) => assert!(!allowed, "Expected 127.1 to be blocked as localhost"), + Err(_) => (), // InvalidIP is acceptable - still prevents bypass + } +} + +#[test] +fn test_url_parsing_at_redirects() { + // Verify url crate correctly handles @ redirect patterns + // These are Orange Tsai's URL parsing discrepancy bypasses + + // evil.com@127.0.0.1 - host is correctly identified as 127.0.0.1 + let url = url::Url::parse("http://evil.com@127.0.0.1/").unwrap(); + assert_eq!(url.host_str(), Some("127.0.0.1")); + + // 127.0.0.1@evil.com - host is correctly identified as evil.com + let url = url::Url::parse("http://127.0.0.1@evil.com/").unwrap(); + assert_eq!(url.host_str(), Some("evil.com")); + + // Port + @ confusion: 127.1.1.1:80@127.2.2.2:80 + let url = url::Url::parse("http://127.1.1.1:80@127.2.2.2:80/").unwrap(); + assert_eq!(url.host_str(), Some("127.2.2.2")); +} diff --git a/scripts/build-domains-rust.sh b/scripts/build-domains-rust.sh new file mode 100755 index 0000000..5dd6fd6 --- /dev/null +++ b/scripts/build-domains-rust.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Script to build src/domains.rs from config/Domains.json +# Generates a Rust module with static domain constants + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +JSON_FILE="$SCRIPT_DIR/../config/Domains.json" +RS_FILE="$SCRIPT_DIR/../rust/src/domains.rs" + +if ! command -v jq &> /dev/null; then + echo "Error: jq is required but not installed." + exit 1 +fi + +if [[ ! -f "$JSON_FILE" ]]; then + echo "Error: Domains.json not found in $SCRIPT_DIR/../config/" + exit 1 +fi + +# Generate Rust file +cat > "$RS_FILE" << 'EOF' +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Well-known Azure service domains for URL validation. +//! +//! This module provides static domain lists used by [`crate::URIValidator`] to +//! recognise Azure Key Vault and Azure Storage endpoints. The lists cover +//! all public Azure sovereign clouds (global Azure, China, US Government). +//! +//! # Usage +//! +//! These constants are typically consumed indirectly through +//! [`crate::URIValidator::in_azure_key_vault_domain`] and +//! [`crate::URIValidator::in_azure_storage_domain`], but they are also public +//! in case you need to build custom domain checks. +//! +//! This file is auto-generated from `config/Domains.json`. +//! Do not edit manually; run `scripts/build-domains-rust.sh` to regenerate. + +EOF + +# Generate Azure Key Vault domains +{ + echo "/// Azure Key Vault service domains across all public Azure environments." + echo "///" + echo "/// Covers vault and managed-HSM endpoints for:" + echo "///" + echo "/// | Suffix | Cloud |" + echo "/// |--------|-------|" + echo "/// | \`vault.azure.net\` | Global Azure |" + echo "/// | \`vault.azure.cn\` | Azure China |" + echo "/// | \`vault.usgovcloudapi.net\` | Azure US Government |" + echo "///" + echo "/// Hostnames containing \`--\` are rejected by [\`crate::URIValidator::in_azure_key_vault_domain\`]" + echo "/// per Azure naming restrictions." + echo "pub const AZURE_KEY_VAULT_DOMAINS: &[&str] = &[" + jq -r '.azureKeyVault.domains[] | " \"" + . + "\","' "$JSON_FILE" + echo "];" + echo "" +} >> "$RS_FILE" + +# Generate Azure Storage domains +{ + echo "/// Azure Storage service domains across all public Azure environments." + echo "///" + echo "/// Covers Blob, Web, Data Lake Storage Gen2 (DFS), File, Queue, and Table" + echo "/// endpoints for:" + echo "///" + echo "/// | Suffix | Cloud |" + echo "/// |--------|-------|" + echo "/// | \`*.core.windows.net\` | Global Azure |" + echo "/// | \`*.storage.azure.net\` | Global Azure (alternative) |" + echo "/// | \`*.core.usgovcloudapi.net\` | Azure US Government |" + echo "/// | \`*.core.chinacloudapi.cn\` | Azure China |" + echo "///" + echo "/// Like [\`AZURE_KEY_VAULT_DOMAINS\`], hostnames containing \`--\` are rejected" + echo "/// by [\`crate::URIValidator::in_azure_storage_domain\`] per Azure naming restrictions." + echo "pub const AZURE_STORAGE_DOMAINS: &[&str] = &[" + jq -r '.azureStorage.domains[] | " \"" + . + "\","' "$JSON_FILE" + echo "];" +} >> "$RS_FILE" + +echo "Successfully generated $RS_FILE" diff --git a/scripts/build-ip-ranges-rust.sh b/scripts/build-ip-ranges-rust.sh new file mode 100755 index 0000000..9347710 --- /dev/null +++ b/scripts/build-ip-ranges-rust.sh @@ -0,0 +1,282 @@ +#!/bin/bash + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Script to build src/ip_address_ranges.rs from config/IPAddressRanges.json +# Generates a Rust module with static constants + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +JSON_FILE="$SCRIPT_DIR/../config/IPAddressRanges.json" +RS_FILE="$SCRIPT_DIR/../rust/src/ip_address_ranges.rs" + +if ! command -v jq &> /dev/null; then + echo "Error: jq is required but not installed." + exit 1 +fi + +if [[ ! -f "$JSON_FILE" ]]; then + echo "Error: IPAddressRanges.json not found in $SCRIPT_DIR/../config/" + exit 1 +fi + +# Generate module header +cat > "$RS_FILE" << 'EOF' +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Static IP address ranges for AntiSSRF protection. +//! +//! Each constant in this module represents a category of special-purpose IP +//! addresses defined by IANA / IETF RFCs. They are used by +//! [`AntiSSRFPolicy`](crate::AntiSSRFPolicy) +//! to build deny/allow lists. +//! +//! # Usage +//! +//! ```rust +//! use antissrf::ip_address_ranges; +//! +//! // Block the Azure Instance Metadata Service endpoint +//! let imds = ip_address_ranges::IMDS; +//! assert!(imds.contains(&"169.254.169.254/32")); +//! ``` +//! +//! # Source +//! +//! This file is auto-generated from `config/IPAddressRanges.json`. +//! Do not edit manually; run `scripts/build-ip-ranges-rust.sh` to regenerate. +//! +//! # References +//! +//! - [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) +//! - [RFC 6890](https://tools.ietf.org/html/rfc6890) β€” Special-Purpose IP Address Registries + +EOF + +# Helper: output doc comment lines for a constant +doc_comment() { + local key=$1 + case $key in + amt) + echo "/// Address Management Transitions (AMT) relay addresses." + echo "///" + echo "/// Defined in [RFC 7450](https://tools.ietf.org/html/rfc7450)." + echo "/// Not typically relevant for SSRF unless your application explicitly uses AMT." + ;; + as112) + echo "/// AS112 DNS server addresses for reverse DNS of private-use space." + echo "///" + echo "/// Defined in [RFC 7535](https://tools.ietf.org/html/rfc7535)." + ;; + benchmarking) + echo "/// Benchmarking addresses for network interconnection devices." + echo "///" + echo "/// Defined in [RFC 2544](https://tools.ietf.org/html/rfc2544) and" + echo "/// [RFC 5180](https://tools.ietf.org/html/rfc5180)." + ;; + deprecated) + echo "/// Deprecated 6to4 anycast relay addresses." + echo "///" + echo "/// Formerly used for 6to4 transition; now deprecated per" + echo "/// [RFC 7526](https://tools.ietf.org/html/rfc7526)." + ;; + detsPrefix) + echo "/// DetNet Service Prefixes for Deterministic Networking." + echo "///" + echo "/// Defined in [RFC 9023](https://tools.ietf.org/html/rfc9023)." + ;; + discardOnly) + echo "/// Discard-only prefix for IPv6 documentation." + echo "///" + echo "/// Defined in [RFC 6666](https://tools.ietf.org/html/rfc6666)." + ;; + documentation) + echo "/// Documentation and example addresses." + echo "///" + echo "/// These ranges are reserved for use in documentation and examples" + echo "/// ([RFC 5737](https://tools.ietf.org/html/rfc5737), [RFC 3849](https://tools.ietf.org/html/rfc3849))." + echo "/// They should never appear in legitimate traffic." + ;; + dummy) + echo "/// Dummy / test addresses for protocol experiments." + echo "///" + echo "/// Defined in [RFC 4727](https://tools.ietf.org/html/rfc4727)." + ;; + ietfProtocol) + echo "/// IETF protocol assignment addresses." + echo "///" + echo "/// Used for protocol number assignments and other IETF purposes" + echo "/// ([RFC 6890](https://tools.ietf.org/html/rfc6890))." + ;; + imds) + echo "/// Azure Instance Metadata Service (IMDS) endpoint." + echo "///" + echo "/// \`169.254.169.254\` is the link-local address used by Azure VMs to retrieve" + echo "/// instance metadata, tokens, and secrets. **Blocking this is critical**" + echo "/// for SSRF prevention in Azure environments." + ;; + ipv4Ipv6Translat) + echo "/// IPv4/IPv6 translation well-known prefixes." + echo "///" + echo "/// Defined in [RFC 6052](https://tools.ietf.org/html/rfc6052) and" + echo "/// [RFC 8215](https://tools.ietf.org/html/rfc8215)." + ;; + ipv4ServiceContinuity) + echo "/// IPv4 Service Continuity Prefix for DS-Lite." + echo "///" + echo "/// Defined in [RFC 6333](https://tools.ietf.org/html/rfc6333)." + ;; + broadcast) + echo "/// Limited broadcast address." + echo "///" + echo "/// \`255.255.255.255\` is the IPv4 limited broadcast destination" + echo "/// ([RFC 919](https://tools.ietf.org/html/rfc919))." + ;; + linkLocal) + echo "/// Link-local addresses (auto-configured, non-routable)." + echo "///" + echo "/// \`169.254.0.0/16\` (IPv4) and \`fe80::/10\` (IPv6) are used for local network" + echo "/// communication without a DHCP server. Commonly exploited in SSRF attacks." + ;; + loopback) + echo "/// Loopback addresses." + echo "///" + echo "/// \`127.0.0.0/8\` (IPv4) and \`::1/128\` (IPv6) refer to the local host." + echo "/// **Always block in ExternalOnly policies** to prevent connections to" + echo "/// local services." + ;; + multicast) + echo "/// Multicast addresses." + echo "///" + echo "/// \`224.0.0.0/4\` (IPv4) and \`ff00::/8\` (IPv6) are reserved for multicast" + echo "/// traffic ([RFC 1112](https://tools.ietf.org/html/rfc1112), [RFC 4291](https://tools.ietf.org/html/rfc4291))." + ;; + orchidv2) + echo "/// ORCHIDv2 overlay routable cryptographic hash identifiers." + echo "///" + echo "/// Defined in [RFC 7343](https://tools.ietf.org/html/rfc7343)." + ;; + privateUse) + echo "/// Private-use (RFC 1918) addresses." + echo "///" + echo "/// \`10.0.0.0/8\`, \`172.16.0.0/12\`, and \`192.168.0.0/16\` are the standard" + echo "/// private IPv4 ranges. These are the **most common targets** for SSRF" + echo "/// attacks against internal infrastructure." + ;; + reserved) + echo "/// Reserved IPv4 addresses (future use)." + echo "///" + echo "/// \`240.0.0.0/4\` is reserved by [RFC 1112](https://tools.ietf.org/html/rfc1112)" + echo "/// for future use." + ;; + sharedAddressSpace) + echo "/// Carrier-grade NAT (CGNAT) shared address space." + echo "///" + echo "/// \`100.64.0.0/10\` is reserved for ISP-level NAT per" + echo "/// [RFC 6598](https://tools.ietf.org/html/rfc6598). Should be treated as" + echo "/// internal for SSRF purposes." + ;; + siteLocal) + echo "/// Deprecated site-local IPv6 addresses." + echo "///" + echo "/// \`fec0::/10\` was deprecated by [RFC 3879](https://tools.ietf.org/html/rfc3879)." + ;; + sixto4) + echo "/// 6to4 transition anycast addresses." + echo "///" + echo "/// Defined in [RFC 3056](https://tools.ietf.org/html/rfc3056)." + ;; + srv6Sid) + echo "/// SRv6 Segment Identifier (SID) prefix." + echo "///" + echo "/// Defined in [RFC 9602](https://tools.ietf.org/html/rfc9602)." + ;; + teredo) + echo "/// Teredo transition tunneling addresses." + echo "///" + echo "/// Defined in [RFC 4380](https://tools.ietf.org/html/rfc4380)." + ;; + uniqueLocal) + echo "/// Unique local IPv6 unicast addresses (ULA)." + echo "///" + echo "/// \`fc00::/7\` is the IPv6 equivalent of RFC 1918 private addresses" + echo "/// ([RFC 4193](https://tools.ietf.org/html/rfc4193))." + ;; + unspecified) + echo "/// Unspecified addresses." + echo "///" + echo "/// \`0.0.0.0/8\` (IPv4) and \`::/128\` (IPv6) represent \"this host on this network\"" + echo "/// ([RFC 1122](https://tools.ietf.org/html/rfc1122))." + ;; + wireserver) + echo "/// Azure Wire Server endpoint." + echo "///" + echo "/// \`168.63.129.16\` is used by Azure for VM Agent communication and" + echo "/// DHCP-like functionality. **Block in ExternalOnly policies** to prevent" + echo "/// SSRF-based metadata exfiltration." + ;; + recommendedV1) + echo "/// Recommended deny list (version 1) β€” comprehensive combination of all" + echo "/// dangerous special-purpose ranges." + echo "///" + echo "/// This list includes:" + echo "///" + echo "/// - Loopback, link-local, multicast, broadcast" + echo "/// - Private-use (RFC 1918) and CGNAT (RFC 6598)" + echo "/// - Documentation / example ranges" + echo "/// - Transition / deprecated ranges" + echo "/// - Azure-specific endpoints ([\`IMDS\`], [\`WIRESERVER\`])" + echo "///" + echo "/// Used by [\`PolicyConfigOptions::ExternalOnlyLatest\`](crate::PolicyConfigOptions::ExternalOnlyLatest)." + echo "/// This is the default comprehensive blocklist for production use." + ;; + esac +} + +# Helper: format a CIDR array as a Rust const +# Arguments: constant_name, UPPER_CASE_NAME +format_const() { + local key=$1 + local uc_key=$2 + local count + + count=$(jq -r ".\"$key\".cidr | length" "$JSON_FILE") + + # Output doc comment + doc_comment "$key" + + if [[ "$count" -le 3 ]]; then + # Single line β€” join with ", " + local values + values=$(jq -r ".\"$key\".cidr | join(\"\\\", \\\"\")" "$JSON_FILE") + echo "pub const $uc_key: &[&str] = &[\"$values\"];" + else + # Multi-line β€” one CIDR per line with trailing comma + echo "pub const $uc_key: &[&str] = &[" + jq -r ".\"$key\".cidr[] | \" \\\"\" + . + \"\\\",\"" "$JSON_FILE" + echo "];" + fi + echo "" +} + +# Process each standalone variable in JSON order +for key in $(jq -r 'to_entries | map(select(.key != "_sources" and .value.standaloneVariable == true)) | .[].key' "$JSON_FILE"); do + uc_key=$(echo "$key" | tr '[:lower:]' '[:upper:]') + format_const "$key" "$uc_key" >> "$RS_FILE" +done + +# Add recommendedV1 (also standaloneVariable=true but handled above) +# Then RECOMMENDED_LATEST alias +{ + echo "/// Alias for the current recommended deny list." + echo "///" + echo "/// Always points to [\`RECOMMENDEDV1\`]. When a new version is introduced," + echo "/// this alias will be updated to reference it." + echo "pub const RECOMMENDED_LATEST: &[&str] = RECOMMENDEDV1;" +} >> "$RS_FILE" + +echo "Successfully generated $RS_FILE"