From 51f8cd46595fd8f0a38b2eeca75c46269c04dcac Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Fri, 28 Nov 2025 10:38:09 -0500 Subject: [PATCH] Add path-based JWT validation support in JwtValidatorPlugin - Introduced `paths` and `only_paths` configuration options to define protected API paths. - Enhanced HAProxy configuration generation to handle path-specific JWT validation. - Updated plugin metadata to include path details and validation logic. - Modified documentation with detailed examples for protecting paths. - Added comprehensive tests for path-based validation scenarios and edge cases. --- docs/plugins.md | 122 +++++++++++++++++++++++++-- src/plugins/builtin/jwt_validator.py | 88 +++++++++++++++---- src/tests/test_plugins.py | 122 ++++++++++++++++++++++++++- 3 files changed, 310 insertions(+), 22 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index db4beb5..6db84fc 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -177,9 +177,16 @@ Validates JWT (JSON Web Token) authentication tokens using HAProxy's built-in JW - `issuer` - Expected JWT issuer (optional, set to `none`/`null` to skip validation) - `audience` - Expected JWT audience (optional, set to `none`/`null` to skip validation) - `pubkey_path` - Path to public key file (required if `pubkey` not provided) -- `pubkey` - Public key content as string (required if `pubkey_path` not provided) +- `pubkey` - Public key content as base64-encoded string (required if `pubkey_path` not provided) +- `paths` - List of paths that require JWT validation (optional, if not set ALL domain is protected) +- `only_paths` - If `true`, only specified paths are accessible; if `false` (default), only specified paths require JWT validation -**Enable via container label:** +**Path Validation Logic:** +- **No paths configured:** ALL requests to the domain require JWT validation (default behavior) +- **Paths configured + `only_paths=false`:** Only specified paths require JWT validation, other paths pass through without validation +- **Paths configured + `only_paths=true`:** Only specified paths are accessible (with JWT validation), all other paths are denied + +**Enable via container label (protect all paths):** ```yaml services: api: @@ -194,6 +201,24 @@ services: - ./pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro ``` +**Protect specific paths only (others can pass without JWT):** +```yaml +labels: + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive + easyhaproxy.http.plugin.jwt_validator.only_paths: false +``` + +**Only allow specific paths (deny all others):** +```yaml +labels: + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/public,/api/v1 + easyhaproxy.http.plugin.jwt_validator.only_paths: true +``` + **Skip issuer/audience validation:** ```yaml labels: @@ -202,7 +227,7 @@ labels: easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem ``` -**HAProxy config generated:** +**HAProxy config generated (all paths protected):** ``` # JWT Validator - Validate JWT tokens http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } @@ -224,6 +249,59 @@ http-request set-var(txn.now) date() http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } ``` +**HAProxy config generated (specific paths, only_paths=false):** +``` +# JWT Validator - Validate JWT tokens + +# Define paths that require JWT validation +acl jwt_protected_path path_beg /api/admin +acl jwt_protected_path path_beg /api/sensitive + +http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } if jwt_protected_path + +# Extract JWT header and payload +http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') if jwt_protected_path +http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') if jwt_protected_path +http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') if jwt_protected_path +http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') if jwt_protected_path + +# Validate JWT (only on protected paths) +http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } if jwt_protected_path +http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } if jwt_protected_path + +# Validate expiration +http-request set-var(txn.now) date() if jwt_protected_path +http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } if jwt_protected_path +``` + +**HAProxy config generated (specific paths, only_paths=true):** +``` +# JWT Validator - Validate JWT tokens + +# Define paths that require JWT validation +acl jwt_protected_path path_beg /api/public +acl jwt_protected_path path_beg /api/v1 + +# Deny access to paths not in the protected list +http-request deny content-type 'text/html' string 'Access denied' unless jwt_protected_path + +http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } + +# Extract JWT header and payload +http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') +http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') +http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') +http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') + +# Validate JWT (all requests at this point are on allowed paths) +http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } +http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } + +# Validate expiration +http-request set-var(txn.now) date() +http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } +``` + **What it validates:** - ✅ Authorization header presence - ✅ JWT signing algorithm (RS256, RS512, etc.) @@ -250,11 +328,13 @@ metadata: kubernetes.io/ingress.class: easyhaproxy-ingress # Enable plugins easyhaproxy.plugins: "jwt_validator,deny_pages" - # Configure jwt_validator plugin + # Configure jwt_validator plugin (protect specific paths only) easyhaproxy.plugin.jwt_validator.algorithm: "RS256" easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users" + easyhaproxy.plugin.jwt_validator.only_paths: "false" # Configure deny_pages plugin easyhaproxy.plugin.deny_pages.paths: "/admin,/private" easyhaproxy.plugin.deny_pages.status_code: "403" @@ -352,7 +432,7 @@ EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH=/etc/haproxy/cloudflare_ips.lst ### Protect API with JWT Authentication -Secure your API endpoints with JWT token validation: +**Secure entire API domain:** ```yaml services: @@ -367,6 +447,38 @@ services: - ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro ``` +**Protect only admin/sensitive endpoints:** + +```yaml +services: + api: + labels: + easyhaproxy.http.host: api.example.com + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/users,/api/billing + easyhaproxy.http.plugin.jwt_validator.only_paths: false + volumes: + - ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro +# /api/health, /api/docs, etc. remain publicly accessible +``` + +**Restrict API to only allow specific endpoints:** + +```yaml +services: + api: + labels: + easyhaproxy.http.host: api.example.com + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/v1,/api/v2 + easyhaproxy.http.plugin.jwt_validator.only_paths: true + volumes: + - ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro +# All paths except /api/v1 and /api/v2 are denied +``` + ### Restrict Admin Panel to Office IPs Protect admin panel by only allowing access from office network: diff --git a/src/plugins/builtin/jwt_validator.py b/src/plugins/builtin/jwt_validator.py index 8fa5a09..93df35d 100644 --- a/src/plugins/builtin/jwt_validator.py +++ b/src/plugins/builtin/jwt_validator.py @@ -10,7 +10,14 @@ Configuration: - issuer: Expected JWT issuer (optional, set to "none"/"null" to skip validation) - audience: Expected JWT audience (optional, set to "none"/"null" to skip validation) - pubkey_path: Path to public key file (required if pubkey not provided) - - pubkey: Public key content as string (required if pubkey_path not provided) + - pubkey: Public key content as base64-encoded string (required if pubkey_path not provided) + - paths: List of paths that require JWT validation (optional, if not set ALL domain is protected) + - only_paths: If true, only specified paths are accessible; if false (default), only specified paths require JWT validation + +Path Validation Logic: + - No paths configured: ALL requests to the domain require JWT validation (default behavior) + - Paths configured + only_paths=false: Only specified paths require JWT validation, others pass through + - Paths configured + only_paths=true: Only specified paths are accessible (with JWT), all others are denied Example YAML config: plugins: @@ -20,6 +27,10 @@ Example YAML config: issuer: https://myaccount.auth0.com/ audience: https://api.mywebsite.com pubkey_path: /etc/haproxy/jwt_keys/pubkey.pem + paths: + - /api/admin + - /api/sensitive + only_paths: false Example Container Label: easyhaproxy.http.plugins: "jwt_validator" @@ -27,6 +38,8 @@ Example Container Label: easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive + easyhaproxy.http.plugin.jwt_validator.only_paths: true HAProxy Config Generated: # JWT Validator - Validate JWT tokens @@ -49,6 +62,7 @@ HAProxy Config Generated: http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } """ +import base64 import os import sys @@ -69,6 +83,8 @@ class JwtValidatorPlugin(PluginInterface): self.audience = None # Optional self.pubkey_path = None # Path to public key file self.pubkey = None # Public key content (alternative to pubkey_path) + self.paths = [] # List of paths that require JWT validation + self.only_paths = False # If true, only specified paths are accessible @property def name(self) -> str: @@ -89,7 +105,9 @@ class JwtValidatorPlugin(PluginInterface): - issuer: Expected JWT issuer (optional) - audience: Expected JWT audience (optional) - pubkey_path: Path to public key file - - pubkey: Public key content as string + - pubkey: Public key content as base64-encoded string + - paths: List of paths that require JWT validation (optional) + - only_paths: If true, only specified paths are accessible (default: false) """ if "enabled" in config: self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] @@ -114,7 +132,22 @@ class JwtValidatorPlugin(PluginInterface): self.pubkey_path = config["pubkey_path"] if "pubkey" in config: - self.pubkey = config["pubkey"] + # Decode from base64 (consistent with sslcert parameter) + self.pubkey = base64.b64decode(config["pubkey"]).decode('ascii') + + # Path configuration + if "paths" in config: + paths_config = config["paths"] + if isinstance(paths_config, list): + self.paths = [str(p).strip() for p in paths_config if str(p).strip()] + elif isinstance(paths_config, str): + # Support comma-separated paths for container labels + self.paths = [p.strip() for p in paths_config.split(",") if p.strip()] + else: + self.paths = [] + + if "only_paths" in config: + self.only_paths = str(config["only_paths"]).lower() in ["true", "1", "yes"] def process(self, context: PluginContext) -> PluginResult: """ @@ -143,38 +176,59 @@ class JwtValidatorPlugin(PluginInterface): # Build HAProxy configuration lines = ["# JWT Validator - Validate JWT tokens"] + # Determine path condition suffix + path_condition = "" + if self.paths: + # Define ACL for protected paths + lines.append("") + lines.append("# Define paths that require JWT validation") + for path in self.paths: + lines.append(f"acl jwt_protected_path path_beg {path}") + lines.append("") + + if self.only_paths: + # Deny all paths that are not in the protected list + lines.append("# Deny access to paths not in the protected list") + lines.append("http-request deny content-type 'text/html' string 'Access denied' unless jwt_protected_path") + lines.append("") + # All remaining requests are on protected paths, no condition needed + path_condition = "" + else: + # Only validate JWT on protected paths + path_condition = " if jwt_protected_path" + # Check for Authorization header - lines.append("http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }") + lines.append(f"http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless {{ req.hdr(authorization) -m found }}{path_condition}") # Extract JWT parts lines.append("") lines.append("# Extract JWT header and payload") - lines.append("http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')") - lines.append("http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss')") - lines.append("http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud')") - lines.append("http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int')") + lines.append(f"http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg'){path_condition}") + lines.append(f"http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss'){path_condition}") + lines.append(f"http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud'){path_condition}") + lines.append(f"http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int'){path_condition}") # Validate JWT lines.append("") lines.append("# Validate JWT") - lines.append(f"http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless {{ var(txn.alg) -m str {self.algorithm} }}") + lines.append(f"http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless {{ var(txn.alg) -m str {self.algorithm} }}{path_condition}") # Validate issuer (if configured) if self.issuer: - lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless {{ var(txn.iss) -m str {self.issuer} }}") + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless {{ var(txn.iss) -m str {self.issuer} }}{path_condition}") # Validate audience (if configured) if self.audience: - lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT audience' unless {{ var(txn.aud) -m str {self.audience} }}") + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT audience' unless {{ var(txn.aud) -m str {self.audience} }}{path_condition}") # Validate signature - lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT signature' unless {{ http_auth_bearer,jwt_verify(txn.alg,\"{pubkey_file}\") -m int 1 }}") + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT signature' unless {{ http_auth_bearer,jwt_verify(txn.alg,\"{pubkey_file}\") -m int 1 }}{path_condition}") # Validate expiration lines.append("") lines.append("# Validate expiration") - lines.append("http-request set-var(txn.now) date()") - lines.append("http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 }") + lines.append(f"http-request set-var(txn.now) date(){path_condition}") + lines.append(f"http-request deny content-type 'text/html' string 'JWT has expired' if {{ var(txn.exp),sub(txn.now) -m int lt 0 }}{path_condition}") haproxy_config = "\n".join(lines) @@ -184,7 +238,9 @@ class JwtValidatorPlugin(PluginInterface): "algorithm": self.algorithm, "pubkey_file": pubkey_file, "validates_issuer": self.issuer is not None, - "validates_audience": self.audience is not None + "validates_audience": self.audience is not None, + "path_validation": len(self.paths) > 0, + "only_paths": self.only_paths } if self.issuer: @@ -193,6 +249,8 @@ class JwtValidatorPlugin(PluginInterface): metadata["audience"] = self.audience if self.pubkey: metadata["pubkey_content"] = self.pubkey + if self.paths: + metadata["paths"] = self.paths return PluginResult( haproxy_config=haproxy_config, diff --git a/src/tests/test_plugins.py b/src/tests/test_plugins.py index f131bfe..52600fd 100644 --- a/src/tests/test_plugins.py +++ b/src/tests/test_plugins.py @@ -518,11 +518,13 @@ class TestJwtValidatorPlugin: assert result.metadata["validates_audience"] is True def test_jwt_validator_plugin_generates_config_with_pubkey_content(self): - """Test plugin generates correct HAProxy config using pubkey content""" + """Test plugin generates correct HAProxy config using pubkey content (base64-encoded)""" plugin = JwtValidatorPlugin() + # Base64-encoded version of "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...\n-----END PUBLIC KEY-----" + pubkey_base64 = "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaC4uLgotLS0tLUVORCBQVUJMSUMgS0VZLS0tLS0=" plugin.configure({ "algorithm": "RS256", - "pubkey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...\n-----END PUBLIC KEY-----" + "pubkey": pubkey_base64 }) context = PluginContext( @@ -539,6 +541,7 @@ class TestJwtValidatorPlugin: assert result.haproxy_config is not None assert "JWT Validator" in result.haproxy_config assert "/etc/haproxy/jwt_keys/api_example_com_pubkey.pem" in result.haproxy_config + # Verify the decoded content is stored in metadata assert result.metadata["pubkey_content"] == "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...\n-----END PUBLIC KEY-----" def test_jwt_validator_plugin_no_issuer_audience_validation(self): @@ -617,6 +620,121 @@ class TestJwtValidatorPlugin: assert "http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')" in haproxy_config assert "jwt_verify" in haproxy_config + def test_jwt_validator_plugin_with_paths_only_paths_false(self): + """Test plugin with paths configured and only_paths=false""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem", + "paths": ["/api/admin", "/api/sensitive"], + "only_paths": "false" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + # Check that ACLs are defined for paths + assert "acl jwt_protected_path path_beg /api/admin" in result.haproxy_config + assert "acl jwt_protected_path path_beg /api/sensitive" in result.haproxy_config + # Check that validation rules have "if jwt_protected_path" condition + assert "unless { req.hdr(authorization) -m found } if jwt_protected_path" in result.haproxy_config + assert "http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') if jwt_protected_path" in result.haproxy_config + # Check that "Access denied" for non-protected paths is NOT present (only_paths=false) + assert "Access denied" not in result.haproxy_config + # Check metadata + assert result.metadata["path_validation"] is True + assert result.metadata["only_paths"] is False + assert result.metadata["paths"] == ["/api/admin", "/api/sensitive"] + + def test_jwt_validator_plugin_with_paths_only_paths_true(self): + """Test plugin with paths configured and only_paths=true""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem", + "paths": ["/api/public"], + "only_paths": "true" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + # Check that ACL is defined for path + assert "acl jwt_protected_path path_beg /api/public" in result.haproxy_config + # Check that "Access denied" for non-protected paths IS present (only_paths=true) + assert "http-request deny content-type 'text/html' string 'Access denied' unless jwt_protected_path" in result.haproxy_config + # Check that validation rules do NOT have "if jwt_protected_path" (since all non-protected paths are denied) + assert "unless { req.hdr(authorization) -m found } if jwt_protected_path" not in result.haproxy_config + # The rules should not have any condition suffix when only_paths=true + assert "http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }" in result.haproxy_config + # Check metadata + assert result.metadata["path_validation"] is True + assert result.metadata["only_paths"] is True + assert result.metadata["paths"] == ["/api/public"] + + def test_jwt_validator_plugin_paths_from_comma_separated_string(self): + """Test plugin parses comma-separated paths from container labels""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem", + "paths": "/api/admin,/api/sensitive,/api/protected" + }) + + assert plugin.paths == ["/api/admin", "/api/sensitive", "/api/protected"] + + def test_jwt_validator_plugin_paths_from_list(self): + """Test plugin parses paths from list (YAML config)""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem", + "paths": ["/api/admin", "/api/sensitive"] + }) + + assert plugin.paths == ["/api/admin", "/api/sensitive"] + + def test_jwt_validator_plugin_no_paths_protects_all(self): + """Test plugin protects all paths when paths is not configured""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + # Check that no ACL is defined + assert "acl jwt_protected_path" not in result.haproxy_config + # Check that validation rules do NOT have any condition suffix (all paths protected) + assert "unless { req.hdr(authorization) -m found } if jwt_protected_path" not in result.haproxy_config + assert "http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }" in result.haproxy_config + # Check metadata + assert result.metadata["path_validation"] is False + class TestPluginManager: """Test cases for PluginManager"""