From 50b7fbdbb5105dad8ece2e32e346cf3816eeea36 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 18 Sep 2025 10:30:57 +0200 Subject: [PATCH 01/35] Adds support for custom headers in URL fetch requests Introduces the ability to specify optional HTTP headers for URL-based data fetching. These headers are passed to the fetch logic to enhance flexibility in handling authenticated or customized requests. --- client/src/api/schema/schema.ts | 7 +++++++ lib/galaxy/schema/fetch_data.py | 1 + lib/galaxy/tools/data_fetch.py | 17 ++++++++++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index fadb7b4d765f..a5bede5c3925 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -23479,6 +23479,13 @@ export interface components { extra_files?: components["schemas"]["ExtraFiles"] | null; /** Hashes */ hashes?: components["schemas"]["FetchDatasetHash"][] | null; + /** + * Headers + * @description Optional headers to include in the URL fetch request + */ + headers?: { + [key: string]: string; + } | null; /** * Info * @description Free text field that can be used to store arbitrary information about the dataset. This used to be prominently diff --git a/lib/galaxy/schema/fetch_data.py b/lib/galaxy/schema/fetch_data.py index 91cfe8348e6e..6cc616bdc1a7 100644 --- a/lib/galaxy/schema/fetch_data.py +++ b/lib/galaxy/schema/fetch_data.py @@ -158,6 +158,7 @@ class PastedDataElement(BaseDataElement): class UrlDataElement(BaseDataElement): src: Literal["url"] url: str = Field(..., description="URL to upload") + headers: Optional[dict[str, str]] = Field(None, description="Optional headers to include in the URL fetch request") class ServerDirElement(BaseDataElement): diff --git a/lib/galaxy/tools/data_fetch.py b/lib/galaxy/tools/data_fetch.py index 36b70275f402..3d9f01154935 100644 --- a/lib/galaxy/tools/data_fetch.py +++ b/lib/galaxy/tools/data_fetch.py @@ -19,6 +19,10 @@ handle_upload, UploadProblemException, ) +from galaxy.files.models import ( + FilesSourceOptions, + PartialFilesSourceProperties, +) from galaxy.files.uris import ( ensure_file_sources, stream_to_file, @@ -540,8 +544,19 @@ def _has_src_to_path( is_link = True return name, path, is_link + headers = item.get("headers") + file_source_options: Optional[FilesSourceOptions] = None + if headers: + extra_props = PartialFilesSourceProperties(**{"http_headers": headers}) + file_source_options = FilesSourceOptions(extra_props=extra_props) + try: - path = stream_url_to_file(url, file_sources=upload_config.file_sources, dir=upload_config.working_directory) + path = stream_url_to_file( + url, + file_sources=upload_config.file_sources, + dir=upload_config.working_directory, + file_source_opts=file_source_options, + ) except Exception as e: raise Exception(f"Failed to fetch url {url}. {str(e)}") From a82347149faa241bf63572abfe69aba75edfc8d5 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 18 Sep 2025 17:56:22 +0200 Subject: [PATCH 02/35] Adds header encryption utilities using Vault system Introduces functions to identify, encrypt, and decrypt sensitive HTTP headers securely using Galaxy's Vault system. --- lib/galaxy/managers/headers_encryption.py | 196 ++++++++++++++ .../app/managers/test_headers_encryption.py | 239 ++++++++++++++++++ 2 files changed, 435 insertions(+) create mode 100644 lib/galaxy/managers/headers_encryption.py create mode 100644 test/unit/app/managers/test_headers_encryption.py diff --git a/lib/galaxy/managers/headers_encryption.py b/lib/galaxy/managers/headers_encryption.py new file mode 100644 index 000000000000..dd7475d495db --- /dev/null +++ b/lib/galaxy/managers/headers_encryption.py @@ -0,0 +1,196 @@ +""" +Utilities for encrypting sensitive headers using Galaxy's Vault system. + +This module provides functionality to: +1. Identify sensitive headers that should be encrypted +2. Encrypt/decrypt headers using the vault +3. Transform data structures to use vault references for sensitive headers + +This can be used for any scenario where HTTP headers containing sensitive information +(like authorization tokens, API keys, etc.) need to be stored securely in the database. +""" + +import re +from typing import ( + Any, + Optional, +) + +from galaxy.security.vault import Vault + +# Headers that should always be encrypted when stored in the database +SENSITIVE_HEADER_PATTERNS = [ + # Authorization patterns (Authorization, Proxy-Authorization, etc.) + re.compile(r".*authorization$", re.IGNORECASE), + # Authentication patterns (Authentication, WWW-Authenticate, etc.) + re.compile(r".*authenticat.*", re.IGNORECASE), + # Key patterns (API-Key, Auth-Key, X-API-Key, etc.) + re.compile(r".*key$", re.IGNORECASE), + # Token patterns (Bearer-Token, API-Token, X-TOKEN, etc.) + re.compile(r".*token$", re.IGNORECASE), + # Secret patterns (Client-Secret, API-Secret, etc.) + re.compile(r".*secret$", re.IGNORECASE), + # Auth patterns (Custom-Auth, Basic-Auth, OAuth, etc.) + re.compile(r".*auth$", re.IGNORECASE), + re.compile(r"^x-auth", re.IGNORECASE), + re.compile(r"^oauth", re.IGNORECASE), + # Cookie patterns + re.compile(r".*cookie.*", re.IGNORECASE), + # Bearer (standalone) + re.compile(r"^bearer$", re.IGNORECASE), +] + +# Default vault key prefix for headers +DEFAULT_VAULT_KEY_PREFIX = "headers" + + +def is_sensitive_header(header_name: str) -> bool: + for pattern in SENSITIVE_HEADER_PATTERNS: + if pattern.match(header_name): + return True + return False + + +def create_vault_key(context_id: str, header_name: str, key_prefix: Optional[str] = None) -> str: + """ + Create a vault key for storing a header value. + + Args: + context_id: Unique identifier for the context (e.g., UUID of a request, session ID, etc.) + header_name: Name of the header (will be normalized to lowercase) + key_prefix: Optional custom prefix for the vault key. Defaults to DEFAULT_VAULT_KEY_PREFIX + + Returns: + Vault key path for the header + """ + if key_prefix is None: + key_prefix = DEFAULT_VAULT_KEY_PREFIX + normalized_header = header_name.lower().replace("-", "_") + return f"{key_prefix}/{context_id}/{normalized_header}" + + +def create_vault_reference(header_name: str, reference_prefix: str = "VAULT_HEADER") -> str: + """ + Create a vault reference placeholder that will be stored in data structures. + + Args: + header_name: Name of the header + reference_prefix: Prefix for the vault reference. Defaults to "VAULT_HEADER" + + Returns: + Vault reference string that indicates this value should be decrypted + """ + return f"__{reference_prefix}_{header_name.upper().replace('-', '_')}__" + + +def encrypt_headers_in_data( + data: dict, context_id: str, vault: Vault, key_prefix: Optional[str] = None, reference_prefix: str = "VAULT_HEADER" +) -> dict: + """ + Recursively process data structure to encrypt sensitive headers. + + This function walks through a dictionary structure and: + 1. Identifies elements with headers + 2. Encrypts sensitive headers to the vault + 3. Replaces sensitive header values with vault references + + Args: + data: The data dictionary structure containing headers + context_id: Unique identifier for the context (e.g., UUID, session ID, etc.) + vault: Vault instance for encryption + key_prefix: Optional custom prefix for vault keys. Defaults to DEFAULT_VAULT_KEY_PREFIX + reference_prefix: Prefix for vault references. Defaults to "VAULT_HEADER" + + Returns: + Modified data structure with sensitive headers encrypted + """ + # Make a deep copy to avoid modifying the original + encrypted_data: dict[str, Any] = {} + + for key, value in data.items(): + if key == "headers" and isinstance(value, dict): + encrypted_headers = {} + for header_name, header_value in value.items(): + if is_sensitive_header(header_name): + # Encrypt sensitive header + vault_key = create_vault_key(context_id, header_name, key_prefix) + vault.write_secret(vault_key, header_value) + encrypted_headers[header_name] = create_vault_reference(header_name, reference_prefix) + else: + # Keep non-sensitive headers as-is + encrypted_headers[header_name] = header_value + encrypted_data[key] = encrypted_headers + elif isinstance(value, dict): + encrypted_data[key] = encrypt_headers_in_data(value, context_id, vault, key_prefix, reference_prefix) + elif isinstance(value, list): + encrypted_data[key] = [ + ( + encrypt_headers_in_data(item, context_id, vault, key_prefix, reference_prefix) + if isinstance(item, dict) + else item + ) + for item in value + ] + else: + encrypted_data[key] = value + + return encrypted_data + + +def decrypt_headers_in_data( + data: dict, context_id: str, vault: Vault, key_prefix: Optional[str] = None, reference_prefix: str = "VAULT_HEADER" +) -> dict: + """ + Recursively process data structure to decrypt sensitive headers from vault. + + This function walks through a dictionary structure and: + 1. Identifies vault references in headers + 2. Decrypts the actual header values from the vault + 3. Replaces vault references with actual header values + + Args: + data: The data dictionary structure with vault references + context_id: Unique identifier for the context (e.g., UUID, session ID, etc.) + vault: Vault instance for decryption + key_prefix: Optional custom prefix for vault keys. Defaults to DEFAULT_VAULT_KEY_PREFIX + reference_prefix: Prefix for vault references. Defaults to "VAULT_HEADER" + + Returns: + Modified data structure with actual header values restored + """ + # Make a deep copy to avoid modifying the original + decrypted_data: dict[str, Any] = {} + + for key, value in data.items(): + if key == "headers" and isinstance(value, dict): + decrypted_headers = {} + for header_name, header_value in value.items(): + if isinstance(header_value, str) and header_value.startswith(f"__{reference_prefix}_"): + # Decrypt vault reference + vault_key = create_vault_key(context_id, header_name, key_prefix) + decrypted_value = vault.read_secret(vault_key) + if decrypted_value is not None: + decrypted_headers[header_name] = decrypted_value + else: + # Handle case where vault key doesn't exist (shouldn't happen in normal flow) + # Log warning and skip this header + decrypted_headers[header_name] = header_value # Keep vault reference as fallback + else: + # Keep non-vault headers as-is + decrypted_headers[header_name] = header_value + decrypted_data[key] = decrypted_headers + elif isinstance(value, dict): + decrypted_data[key] = decrypt_headers_in_data(value, context_id, vault, key_prefix, reference_prefix) + elif isinstance(value, list): + decrypted_data[key] = [ + ( + decrypt_headers_in_data(item, context_id, vault, key_prefix, reference_prefix) + if isinstance(item, dict) + else item + ) + for item in value + ] + else: + decrypted_data[key] = value + + return decrypted_data diff --git a/test/unit/app/managers/test_headers_encryption.py b/test/unit/app/managers/test_headers_encryption.py new file mode 100644 index 000000000000..59fdb5610f09 --- /dev/null +++ b/test/unit/app/managers/test_headers_encryption.py @@ -0,0 +1,239 @@ +from typing import Optional + +from galaxy.managers.headers_encryption import ( + create_vault_key, + create_vault_reference, + decrypt_headers_in_data, + encrypt_headers_in_data, + is_sensitive_header, +) +from galaxy.security.vault import Vault + + +class MockVault(Vault): + """Mock vault for testing encryption/decryption.""" + + def __init__(self): + self.storage = {} + + def write_secret(self, key: str, value: str) -> None: + self.storage[key] = value + + def read_secret(self, key: str) -> Optional[str]: + return self.storage.get(key) + + def list_secrets(self, key: str) -> list[str]: + """Mock implementation - not used in header tests.""" + return [] + + +class TestSensitiveHeaderDetection: + """Test sensitive header pattern matching.""" + + def test_sensitive_headers_detected(self): + """Test that known sensitive headers are detected.""" + assert is_sensitive_header("Authorization") + assert is_sensitive_header("authorization") + assert is_sensitive_header("Proxy-Authorization") + assert is_sensitive_header("Authentication") + assert is_sensitive_header("WWW-Authenticate") + assert is_sensitive_header("X-API-Key") + assert is_sensitive_header("x-api-key") + assert is_sensitive_header("API-Key") + assert is_sensitive_header("Auth-Key") + assert is_sensitive_header("Session-Key") + assert is_sensitive_header("Bearer-Token") + assert is_sensitive_header("API-Token") + assert is_sensitive_header("X-TOKEN") + assert is_sensitive_header("X-Auth-Token") + assert is_sensitive_header("X-Access-Token") + assert is_sensitive_header("My-Secret") + assert is_sensitive_header("Client-Secret") + assert is_sensitive_header("API-Secret") + assert is_sensitive_header("Custom-Auth") + assert is_sensitive_header("Basic-Auth") + assert is_sensitive_header("X-Auth-Key") + assert is_sensitive_header("OAuth") + assert is_sensitive_header("Cookie") + assert is_sensitive_header("cookie") + assert is_sensitive_header("Set-Cookie") + assert is_sensitive_header("Bearer") + + def test_non_sensitive_headers_not_detected(self): + """Test that non-sensitive headers are not detected.""" + assert not is_sensitive_header("User-Agent") + assert not is_sensitive_header("Content-Type") + assert not is_sensitive_header("Accept") + assert not is_sensitive_header("X-Custom-Header") + assert not is_sensitive_header("Content-Length") + assert not is_sensitive_header("Host") + # Edge cases that contain keywords but aren't auth headers + assert not is_sensitive_header("Token-Bucket") + assert not is_sensitive_header("Key-Value") + + +class TestVaultKeyAndReference: + """Test vault key and reference creation.""" + + def test_create_vault_key_default_prefix(self): + """Test vault key creation with default prefix.""" + key = create_vault_key("uuid-123", "Authorization") + assert key == "headers/uuid-123/authorization" + + key = create_vault_key("uuid-456", "X-API-Key") + assert key == "headers/uuid-456/x_api_key" + + def test_create_vault_key_custom_prefix(self): + """Test vault key creation with custom prefix.""" + key = create_vault_key("uuid-123", "Authorization", "custom_prefix") + assert key == "custom_prefix/uuid-123/authorization" + + key = create_vault_key("uuid-456", "X-API-Key", "landing_request/headers") + assert key == "landing_request/headers/uuid-456/x_api_key" + + def test_create_vault_reference_default(self): + """Test vault reference creation with default prefix.""" + ref = create_vault_reference("Authorization") + assert ref == "__VAULT_HEADER_AUTHORIZATION__" + + ref = create_vault_reference("X-API-Key") + assert ref == "__VAULT_HEADER_X_API_KEY__" + + def test_create_vault_reference_custom_prefix(self): + """Test vault reference creation with custom prefix.""" + ref = create_vault_reference("Authorization", "CUSTOM_REF") + assert ref == "__CUSTOM_REF_AUTHORIZATION__" + + ref = create_vault_reference("X-API-Key", "SESSION_HEADER") + assert ref == "__SESSION_HEADER_X_API_KEY__" + + +class TestHeaderEncryptionDecryption: + """Test end-to-end header encryption and decryption.""" + + def test_encrypt_decrypt_simple_headers(self): + """Test encrypting and decrypting a simple headers structure.""" + vault = MockVault() + context_id = "test-uuid" + + # Simple case with headers at top level + data = { + "headers": { + "Authorization": "Bearer secret-token", + "X-API-Key": "api-key-123", + "User-Agent": "Galaxy/1.0", + } + } + + # Encrypt + encrypted = encrypt_headers_in_data(data, context_id, vault) + + # Check that sensitive values are replaced with vault references + headers = encrypted["headers"] + assert headers["Authorization"] == "__VAULT_HEADER_AUTHORIZATION__" + assert headers["X-API-Key"] == "__VAULT_HEADER_X_API_KEY__" + assert headers["User-Agent"] == "Galaxy/1.0" # Non-sensitive unchanged + + # Check vault was written to with new default format + assert len(vault.storage) == 2 + assert "headers/test-uuid/authorization" in vault.storage + assert "headers/test-uuid/x_api_key" in vault.storage + + # Decrypt + decrypted = decrypt_headers_in_data(encrypted, context_id, vault) + + # Check original values are restored + decrypted_headers = decrypted["headers"] + assert decrypted_headers["Authorization"] == "Bearer secret-token" + assert decrypted_headers["X-API-Key"] == "api-key-123" + assert decrypted_headers["User-Agent"] == "Galaxy/1.0" + + def test_encrypt_decrypt_nested_headers(self): + """Test encrypting and decrypting headers in a complex nested structure.""" + vault = MockVault() + context_id = "test-uuid" + + # Complex nested structure like in the actual data landing request + data: dict = { + "request_version": "1", + "request_json": { + "targets": [ + { + "destination": {"type": "hdas"}, + "elements": [ + { + "src": "url", + "url": "base64://data", + "headers": { + "Authorization": "Bearer secret-token", + "X-API-Key": "api-key-123", + "User-Agent": "Galaxy/1.0", + }, + } + ], + } + ] + }, + } + + # Encrypt + encrypted = encrypt_headers_in_data(data, context_id, vault) + + # Check structure is preserved + assert encrypted["request_version"] == "1" + + # Check headers are encrypted + headers = encrypted["request_json"]["targets"][0]["elements"][0]["headers"] + assert headers["Authorization"] == "__VAULT_HEADER_AUTHORIZATION__" + assert headers["X-API-Key"] == "__VAULT_HEADER_X_API_KEY__" + assert headers["User-Agent"] == "Galaxy/1.0" + + # Decrypt + decrypted = decrypt_headers_in_data(encrypted, context_id, vault) + + # Check original structure and values are restored + original_headers = data["request_json"]["targets"][0]["elements"][0]["headers"] + decrypted_headers = decrypted["request_json"]["targets"][0]["elements"][0]["headers"] + + assert decrypted_headers == original_headers + + def test_multiple_headers_sections(self): + """Test handling multiple headers sections in different parts of the structure.""" + vault = MockVault() + context_id = "test-uuid" + + data = { + "section1": { + "headers": { + "Authorization": "Bearer token1", + "User-Agent": "Galaxy/1.0", + } + }, + "section2": { + "data": { + "headers": { + "X-API-Key": "key123", + "Content-Type": "application/json", + } + } + }, + } + + # Encrypt + encrypted = encrypt_headers_in_data(data, context_id, vault) + + # Check both sections are encrypted + section1_headers = encrypted["section1"]["headers"] + assert section1_headers["Authorization"] == "__VAULT_HEADER_AUTHORIZATION__" + assert section1_headers["User-Agent"] == "Galaxy/1.0" + + section2_headers = encrypted["section2"]["data"]["headers"] + assert section2_headers["X-API-Key"] == "__VAULT_HEADER_X_API_KEY__" + assert section2_headers["Content-Type"] == "application/json" + + # Decrypt + decrypted = decrypt_headers_in_data(encrypted, context_id, vault) + + # Check original values are restored + assert decrypted["section1"]["headers"]["Authorization"] == "Bearer token1" + assert decrypted["section2"]["data"]["headers"]["X-API-Key"] == "key123" From 19b3db76b46d2f67b961844929c0eace2e241af8 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 18 Sep 2025 17:57:49 +0200 Subject: [PATCH 03/35] Replaces hardcoded tool ID with a constant --- lib/galaxy/managers/landing.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/managers/landing.py b/lib/galaxy/managers/landing.py index 38d085edab81..e228fca33c0c 100644 --- a/lib/galaxy/managers/landing.py +++ b/lib/galaxy/managers/landing.py @@ -59,6 +59,8 @@ LandingRequestModel = Union[ToolLandingRequestModel, WorkflowLandingRequestModel] +FETCH_TOOL_ID = "__DATA_FETCH__" + class LandingRequestManager: @@ -87,7 +89,7 @@ def create_tool_landing_request(self, payload: CreateToolLandingRequestPayload, if hasattr(tool, "parameters"): internal_landing_request_state = landing_decode(landing_request_state, tool, self.security.decode_id) else: - assert tool.id == "__DATA_FETCH__" + assert tool.id == FETCH_TOOL_ID # we have validated the payload as part of the API request # nothing else to decode ideally so just swap to internal model state object internal_landing_request_state = LandingRequestInternalToolState( From b035a438dab3d6f370d92bcb601b94aa150f98fb Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 18 Sep 2025 17:59:16 +0200 Subject: [PATCH 04/35] Adds header encryption/decryption for tool landing requests --- lib/galaxy/managers/landing.py | 46 ++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/managers/landing.py b/lib/galaxy/managers/landing.py index e228fca33c0c..7c09ec573cc1 100644 --- a/lib/galaxy/managers/landing.py +++ b/lib/galaxy/managers/landing.py @@ -37,6 +37,7 @@ WorkflowLandingRequest, ) from galaxy.security.idencoding import IdEncodingHelper +from galaxy.security.vault import Vault from galaxy.structured_app import ( MinimalManagerApp, StructuredApp, @@ -52,6 +53,10 @@ ) from galaxy.util import safe_str_cmp from .context import ProvidesUserContext +from .headers_encryption import ( + decrypt_headers_in_data, + encrypt_headers_in_data, +) from .tools import ( get_tool_from_toolbox, ToolRunReference, @@ -70,11 +75,13 @@ def __init__( security: IdEncodingHelper, workflow_contents_manager: WorkflowContentsManager, app: MinimalManagerApp, + vault: Optional[Vault] = None, ): self.sa_session = sa_session self.security = security self.workflow_contents_manager = workflow_contents_manager self.app = app + self.vault = vault def create_tool_landing_request(self, payload: CreateToolLandingRequestPayload, user_id=None) -> ToolLandingRequest: tool_id = payload.tool_id @@ -131,13 +138,18 @@ def create_tool_landing_request(self, payload: CreateToolLandingRequestPayload, model = ToolLandingRequestModel() model.tool_id = tool_id model.tool_version = tool_version - model.request_state = internal_landing_request_state.input_state model.uuid = uuid4() model.client_secret = payload.client_secret model.public = payload.public model.origin = str(payload.origin) if payload.origin else None if user_id: model.user_id = user_id + + request_state = self._encrypt_headers_in_request_state( + internal_landing_request_state.input_state, tool_id, str(model.uuid) + ) + model.request_state = request_state + self._save(model) return self._tool_response(model) @@ -285,10 +297,12 @@ def _get_claimed_workflow_landing_request( return request def _tool_response(self, model: ToolLandingRequestModel) -> ToolLandingRequest: + request_state = self._decrypt_headers_in_request_state(model.request_state, model.tool_id, str(model.uuid)) + response_model = ToolLandingRequest( tool_id=model.tool_id, tool_version=model.tool_version, - request_state=model.request_state, + request_state=request_state, uuid=model.uuid, state=self._state(model), origin=model.origin, @@ -331,3 +345,31 @@ def _save(self, model: LandingRequestModel): sa_session = self.sa_session sa_session.add(model) sa_session.commit() + + def _encrypt_headers_in_request_state( + self, request_state: Optional[dict], tool_id: str, landing_uuid: str + ) -> Optional[dict]: + if request_state is not None and self.vault and tool_id == FETCH_TOOL_ID: + try: + return encrypt_headers_in_data( + request_state, + landing_uuid, + self.vault, + key_prefix="landing_request/headers", + ) + except Exception: + pass # Continue without encryption if vault fails + return request_state + + def _decrypt_headers_in_request_state(self, request_state: Optional[dict], tool_id: str, landing_uuid: str): + if request_state is not None and self.vault and tool_id == FETCH_TOOL_ID: + try: + return decrypt_headers_in_data( + request_state, + landing_uuid, + self.vault, + key_prefix="landing_request/headers", + ) + except Exception: + pass # Continue with encrypted state if decryption fails + return request_state From 37fb0a057c6fe0ec89739ebcf3b7380dd8888f6b Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:04:09 +0200 Subject: [PATCH 05/35] Adds integration test for encrypted sensitive headers in landing requests --- test/integration/test_landing_requests.py | 103 ++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 test/integration/test_landing_requests.py diff --git a/test/integration/test_landing_requests.py b/test/integration/test_landing_requests.py new file mode 100644 index 000000000000..03b625cfa147 --- /dev/null +++ b/test/integration/test_landing_requests.py @@ -0,0 +1,103 @@ +from sqlalchemy import select + +from galaxy.model import ToolLandingRequest +from galaxy.schema.fetch_data import ( + CreateDataLandingPayload, + DataLandingRequestState, +) +from galaxy_test.base.populators import DatasetPopulator +from galaxy_test.driver import integration_util + + +class TestLandingRequestsIntegration(integration_util.IntegrationTestCase, integration_util.ConfiguresDatabaseVault): + dataset_populator: DatasetPopulator + + @classmethod + def handle_galaxy_config_kwds(cls, config): + super().handle_galaxy_config_kwds(config) + cls._configure_database_vault(config) + + def setUp(self): + super().setUp() + self.dataset_populator = DatasetPopulator(self.galaxy_interactor) + + def test_data_landing_with_encrypted_headers(self): + """Test that sensitive headers are encrypted in the vault when stored in landing requests. + + This test verifies that headers containing sensitive information like authorization tokens + are encrypted using Galaxy's vault system instead of being stored in plain text in the + database. Headers are automatically detected and encrypted based on their names. + """ + authorization_secret = "Bearer secret-token-should-be-encrypted" + x_api_key_secret = "secret-api-key-123456" + data_landing_request_state = DataLandingRequestState( + targets=[ + { + "destination": {"type": "hdas"}, + "items": [ + { + "src": "url", + "url": "base64://eyJ0ZXN0IjogInRlc3QifQ==", # base64 encoded {"test": "test"} + "ext": "txt", + "deferred": False, + "headers": { + "Authorization": authorization_secret, + "X-API-Key": x_api_key_secret, + "User-Agent": "Galaxy-Test/1.0", # Non-sensitive header + "X-Custom-Header": "custom-value", # Non-sensitive header + }, + } + ], + } + ], + ) + payload = CreateDataLandingPayload(request_state=data_landing_request_state, public=True) + response = self.dataset_populator.create_data_landing(payload) + assert response.tool_id == "__DATA_FETCH__" + + tool_landing = self.dataset_populator.use_tool_landing(response.uuid) + request_state = tool_landing.request_state + assert request_state + request_json = request_state["request_json"] + assert request_json + targets = request_json["targets"] + assert targets + assert len(targets) == 1 + target = targets[0] + assert "elements" in target + assert target["elements"] + assert len(target["elements"]) == 1 + + # Verify that headers are preserved in the request + element = target["elements"][0] + assert "headers" in element + headers = element["headers"] + + # Sensitive headers should be decrypted and available + assert headers["Authorization"] == authorization_secret + assert headers["X-API-Key"] == x_api_key_secret + # Non-sensitive headers should remain as-is + assert headers["User-Agent"] == "Galaxy-Test/1.0" + assert headers["X-Custom-Header"] == "custom-value" + + # Verify that sensitive headers are stored encrypted in the database + self._verify_headers_encrypted_in_db( + str(response.uuid), + expect_not_to_find=[authorization_secret, x_api_key_secret], + ) + + def _verify_headers_encrypted_in_db(self, landing_request_uuid: str, expect_not_to_find: list[str]): + landing_request = self._get_landing_request_from_db(landing_request_uuid) + assert landing_request is not None, "Landing request not found in database" + request_state_json = landing_request.request_state + assert request_state_json is not None, "Request state is None in database" + request_state_json_str = str(request_state_json) + + # Check that sensitive headers are not present in plain text + for header_name in expect_not_to_find: + assert header_name not in request_state_json_str, f"Sensitive header {header_name} found in plain text" + + def _get_landing_request_from_db(self, uuid: str): + session = self._app.model.session + stmt = select(ToolLandingRequest).where(ToolLandingRequest.uuid == uuid) + return session.execute(stmt).scalar_one_or_none() From d66a7e512722d8fe5f60ef40c19e91c3b2380f8d Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:26:42 +0200 Subject: [PATCH 06/35] Adds header encryption for workflow landings Refactors header encryption and decryption logic to remove tool-specific dependencies, enabling support for workflow landings. --- lib/galaxy/managers/landing.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/lib/galaxy/managers/landing.py b/lib/galaxy/managers/landing.py index 7c09ec573cc1..5d282ec3c321 100644 --- a/lib/galaxy/managers/landing.py +++ b/lib/galaxy/managers/landing.py @@ -146,7 +146,7 @@ def create_tool_landing_request(self, payload: CreateToolLandingRequestPayload, model.user_id = user_id request_state = self._encrypt_headers_in_request_state( - internal_landing_request_state.input_state, tool_id, str(model.uuid) + internal_landing_request_state.input_state, str(model.uuid) ) model.request_state = request_state @@ -168,7 +168,11 @@ def create_workflow_landing_request(self, payload: CreateWorkflowLandingRequestP model.workflow_source = payload.workflow_id model.uuid = uuid4() model.client_secret = payload.client_secret - model.request_state = self.validate_workflow_request_state(payload.request_state) + + validated_request_state = self.validate_workflow_request_state(payload.request_state) + request_state = self._encrypt_headers_in_request_state(validated_request_state, str(model.uuid)) + model.request_state = request_state + model.public = payload.public self._save(model) return self._workflow_response(model) @@ -297,7 +301,7 @@ def _get_claimed_workflow_landing_request( return request def _tool_response(self, model: ToolLandingRequestModel) -> ToolLandingRequest: - request_state = self._decrypt_headers_in_request_state(model.request_state, model.tool_id, str(model.uuid)) + request_state = self._decrypt_headers_in_request_state(model.request_state, str(model.uuid)) response_model = ToolLandingRequest( tool_id=model.tool_id, @@ -322,10 +326,13 @@ def _workflow_response(self, model: WorkflowLandingRequestModel) -> WorkflowLand target_type = model.workflow_source_type workflow_id = model.workflow_source assert workflow_id + + request_state = self._decrypt_headers_in_request_state(model.request_state, str(model.uuid)) + response_model = WorkflowLandingRequest( workflow_id=self.security.encode_id(workflow_id) if isinstance(workflow_id, int) else workflow_id, workflow_target_type=target_type, - request_state=model.request_state, + request_state=request_state, uuid=model.uuid, state=self._state(model), origin=model.origin, @@ -346,10 +353,8 @@ def _save(self, model: LandingRequestModel): sa_session.add(model) sa_session.commit() - def _encrypt_headers_in_request_state( - self, request_state: Optional[dict], tool_id: str, landing_uuid: str - ) -> Optional[dict]: - if request_state is not None and self.vault and tool_id == FETCH_TOOL_ID: + def _encrypt_headers_in_request_state(self, request_state: Optional[dict], landing_uuid: str) -> Optional[dict]: + if request_state is not None and self.vault: try: return encrypt_headers_in_data( request_state, @@ -361,8 +366,8 @@ def _encrypt_headers_in_request_state( pass # Continue without encryption if vault fails return request_state - def _decrypt_headers_in_request_state(self, request_state: Optional[dict], tool_id: str, landing_uuid: str): - if request_state is not None and self.vault and tool_id == FETCH_TOOL_ID: + def _decrypt_headers_in_request_state(self, request_state: Optional[dict], landing_uuid: str): + if request_state is not None and self.vault: try: return decrypt_headers_in_data( request_state, From a80d66926b64b34ac7488f72ba5dc777acc0105e Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:49:39 +0200 Subject: [PATCH 07/35] Add integration test for workflow landing header encryption Introduces a new integration test to verify the encryption of sensitive headers in workflow landing requests. Ensures that headers containing authorization tokens and API keys are securely encrypted using Galaxy's vault system and not stored in plain text in the database. Refactors helper methods to support both tool and workflow landing request models. --- test/integration/test_landing_requests.py | 122 ++++++++++++++++++++-- 1 file changed, 111 insertions(+), 11 deletions(-) diff --git a/test/integration/test_landing_requests.py b/test/integration/test_landing_requests.py index 03b625cfa147..113685c149db 100644 --- a/test/integration/test_landing_requests.py +++ b/test/integration/test_landing_requests.py @@ -1,16 +1,31 @@ +from typing import ( + cast, + Optional, + Type, +) + from sqlalchemy import select -from galaxy.model import ToolLandingRequest +from galaxy.managers.landing import LandingRequestModel +from galaxy.model import ( + ToolLandingRequest, + WorkflowLandingRequest, +) from galaxy.schema.fetch_data import ( CreateDataLandingPayload, DataLandingRequestState, ) -from galaxy_test.base.populators import DatasetPopulator +from galaxy.schema.schema import CreateWorkflowLandingRequestPayload +from galaxy_test.base.populators import ( + DatasetPopulator, + WorkflowPopulator, +) from galaxy_test.driver import integration_util class TestLandingRequestsIntegration(integration_util.IntegrationTestCase, integration_util.ConfiguresDatabaseVault): dataset_populator: DatasetPopulator + workflow_populator: WorkflowPopulator @classmethod def handle_galaxy_config_kwds(cls, config): @@ -20,6 +35,7 @@ def handle_galaxy_config_kwds(cls, config): def setUp(self): super().setUp() self.dataset_populator = DatasetPopulator(self.galaxy_interactor) + self.workflow_populator = WorkflowPopulator(self.galaxy_interactor) def test_data_landing_with_encrypted_headers(self): """Test that sensitive headers are encrypted in the vault when stored in landing requests. @@ -84,20 +100,104 @@ def test_data_landing_with_encrypted_headers(self): self._verify_headers_encrypted_in_db( str(response.uuid), expect_not_to_find=[authorization_secret, x_api_key_secret], + model_class=ToolLandingRequest, + ) + + def test_workflow_landing_with_encrypted_headers(self): + """Test that sensitive headers are encrypted in workflow landing requests. + + This test verifies that headers containing sensitive information like authorization tokens + are encrypted using Galaxy's vault system when workflow landing requests contain URL fetch + steps with headers. + """ + authorization_secret = "Bearer secret-workflow-token-encrypted" + x_api_key_secret = "workflow-api-key-987654" + + # Create a workflow landing request with headers in the request_state + workflow_request_state = { + "WorkflowInput1": { + "src": "url", + "url": "base64://eyJ3b3JrZmxvdyI6ICJ0ZXN0In0=", # base64 encoded {"workflow": "test"} + "ext": "txt", + "deferred": False, + "headers": { + "Authorization": authorization_secret, + "X-API-Key": x_api_key_secret, + "User-Agent": "Galaxy-Workflow-Test/1.0", # Non-sensitive header + "Content-Type": "application/json", # Non-sensitive header + }, + }, + "WorkflowInput2": { + "src": "url", + "url": "base64://eyJ3b3JrZmxvdzIiOiAidGVzdCJ9", # base64 encoded {"workflow2": "test"} + "ext": "txt", + "deferred": False, + "headers": { + "Authorization": authorization_secret, # Same sensitive header + "X-Custom-Header": "custom-value", # Non-sensitive header + }, + }, + } + + # Create a simple workflow and make it public + workflow_id = self.workflow_populator.simple_workflow("test_landing_encrypted_headers") + self.workflow_populator.make_public(workflow_id) + + payload = CreateWorkflowLandingRequestPayload( + workflow_id=workflow_id, + workflow_target_type="stored_workflow", + request_state=workflow_request_state, + public=True, + ) + + # Create workflow landing request - headers should be encrypted + workflow_landing = self.dataset_populator.create_workflow_landing(payload) + assert workflow_landing.workflow_target_type == "stored_workflow" + + # Use the workflow landing request - headers should be decrypted + retrieved_workflow_landing = self.dataset_populator.use_workflow_landing(workflow_landing.uuid) + request_state = retrieved_workflow_landing.request_state + + # Verify that headers are preserved in both workflow inputs + assert "WorkflowInput1" in request_state + assert "WorkflowInput2" in request_state + + # Check WorkflowInput1 headers + input1_headers = request_state["WorkflowInput1"]["headers"] + assert input1_headers["Authorization"] == authorization_secret + assert input1_headers["X-API-Key"] == x_api_key_secret + assert input1_headers["User-Agent"] == "Galaxy-Workflow-Test/1.0" + assert input1_headers["Content-Type"] == "application/json" + + # Check WorkflowInput2 headers + input2_headers = request_state["WorkflowInput2"]["headers"] + assert input2_headers["Authorization"] == authorization_secret + assert input2_headers["X-Custom-Header"] == "custom-value" + + # Verify that sensitive headers are stored encrypted in the database + self._verify_headers_encrypted_in_db( + str(workflow_landing.uuid), + expect_not_to_find=[authorization_secret, x_api_key_secret], + model_class=WorkflowLandingRequest, ) - def _verify_headers_encrypted_in_db(self, landing_request_uuid: str, expect_not_to_find: list[str]): - landing_request = self._get_landing_request_from_db(landing_request_uuid) - assert landing_request is not None, "Landing request not found in database" + def _verify_headers_encrypted_in_db( + self, landing_request_uuid: str, expect_not_to_find: list[str], model_class: Type[LandingRequestModel] + ): + landing_request = self._get_landing_request_from_db(landing_request_uuid, model_class) + assert ( + landing_request is not None + ), f"{model_class.__name__} with UUID {landing_request_uuid} not found in database" request_state_json = landing_request.request_state assert request_state_json is not None, "Request state is None in database" request_state_json_str = str(request_state_json) - # Check that sensitive headers are not present in plain text - for header_name in expect_not_to_find: - assert header_name not in request_state_json_str, f"Sensitive header {header_name} found in plain text" + for header_value in expect_not_to_find: + assert header_value not in request_state_json_str, f"Sensitive header {header_value} found in plain text" - def _get_landing_request_from_db(self, uuid: str): + def _get_landing_request_from_db( + self, uuid: str, model_class: Type[LandingRequestModel] + ) -> Optional[LandingRequestModel]: session = self._app.model.session - stmt = select(ToolLandingRequest).where(ToolLandingRequest.uuid == uuid) - return session.execute(stmt).scalar_one_or_none() + stmt = select(model_class).where(model_class.uuid == uuid) + return cast(Optional[LandingRequestModel], session.execute(stmt).scalar_one_or_none()) From 385d7095d2a85be069056475719760c6c9e1e0e0 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:18:55 +0200 Subject: [PATCH 08/35] Simplifies sensitive header pattern matching --- lib/galaxy/managers/headers_encryption.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/galaxy/managers/headers_encryption.py b/lib/galaxy/managers/headers_encryption.py index dd7475d495db..1225fb0f2f08 100644 --- a/lib/galaxy/managers/headers_encryption.py +++ b/lib/galaxy/managers/headers_encryption.py @@ -20,20 +20,14 @@ # Headers that should always be encrypted when stored in the database SENSITIVE_HEADER_PATTERNS = [ - # Authorization patterns (Authorization, Proxy-Authorization, etc.) - re.compile(r".*authorization$", re.IGNORECASE), - # Authentication patterns (Authentication, WWW-Authenticate, etc.) - re.compile(r".*authenticat.*", re.IGNORECASE), + # Auth patterns (covers authorization, authentication, auth, oauth, x-auth, etc.) + re.compile(r".*auth.*", re.IGNORECASE), # Key patterns (API-Key, Auth-Key, X-API-Key, etc.) re.compile(r".*key$", re.IGNORECASE), # Token patterns (Bearer-Token, API-Token, X-TOKEN, etc.) re.compile(r".*token$", re.IGNORECASE), # Secret patterns (Client-Secret, API-Secret, etc.) re.compile(r".*secret$", re.IGNORECASE), - # Auth patterns (Custom-Auth, Basic-Auth, OAuth, etc.) - re.compile(r".*auth$", re.IGNORECASE), - re.compile(r"^x-auth", re.IGNORECASE), - re.compile(r"^oauth", re.IGNORECASE), # Cookie patterns re.compile(r".*cookie.*", re.IGNORECASE), # Bearer (standalone) From a9ede650eea44037d3a324c07ac6bec1149b27b2 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:19:36 +0200 Subject: [PATCH 09/35] Adds logging for encryption/decryption failures in landing requests Introduces warning logs to capture encryption and decryption failures in the landing request state, providing better visibility into issues with header processing. This helps in diagnosing and addressing potential problems during runtime without halting execution. --- lib/galaxy/managers/landing.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/galaxy/managers/landing.py b/lib/galaxy/managers/landing.py index 5d282ec3c321..39219d18d1c8 100644 --- a/lib/galaxy/managers/landing.py +++ b/lib/galaxy/managers/landing.py @@ -1,3 +1,4 @@ +import logging from typing import ( Optional, Union, @@ -66,6 +67,8 @@ FETCH_TOOL_ID = "__DATA_FETCH__" +log = logging.getLogger(__name__) + class LandingRequestManager: @@ -363,6 +366,7 @@ def _encrypt_headers_in_request_state(self, request_state: Optional[dict], landi key_prefix="landing_request/headers", ) except Exception: + log.warning("Failed to encrypt headers in landing request state", exc_info=True) pass # Continue without encryption if vault fails return request_state @@ -376,5 +380,6 @@ def _decrypt_headers_in_request_state(self, request_state: Optional[dict], landi key_prefix="landing_request/headers", ) except Exception: + log.warning("Failed to decrypt headers in landing request state", exc_info=True) pass # Continue with encrypted state if decryption fails return request_state From e879af8744ef388c3c24458188b67d81e0b78236 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:26:31 +0200 Subject: [PATCH 10/35] Refactors header encryption/decryption logic into helper methods --- lib/galaxy/managers/headers_encryption.py | 100 ++++++++++++++++------ 1 file changed, 73 insertions(+), 27 deletions(-) diff --git a/lib/galaxy/managers/headers_encryption.py b/lib/galaxy/managers/headers_encryption.py index 1225fb0f2f08..af09fd3552f4 100644 --- a/lib/galaxy/managers/headers_encryption.py +++ b/lib/galaxy/managers/headers_encryption.py @@ -103,17 +103,7 @@ def encrypt_headers_in_data( for key, value in data.items(): if key == "headers" and isinstance(value, dict): - encrypted_headers = {} - for header_name, header_value in value.items(): - if is_sensitive_header(header_name): - # Encrypt sensitive header - vault_key = create_vault_key(context_id, header_name, key_prefix) - vault.write_secret(vault_key, header_value) - encrypted_headers[header_name] = create_vault_reference(header_name, reference_prefix) - else: - # Keep non-sensitive headers as-is - encrypted_headers[header_name] = header_value - encrypted_data[key] = encrypted_headers + encrypted_data[key] = _encrypt_headers_dict(value, context_id, vault, key_prefix, reference_prefix) elif isinstance(value, dict): encrypted_data[key] = encrypt_headers_in_data(value, context_id, vault, key_prefix, reference_prefix) elif isinstance(value, list): @@ -157,22 +147,7 @@ def decrypt_headers_in_data( for key, value in data.items(): if key == "headers" and isinstance(value, dict): - decrypted_headers = {} - for header_name, header_value in value.items(): - if isinstance(header_value, str) and header_value.startswith(f"__{reference_prefix}_"): - # Decrypt vault reference - vault_key = create_vault_key(context_id, header_name, key_prefix) - decrypted_value = vault.read_secret(vault_key) - if decrypted_value is not None: - decrypted_headers[header_name] = decrypted_value - else: - # Handle case where vault key doesn't exist (shouldn't happen in normal flow) - # Log warning and skip this header - decrypted_headers[header_name] = header_value # Keep vault reference as fallback - else: - # Keep non-vault headers as-is - decrypted_headers[header_name] = header_value - decrypted_data[key] = decrypted_headers + decrypted_data[key] = _decrypt_headers_dict(value, context_id, vault, key_prefix, reference_prefix) elif isinstance(value, dict): decrypted_data[key] = decrypt_headers_in_data(value, context_id, vault, key_prefix, reference_prefix) elif isinstance(value, list): @@ -188,3 +163,74 @@ def decrypt_headers_in_data( decrypted_data[key] = value return decrypted_data + + +def _encrypt_headers_dict( + headers: dict[str, str], + context_id: str, + vault: Vault, + key_prefix: Optional[str] = None, + reference_prefix: str = "VAULT_HEADER", +) -> dict[str, str]: + """ + Encrypt sensitive headers in a headers dictionary. + + Args: + headers: Dictionary of header name -> header value + context_id: Unique identifier for the context + vault: Vault instance for encryption + key_prefix: Optional custom prefix for vault keys + reference_prefix: Prefix for vault references + + Returns: + Dictionary with sensitive headers replaced by vault references + """ + encrypted_headers = {} + for header_name, header_value in headers.items(): + if is_sensitive_header(header_name): + # Encrypt sensitive header + vault_key = create_vault_key(context_id, header_name, key_prefix) + vault.write_secret(vault_key, header_value) + encrypted_headers[header_name] = create_vault_reference(header_name, reference_prefix) + else: + # Keep non-sensitive headers as-is + encrypted_headers[header_name] = header_value + return encrypted_headers + + +def _decrypt_headers_dict( + headers: dict[str, str], + context_id: str, + vault: Vault, + key_prefix: Optional[str] = None, + reference_prefix: str = "VAULT_HEADER", +) -> dict[str, str]: + """ + Decrypt vault references in a headers dictionary. + + Args: + headers: Dictionary of header name -> header value (may contain vault references) + context_id: Unique identifier for the context + vault: Vault instance for decryption + key_prefix: Optional custom prefix for vault keys + reference_prefix: Prefix for vault references + + Returns: + Dictionary with vault references replaced by actual header values + """ + decrypted_headers = {} + for header_name, header_value in headers.items(): + if isinstance(header_value, str) and header_value.startswith(f"__{reference_prefix}_"): + # Decrypt vault reference + vault_key = create_vault_key(context_id, header_name, key_prefix) + decrypted_value = vault.read_secret(vault_key) + if decrypted_value is not None: + decrypted_headers[header_name] = decrypted_value + else: + # Handle case where vault key doesn't exist (shouldn't happen in normal flow) + # Log warning and skip this header + decrypted_headers[header_name] = header_value # Keep vault reference as fallback + else: + # Keep non-vault headers as-is + decrypted_headers[header_name] = header_value + return decrypted_headers From 16fb142116dc0e85bfbbf0ee947c0889306d1cfc Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:33:23 +0200 Subject: [PATCH 11/35] Adds logging for missing vault keys in header decryption --- lib/galaxy/managers/headers_encryption.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/managers/headers_encryption.py b/lib/galaxy/managers/headers_encryption.py index af09fd3552f4..84358c4c1943 100644 --- a/lib/galaxy/managers/headers_encryption.py +++ b/lib/galaxy/managers/headers_encryption.py @@ -10,6 +10,7 @@ (like authorization tokens, API keys, etc.) need to be stored securely in the database. """ +import logging import re from typing import ( Any, @@ -37,6 +38,8 @@ # Default vault key prefix for headers DEFAULT_VAULT_KEY_PREFIX = "headers" +log = logging.getLogger(__name__) + def is_sensitive_header(header_name: str) -> bool: for pattern in SENSITIVE_HEADER_PATTERNS: @@ -228,7 +231,9 @@ def _decrypt_headers_dict( decrypted_headers[header_name] = decrypted_value else: # Handle case where vault key doesn't exist (shouldn't happen in normal flow) - # Log warning and skip this header + log.warning( + f"Vault key not found for header '{header_name}' with key '{vault_key}'. Keeping vault reference as fallback." + ) decrypted_headers[header_name] = header_value # Keep vault reference as fallback else: # Keep non-vault headers as-is From a91fc7307bc26449fa67f12587c6d2d7210d1dc6 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 22 Sep 2025 10:38:33 +0200 Subject: [PATCH 12/35] Let encrypt/decrypt headers fail fast Ensuring that issues with the vault or encryption process are surfaced immediately. --- lib/galaxy/managers/landing.py | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/lib/galaxy/managers/landing.py b/lib/galaxy/managers/landing.py index 39219d18d1c8..49e5943605b7 100644 --- a/lib/galaxy/managers/landing.py +++ b/lib/galaxy/managers/landing.py @@ -358,28 +358,21 @@ def _save(self, model: LandingRequestModel): def _encrypt_headers_in_request_state(self, request_state: Optional[dict], landing_uuid: str) -> Optional[dict]: if request_state is not None and self.vault: - try: - return encrypt_headers_in_data( - request_state, - landing_uuid, - self.vault, - key_prefix="landing_request/headers", - ) - except Exception: - log.warning("Failed to encrypt headers in landing request state", exc_info=True) - pass # Continue without encryption if vault fails + return encrypt_headers_in_data( + request_state, + landing_uuid, + self.vault, + key_prefix="landing_request/headers", + ) + return request_state def _decrypt_headers_in_request_state(self, request_state: Optional[dict], landing_uuid: str): if request_state is not None and self.vault: - try: - return decrypt_headers_in_data( - request_state, - landing_uuid, - self.vault, - key_prefix="landing_request/headers", - ) - except Exception: - log.warning("Failed to decrypt headers in landing request state", exc_info=True) - pass # Continue with encrypted state if decryption fails + return decrypt_headers_in_data( + request_state, + landing_uuid, + self.vault, + key_prefix="landing_request/headers", + ) return request_state From 6a8766295499e21d077e37930b1cde9baa8a4c47 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:27:36 +0200 Subject: [PATCH 13/35] Adds recursive sensitive header detection utility Introduces a utility function to recursively check for sensitive headers in nested data structures, enhancing the ability to identify headers requiring encryption. Includes unit tests covering various cases such as nested headers, non-sensitive headers, and edge cases to ensure robustness. --- lib/galaxy/managers/headers_encryption.py | 28 +++++++++++++++++++ .../app/managers/test_headers_encryption.py | 24 ++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/lib/galaxy/managers/headers_encryption.py b/lib/galaxy/managers/headers_encryption.py index 84358c4c1943..9d727afd14a0 100644 --- a/lib/galaxy/managers/headers_encryption.py +++ b/lib/galaxy/managers/headers_encryption.py @@ -48,6 +48,34 @@ def is_sensitive_header(header_name: str) -> bool: return False +def has_sensitive_headers(data: dict) -> bool: + """ + Check if the data structure contains any sensitive headers that would require encryption. + + This function recursively searches through a dictionary structure to detect + if any sensitive headers are present that would need to be encrypted. + + Args: + data: The data dictionary structure to check for sensitive headers + + Returns: + True if sensitive headers are found, False otherwise + """ + for key, value in data.items(): + if key == "headers" and isinstance(value, dict): + for header_name in value.keys(): + if is_sensitive_header(header_name): + return True + elif isinstance(value, dict): + if has_sensitive_headers(value): + return True + elif isinstance(value, list): + for item in value: + if isinstance(item, dict) and has_sensitive_headers(item): + return True + return False + + def create_vault_key(context_id: str, header_name: str, key_prefix: Optional[str] = None) -> str: """ Create a vault key for storing a header value. diff --git a/test/unit/app/managers/test_headers_encryption.py b/test/unit/app/managers/test_headers_encryption.py index 59fdb5610f09..7e18d95869f6 100644 --- a/test/unit/app/managers/test_headers_encryption.py +++ b/test/unit/app/managers/test_headers_encryption.py @@ -5,6 +5,7 @@ create_vault_reference, decrypt_headers_in_data, encrypt_headers_in_data, + has_sensitive_headers, is_sensitive_header, ) from galaxy.security.vault import Vault @@ -72,6 +73,29 @@ def test_non_sensitive_headers_not_detected(self): assert not is_sensitive_header("Key-Value") +class TestHasSensitiveHeaders: + """Test has_sensitive_headers function that recursively checks for sensitive headers.""" + + def test_detects_sensitive_headers(self): + """Test detection of sensitive headers in various structures.""" + assert has_sensitive_headers({"headers": {"Authorization": "Bearer token"}}) + + nested_data = {"request_json": {"targets": [{"elements": [{"headers": {"X-API-Key": "secret"}}]}]}} + assert has_sensitive_headers(nested_data) + + def test_ignores_non_sensitive_headers(self): + """Test that non-sensitive headers are ignored.""" + data = {"headers": {"Content-Type": "application/json"}} + assert not has_sensitive_headers(data) + + def test_handles_missing_or_invalid_headers(self): + """Test edge cases with missing or invalid headers.""" + assert not has_sensitive_headers({}) # Empty data + assert not has_sensitive_headers({"no_headers": "value"}) # No headers key + assert not has_sensitive_headers({"headers": {}}) # Empty headers + assert not has_sensitive_headers({"headers": "not a dict"}) # Invalid headers type + + class TestVaultKeyAndReference: """Test vault key and reference creation.""" From 089a5897a84adf9672ae8a8f1434d31ad0d8a1ab Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:35:25 +0200 Subject: [PATCH 14/35] Enforce vault configuration when sensitive headers are present --- lib/galaxy/managers/landing.py | 29 +- test/integration/test_landing_requests.py | 352 ++++++++++++++-------- 2 files changed, 244 insertions(+), 137 deletions(-) diff --git a/lib/galaxy/managers/landing.py b/lib/galaxy/managers/landing.py index 49e5943605b7..f45d2a004fcb 100644 --- a/lib/galaxy/managers/landing.py +++ b/lib/galaxy/managers/landing.py @@ -38,7 +38,10 @@ WorkflowLandingRequest, ) from galaxy.security.idencoding import IdEncodingHelper -from galaxy.security.vault import Vault +from galaxy.security.vault import ( + InvalidVaultConfigException, + Vault, +) from galaxy.structured_app import ( MinimalManagerApp, StructuredApp, @@ -57,6 +60,7 @@ from .headers_encryption import ( decrypt_headers_in_data, encrypt_headers_in_data, + has_sensitive_headers, ) from .tools import ( get_tool_from_toolbox, @@ -357,14 +361,21 @@ def _save(self, model: LandingRequestModel): sa_session.commit() def _encrypt_headers_in_request_state(self, request_state: Optional[dict], landing_uuid: str) -> Optional[dict]: - if request_state is not None and self.vault: - return encrypt_headers_in_data( - request_state, - landing_uuid, - self.vault, - key_prefix="landing_request/headers", - ) - + if request_state is not None: + if has_sensitive_headers(request_state): + # Sensitive headers found - vault is required + if not self.vault: + raise InvalidVaultConfigException( + "Sensitive headers detected in landing request but no vault is configured. " + "Configure a vault to securely store sensitive header information." + ) + # Encrypt the sensitive headers + return encrypt_headers_in_data( + request_state, + landing_uuid, + self.vault, + key_prefix="landing_request/headers", + ) return request_state def _decrypt_headers_in_request_state(self, request_state: Optional[dict], landing_uuid: str): diff --git a/test/integration/test_landing_requests.py b/test/integration/test_landing_requests.py index 113685c149db..934bca738bf1 100644 --- a/test/integration/test_landing_requests.py +++ b/test/integration/test_landing_requests.py @@ -1,107 +1,168 @@ from typing import ( cast, Optional, - Type, ) from sqlalchemy import select from galaxy.managers.landing import LandingRequestModel from galaxy.model import ( - ToolLandingRequest, - WorkflowLandingRequest, + ToolLandingRequest as ToolLandingRequestModel, + WorkflowLandingRequest as WorkflowLandingRequestModel, ) from galaxy.schema.fetch_data import ( CreateDataLandingPayload, DataLandingRequestState, ) -from galaxy.schema.schema import CreateWorkflowLandingRequestPayload +from galaxy.schema.schema import ( + CreateWorkflowLandingRequestPayload, + ToolLandingRequest, +) from galaxy_test.base.populators import ( DatasetPopulator, WorkflowPopulator, ) from galaxy_test.driver import integration_util +TEST_URL = "base64://eyJ0ZXN0IjogInRlc3QifQ==" # base64 encoded {"test": "test"} + + +class BaseLandingRequestTest(integration_util.IntegrationTestCase): + """Base class with common setup for landing request tests.""" -class TestLandingRequestsIntegration(integration_util.IntegrationTestCase, integration_util.ConfiguresDatabaseVault): dataset_populator: DatasetPopulator workflow_populator: WorkflowPopulator - @classmethod - def handle_galaxy_config_kwds(cls, config): - super().handle_galaxy_config_kwds(config) - cls._configure_database_vault(config) - def setUp(self): super().setUp() self.dataset_populator = DatasetPopulator(self.galaxy_interactor) self.workflow_populator = WorkflowPopulator(self.galaxy_interactor) - def test_data_landing_with_encrypted_headers(self): - """Test that sensitive headers are encrypted in the vault when stored in landing requests. + def _create_data_item_with_headers(self, headers: dict[str, str], url: str = TEST_URL) -> dict: + """Create a data item with specified headers.""" + return { + "src": "url", + "url": url, + "ext": "txt", + "deferred": False, + "headers": headers, + } - This test verifies that headers containing sensitive information like authorization tokens - are encrypted using Galaxy's vault system instead of being stored in plain text in the - database. Headers are automatically detected and encrypted based on their names. - """ - authorization_secret = "Bearer secret-token-should-be-encrypted" - x_api_key_secret = "secret-api-key-123456" - data_landing_request_state = DataLandingRequestState( + def _create_data_landing_request_state( + self, headers: dict[str, str], url: str = TEST_URL + ) -> DataLandingRequestState: + """Create a DataLandingRequestState with specified headers.""" + return DataLandingRequestState( targets=[ { "destination": {"type": "hdas"}, - "items": [ - { - "src": "url", - "url": "base64://eyJ0ZXN0IjogInRlc3QifQ==", # base64 encoded {"test": "test"} - "ext": "txt", - "deferred": False, - "headers": { - "Authorization": authorization_secret, - "X-API-Key": x_api_key_secret, - "User-Agent": "Galaxy-Test/1.0", # Non-sensitive header - "X-Custom-Header": "custom-value", # Non-sensitive header - }, - } - ], + "items": [self._create_data_item_with_headers(headers, url)], } ], ) - payload = CreateDataLandingPayload(request_state=data_landing_request_state, public=True) - response = self.dataset_populator.create_data_landing(payload) - assert response.tool_id == "__DATA_FETCH__" - tool_landing = self.dataset_populator.use_tool_landing(response.uuid) + def _create_workflow_input_with_headers( + self, headers: dict[str, str], input_name: str = "WorkflowInput1", url: str = TEST_URL + ) -> dict[str, dict]: + """Create a workflow input with specified headers.""" + return { + input_name: { + "src": "url", + "url": url, + "ext": "txt", + "deferred": False, + "headers": headers, + } + } + + def _assert_headers_match(self, actual_headers: dict[str, str], expected_headers: dict[str, str]) -> None: + """Assert that headers match expected values.""" + for key, expected_value in expected_headers.items(): + assert ( + actual_headers[key] == expected_value + ), f"Header {key} mismatch: expected {expected_value}, got {actual_headers.get(key)}" + + def _extract_data_landing_headers(self, tool_landing: ToolLandingRequest) -> dict[str, str]: + """Extract headers from a tool landing response.""" request_state = tool_landing.request_state - assert request_state + assert request_state, "Request state is None" request_json = request_state["request_json"] - assert request_json + assert request_json, "Request JSON is None" targets = request_json["targets"] - assert targets - assert len(targets) == 1 + assert targets and len(targets) == 1, "Expected exactly one target" target = targets[0] - assert "elements" in target - assert target["elements"] - assert len(target["elements"]) == 1 - - # Verify that headers are preserved in the request + assert "elements" in target and target["elements"], "No elements found in target" + assert len(target["elements"]) == 1, "Expected exactly one element" element = target["elements"][0] - assert "headers" in element - headers = element["headers"] + assert "headers" in element, "No headers found in element" + return element["headers"] + + def _verify_headers_encrypted_in_db( + self, landing_request_uuid: str, expect_not_to_find: list[str], model_class: type[LandingRequestModel] + ): + """Verify that sensitive headers are stored encrypted in the database.""" + landing_request = self._get_landing_request_from_db(landing_request_uuid, model_class) + assert ( + landing_request is not None + ), f"{model_class.__name__} with UUID {landing_request_uuid} not found in database" + request_state_json = landing_request.request_state + assert request_state_json is not None, "Request state is None in database" + request_state_json_str = str(request_state_json) + + for header_value in expect_not_to_find: + assert header_value not in request_state_json_str, f"Sensitive header {header_value} found in plain text" + + def _get_landing_request_from_db( + self, uuid: str, model_class: type[LandingRequestModel] + ) -> Optional[LandingRequestModel]: + """Get a landing request from the database by UUID.""" + session = self._app.model.session + stmt = select(model_class).where(model_class.uuid == uuid) + return cast(Optional[LandingRequestModel], session.execute(stmt).scalar_one_or_none()) + + def _create_and_make_public_workflow(self, workflow_name: str) -> str: + """Create a simple workflow and make it public.""" + workflow_id = self.workflow_populator.simple_workflow(workflow_name) + self.workflow_populator.make_public(workflow_id) + return workflow_id + + +class TestLandingRequestsIntegration(BaseLandingRequestTest, integration_util.ConfiguresDatabaseVault): + @classmethod + def handle_galaxy_config_kwds(cls, config): + super().handle_galaxy_config_kwds(config) + cls._configure_database_vault(config) + + def test_data_landing_with_encrypted_headers(self): + """Test that sensitive headers are encrypted in the vault when stored in landing requests. + + This test verifies that headers containing sensitive information like authorization tokens + are encrypted using Galaxy's vault system instead of being stored in plain text in the + database. Headers are automatically detected and encrypted based on their names. + """ + # Create test headers with both sensitive and non-sensitive values + headers = { + "Authorization": "Bearer data-test-token-should-be-encrypted", + "X-API-Key": "data-test-api-key-123456", + "User-Agent": "Galaxy-Test/1.0", + "Content-Type": "application/json", + "X-Custom-Header": "custom-value", + } + + # Create and execute data landing request + request_state = self._create_data_landing_request_state(headers) + payload = CreateDataLandingPayload(request_state=request_state, public=True) + response = self.dataset_populator.create_data_landing(payload) + assert response.tool_id == "__DATA_FETCH__" - # Sensitive headers should be decrypted and available - assert headers["Authorization"] == authorization_secret - assert headers["X-API-Key"] == x_api_key_secret - # Non-sensitive headers should remain as-is - assert headers["User-Agent"] == "Galaxy-Test/1.0" - assert headers["X-Custom-Header"] == "custom-value" + # Verify headers are preserved after decryption + tool_landing = self.dataset_populator.use_tool_landing(response.uuid) + actual_headers = self._extract_data_landing_headers(tool_landing) + self._assert_headers_match(actual_headers, headers) # Verify that sensitive headers are stored encrypted in the database - self._verify_headers_encrypted_in_db( - str(response.uuid), - expect_not_to_find=[authorization_secret, x_api_key_secret], - model_class=ToolLandingRequest, - ) + sensitive_values = ["Bearer data-test-token-should-be-encrypted", "data-test-api-key-123456"] + self._verify_headers_encrypted_in_db(str(response.uuid), sensitive_values, ToolLandingRequestModel) def test_workflow_landing_with_encrypted_headers(self): """Test that sensitive headers are encrypted in workflow landing requests. @@ -110,39 +171,26 @@ def test_workflow_landing_with_encrypted_headers(self): are encrypted using Galaxy's vault system when workflow landing requests contain URL fetch steps with headers. """ - authorization_secret = "Bearer secret-workflow-token-encrypted" - x_api_key_secret = "workflow-api-key-987654" - - # Create a workflow landing request with headers in the request_state - workflow_request_state = { - "WorkflowInput1": { - "src": "url", - "url": "base64://eyJ3b3JrZmxvdyI6ICJ0ZXN0In0=", # base64 encoded {"workflow": "test"} - "ext": "txt", - "deferred": False, - "headers": { - "Authorization": authorization_secret, - "X-API-Key": x_api_key_secret, - "User-Agent": "Galaxy-Workflow-Test/1.0", # Non-sensitive header - "Content-Type": "application/json", # Non-sensitive header - }, - }, - "WorkflowInput2": { - "src": "url", - "url": "base64://eyJ3b3JrZmxvdzIiOiAidGVzdCJ9", # base64 encoded {"workflow2": "test"} - "ext": "txt", - "deferred": False, - "headers": { - "Authorization": authorization_secret, # Same sensitive header - "X-Custom-Header": "custom-value", # Non-sensitive header - }, - }, + # Create test headers for workflow inputs + input1_headers = { + "Authorization": "Bearer workflow-test-token-should-be-encrypted", + "X-API-Key": "workflow-test-api-key-123456", + "User-Agent": "Galaxy-Workflow-Test/1.0", + "Content-Type": "application/json", + } + input2_headers = { + "Authorization": "Bearer workflow-test-token-should-be-encrypted", + "X-API-Key": "workflow-test-api-key-123456", + "X-Custom-Header": "custom-value", } - # Create a simple workflow and make it public - workflow_id = self.workflow_populator.simple_workflow("test_landing_encrypted_headers") - self.workflow_populator.make_public(workflow_id) + # Create workflow request state with multiple inputs + workflow_request_state = {} + workflow_request_state.update(self._create_workflow_input_with_headers(input1_headers, "WorkflowInput1")) + workflow_request_state.update(self._create_workflow_input_with_headers(input2_headers, "WorkflowInput2")) + # Create workflow and landing request + workflow_id = self._create_and_make_public_workflow("test_landing_encrypted_headers") payload = CreateWorkflowLandingRequestPayload( workflow_id=workflow_id, workflow_target_type="stored_workflow", @@ -150,54 +198,102 @@ def test_workflow_landing_with_encrypted_headers(self): public=True, ) - # Create workflow landing request - headers should be encrypted + # Create and retrieve workflow landing request workflow_landing = self.dataset_populator.create_workflow_landing(payload) assert workflow_landing.workflow_target_type == "stored_workflow" - # Use the workflow landing request - headers should be decrypted retrieved_workflow_landing = self.dataset_populator.use_workflow_landing(workflow_landing.uuid) request_state = retrieved_workflow_landing.request_state - # Verify that headers are preserved in both workflow inputs - assert "WorkflowInput1" in request_state - assert "WorkflowInput2" in request_state + # Verify headers are preserved in both workflow inputs + assert "WorkflowInput1" in request_state and "WorkflowInput2" in request_state + self._assert_headers_match(request_state["WorkflowInput1"]["headers"], input1_headers) + self._assert_headers_match(request_state["WorkflowInput2"]["headers"], input2_headers) - # Check WorkflowInput1 headers - input1_headers = request_state["WorkflowInput1"]["headers"] - assert input1_headers["Authorization"] == authorization_secret - assert input1_headers["X-API-Key"] == x_api_key_secret - assert input1_headers["User-Agent"] == "Galaxy-Workflow-Test/1.0" - assert input1_headers["Content-Type"] == "application/json" + # Verify that sensitive headers are stored encrypted in the database + sensitive_values = ["Bearer workflow-test-token-should-be-encrypted", "workflow-test-api-key-123456"] + self._verify_headers_encrypted_in_db(str(workflow_landing.uuid), sensitive_values, WorkflowLandingRequestModel) - # Check WorkflowInput2 headers - input2_headers = request_state["WorkflowInput2"]["headers"] - assert input2_headers["Authorization"] == authorization_secret - assert input2_headers["X-Custom-Header"] == "custom-value" - # Verify that sensitive headers are stored encrypted in the database - self._verify_headers_encrypted_in_db( - str(workflow_landing.uuid), - expect_not_to_find=[authorization_secret, x_api_key_secret], - model_class=WorkflowLandingRequest, - ) +class TestLandingRequestsWithoutVaultIntegration(BaseLandingRequestTest): + """Test landing requests when vault is not configured. - def _verify_headers_encrypted_in_db( - self, landing_request_uuid: str, expect_not_to_find: list[str], model_class: Type[LandingRequestModel] - ): - landing_request = self._get_landing_request_from_db(landing_request_uuid, model_class) - assert ( - landing_request is not None - ), f"{model_class.__name__} with UUID {landing_request_uuid} not found in database" - request_state_json = landing_request.request_state - assert request_state_json is not None, "Request state is None in database" - request_state_json_str = str(request_state_json) + This class tests the behavior when no vault is configured but sensitive headers + are present in the request. The system should fail fast rather than storing + sensitive information in plain text. + """ - for header_value in expect_not_to_find: - assert header_value not in request_state_json_str, f"Sensitive header {header_value} found in plain text" + def test_data_landing_fails_without_vault_when_sensitive_headers_present(self): + """Test that data landing requests fail when vault is not configured but sensitive headers are present. - def _get_landing_request_from_db( - self, uuid: str, model_class: Type[LandingRequestModel] - ) -> Optional[LandingRequestModel]: - session = self._app.model.session - stmt = select(model_class).where(model_class.uuid == uuid) - return cast(Optional[LandingRequestModel], session.execute(stmt).scalar_one_or_none()) + This test verifies that when sensitive headers (like Authorization, API keys, etc.) are present + in a landing request but no vault is configured, the system fails fast rather than storing + the sensitive information in plain text in the database. + """ + # Create headers with sensitive values + headers = { + "Authorization": "Bearer no-vault-test-token-should-fail", + "X-API-Key": "no-vault-test-api-key-should-fail", + "User-Agent": "Galaxy-Test/1.0", + } + + # Create data landing request with sensitive headers + request_state = self._create_data_landing_request_state(headers) + payload = CreateDataLandingPayload(request_state=request_state, public=True) + + # Should return 500 status code when trying to create the landing request + # because sensitive headers are present but vault is not configured + response = self.dataset_populator.create_data_landing_raw(payload) + assert response.status_code == 500 + + def test_data_landing_succeeds_without_vault_when_no_sensitive_headers(self): + """Test that data landing requests succeed when vault is not configured but no sensitive headers are present. + + This test verifies that when only non-sensitive headers are present in a landing request + and no vault is configured, the system works normally since encryption is not required. + """ + # Create only non-sensitive headers + headers = { + "User-Agent": "Galaxy-Test/1.0", + "Content-Type": "application/json", + "X-Custom-Header": "custom-value", + } + + # Create data landing request with only non-sensitive headers + request_state = self._create_data_landing_request_state(headers) + payload = CreateDataLandingPayload(request_state=request_state, public=True) + + # Should succeed because no sensitive headers are present + response = self.dataset_populator.create_data_landing(payload) + assert response.tool_id == "__DATA_FETCH__" + + # Verify we can retrieve the landing request and headers are preserved + tool_landing = self.dataset_populator.use_tool_landing(response.uuid) + actual_headers = self._extract_data_landing_headers(tool_landing) + self._assert_headers_match(actual_headers, headers) + + def test_workflow_landing_fails_without_vault_when_sensitive_headers_present(self): + """Test that workflow landing requests fail when vault is not configured but sensitive headers are present.""" + # Create workflow input with sensitive headers + headers = { + "Authorization": "Bearer workflow-no-vault-token-should-fail", + "X-API-Key": "workflow-no-vault-api-key-should-fail", + "User-Agent": "Galaxy-Workflow-Test/1.0", + } + workflow_request_state = self._create_workflow_input_with_headers(headers) + + # Create workflow and landing request + workflow_id = self._create_and_make_public_workflow("test_landing_no_vault") + payload = CreateWorkflowLandingRequestPayload( + workflow_id=workflow_id, + workflow_target_type="stored_workflow", + request_state=workflow_request_state, + public=True, + ) + + # Should return 500 status code when trying to create the workflow landing request + # because sensitive headers are present but vault is not configured + create_url = "workflow_landings" + json = payload.model_dump(mode="json") + response = self.dataset_populator._post(create_url, json, json=True, anon=True) + assert response.status_code == 500 From 6d294088f3c5b4db4ca2bc292b9edadd11a88f4e Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:31:14 +0200 Subject: [PATCH 15/35] Introduce configurable URL header allow-list --- lib/galaxy/config/schemas/config_schema.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/galaxy/config/schemas/config_schema.yml b/lib/galaxy/config/schemas/config_schema.yml index 8757133292f1..20631d6459c8 100644 --- a/lib/galaxy/config/schemas/config_schema.yml +++ b/lib/galaxy/config/schemas/config_schema.yml @@ -4162,6 +4162,20 @@ mapping: desc: | Vault config file. + url_headers_config_file: + type: str + default: url_headers_conf.yml + path_resolves_to: config_dir + required: false + desc: | + Configuration file for URL request headers allow-list with URL pattern matching. + This file defines which HTTP headers are allowed in URL fetch requests based + on URL patterns, and whether they should be treated as sensitive (encrypted + in the vault) or not. If no allow-list is specified, no headers will be + allowed in URL requests. This provides fine-grained security control over + what headers can be sent when Galaxy fetches external URLs on behalf of users, + allowing different headers for different target domains or services. + display_builtin_converters: type: bool default: true From 9be90810d9ad42b44720d51bdd0b927fd218f211 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:31:14 +0200 Subject: [PATCH 16/35] Use configurable patterns for header sensitivity --- lib/galaxy/managers/headers_encryption.py | 136 ++++++++++++++++------ 1 file changed, 100 insertions(+), 36 deletions(-) diff --git a/lib/galaxy/managers/headers_encryption.py b/lib/galaxy/managers/headers_encryption.py index 9d727afd14a0..46b23bf2547e 100644 --- a/lib/galaxy/managers/headers_encryption.py +++ b/lib/galaxy/managers/headers_encryption.py @@ -2,78 +2,142 @@ Utilities for encrypting sensitive headers using Galaxy's Vault system. This module provides functionality to: -1. Identify sensitive headers that should be encrypted +1. Identify sensitive headers that should be encrypted based on configuration 2. Encrypt/decrypt headers using the vault 3. Transform data structures to use vault references for sensitive headers This can be used for any scenario where HTTP headers containing sensitive information (like authorization tokens, API keys, etc.) need to be stored securely in the database. +Header sensitivity is determined by the URL headers configuration file. """ import logging -import re from typing import ( Any, Optional, ) +from galaxy.config.url_headers import UrlHeadersConfig from galaxy.security.vault import Vault -# Headers that should always be encrypted when stored in the database -SENSITIVE_HEADER_PATTERNS = [ - # Auth patterns (covers authorization, authentication, auth, oauth, x-auth, etc.) - re.compile(r".*auth.*", re.IGNORECASE), - # Key patterns (API-Key, Auth-Key, X-API-Key, etc.) - re.compile(r".*key$", re.IGNORECASE), - # Token patterns (Bearer-Token, API-Token, X-TOKEN, etc.) - re.compile(r".*token$", re.IGNORECASE), - # Secret patterns (Client-Secret, API-Secret, etc.) - re.compile(r".*secret$", re.IGNORECASE), - # Cookie patterns - re.compile(r".*cookie.*", re.IGNORECASE), - # Bearer (standalone) - re.compile(r"^bearer$", re.IGNORECASE), -] - # Default vault key prefix for headers DEFAULT_VAULT_KEY_PREFIX = "headers" log = logging.getLogger(__name__) -def is_sensitive_header(header_name: str) -> bool: - for pattern in SENSITIVE_HEADER_PATTERNS: - if pattern.match(header_name): - return True +def is_sensitive_header( + header_name: str, url_headers_config: Optional[UrlHeadersConfig] = None, url: Optional[str] = None +) -> bool: + """ + Check if a header contains sensitive information and should be encrypted. + + Args: + header_name: The header name to check + url_headers_config: URL headers configuration. This is required to determine + sensitivity as we no longer use hardcoded patterns. + url: Optional target URL for URL-specific header validation + + Returns: + True if the header should be encrypted, False otherwise + """ + if url_headers_config: + if url: + return url_headers_config.is_header_sensitive_for_url(header_name, url) + else: + # No URL provided - cannot perform URL-specific sensitivity checking + # In our pattern-based system, headers without URLs cannot be properly validated + # Default to not sensitive (individual header checking should not fail fast) + log.debug(f"No URL provided for sensitivity check of header '{header_name}' - defaulting to not sensitive") + return False + + # No configuration provided - default to not sensitive for security + # (better to not encrypt than to encrypt everything without knowing) + log.debug( + f"No URL headers configuration provided for sensitivity check of header '{header_name}' - defaulting to not sensitive" + ) return False -def has_sensitive_headers(data: dict) -> bool: +def has_sensitive_headers( + data: dict, url_headers_config: Optional[UrlHeadersConfig] = None, url: Optional[str] = None +) -> bool: """ Check if the data structure contains any sensitive headers that would require encryption. This function recursively searches through a dictionary structure to detect if any sensitive headers are present that would need to be encrypted. + IMPORTANT: This function fails fast if headers are found but no configuration + is provided. + Args: data: The data dictionary structure to check for sensitive headers + url_headers_config: URL headers configuration for sensitivity checks (required if headers present) + url: Optional target URL for URL-specific header validation Returns: True if sensitive headers are found, False otherwise + + Raises: + ValueError: If headers are present but no configuration is provided """ - for key, value in data.items(): - if key == "headers" and isinstance(value, dict): - for header_name in value.keys(): - if is_sensitive_header(header_name): - return True - elif isinstance(value, dict): - if has_sensitive_headers(value): - return True - elif isinstance(value, list): - for item in value: - if isinstance(item, dict) and has_sensitive_headers(item): - return True - return False + if not url_headers_config: + # Without configuration, headers are not allowed at all + # Fail fast if any headers are found anywhere in the data structure + def check_for_headers(obj): + if isinstance(obj, dict): + for key, value in obj.items(): + if key == "headers" and isinstance(value, dict) and value: + header_names = list(value.keys()) + raise ValueError( + f"Headers are not allowed without proper URL headers configuration. " + f"Found headers: {header_names}. " + f"Please configure url_headers_config_file in Galaxy configuration to enable header usage." + ) + elif isinstance(value, (dict, list)): + check_for_headers(value) + elif isinstance(obj, list): + for item in obj: + if isinstance(item, (dict, list)): + check_for_headers(item) + + check_for_headers(data) + return False + + # Configuration exists - check for sensitive headers recursively + def check_sensitivity(obj, inherited_url=None): + if isinstance(obj, dict): + for key, value in obj.items(): + if key == "headers" and isinstance(value, dict) and value: + # Look for a URL at the same level as the headers (e.g., in UrlDataElement) + element_url = obj.get("url") if "url" in obj else inherited_url + + if not element_url: + # No URL available - cannot perform URL-specific sensitivity checking + # In a pattern-based system, headers without URLs cannot be properly validated + # This should fail fast for security + header_names = list(value.keys()) + raise ValueError( + f"URL is required for header validation in pattern-based configuration. " + f"Found headers: {header_names}. " + f"Cannot validate headers without knowing the target URL." + ) + + for header_name in value.keys(): + if is_sensitive_header(header_name, url_headers_config, element_url): + return True + elif isinstance(value, (dict, list)): + if check_sensitivity(value, inherited_url): + return True + elif isinstance(obj, list): + for item in obj: + if isinstance(item, (dict, list)): + if check_sensitivity(item, inherited_url): + return True + return False + + return check_sensitivity(data, url) def create_vault_key(context_id: str, header_name: str, key_prefix: Optional[str] = None) -> str: From ae1e89be790ab716e3fad6c6e2e6efe3ede35e45 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:31:14 +0200 Subject: [PATCH 17/35] Update encryption/decryption API for URL-aware config --- lib/galaxy/managers/headers_encryption.py | 47 +++++++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/lib/galaxy/managers/headers_encryption.py b/lib/galaxy/managers/headers_encryption.py index 46b23bf2547e..68cf17f32b91 100644 --- a/lib/galaxy/managers/headers_encryption.py +++ b/lib/galaxy/managers/headers_encryption.py @@ -173,7 +173,12 @@ def create_vault_reference(header_name: str, reference_prefix: str = "VAULT_HEAD def encrypt_headers_in_data( - data: dict, context_id: str, vault: Vault, key_prefix: Optional[str] = None, reference_prefix: str = "VAULT_HEADER" + data: dict, + context_id: str, + vault: Vault, + key_prefix: Optional[str] = None, + reference_prefix: str = "VAULT_HEADER", + url_headers_config: Optional[UrlHeadersConfig] = None, ) -> dict: """ Recursively process data structure to encrypt sensitive headers. @@ -189,22 +194,36 @@ def encrypt_headers_in_data( vault: Vault instance for encryption key_prefix: Optional custom prefix for vault keys. Defaults to DEFAULT_VAULT_KEY_PREFIX reference_prefix: Prefix for vault references. Defaults to "VAULT_HEADER" + url_headers_config: Optional URL headers configuration for sensitivity checks Returns: Modified data structure with sensitive headers encrypted + + Raises: + ValueError: If headers are present but proper configuration or URL is not provided """ + # Validate headers before processing - this will fail fast if headers are found + # but configuration or URLs are missing + has_sensitive_headers(data, url_headers_config) + # Make a deep copy to avoid modifying the original encrypted_data: dict[str, Any] = {} for key, value in data.items(): if key == "headers" and isinstance(value, dict): - encrypted_data[key] = _encrypt_headers_dict(value, context_id, vault, key_prefix, reference_prefix) + # Look for a URL at the same level as the headers (e.g., in UrlDataElement) + element_url = data.get("url") if "url" in data else None + encrypted_data[key] = _encrypt_headers_dict( + value, context_id, vault, key_prefix, reference_prefix, url_headers_config, element_url + ) elif isinstance(value, dict): - encrypted_data[key] = encrypt_headers_in_data(value, context_id, vault, key_prefix, reference_prefix) + encrypted_data[key] = encrypt_headers_in_data( + value, context_id, vault, key_prefix, reference_prefix, url_headers_config + ) elif isinstance(value, list): encrypted_data[key] = [ ( - encrypt_headers_in_data(item, context_id, vault, key_prefix, reference_prefix) + encrypt_headers_in_data(item, context_id, vault, key_prefix, reference_prefix, url_headers_config) if isinstance(item, dict) else item ) @@ -217,7 +236,12 @@ def encrypt_headers_in_data( def decrypt_headers_in_data( - data: dict, context_id: str, vault: Vault, key_prefix: Optional[str] = None, reference_prefix: str = "VAULT_HEADER" + data: dict, + context_id: str, + vault: Vault, + key_prefix: Optional[str] = None, + reference_prefix: str = "VAULT_HEADER", + url_headers_config: Optional[UrlHeadersConfig] = None, ) -> dict: """ Recursively process data structure to decrypt sensitive headers from vault. @@ -233,6 +257,7 @@ def decrypt_headers_in_data( vault: Vault instance for decryption key_prefix: Optional custom prefix for vault keys. Defaults to DEFAULT_VAULT_KEY_PREFIX reference_prefix: Prefix for vault references. Defaults to "VAULT_HEADER" + url_headers_config: Optional URL headers configuration for sensitivity checks Returns: Modified data structure with actual header values restored @@ -244,11 +269,13 @@ def decrypt_headers_in_data( if key == "headers" and isinstance(value, dict): decrypted_data[key] = _decrypt_headers_dict(value, context_id, vault, key_prefix, reference_prefix) elif isinstance(value, dict): - decrypted_data[key] = decrypt_headers_in_data(value, context_id, vault, key_prefix, reference_prefix) + decrypted_data[key] = decrypt_headers_in_data( + value, context_id, vault, key_prefix, reference_prefix, url_headers_config + ) elif isinstance(value, list): decrypted_data[key] = [ ( - decrypt_headers_in_data(item, context_id, vault, key_prefix, reference_prefix) + decrypt_headers_in_data(item, context_id, vault, key_prefix, reference_prefix, url_headers_config) if isinstance(item, dict) else item ) @@ -266,6 +293,8 @@ def _encrypt_headers_dict( vault: Vault, key_prefix: Optional[str] = None, reference_prefix: str = "VAULT_HEADER", + url_headers_config: Optional[UrlHeadersConfig] = None, + url: Optional[str] = None, ) -> dict[str, str]: """ Encrypt sensitive headers in a headers dictionary. @@ -276,13 +305,15 @@ def _encrypt_headers_dict( vault: Vault instance for encryption key_prefix: Optional custom prefix for vault keys reference_prefix: Prefix for vault references + url_headers_config: Optional URL headers configuration for sensitivity checks + url: Optional target URL for URL-specific header validation Returns: Dictionary with sensitive headers replaced by vault references """ encrypted_headers = {} for header_name, header_value in headers.items(): - if is_sensitive_header(header_name): + if is_sensitive_header(header_name, url_headers_config, url): # Encrypt sensitive header vault_key = create_vault_key(context_id, header_name, key_prefix) vault.write_secret(vault_key, header_value) From 3012450d5c29a500b057c9c4aa58cd488ede8a2a Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:31:14 +0200 Subject: [PATCH 18/35] Use URL-aware header encryption for landing requests --- lib/galaxy/managers/landing.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/galaxy/managers/landing.py b/lib/galaxy/managers/landing.py index f45d2a004fcb..b3ed05c9911a 100644 --- a/lib/galaxy/managers/landing.py +++ b/lib/galaxy/managers/landing.py @@ -11,6 +11,7 @@ ) from sqlalchemy import select +from galaxy.config.url_headers import UrlHeadersConfig from galaxy.exceptions import ( InsufficientPermissionsException, ItemAlreadyClaimedException, @@ -89,6 +90,7 @@ def __init__( self.workflow_contents_manager = workflow_contents_manager self.app = app self.vault = vault + self.url_headers_config = UrlHeadersConfig(app.config.url_headers_config_file) def create_tool_landing_request(self, payload: CreateToolLandingRequestPayload, user_id=None) -> ToolLandingRequest: tool_id = payload.tool_id @@ -362,19 +364,18 @@ def _save(self, model: LandingRequestModel): def _encrypt_headers_in_request_state(self, request_state: Optional[dict], landing_uuid: str) -> Optional[dict]: if request_state is not None: - if has_sensitive_headers(request_state): - # Sensitive headers found - vault is required + if has_sensitive_headers(request_state, self.url_headers_config): if not self.vault: raise InvalidVaultConfigException( "Sensitive headers detected in landing request but no vault is configured. " "Configure a vault to securely store sensitive header information." ) - # Encrypt the sensitive headers return encrypt_headers_in_data( request_state, landing_uuid, self.vault, key_prefix="landing_request/headers", + url_headers_config=self.url_headers_config, ) return request_state @@ -385,5 +386,6 @@ def _decrypt_headers_in_request_state(self, request_state: Optional[dict], landi landing_uuid, self.vault, key_prefix="landing_request/headers", + url_headers_config=self.url_headers_config, ) return request_state From 2a734e1da6a4c5d562060bd04301c0379eb59062 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:31:14 +0200 Subject: [PATCH 19/35] Update headers_encryption tests for URL config --- .../app/managers/test_headers_encryption.py | 301 +++++++++++++----- 1 file changed, 225 insertions(+), 76 deletions(-) diff --git a/test/unit/app/managers/test_headers_encryption.py b/test/unit/app/managers/test_headers_encryption.py index 7e18d95869f6..36cb310bd53e 100644 --- a/test/unit/app/managers/test_headers_encryption.py +++ b/test/unit/app/managers/test_headers_encryption.py @@ -1,12 +1,18 @@ from typing import Optional +import pytest + +from galaxy.config.url_headers import ( + HeaderConfig, + UrlHeadersConfig, + UrlPatternConfig, +) from galaxy.managers.headers_encryption import ( create_vault_key, create_vault_reference, decrypt_headers_in_data, encrypt_headers_in_data, has_sensitive_headers, - is_sensitive_header, ) from galaxy.security.vault import Vault @@ -28,49 +34,88 @@ def list_secrets(self, key: str) -> list[str]: return [] +def create_test_url_headers_config(): + config = UrlHeadersConfig() + + # GitHub API pattern - allows auth headers + github_pattern = UrlPatternConfig( + url_pattern=r"^https://api\.github\.com/.*", + headers=[ + HeaderConfig(name="Authorization", sensitive=True), + HeaderConfig(name="Accept", sensitive=False), + HeaderConfig(name="Accept-Encoding", sensitive=False), + ], + ) + + # Generic HTTPS pattern - only basic headers + https_pattern = UrlPatternConfig( + url_pattern=r"^https://.*", + headers=[ + HeaderConfig(name="Accept", sensitive=False), + HeaderConfig(name="Accept-Language", sensitive=False), + HeaderConfig(name="Content-Type", sensitive=False), + ], + ) + + # Test API pattern - for testing sensitive headers + test_api_pattern = UrlPatternConfig( + url_pattern=r"^https://api\.example\.com/.*", + headers=[ + HeaderConfig(name="Authorization", sensitive=True), + HeaderConfig(name="X-API-Key", sensitive=True), + HeaderConfig(name="Content-Type", sensitive=False), + HeaderConfig(name="Accept", sensitive=False), + ], + ) + + # Set up the patterns in the config + config._patterns = [github_pattern, test_api_pattern, https_pattern] + + # Set up compiled patterns + import re + + config._compiled_patterns = [ + (re.compile(github_pattern.url_pattern), github_pattern), + (re.compile(test_api_pattern.url_pattern), test_api_pattern), + (re.compile(https_pattern.url_pattern), https_pattern), + ] + + return config + + class TestSensitiveHeaderDetection: - """Test sensitive header pattern matching.""" + """Test sensitive header pattern matching using real URL headers configuration.""" def test_sensitive_headers_detected(self): - """Test that known sensitive headers are detected.""" - assert is_sensitive_header("Authorization") - assert is_sensitive_header("authorization") - assert is_sensitive_header("Proxy-Authorization") - assert is_sensitive_header("Authentication") - assert is_sensitive_header("WWW-Authenticate") - assert is_sensitive_header("X-API-Key") - assert is_sensitive_header("x-api-key") - assert is_sensitive_header("API-Key") - assert is_sensitive_header("Auth-Key") - assert is_sensitive_header("Session-Key") - assert is_sensitive_header("Bearer-Token") - assert is_sensitive_header("API-Token") - assert is_sensitive_header("X-TOKEN") - assert is_sensitive_header("X-Auth-Token") - assert is_sensitive_header("X-Access-Token") - assert is_sensitive_header("My-Secret") - assert is_sensitive_header("Client-Secret") - assert is_sensitive_header("API-Secret") - assert is_sensitive_header("Custom-Auth") - assert is_sensitive_header("Basic-Auth") - assert is_sensitive_header("X-Auth-Key") - assert is_sensitive_header("OAuth") - assert is_sensitive_header("Cookie") - assert is_sensitive_header("cookie") - assert is_sensitive_header("Set-Cookie") - assert is_sensitive_header("Bearer") + """Test that configured sensitive headers are detected for matching URLs.""" + config = create_test_url_headers_config() + + # Test GitHub API URL - Authorization should be sensitive + assert config.is_header_sensitive_for_url("Authorization", "https://api.github.com/repos") + assert not config.is_header_sensitive_for_url("Accept", "https://api.github.com/repos") + assert not config.is_header_sensitive_for_url("Accept-Encoding", "https://api.github.com/repos") + + # Test API example URL - Authorization and X-API-Key should be sensitive + assert config.is_header_sensitive_for_url("Authorization", "https://api.example.com/data") + assert config.is_header_sensitive_for_url("X-API-Key", "https://api.example.com/data") + assert not config.is_header_sensitive_for_url("Content-Type", "https://api.example.com/data") def test_non_sensitive_headers_not_detected(self): """Test that non-sensitive headers are not detected.""" - assert not is_sensitive_header("User-Agent") - assert not is_sensitive_header("Content-Type") - assert not is_sensitive_header("Accept") - assert not is_sensitive_header("X-Custom-Header") - assert not is_sensitive_header("Content-Length") - assert not is_sensitive_header("Host") - # Edge cases that contain keywords but aren't auth headers - assert not is_sensitive_header("Token-Bucket") - assert not is_sensitive_header("Key-Value") + config = create_test_url_headers_config() + + # Test generic HTTPS URL - no sensitive headers configured + assert not config.is_header_sensitive_for_url("Accept", "https://example.com/data") + assert not config.is_header_sensitive_for_url("Accept-Language", "https://example.com/data") + assert not config.is_header_sensitive_for_url("Content-Type", "https://example.com/data") + + def test_headers_not_allowed_for_url(self): + """Test that headers not in patterns are not considered sensitive.""" + config = create_test_url_headers_config() + + # Header not in any pattern should not be sensitive + assert not config.is_header_sensitive_for_url("X-Custom-Header", "https://api.github.com/repos") + assert not config.is_header_sensitive_for_url("X-Custom-Header", "https://example.com/data") class TestHasSensitiveHeaders: @@ -78,22 +123,58 @@ class TestHasSensitiveHeaders: def test_detects_sensitive_headers(self): """Test detection of sensitive headers in various structures.""" - assert has_sensitive_headers({"headers": {"Authorization": "Bearer token"}}) + config = create_test_url_headers_config() - nested_data = {"request_json": {"targets": [{"elements": [{"headers": {"X-API-Key": "secret"}}]}]}} - assert has_sensitive_headers(nested_data) + # Test with config and URL - should work normally + data_with_url = {"url": "https://api.github.com/repos", "headers": {"Authorization": "Bearer token"}} + assert has_sensitive_headers(data_with_url, url_headers_config=config) + + # Test nested structure with URL + nested_data = { + "request_json": { + "targets": [{"elements": [{"url": "https://api.example.com/data", "headers": {"X-API-Key": "secret"}}]}] + } + } + assert has_sensitive_headers(nested_data, url_headers_config=config) def test_ignores_non_sensitive_headers(self): - """Test that non-sensitive headers are ignored.""" - data = {"headers": {"Content-Type": "application/json"}} - assert not has_sensitive_headers(data) + """Test that non-sensitive headers are ignored when URL is provided.""" + config = create_test_url_headers_config() + # When URL is provided, pattern-based checking is used + data = {"url": "https://example.com/api", "headers": {"Content-Type": "application/json"}} + assert not has_sensitive_headers(data, url_headers_config=config) def test_handles_missing_or_invalid_headers(self): """Test edge cases with missing or invalid headers.""" + config = create_test_url_headers_config() + assert not has_sensitive_headers({}, url_headers_config=config) # Empty data + assert not has_sensitive_headers({"no_headers": "value"}, url_headers_config=config) # No headers key + assert not has_sensitive_headers({"headers": {}}, url_headers_config=config) # Empty headers (ignored) + assert not has_sensitive_headers({"headers": "not a dict"}, url_headers_config=config) # Invalid headers type + + def test_headers_without_url_fail_fast(self): + """Test that headers without URL fail fast in pattern-based system.""" + config = create_test_url_headers_config() + # Without URL, headers should fail fast since we can't validate them + data = {"headers": {"Content-Type": "application/json"}} + with pytest.raises(ValueError, match="URL is required for header validation"): + has_sensitive_headers(data, url_headers_config=config) + + def test_fails_without_config(self): + """Test that function fails fast when no configuration is provided.""" + + # Should raise ValueError when headers exist but no config + with pytest.raises(ValueError, match="Headers are not allowed without proper URL headers configuration"): + has_sensitive_headers({"headers": {"Authorization": "Bearer token"}}) + + # Should raise ValueError for nested headers too + nested_data = {"request_json": {"targets": [{"elements": [{"headers": {"X-API-Key": "secret"}}]}]}} + with pytest.raises(ValueError, match="Headers are not allowed without proper URL headers configuration"): + has_sensitive_headers(nested_data) + + # Should NOT raise error when no headers present assert not has_sensitive_headers({}) # Empty data assert not has_sensitive_headers({"no_headers": "value"}) # No headers key - assert not has_sensitive_headers({"headers": {}}) # Empty headers - assert not has_sensitive_headers({"headers": "not a dict"}) # Invalid headers type class TestVaultKeyAndReference: @@ -139,26 +220,28 @@ def test_encrypt_decrypt_simple_headers(self): """Test encrypting and decrypting a simple headers structure.""" vault = MockVault() context_id = "test-uuid" + config = create_test_url_headers_config() - # Simple case with headers at top level + # Simple case with headers at top level and URL for validation data = { + "url": "https://api.example.com/data", "headers": { "Authorization": "Bearer secret-token", "X-API-Key": "api-key-123", - "User-Agent": "Galaxy/1.0", - } + "Accept": "application/json", + }, } # Encrypt - encrypted = encrypt_headers_in_data(data, context_id, vault) + encrypted = encrypt_headers_in_data(data, context_id, vault, url_headers_config=config) - # Check that sensitive values are replaced with vault references + # Check that sensitive headers are encrypted headers = encrypted["headers"] - assert headers["Authorization"] == "__VAULT_HEADER_AUTHORIZATION__" - assert headers["X-API-Key"] == "__VAULT_HEADER_X_API_KEY__" - assert headers["User-Agent"] == "Galaxy/1.0" # Non-sensitive unchanged + assert headers["Authorization"] == "__VAULT_HEADER_AUTHORIZATION__" # Encrypted (sensitive) + assert headers["X-API-Key"] == "__VAULT_HEADER_X_API_KEY__" # Encrypted (sensitive) + assert headers["Accept"] == "application/json" # Not encrypted (not sensitive) - # Check vault was written to with new default format + # Check vault has the encrypted headers assert len(vault.storage) == 2 assert "headers/test-uuid/authorization" in vault.storage assert "headers/test-uuid/x_api_key" in vault.storage @@ -170,12 +253,13 @@ def test_encrypt_decrypt_simple_headers(self): decrypted_headers = decrypted["headers"] assert decrypted_headers["Authorization"] == "Bearer secret-token" assert decrypted_headers["X-API-Key"] == "api-key-123" - assert decrypted_headers["User-Agent"] == "Galaxy/1.0" + assert decrypted_headers["Accept"] == "application/json" def test_encrypt_decrypt_nested_headers(self): """Test encrypting and decrypting headers in a complex nested structure.""" vault = MockVault() context_id = "test-uuid" + config = create_test_url_headers_config() # Complex nested structure like in the actual data landing request data: dict = { @@ -187,11 +271,11 @@ def test_encrypt_decrypt_nested_headers(self): "elements": [ { "src": "url", - "url": "base64://data", + "url": "https://api.github.com/repos/test/repo", "headers": { "Authorization": "Bearer secret-token", - "X-API-Key": "api-key-123", - "User-Agent": "Galaxy/1.0", + "Accept": "application/vnd.github.v3+json", + "Accept-Encoding": "gzip, deflate", }, } ], @@ -201,16 +285,16 @@ def test_encrypt_decrypt_nested_headers(self): } # Encrypt - encrypted = encrypt_headers_in_data(data, context_id, vault) + encrypted = encrypt_headers_in_data(data, context_id, vault, url_headers_config=config) # Check structure is preserved assert encrypted["request_version"] == "1" - # Check headers are encrypted + # Check headers are encrypted based on GitHub API pattern headers = encrypted["request_json"]["targets"][0]["elements"][0]["headers"] - assert headers["Authorization"] == "__VAULT_HEADER_AUTHORIZATION__" - assert headers["X-API-Key"] == "__VAULT_HEADER_X_API_KEY__" - assert headers["User-Agent"] == "Galaxy/1.0" + assert headers["Authorization"] == "__VAULT_HEADER_AUTHORIZATION__" # Sensitive for GitHub API + assert headers["Accept"] == "application/vnd.github.v3+json" # Not sensitive + assert headers["Accept-Encoding"] == "gzip, deflate" # Not sensitive # Decrypt decrypted = decrypt_headers_in_data(encrypted, context_id, vault) @@ -225,39 +309,104 @@ def test_multiple_headers_sections(self): """Test handling multiple headers sections in different parts of the structure.""" vault = MockVault() context_id = "test-uuid" + config = create_test_url_headers_config() + # Multiple sections with different URLs data = { "section1": { + "url": "https://api.github.com/repos", "headers": { "Authorization": "Bearer token1", - "User-Agent": "Galaxy/1.0", - } + "Accept": "application/vnd.github.v3+json", + }, }, "section2": { "data": { + "url": "https://example.com/data", "headers": { - "X-API-Key": "key123", + "Accept": "application/json", "Content-Type": "application/json", - } + }, } }, } # Encrypt - encrypted = encrypt_headers_in_data(data, context_id, vault) + encrypted = encrypt_headers_in_data(data, context_id, vault, url_headers_config=config) - # Check both sections are encrypted + # Check section1 (GitHub API pattern - Authorization is sensitive) section1_headers = encrypted["section1"]["headers"] - assert section1_headers["Authorization"] == "__VAULT_HEADER_AUTHORIZATION__" - assert section1_headers["User-Agent"] == "Galaxy/1.0" + assert section1_headers["Authorization"] == "__VAULT_HEADER_AUTHORIZATION__" # Encrypted + assert section1_headers["Accept"] == "application/vnd.github.v3+json" # Not encrypted + # Check section2 (generic HTTPS pattern - no sensitive headers) section2_headers = encrypted["section2"]["data"]["headers"] - assert section2_headers["X-API-Key"] == "__VAULT_HEADER_X_API_KEY__" - assert section2_headers["Content-Type"] == "application/json" + assert section2_headers["Accept"] == "application/json" # Not encrypted + assert section2_headers["Content-Type"] == "application/json" # Not encrypted # Decrypt decrypted = decrypt_headers_in_data(encrypted, context_id, vault) - # Check original values are restored + # Check original values are preserved assert decrypted["section1"]["headers"]["Authorization"] == "Bearer token1" - assert decrypted["section2"]["data"]["headers"]["X-API-Key"] == "key123" + assert decrypted["section1"]["headers"]["Accept"] == "application/vnd.github.v3+json" + assert decrypted["section2"]["data"]["headers"]["Accept"] == "application/json" + assert decrypted["section2"]["data"]["headers"]["Content-Type"] == "application/json" + + def test_encrypt_fails_without_config(self): + """Test that encryption fails fast when no configuration is provided.""" + + vault = MockVault() + context_id = "test-uuid" + + data = { + "headers": { + "Authorization": "Bearer secret-token", + "X-API-Key": "api-key-123", + } + } + + # Should raise ValueError when trying to encrypt without config + with pytest.raises(ValueError, match="Headers are not allowed without proper URL headers configuration"): + encrypt_headers_in_data(data, context_id, vault) + + def test_encrypt_headers_with_url_pattern_checking(self): + """Test encryption with URL-based pattern checking.""" + vault = MockVault() + context_id = "test-uuid" + config = create_test_url_headers_config() + + # Test: Headers WITHOUT URL should fail fast + data_no_url = { + "headers": { + "Authorization": "Bearer token", + "Content-Type": "application/json", + "Accept-Language": "en-US,en;q=0.9", + } + } + + with pytest.raises(ValueError, match="URL is required for header validation"): + encrypt_headers_in_data(data_no_url, context_id, vault, url_headers_config=config) + + # Test: Headers WITH URL - pattern-based checking works + vault2 = MockVault() # Fresh vault for second test + data_with_url = { + "url": "https://api.example.com/data", + "headers": { + "Authorization": "Bearer token", + "Content-Type": "application/json", + "Accept-Language": "en-US,en;q=0.9", + }, + } + + encrypted_with_url = encrypt_headers_in_data(data_with_url, context_id, vault2, url_headers_config=config) + headers_with_url = encrypted_with_url["headers"] + + # Only sensitive headers encrypted (URL-based pattern matching) + assert headers_with_url["Authorization"] == "__VAULT_HEADER_AUTHORIZATION__" # Sensitive for api.example.com + assert headers_with_url["Content-Type"] == "application/json" # Not sensitive + assert headers_with_url["Accept-Language"] == "en-US,en;q=0.9" # Not sensitive + + # Verify vault has the encrypted header + assert len(vault2.storage) == 1 + assert "headers/test-uuid/authorization" in vault2.storage From 7d3ecc47b3c39828ae1228bd9ff977cac8cff373 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:32:22 +0200 Subject: [PATCH 20/35] Adds URL header allow-list management Introduces a new module to configure and manage allowed HTTP request headers for external URL fetches. --- lib/galaxy/config/url_headers.py | 188 +++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 lib/galaxy/config/url_headers.py diff --git a/lib/galaxy/config/url_headers.py b/lib/galaxy/config/url_headers.py new file mode 100644 index 000000000000..e1c5af3b5609 --- /dev/null +++ b/lib/galaxy/config/url_headers.py @@ -0,0 +1,188 @@ +""" +Configuration and management for URL request headers allow-list with URL pattern matching. + +This module provides functionality to: +1. Load and parse URL headers configuration from YAML with URL patterns +2. Validate whether headers are allowed for specific URLs based on patterns +3. Determine if headers should be treated as sensitive (encrypted) + +This is a security feature to control what headers can be sent when Galaxy +fetches external URLs on behalf of users, with fine-grained control based +on the target URL. +""" + +import logging +import re +from typing import Optional + +import yaml +from pydantic import ( + BaseModel, + field_validator, +) + +log = logging.getLogger(__name__) + + +class HeaderConfig(BaseModel): + """Configuration for a single header.""" + + name: str + sensitive: bool = False + + @field_validator("name") + @classmethod + def name_must_not_be_empty(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("Header name cannot be empty") + return v.strip() + + +class UrlPatternConfig(BaseModel): + """Configuration for a URL pattern and its allowed headers.""" + + url_pattern: str + headers: list[HeaderConfig] = [] + + @field_validator("url_pattern") + @classmethod + def pattern_must_not_be_empty(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("URL pattern cannot be empty") + return v.strip() + + +class UrlHeadersConfig: + """ + Manages the configuration for allowed URL request headers with pattern matching. + + This class loads and manages the allow-list of headers that can be included + in URL fetch requests based on URL patterns, along with their sensitivity settings. + """ + + def __init__(self, config_file: Optional[str] = None): + """ + Initialize the URL headers configuration. + + Args: + config_file: Path to the YAML configuration file. If None or file + doesn't exist, no headers will be allowed. + """ + self._patterns: list[UrlPatternConfig] = [] + self._compiled_patterns: list[tuple[re.Pattern[str], UrlPatternConfig]] = [] + self._config_file = config_file + + if config_file: + self._load_config(config_file) + + def _load_config(self, config_file: str) -> None: + """Load configuration from YAML file.""" + try: + with open(config_file, "r") as f: + config_data = yaml.safe_load(f) + + if not config_data: + log.info(f"URL headers config file {config_file} is empty - no headers will be allowed") + return + + # Load patterns + patterns_data = config_data.get("patterns", []) + if patterns_data: + for pattern_data in patterns_data: + try: + pattern_config = UrlPatternConfig(**pattern_data) + self._patterns.append(pattern_config) + + # Compile regex pattern + try: + compiled_pattern = re.compile(pattern_config.url_pattern) + self._compiled_patterns.append((compiled_pattern, pattern_config)) + log.debug( + f"Loaded URL pattern: {pattern_config.url_pattern} " + f"({len(pattern_config.headers)} headers)" + ) + except re.error as e: + log.warning(f"Invalid regex pattern '{pattern_config.url_pattern}' in {config_file}: {e}") + + except Exception as e: + log.warning(f"Invalid pattern configuration in {config_file}: {pattern_data} - {e}") + + log.info(f"Loaded {len(self._patterns)} URL patterns from {config_file}") + + except FileNotFoundError: + log.info(f"URL headers config file {config_file} not found - no headers will be allowed") + except yaml.YAMLError as e: + log.error(f"Error parsing URL headers config file {config_file}: {e}") + except Exception as e: + log.error(f"Error loading URL headers config file {config_file}: {e}") + + def _find_matching_pattern(self, url: str) -> Optional[UrlPatternConfig]: + """ + Find the first URL pattern that matches the given URL. + + Args: + url: The URL to match against patterns + + Returns: + The first matching UrlPatternConfig, or None if no pattern matches + """ + for compiled_pattern, pattern_config in self._compiled_patterns: + if compiled_pattern.match(url): + log.debug(f"URL '{url}' matched pattern: {pattern_config.url_pattern}") + return pattern_config + return None + + def is_header_allowed_for_url(self, header_name: str, url: str) -> bool: + """ + Check if a header is allowed for a specific URL. + + Args: + header_name: The header name to check (case-insensitive) + url: The target URL + + Returns: + True if the header is allowed for this URL, False otherwise + """ + pattern_config = self._find_matching_pattern(url) + if not pattern_config: + log.debug(f"No pattern matched URL '{url}' - header '{header_name}' denied") + return False + + # Check if header is allowed in this pattern + header_name_lower = header_name.lower() + for header_config in pattern_config.headers: + if header_config.name.lower() == header_name_lower: + return True + + log.debug(f"Header '{header_name}' not allowed for URL '{url}' " f"(pattern: {pattern_config.url_pattern})") + return False + + def is_header_sensitive_for_url(self, header_name: str, url: str) -> bool: + """ + Check if a header should be treated as sensitive for a specific URL. + + Args: + header_name: The header name to check (case-insensitive) + url: The target URL + + Returns: + True if the header is allowed and marked as sensitive, False otherwise + """ + pattern_config = self._find_matching_pattern(url) + if not pattern_config: + return False + + header_name_lower = header_name.lower() + for header_config in pattern_config.headers: + if header_config.name.lower() == header_name_lower: + return header_config.sensitive + + return False + + def __str__(self) -> str: + """String representation for debugging.""" + return f"UrlHeadersConfig(file={self._config_file}, patterns={len(self._patterns)})" + + def __repr__(self) -> str: + """String representation for debugging.""" + return self.__str__() From 71d6685fc993697e691f56197ffac2f1e7dfdf38 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 13 Oct 2025 17:46:12 +0200 Subject: [PATCH 21/35] Replaces generic ValueErrors with specific exceptions --- lib/galaxy/managers/headers_encryption.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/galaxy/managers/headers_encryption.py b/lib/galaxy/managers/headers_encryption.py index 68cf17f32b91..2bff5233bb1c 100644 --- a/lib/galaxy/managers/headers_encryption.py +++ b/lib/galaxy/managers/headers_encryption.py @@ -18,6 +18,10 @@ ) from galaxy.config.url_headers import UrlHeadersConfig +from galaxy.exceptions import ( + ConfigDoesNotAllowException, + RequestParameterMissingException, +) from galaxy.security.vault import Vault # Default vault key prefix for headers @@ -80,7 +84,7 @@ def has_sensitive_headers( True if sensitive headers are found, False otherwise Raises: - ValueError: If headers are present but no configuration is provided + ConfigDoesNotAllowException: If headers are present but no configuration is provided """ if not url_headers_config: # Without configuration, headers are not allowed at all @@ -90,10 +94,10 @@ def check_for_headers(obj): for key, value in obj.items(): if key == "headers" and isinstance(value, dict) and value: header_names = list(value.keys()) - raise ValueError( - f"Headers are not allowed without proper URL headers configuration. " + raise ConfigDoesNotAllowException( + "Headers are not allowed without proper URL headers configuration. " f"Found headers: {header_names}. " - f"Please configure url_headers_config_file in Galaxy configuration to enable header usage." + "If you need to use headers, please contact your Galaxy administrator to whitelist them." ) elif isinstance(value, (dict, list)): check_for_headers(value) @@ -110,15 +114,11 @@ def check_sensitivity(obj, inherited_url=None): if isinstance(obj, dict): for key, value in obj.items(): if key == "headers" and isinstance(value, dict) and value: - # Look for a URL at the same level as the headers (e.g., in UrlDataElement) element_url = obj.get("url") if "url" in obj else inherited_url if not element_url: - # No URL available - cannot perform URL-specific sensitivity checking - # In a pattern-based system, headers without URLs cannot be properly validated - # This should fail fast for security header_names = list(value.keys()) - raise ValueError( + raise RequestParameterMissingException( f"URL is required for header validation in pattern-based configuration. " f"Found headers: {header_names}. " f"Cannot validate headers without knowing the target URL." @@ -200,7 +200,7 @@ def encrypt_headers_in_data( Modified data structure with sensitive headers encrypted Raises: - ValueError: If headers are present but proper configuration or URL is not provided + ConfigDoesNotAllowException: If headers are present but proper configuration or URL is not provided """ # Validate headers before processing - this will fail fast if headers are found # but configuration or URLs are missing From 94dd7dde4cf33682381c2714d71b6f5b98e71a1e Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:41:52 +0200 Subject: [PATCH 22/35] Refactor UrlHeadersConfig to use ABC and Null Object pattern --- lib/galaxy/config/url_headers.py | 173 ++++++++++++++++++------------- 1 file changed, 103 insertions(+), 70 deletions(-) diff --git a/lib/galaxy/config/url_headers.py b/lib/galaxy/config/url_headers.py index e1c5af3b5609..7e45262e7316 100644 --- a/lib/galaxy/config/url_headers.py +++ b/lib/galaxy/config/url_headers.py @@ -1,16 +1,4 @@ -""" -Configuration and management for URL request headers allow-list with URL pattern matching. - -This module provides functionality to: -1. Load and parse URL headers configuration from YAML with URL patterns -2. Validate whether headers are allowed for specific URLs based on patterns -3. Determine if headers should be treated as sensitive (encrypted) - -This is a security feature to control what headers can be sent when Galaxy -fetches external URLs on behalf of users, with fine-grained control based -on the target URL. -""" - +import abc import logging import re from typing import Optional @@ -21,9 +9,18 @@ field_validator, ) +from galaxy.config import GalaxyAppConfiguration +from galaxy.exceptions import ConfigDoesNotAllowException + log = logging.getLogger(__name__) +class UrlHeadersConfigurationException(Exception): + """Raised when there is an error in the URL headers configuration.""" + + pass + + class HeaderConfig(BaseModel): """Configuration for a single header.""" @@ -52,90 +49,126 @@ def pattern_must_not_be_empty(cls, v: str) -> str: return v.strip() -class UrlHeadersConfig: +class UrlHeadersConfig(abc.ABC): """ Manages the configuration for allowed URL request headers with pattern matching. + """ - This class loads and manages the allow-list of headers that can be included - in URL fetch requests based on URL patterns, along with their sensitivity settings. + @abc.abstractmethod + def is_header_allowed_for_url(self, header_name: str, url: str) -> bool: + pass + + @abc.abstractmethod + def is_header_sensitive_for_url(self, header_name: str, url: str) -> bool: + pass + + @abc.abstractmethod + def find_all_matching(self, url: str) -> list[UrlPatternConfig]: + pass + + +class NullUrlHeadersConfiguration(UrlHeadersConfig): + """ + Default configuration when there is no real configuration for allowing URL request headers. + + This configuration raises exceptions when any method is called, providing fail-fast + behavior to clearly indicate that headers require configuration. """ - def __init__(self, config_file: Optional[str] = None): + _ERROR_MESSAGE = ( + "No URL headers configuration is available. " + "Headers require explicit configuration to be allowed. " + "Please contact your Galaxy administrator to configure URL headers." + ) + + def is_header_allowed_for_url(self, header_name: str, url: str) -> bool: """ - Initialize the URL headers configuration. + Raises an exception - headers require configuration. - Args: - config_file: Path to the YAML configuration file. If None or file - doesn't exist, no headers will be allowed. + Raises: + ConfigDoesNotAllowException: Always raised to indicate missing configuration """ + raise ConfigDoesNotAllowException(f"Cannot check if header '{header_name}' is allowed: {self._ERROR_MESSAGE}") + + def is_header_sensitive_for_url(self, header_name: str, url: str) -> bool: + """ + Raises an exception - headers require configuration. + + Raises: + ConfigDoesNotAllowException: Always raised to indicate missing configuration + """ + raise ConfigDoesNotAllowException(f"Cannot check if header '{header_name}' is sensitive: {self._ERROR_MESSAGE}") + + def find_all_matching(self, url: str) -> list[UrlPatternConfig]: + """ + Raises an exception - no patterns are configured. + + Raises: + ConfigDoesNotAllowException: Always raised to indicate missing configuration + """ + raise ConfigDoesNotAllowException(f"Cannot find matching patterns for URL '{url}': {self._ERROR_MESSAGE}") + + +class UrlHeadersConfiguration(UrlHeadersConfig): + """Contains valid configuration to allow URL request headers.""" + + def __init__(self): self._patterns: list[UrlPatternConfig] = [] self._compiled_patterns: list[tuple[re.Pattern[str], UrlPatternConfig]] = [] - self._config_file = config_file - if config_file: - self._load_config(config_file) - - def _load_config(self, config_file: str) -> None: - """Load configuration from YAML file.""" + def _add_pattern(self, pattern_config: UrlPatternConfig) -> None: + self._patterns.append(pattern_config) try: - with open(config_file, "r") as f: - config_data = yaml.safe_load(f) - - if not config_data: - log.info(f"URL headers config file {config_file} is empty - no headers will be allowed") - return - - # Load patterns - patterns_data = config_data.get("patterns", []) - if patterns_data: - for pattern_data in patterns_data: - try: - pattern_config = UrlPatternConfig(**pattern_data) - self._patterns.append(pattern_config) - - # Compile regex pattern - try: - compiled_pattern = re.compile(pattern_config.url_pattern) - self._compiled_patterns.append((compiled_pattern, pattern_config)) - log.debug( - f"Loaded URL pattern: {pattern_config.url_pattern} " - f"({len(pattern_config.headers)} headers)" - ) - except re.error as e: - log.warning(f"Invalid regex pattern '{pattern_config.url_pattern}' in {config_file}: {e}") - - except Exception as e: - log.warning(f"Invalid pattern configuration in {config_file}: {pattern_data} - {e}") - - log.info(f"Loaded {len(self._patterns)} URL patterns from {config_file}") - - except FileNotFoundError: - log.info(f"URL headers config file {config_file} not found - no headers will be allowed") - except yaml.YAMLError as e: - log.error(f"Error parsing URL headers config file {config_file}: {e}") - except Exception as e: - log.error(f"Error loading URL headers config file {config_file}: {e}") - - def _find_matching_pattern(self, url: str) -> Optional[UrlPatternConfig]: + compiled_pattern = re.compile(pattern_config.url_pattern) + self._compiled_patterns.append((compiled_pattern, pattern_config)) + except re.error as e: + raise UrlHeadersConfigurationException( + f"Invalid regex pattern '{pattern_config.url_pattern}' in URL headers configuration: {e}" + ) from e + + def find_all_matching(self, url: str) -> list[UrlPatternConfig]: """ - Find the first URL pattern that matches the given URL. + Find all URL patterns that match the given URL. Args: url: The URL to match against patterns Returns: - The first matching UrlPatternConfig, or None if no pattern matches + List of all matching UrlPatternConfig objects (may be empty) """ + matching_patterns = [] for compiled_pattern, pattern_config in self._compiled_patterns: if compiled_pattern.match(url): - log.debug(f"URL '{url}' matched pattern: {pattern_config.url_pattern}") - return pattern_config + matching_patterns.append(pattern_config) + return matching_patterns + + def _find_header_in_patterns( + self, header_name: str, matching_patterns: list[UrlPatternConfig] + ) -> Optional[tuple[HeaderConfig, UrlPatternConfig]]: + """ + Find a header configuration in matching patterns. + + Args: + header_name: The header name to find (case-insensitive) + matching_patterns: List of patterns to search + + Returns: + Tuple of (HeaderConfig, UrlPatternConfig) for first match, or None if not found + """ + header_name_lower = header_name.lower() + for pattern_config in matching_patterns: + for header_config in pattern_config.headers: + if header_config.name.lower() == header_name_lower: + return (header_config, pattern_config) return None def is_header_allowed_for_url(self, header_name: str, url: str) -> bool: """ Check if a header is allowed for a specific URL. + If multiple patterns match the URL, the header is allowed if it appears + in ANY of the matching patterns. + Args: header_name: The header name to check (case-insensitive) url: The target URL From 8698bc1c50c244bfec1b2ce14bce0f7f6f837e27 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:41:52 +0200 Subject: [PATCH 23/35] Implement UrlHeadersConfiguration and UrlHeadersConfigFactory --- lib/galaxy/config/url_headers.py | 208 +++++++++++++++++++++++++++---- 1 file changed, 184 insertions(+), 24 deletions(-) diff --git a/lib/galaxy/config/url_headers.py b/lib/galaxy/config/url_headers.py index 7e45262e7316..b8a06246848d 100644 --- a/lib/galaxy/config/url_headers.py +++ b/lib/galaxy/config/url_headers.py @@ -176,46 +176,206 @@ def is_header_allowed_for_url(self, header_name: str, url: str) -> bool: Returns: True if the header is allowed for this URL, False otherwise """ - pattern_config = self._find_matching_pattern(url) - if not pattern_config: - log.debug(f"No pattern matched URL '{url}' - header '{header_name}' denied") + matching_patterns = self.find_all_matching(url) + if not matching_patterns: return False - # Check if header is allowed in this pattern - header_name_lower = header_name.lower() - for header_config in pattern_config.headers: - if header_config.name.lower() == header_name_lower: - return True - - log.debug(f"Header '{header_name}' not allowed for URL '{url}' " f"(pattern: {pattern_config.url_pattern})") - return False + result = self._find_header_in_patterns(header_name, matching_patterns) + return result is not None def is_header_sensitive_for_url(self, header_name: str, url: str) -> bool: """ Check if a header should be treated as sensitive for a specific URL. + If multiple patterns match the URL and define the same header, the header + is treated as sensitive if ANY matching pattern marks it as sensitive + (secure by default). + Args: header_name: The header name to check (case-insensitive) url: The target URL Returns: - True if the header is allowed and marked as sensitive, False otherwise + True if the header is allowed and marked as sensitive in any matching pattern, + False otherwise """ - pattern_config = self._find_matching_pattern(url) - if not pattern_config: + matching_patterns = self.find_all_matching(url) + if not matching_patterns: return False header_name_lower = header_name.lower() - for header_config in pattern_config.headers: - if header_config.name.lower() == header_name_lower: - return header_config.sensitive - + for pattern_config in matching_patterns: + for header_config in pattern_config.headers: + if header_config.name.lower() == header_name_lower: + if header_config.sensitive: + return True return False - def __str__(self) -> str: - """String representation for debugging.""" - return f"UrlHeadersConfig(file={self._config_file}, patterns={len(self._patterns)})" - def __repr__(self) -> str: - """String representation for debugging.""" - return self.__str__() +class UrlHeadersConfigFactory: + """Factory for creating UrlHeadersConfig instances.""" + + @staticmethod + def _load_patterns_from_file(config_file: str) -> list[UrlPatternConfig]: + """Load pattern configurations from a YAML file. + + Args: + config_file: Path to the YAML configuration file + + Returns: + List of UrlPatternConfig objects + + Raises: + UrlHeadersConfigurationException: If file cannot be read or parsed + """ + try: + with open(config_file) as f: + config_data = yaml.safe_load(f) + except FileNotFoundError as e: + raise UrlHeadersConfigurationException( + f"URL headers configuration file not found: {config_file}. " + "Please check the 'url_headers_config_file' setting in your Galaxy configuration." + ) from e + except yaml.YAMLError as e: + raise UrlHeadersConfigurationException( + f"Failed to parse URL headers configuration file {config_file}: {e}. Please check the YAML syntax." + ) from e + except Exception as e: + raise UrlHeadersConfigurationException( + f"Failed to read URL headers configuration file {config_file}: {e}" + ) from e + + if not config_data: + return [] + + patterns_data = config_data.get("patterns", []) + if not patterns_data: + return [] + + patterns = [] + for i, pattern_data in enumerate(patterns_data): + try: + pattern_config = UrlPatternConfig(**pattern_data) + patterns.append(pattern_config) + except Exception as e: + raise UrlHeadersConfigurationException( + f"Invalid pattern configuration at index {i} in {config_file}: {e}. " + "Each pattern must have 'url_pattern' (valid regex) and 'headers' (list of header configs)." + ) from e + + return patterns + + @staticmethod + def from_app_config(app_config: GalaxyAppConfiguration) -> UrlHeadersConfig: + """ + Create a UrlHeadersConfig from Galaxy app configuration. + + Args: + app_config: Galaxy application configuration + + Returns: + UrlHeadersConfiguration if config file is specified and exists, + NullUrlHeadersConfiguration otherwise + + Raises: + UrlHeadersConfigurationException: If config file exists but is invalid + """ + config_file = app_config.url_headers_config_file + if not config_file: + return NullUrlHeadersConfiguration() + + return UrlHeadersConfigFactory.from_file(config_file) + + @staticmethod + def from_file(config_file: str) -> UrlHeadersConfig: + """ + Create a UrlHeadersConfig from a YAML file. + + If the file doesn't exist, returns NullUrlHeadersConfiguration for backwards compatibility. + + Args: + config_file: Path to the YAML configuration file + + Returns: + UrlHeadersConfiguration with patterns loaded from the file, or + NullUrlHeadersConfiguration if file doesn't exist + + Raises: + UrlHeadersConfigurationException: If file exists but contains errors + """ + try: + config = UrlHeadersConfiguration() + patterns = UrlHeadersConfigFactory._load_patterns_from_file(config_file) + + for pattern in patterns: + config._add_pattern(pattern) + + log.info(f"Loaded {len(config._patterns)} URL patterns from {config_file}") + return config + except UrlHeadersConfigurationException as e: + # If the file doesn't exist, return null config for backwards compatibility + if "not found" in str(e): + log.warning(f"URL headers configuration file not found: {config_file}. Using null configuration.") + return NullUrlHeadersConfiguration() + # For any other configuration error, re-raise + raise + + @staticmethod + def from_dict(config_dict: dict) -> UrlHeadersConfig: + """ + Create a UrlHeadersConfig from a dictionary (useful for testing). + + Args: + config_dict: Dictionary containing URL headers configuration with 'patterns' key + + Returns: + UrlHeadersConfiguration with patterns loaded from the dictionary + + Raises: + UrlHeadersConfigurationException: If configuration is invalid + + Example: + config_dict = { + "patterns": [ + { + "url_pattern": "^https://api\\.github\\.com/.*", + "headers": [ + {"name": "Authorization", "sensitive": True}, + {"name": "Accept", "sensitive": False} + ] + } + ] + } + config = UrlHeadersConfigFactory.from_dict(config_dict) + """ + config = UrlHeadersConfiguration() + + if not config_dict: + return config + + patterns_data = config_dict.get("patterns", []) + if not patterns_data: + return config + + for i, pattern_data in enumerate(patterns_data): + try: + pattern_config = UrlPatternConfig(**pattern_data) + config._add_pattern(pattern_config) + except Exception as e: + raise UrlHeadersConfigurationException( + f"Invalid pattern configuration at index {i}: {e}. " + "Each pattern must have 'url_pattern' (valid regex) and 'headers' (list of header configs)." + ) from e + + log.info(f"Loaded {len(config._patterns)} URL patterns from dictionary") + return config + + @staticmethod + def create_null_config() -> NullUrlHeadersConfiguration: + """ + Create a null configuration that doesn't allow any headers. + + Returns: + NullUrlHeadersConfiguration instance + """ + return NullUrlHeadersConfiguration() From f69086b29ba047580677ba33ea9e45c85eaedfa5 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:41:52 +0200 Subject: [PATCH 24/35] Adapt LandingRequestManager to use UrlHeadersConfigFactory --- lib/galaxy/managers/landing.py | 6 ++++-- test/unit/app/managers/test_landing.py | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/managers/landing.py b/lib/galaxy/managers/landing.py index b3ed05c9911a..24a7c98a6cf3 100644 --- a/lib/galaxy/managers/landing.py +++ b/lib/galaxy/managers/landing.py @@ -11,7 +11,8 @@ ) from sqlalchemy import select -from galaxy.config.url_headers import UrlHeadersConfig +from galaxy.config import GalaxyAppConfiguration +from galaxy.config.url_headers import UrlHeadersConfigFactory from galaxy.exceptions import ( InsufficientPermissionsException, ItemAlreadyClaimedException, @@ -83,6 +84,7 @@ def __init__( security: IdEncodingHelper, workflow_contents_manager: WorkflowContentsManager, app: MinimalManagerApp, + config: GalaxyAppConfiguration, vault: Optional[Vault] = None, ): self.sa_session = sa_session @@ -90,7 +92,7 @@ def __init__( self.workflow_contents_manager = workflow_contents_manager self.app = app self.vault = vault - self.url_headers_config = UrlHeadersConfig(app.config.url_headers_config_file) + self.url_headers_config = UrlHeadersConfigFactory.from_app_config(config) def create_tool_landing_request(self, payload: CreateToolLandingRequestPayload, user_id=None) -> ToolLandingRequest: tool_id = payload.tool_id diff --git a/test/unit/app/managers/test_landing.py b/test/unit/app/managers/test_landing.py index ac7b9664256b..33a30eb424c5 100644 --- a/test/unit/app/managers/test_landing.py +++ b/test/unit/app/managers/test_landing.py @@ -76,6 +76,7 @@ def setUp(self): self.app.security, self.workflow_contents_manager, cast(MinimalManagerApp, MockApp()), + self.app.config, ) self.trans.app.trs_proxy = TrsProxy(GalaxyAppConfiguration(override_tempdir=False)) From 0c96fa4a84879e866c25e5526e8aaab4cd335039 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:41:52 +0200 Subject: [PATCH 25/35] Add utility for configuring allowed URL headers in tests --- lib/galaxy_test/driver/integration_util.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/galaxy_test/driver/integration_util.py b/lib/galaxy_test/driver/integration_util.py index 4ab96b0a6187..9912ad890577 100644 --- a/lib/galaxy_test/driver/integration_util.py +++ b/lib/galaxy_test/driver/integration_util.py @@ -383,3 +383,15 @@ def _disable_workflow_scheduling(cls, config): """ cls._configure_workflow_schedulers(noop_schedulers_conf, config) + + +class ConfigureAllowedUrlHeaders: + _test_driver: GalaxyTestDriver + + @classmethod + def _configure_allowed_url_headers(cls, allowed_url_headers_conf: str, config): + temp_directory = cls._test_driver.mkdtemp() + url_headers_conf_path = os.path.join(temp_directory, "url_headers_conf.yml") + with open(url_headers_conf_path, "w") as f: + f.write(allowed_url_headers_conf) + config["url_headers_config_file"] = url_headers_conf_path From 3b6d01553ac8c65084241fff363fc084c370d5e9 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:41:52 +0200 Subject: [PATCH 26/35] Update headers encryption tests for new config factory and exceptions --- .../app/managers/test_headers_encryption.py | 109 ++++++++---------- 1 file changed, 50 insertions(+), 59 deletions(-) diff --git a/test/unit/app/managers/test_headers_encryption.py b/test/unit/app/managers/test_headers_encryption.py index 36cb310bd53e..5bf5cbeb45c6 100644 --- a/test/unit/app/managers/test_headers_encryption.py +++ b/test/unit/app/managers/test_headers_encryption.py @@ -2,10 +2,10 @@ import pytest -from galaxy.config.url_headers import ( - HeaderConfig, - UrlHeadersConfig, - UrlPatternConfig, +from galaxy.config.url_headers import UrlHeadersConfigFactory +from galaxy.exceptions import ( + ConfigDoesNotAllowException, + RequestParameterMissingException, ) from galaxy.managers.headers_encryption import ( create_vault_key, @@ -35,52 +35,37 @@ def list_secrets(self, key: str) -> list[str]: def create_test_url_headers_config(): - config = UrlHeadersConfig() - - # GitHub API pattern - allows auth headers - github_pattern = UrlPatternConfig( - url_pattern=r"^https://api\.github\.com/.*", - headers=[ - HeaderConfig(name="Authorization", sensitive=True), - HeaderConfig(name="Accept", sensitive=False), - HeaderConfig(name="Accept-Encoding", sensitive=False), - ], - ) - - # Generic HTTPS pattern - only basic headers - https_pattern = UrlPatternConfig( - url_pattern=r"^https://.*", - headers=[ - HeaderConfig(name="Accept", sensitive=False), - HeaderConfig(name="Accept-Language", sensitive=False), - HeaderConfig(name="Content-Type", sensitive=False), - ], - ) - - # Test API pattern - for testing sensitive headers - test_api_pattern = UrlPatternConfig( - url_pattern=r"^https://api\.example\.com/.*", - headers=[ - HeaderConfig(name="Authorization", sensitive=True), - HeaderConfig(name="X-API-Key", sensitive=True), - HeaderConfig(name="Content-Type", sensitive=False), - HeaderConfig(name="Accept", sensitive=False), - ], - ) - - # Set up the patterns in the config - config._patterns = [github_pattern, test_api_pattern, https_pattern] - - # Set up compiled patterns - import re - - config._compiled_patterns = [ - (re.compile(github_pattern.url_pattern), github_pattern), - (re.compile(test_api_pattern.url_pattern), test_api_pattern), - (re.compile(https_pattern.url_pattern), https_pattern), - ] - - return config + config_dict = { + "patterns": [ + { + "url_pattern": r"^https://api\.github\.com/.*", + "headers": [ + {"name": "Authorization", "sensitive": True}, + {"name": "Accept", "sensitive": False}, + {"name": "Accept-Encoding", "sensitive": False}, + ], + }, + { + "url_pattern": r"^https://api\.example\.com/.*", + "headers": [ + {"name": "Authorization", "sensitive": True}, + {"name": "X-API-Key", "sensitive": True}, + {"name": "Content-Type", "sensitive": False}, + {"name": "Accept", "sensitive": False}, + ], + }, + { + "url_pattern": r"^https://.*", + "headers": [ + {"name": "Accept", "sensitive": False}, + {"name": "Accept-Language", "sensitive": False}, + {"name": "Content-Type", "sensitive": False}, + ], + }, + ] + } + + return UrlHeadersConfigFactory.from_dict(config_dict) class TestSensitiveHeaderDetection: @@ -157,19 +142,23 @@ def test_headers_without_url_fail_fast(self): config = create_test_url_headers_config() # Without URL, headers should fail fast since we can't validate them data = {"headers": {"Content-Type": "application/json"}} - with pytest.raises(ValueError, match="URL is required for header validation"): + with pytest.raises(RequestParameterMissingException, match="URL is required for header validation"): has_sensitive_headers(data, url_headers_config=config) def test_fails_without_config(self): """Test that function fails fast when no configuration is provided.""" - # Should raise ValueError when headers exist but no config - with pytest.raises(ValueError, match="Headers are not allowed without proper URL headers configuration"): + # Should raise ConfigDoesNotAllowException when headers exist but no config + with pytest.raises( + ConfigDoesNotAllowException, match="Headers are not allowed without proper URL headers configuration" + ): has_sensitive_headers({"headers": {"Authorization": "Bearer token"}}) - # Should raise ValueError for nested headers too + # Should raise ConfigDoesNotAllowException for nested headers too nested_data = {"request_json": {"targets": [{"elements": [{"headers": {"X-API-Key": "secret"}}]}]}} - with pytest.raises(ValueError, match="Headers are not allowed without proper URL headers configuration"): + with pytest.raises( + ConfigDoesNotAllowException, match="Headers are not allowed without proper URL headers configuration" + ): has_sensitive_headers(nested_data) # Should NOT raise error when no headers present @@ -239,7 +228,7 @@ def test_encrypt_decrypt_simple_headers(self): headers = encrypted["headers"] assert headers["Authorization"] == "__VAULT_HEADER_AUTHORIZATION__" # Encrypted (sensitive) assert headers["X-API-Key"] == "__VAULT_HEADER_X_API_KEY__" # Encrypted (sensitive) - assert headers["Accept"] == "application/json" # Not encrypted (not sensitive) + assert headers["Accept"] == "application/json" # Check vault has the encrypted headers assert len(vault.storage) == 2 @@ -366,8 +355,10 @@ def test_encrypt_fails_without_config(self): } } - # Should raise ValueError when trying to encrypt without config - with pytest.raises(ValueError, match="Headers are not allowed without proper URL headers configuration"): + # Should raise ConfigDoesNotAllowException when trying to encrypt without config + with pytest.raises( + ConfigDoesNotAllowException, match="Headers are not allowed without proper URL headers configuration" + ): encrypt_headers_in_data(data, context_id, vault) def test_encrypt_headers_with_url_pattern_checking(self): @@ -385,7 +376,7 @@ def test_encrypt_headers_with_url_pattern_checking(self): } } - with pytest.raises(ValueError, match="URL is required for header validation"): + with pytest.raises(RequestParameterMissingException, match="URL is required for header validation"): encrypt_headers_in_data(data_no_url, context_id, vault, url_headers_config=config) # Test: Headers WITH URL - pattern-based checking works From 037868fa267b4653c535d2b3a1e21b63091b6686 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:41:52 +0200 Subject: [PATCH 27/35] Add integration tests for URL headers configuration --- test/integration/test_landing_requests.py | 122 +++++++++++++++++++--- 1 file changed, 109 insertions(+), 13 deletions(-) diff --git a/test/integration/test_landing_requests.py b/test/integration/test_landing_requests.py index 934bca738bf1..83b3c8867a09 100644 --- a/test/integration/test_landing_requests.py +++ b/test/integration/test_landing_requests.py @@ -26,8 +26,34 @@ TEST_URL = "base64://eyJ0ZXN0IjogInRlc3QifQ==" # base64 encoded {"test": "test"} - -class BaseLandingRequestTest(integration_util.IntegrationTestCase): +# URL headers configuration for tests - allows both sensitive and non-sensitive headers +ALLOW_URL_HEADERS_CONF = """ +patterns: + # Match all URLs (including base64://) for testing + - url_pattern: "^.*" + headers: + # Sensitive headers - will be encrypted when vault is configured + - name: Authorization + sensitive: true + - name: X-API-Key + sensitive: true + # Non-sensitive headers + - name: Content-Type + sensitive: false + - name: Accept + sensitive: false + - name: Accept-Language + sensitive: false + - name: Accept-Encoding + sensitive: false + - name: Cache-Control + sensitive: false + - name: X-Custom-Header + sensitive: false +""" + + +class BaseLandingRequestTest(integration_util.IntegrationTestCase, integration_util.ConfigureAllowedUrlHeaders): """Base class with common setup for landing request tests.""" dataset_populator: DatasetPopulator @@ -132,6 +158,7 @@ class TestLandingRequestsIntegration(BaseLandingRequestTest, integration_util.Co def handle_galaxy_config_kwds(cls, config): super().handle_galaxy_config_kwds(config) cls._configure_database_vault(config) + cls._configure_allowed_url_headers(ALLOW_URL_HEADERS_CONF, config) def test_data_landing_with_encrypted_headers(self): """Test that sensitive headers are encrypted in the vault when stored in landing requests. @@ -144,7 +171,7 @@ def test_data_landing_with_encrypted_headers(self): headers = { "Authorization": "Bearer data-test-token-should-be-encrypted", "X-API-Key": "data-test-api-key-123456", - "User-Agent": "Galaxy-Test/1.0", + "Accept": "application/json", "Content-Type": "application/json", "X-Custom-Header": "custom-value", } @@ -175,12 +202,13 @@ def test_workflow_landing_with_encrypted_headers(self): input1_headers = { "Authorization": "Bearer workflow-test-token-should-be-encrypted", "X-API-Key": "workflow-test-api-key-123456", - "User-Agent": "Galaxy-Workflow-Test/1.0", + "Accept": "application/json", "Content-Type": "application/json", } input2_headers = { "Authorization": "Bearer workflow-test-token-should-be-encrypted", "X-API-Key": "workflow-test-api-key-123456", + "Accept-Language": "en-US", "X-Custom-Header": "custom-value", } @@ -216,25 +244,30 @@ def test_workflow_landing_with_encrypted_headers(self): class TestLandingRequestsWithoutVaultIntegration(BaseLandingRequestTest): - """Test landing requests when vault is not configured. + """Test landing requests when headers are configured but vault is not configured. - This class tests the behavior when no vault is configured but sensitive headers - are present in the request. The system should fail fast rather than storing - sensitive information in plain text. + This class tests the behavior when headers configuration exists but no vault is configured. + When sensitive headers are present, the system should fail because it cannot encrypt them. """ + @classmethod + def handle_galaxy_config_kwds(cls, config): + super().handle_galaxy_config_kwds(config) + # Configure headers but NOT vault - this tests the vault requirement for sensitive headers + cls._configure_allowed_url_headers(ALLOW_URL_HEADERS_CONF, config) + def test_data_landing_fails_without_vault_when_sensitive_headers_present(self): """Test that data landing requests fail when vault is not configured but sensitive headers are present. This test verifies that when sensitive headers (like Authorization, API keys, etc.) are present - in a landing request but no vault is configured, the system fails fast rather than storing - the sensitive information in plain text in the database. + in a landing request but no vault is configured, the system fails with a 500 error rather than + storing the sensitive information in plain text in the database. """ # Create headers with sensitive values headers = { "Authorization": "Bearer no-vault-test-token-should-fail", "X-API-Key": "no-vault-test-api-key-should-fail", - "User-Agent": "Galaxy-Test/1.0", + "Accept": "application/json", } # Create data landing request with sensitive headers @@ -254,7 +287,7 @@ def test_data_landing_succeeds_without_vault_when_no_sensitive_headers(self): """ # Create only non-sensitive headers headers = { - "User-Agent": "Galaxy-Test/1.0", + "Accept": "application/json", "Content-Type": "application/json", "X-Custom-Header": "custom-value", } @@ -278,7 +311,7 @@ def test_workflow_landing_fails_without_vault_when_sensitive_headers_present(sel headers = { "Authorization": "Bearer workflow-no-vault-token-should-fail", "X-API-Key": "workflow-no-vault-api-key-should-fail", - "User-Agent": "Galaxy-Workflow-Test/1.0", + "Accept": "application/json", } workflow_request_state = self._create_workflow_input_with_headers(headers) @@ -297,3 +330,66 @@ def test_workflow_landing_fails_without_vault_when_sensitive_headers_present(sel json = payload.model_dump(mode="json") response = self.dataset_populator._post(create_url, json, json=True, anon=True) assert response.status_code == 500 + + +class TestLandingRequestsWithoutHeadersConfigIntegration(BaseLandingRequestTest): + """Test landing requests when no headers configuration exists. + + This class tests the behavior when no URL headers configuration file is present. + The system should fail fast with any headers (sensitive or not) because headers + require explicit configuration to be allowed. + """ + + def test_data_landing_fails_without_config(self): + """Test that data landing requests fail when no URL headers configuration exists. + + This test verifies the fail-fast behavior: when no URL headers configuration file + exists, ANY attempt to use headers (sensitive or not) will fail immediately with + a clear error message, rather than silently allowing or denying headers. + """ + # Create only non-sensitive headers + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "X-Custom-Header": "custom-value", + } + + # Create data landing request with headers + request_state = self._create_data_landing_request_state(headers) + payload = CreateDataLandingPayload(request_state=request_state, public=True) + + # Should fail with 403 because no URL headers configuration is available (fail-fast) + response = self.dataset_populator.create_data_landing_raw(payload) + assert response.status_code == 403 + assert "No URL headers configuration is available" in response.json()["err_msg"] + + def test_workflow_landing_fails_without_config(self): + """Test that workflow landing requests fail when no URL headers configuration exists. + + This test verifies the fail-fast behavior for workflow landings: when no URL headers + configuration file exists, any attempt to use headers will fail immediately. + """ + # Create workflow input with headers + headers = { + "Authorization": "Bearer workflow-no-vault-token-should-fail", + "X-API-Key": "workflow-no-vault-api-key-should-fail", + "Accept": "application/json", + } + workflow_request_state = self._create_workflow_input_with_headers(headers) + + # Create workflow and landing request + workflow_id = self._create_and_make_public_workflow("test_landing_no_config") + payload = CreateWorkflowLandingRequestPayload( + workflow_id=workflow_id, + workflow_target_type="stored_workflow", + request_state=workflow_request_state, + public=True, + ) + + # Should return 403 status code when trying to create the workflow landing request + # because no URL headers configuration is available + create_url = "workflow_landings" + json = payload.model_dump(mode="json") + response = self.dataset_populator._post(create_url, json, json=True, anon=True) + assert response.status_code == 403 + assert "No URL headers configuration is available" in response.json()["err_msg"] From 09a2aea19890d9bcce8c889d7615fe17d26301db Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:42:58 +0200 Subject: [PATCH 28/35] Adds unit tests for URL header pattern matching Ensures that when multiple URL patterns match a given URL, header permissions (allowance and sensitivity) are correctly consolidated. --- .../test_headers_url_pattern_matching.py | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 test/unit/app/managers/test_headers_url_pattern_matching.py diff --git a/test/unit/app/managers/test_headers_url_pattern_matching.py b/test/unit/app/managers/test_headers_url_pattern_matching.py new file mode 100644 index 000000000000..30c7aa9aeb52 --- /dev/null +++ b/test/unit/app/managers/test_headers_url_pattern_matching.py @@ -0,0 +1,155 @@ +from galaxy.config.url_headers import UrlHeadersConfigFactory + + +def create_overlapping_patterns_config(): + """Create config with multiple overlapping patterns to test all-matches behavior.""" + config_dict = { + "patterns": [ + { + "url_pattern": r"^https://api\.github\.com/.*", + "headers": [ + {"name": "Accept", "sensitive": False}, + {"name": "Content-Type", "sensitive": False}, + ], + }, + { + "url_pattern": r"^https://api\.github\.com/repos/.*", + "headers": [ + {"name": "Authorization", "sensitive": True}, + ], + }, + { + "url_pattern": r"^https://.*", + "headers": [ + {"name": "Accept-Encoding", "sensitive": False}, + ], + }, + ] + } + + return UrlHeadersConfigFactory.from_dict(config_dict) + + +class TestAllMatchesPatternBehavior: + """Test that all matching patterns are considered (union of permissions).""" + + def test_find_all_matching_returns_all_patterns(self): + """Test that find_all_matching returns all patterns that match a URL.""" + config = create_overlapping_patterns_config() + + # URL matches all three patterns + url = "https://api.github.com/repos/owner/repo" + matching = config.find_all_matching(url) + + assert len(matching) == 3 + + def test_header_allowed_checks_all_matching_patterns(self): + """Test that a header is allowed if ANY matching pattern allows it.""" + config = create_overlapping_patterns_config() + + url = "https://api.github.com/repos/owner/repo" + + # Each header should be allowed if it's in ANY matching pattern + assert config.is_header_allowed_for_url("Accept", url) # From github_basic + assert config.is_header_allowed_for_url("Content-Type", url) # From github_basic + assert config.is_header_allowed_for_url("Authorization", url) # From github_auth + assert config.is_header_allowed_for_url("Accept-Encoding", url) # From https_generic + + # Headers not in any pattern + assert not config.is_header_allowed_for_url("X-Custom-Header", url) + assert not config.is_header_allowed_for_url("Cookie", url) + + def test_header_allowed_subset_of_patterns(self): + """Test headers allowed when only subset of patterns match.""" + config = create_overlapping_patterns_config() + + # URL only matches github_basic and https_generic (not github_auth) + url = "https://api.github.com/users/octocat" + + assert config.is_header_allowed_for_url("Accept", url) # From github_basic + assert config.is_header_allowed_for_url("Accept-Encoding", url) # From https_generic + # Authorization not allowed because github_auth pattern doesn't match + assert not config.is_header_allowed_for_url("Authorization", url) + + def test_header_sensitive_secure_by_default(self): + """Test that if ANY pattern marks header sensitive, it's treated as sensitive.""" + config_dict = { + "patterns": [ + { + "url_pattern": r"^https://test1\.example\.com/.*", + "headers": [{"name": "Authorization", "sensitive": False}], + }, + { + "url_pattern": r"^https://.*\.example\.com/.*", + "headers": [{"name": "Authorization", "sensitive": True}], + }, + ] + } + config = UrlHeadersConfigFactory.from_dict(config_dict) + + # URL matches both patterns + url = "https://test1.example.com/api" + + # Should be treated as sensitive (secure by default) + assert config.is_header_sensitive_for_url("Authorization", url) + + def test_multiple_overlapping_patterns_union(self): + """Test that union of headers from all matching patterns is allowed.""" + config_dict = { + "patterns": [ + { + "url_pattern": r"^https://example\.com/.*", + "headers": [ + {"name": "Header-A", "sensitive": False}, + {"name": "Header-B", "sensitive": True}, + ], + }, + { + "url_pattern": r"^https://example\.com/api/.*", + "headers": [ + {"name": "Header-C", "sensitive": False}, + {"name": "Header-D", "sensitive": True}, + ], + }, + { + "url_pattern": r"^https://example\.com/api/v1/.*", + "headers": [ + {"name": "Header-E", "sensitive": False}, + ], + }, + ] + } + config = UrlHeadersConfigFactory.from_dict(config_dict) + + # URL matches all three patterns + url = "https://example.com/api/v1/resource" + + # All headers from all patterns should be allowed + assert config.is_header_allowed_for_url("Header-A", url) + assert config.is_header_allowed_for_url("Header-B", url) + assert config.is_header_allowed_for_url("Header-C", url) + assert config.is_header_allowed_for_url("Header-D", url) + assert config.is_header_allowed_for_url("Header-E", url) + + # Headers B and D should be sensitive + assert config.is_header_sensitive_for_url("Header-B", url) + assert config.is_header_sensitive_for_url("Header-D", url) + + # Headers A, C, E should not be sensitive + assert not config.is_header_sensitive_for_url("Header-A", url) + assert not config.is_header_sensitive_for_url("Header-C", url) + assert not config.is_header_sensitive_for_url("Header-E", url) + + def test_no_matching_patterns(self): + """Test behavior when no patterns match.""" + config = create_overlapping_patterns_config() + + # URL doesn't match any pattern + url = "http://example.com/api" # http, not https + + matching = config.find_all_matching(url) + assert len(matching) == 0 + + # No headers should be allowed + assert not config.is_header_allowed_for_url("Accept", url) + assert not config.is_header_allowed_for_url("Authorization", url) From d29b61a927946cf620e7a8aa6a3322b03b98f07a Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:51:13 +0200 Subject: [PATCH 29/35] Adds sample for URL header configuration Introduces a new sample configuration to define an allow-list for HTTP headers in external URL fetch requests. This mechanism allows administrators to specify which headers are permitted for different URL patterns, improving security and control over fetch requests. The configuration also supports marking headers as sensitive, prompting encryption of their values. The sample provides illustrative examples for common services like GitHub, AWS S3, and generic cloud storage. --- config/url_headers_conf.yml.sample | 1 + .../config/sample/url_headers_conf.yml.sample | 108 ++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 120000 config/url_headers_conf.yml.sample create mode 100644 lib/galaxy/config/sample/url_headers_conf.yml.sample diff --git a/config/url_headers_conf.yml.sample b/config/url_headers_conf.yml.sample new file mode 120000 index 000000000000..8f1a15c75fa1 --- /dev/null +++ b/config/url_headers_conf.yml.sample @@ -0,0 +1 @@ +../lib/galaxy/config/sample/url_headers_conf.yml.sample \ No newline at end of file diff --git a/lib/galaxy/config/sample/url_headers_conf.yml.sample b/lib/galaxy/config/sample/url_headers_conf.yml.sample new file mode 100644 index 000000000000..c7fe88a87611 --- /dev/null +++ b/lib/galaxy/config/sample/url_headers_conf.yml.sample @@ -0,0 +1,108 @@ +# Allowed URL Headers Configuration +# +# This file defines which HTTP headers are allowed in URL fetch requests based +# on URL patterns, and whether they should be treated as sensitive (encrypted +# in the vault) or not. +# +# If no allow-list is specified or this file is empty/missing, NO headers will +# be allowed in URL requests. +# +# Configuration structure: +# patterns: +# - url_pattern: A regular expression pattern to match URLs +# headers: +# - name: The exact header name (case-insensitive) +# sensitive: Whether this header contains sensitive information that should +# be encrypted when stored in the database (requires vault configuration) +# +# IMPORTANT: +# ------------------------------------ +# When a URL matches MULTIPLE patterns, the union of all allowed headers is used. +# This means you can compose permissions from multiple patterns for flexibility. +# +# Example: A URL matching both pattern A (allows headers X, Y) and pattern B +# (allows headers Y, Z) will allow headers X, Y, and Z. +# +# Security: If ANY matching pattern marks a header as sensitive, it will be +# treated as sensitive (secure-by-default). +# +# Examples: + +patterns: + # GitHub API access - allow authentication headers for GitHub URLs + - url_pattern: "^https://api\\.github\\.com/.*" + headers: + - name: Authorization + sensitive: true + - name: Accept + sensitive: false + - name: X-GitHub-Api-Version + sensitive: false + + # Generic GitHub content (raw files, releases) - no auth needed + - url_pattern: "^https://(raw\\.githubusercontent\\.com|github\\.com/.*/releases/download)/.*" + headers: + - name: Accept + sensitive: false + - name: Accept-Encoding + sensitive: false + + # AWS S3 buckets - allow AWS authentication headers + - url_pattern: "^https://.*\\.s3\\..+\\.amazonaws\\.com/.*" + headers: + - name: Authorization + sensitive: true + - name: X-Amz-Date + sensitive: false + - name: X-Amz-Content-Sha256 + sensitive: false + - name: X-Amz-Security-Token + sensitive: true + + # Generic cloud storage APIs + - url_pattern: "^https://.*\\.(googleapis\\.com|azure\\.com|digitaloceanspaces\\.com)/.*" + headers: + - name: Authorization + sensitive: true + - name: X-API-Key + sensitive: true + - name: Accept + sensitive: false + + # FTP over HTTP services + - url_pattern: "^https?://ftp\\..*/.*" + headers: + - name: Authorization + sensitive: true + - name: Accept + sensitive: false + + # Academic/research data repositories + - url_pattern: "^https://.*(zenodo\\.org|figshare\\.com|dryad\\.org|dataverse\\.org)/.*" + headers: + - name: Authorization + sensitive: true + - name: X-API-Key + sensitive: true + - name: Accept + sensitive: false + + # HTTPS URLs - basic headers only (most restrictive for unknown sources) + - url_pattern: "^https://.*" + headers: + - name: Accept + sensitive: false + - name: Accept-Language + sensitive: false + - name: Accept-Encoding + sensitive: false + - name: Cache-Control + sensitive: false + +# Security notes: +# - All matching patterns contribute their allowed headers (union of permissions) +# - If ANY pattern marks a header as sensitive, it's treated as sensitive +# - Only add headers that are absolutely necessary for your use case +# - When in doubt, mark headers as sensitive to ensure encryption +# - Patterns are order-independent, making configuration more composable +# - HTTP (non-HTTPS) URLs are generally not recommended and may be blocked From 1a71ca8b2920867e1be885a578a16ca8821ece14 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Wed, 15 Oct 2025 10:20:23 +0200 Subject: [PATCH 30/35] Adds URL headers config to mock app --- lib/galaxy/app_unittest_utils/galaxy_mock.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/galaxy/app_unittest_utils/galaxy_mock.py b/lib/galaxy/app_unittest_utils/galaxy_mock.py index 1d5a26565c9d..82a0b7886655 100644 --- a/lib/galaxy/app_unittest_utils/galaxy_mock.py +++ b/lib/galaxy/app_unittest_utils/galaxy_mock.py @@ -291,6 +291,7 @@ def __init__(self, **kwargs): self.monitor_thread_join_timeout = 1 self.integrated_tool_panel_config = None self.vault_config_file = kwargs.get("vault_config_file") + self.url_headers_config_file = None self.max_discovered_files = 10000 self.display_builtin_converters = True self.enable_notification_system = True From d93f093005373831b0ffec1ce9a1ca2f85bf50a5 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Wed, 15 Oct 2025 10:22:07 +0200 Subject: [PATCH 31/35] Rebuild config --- doc/source/admin/galaxy_options.rst | 20 ++++++++++++++++++++ lib/galaxy/config/sample/galaxy.yml.sample | 12 ++++++++++++ 2 files changed, 32 insertions(+) diff --git a/doc/source/admin/galaxy_options.rst b/doc/source/admin/galaxy_options.rst index 8f922c5526ce..91bca4990248 100644 --- a/doc/source/admin/galaxy_options.rst +++ b/doc/source/admin/galaxy_options.rst @@ -5638,6 +5638,26 @@ :Type: str +~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``url_headers_config_file`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:Description: + Configuration file for URL request headers allow-list with URL + pattern matching. This file defines which HTTP headers are allowed + in URL fetch requests based on URL patterns, and whether they + should be treated as sensitive (encrypted in the vault) or not. If + no allow-list is specified, no headers will be allowed in URL + requests. This provides fine-grained security control over what + headers can be sent when Galaxy fetches external URLs on behalf of + users, allowing different headers for different target domains or + services. + The value of this option will be resolved with respect to + . +:Default: ``url_headers_conf.yml`` +:Type: str + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``display_builtin_converters`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/lib/galaxy/config/sample/galaxy.yml.sample b/lib/galaxy/config/sample/galaxy.yml.sample index df7c57d9a77e..a6b611ca089d 100644 --- a/lib/galaxy/config/sample/galaxy.yml.sample +++ b/lib/galaxy/config/sample/galaxy.yml.sample @@ -3048,6 +3048,18 @@ galaxy: # . #vault_config_file: vault_conf.yml + # Configuration file for URL request headers allow-list with URL + # pattern matching. This file defines which HTTP headers are allowed + # in URL fetch requests based on URL patterns, and whether they should + # be treated as sensitive (encrypted in the vault) or not. If no + # allow-list is specified, no headers will be allowed in URL requests. + # This provides fine-grained security control over what headers can be + # sent when Galaxy fetches external URLs on behalf of users, allowing + # different headers for different target domains or services. + # The value of this option will be resolved with respect to + # . + #url_headers_config_file: url_headers_conf.yml + # Display built-in converters in the tool panel. #display_builtin_converters: true From 343d4b89e30bc98b94d2eac86049f3ce067d682d Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Wed, 15 Oct 2025 17:45:05 +0200 Subject: [PATCH 32/35] Removes unused null config factory method --- lib/galaxy/config/url_headers.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/lib/galaxy/config/url_headers.py b/lib/galaxy/config/url_headers.py index b8a06246848d..908999f4abfc 100644 --- a/lib/galaxy/config/url_headers.py +++ b/lib/galaxy/config/url_headers.py @@ -369,13 +369,3 @@ def from_dict(config_dict: dict) -> UrlHeadersConfig: log.info(f"Loaded {len(config._patterns)} URL patterns from dictionary") return config - - @staticmethod - def create_null_config() -> NullUrlHeadersConfiguration: - """ - Create a null configuration that doesn't allow any headers. - - Returns: - NullUrlHeadersConfiguration instance - """ - return NullUrlHeadersConfiguration() From 848f9f0529045728ef264daa8eb35d763d28ceeb Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 16 Oct 2025 12:00:43 +0200 Subject: [PATCH 33/35] Updates sample config with sensitive auth headers Adds common authentication-related headers (Authorization, X-Auth-Token, X-API-Key) to the default sensitive list for HTTPS URLs in the sample configuration. This provides a more secure default example for users, preventing accidental exposure of sensitive credentials. Includes a new comment advising users to only employ the minimum necessary configuration for their specific needs, reinforcing security best practices. --- lib/galaxy/config/sample/url_headers_conf.yml.sample | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/galaxy/config/sample/url_headers_conf.yml.sample b/lib/galaxy/config/sample/url_headers_conf.yml.sample index c7fe88a87611..fb0eb7d8678a 100644 --- a/lib/galaxy/config/sample/url_headers_conf.yml.sample +++ b/lib/galaxy/config/sample/url_headers_conf.yml.sample @@ -26,6 +26,7 @@ # Security: If ANY matching pattern marks a header as sensitive, it will be # treated as sensitive (secure-by-default). # +# The following examples are for illustration purposes only; please use only the minimum configuration for your needs. # Examples: patterns: @@ -90,6 +91,12 @@ patterns: # HTTPS URLs - basic headers only (most restrictive for unknown sources) - url_pattern: "^https://.*" headers: + - name: Authorization + sensitive: true + - name: X-Auth-Token + sensitive: true + - name: X-API-Key + sensitive: true - name: Accept sensitive: false - name: Accept-Language From 962388c20bbe5af7b85de984c3f941fa3e4f2823 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:51:25 +0100 Subject: [PATCH 34/35] Use correct dataset_populator method after rebase --- test/integration/test_landing_requests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/test_landing_requests.py b/test/integration/test_landing_requests.py index 83b3c8867a09..0fd093333a7d 100644 --- a/test/integration/test_landing_requests.py +++ b/test/integration/test_landing_requests.py @@ -276,7 +276,7 @@ def test_data_landing_fails_without_vault_when_sensitive_headers_present(self): # Should return 500 status code when trying to create the landing request # because sensitive headers are present but vault is not configured - response = self.dataset_populator.create_data_landing_raw(payload) + response = self.dataset_populator.create_landing_raw(payload, "data") assert response.status_code == 500 def test_data_landing_succeeds_without_vault_when_no_sensitive_headers(self): @@ -359,7 +359,7 @@ def test_data_landing_fails_without_config(self): payload = CreateDataLandingPayload(request_state=request_state, public=True) # Should fail with 403 because no URL headers configuration is available (fail-fast) - response = self.dataset_populator.create_data_landing_raw(payload) + response = self.dataset_populator.create_landing_raw(payload, "data") assert response.status_code == 403 assert "No URL headers configuration is available" in response.json()["err_msg"] From a12a082a744c589e2c5e60fb4fb9ed06006946ce Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:31:35 +0100 Subject: [PATCH 35/35] Adds docs for enabling HTTP headers in fetch --- .../admin/enable_headers_in_fetch_requests.md | 152 ++++++++++++++++++ doc/source/admin/index.rst | 1 + 2 files changed, 153 insertions(+) create mode 100644 doc/source/admin/enable_headers_in_fetch_requests.md diff --git a/doc/source/admin/enable_headers_in_fetch_requests.md b/doc/source/admin/enable_headers_in_fetch_requests.md new file mode 100644 index 000000000000..9f4ff6f51620 --- /dev/null +++ b/doc/source/admin/enable_headers_in_fetch_requests.md @@ -0,0 +1,152 @@ +# Enabling HTTP Headers in Fetch Requests + +Galaxy allows users to **fetch remote data by URL** (for example via _Upload → Paste/Fetch data_ or via APIs that retrieve external resources). +By default, Galaxy **does not forward any custom HTTP headers** when fetching URLs. This restriction is intentional and is part of Galaxy’s security model. + +Starting with recent Galaxy releases, administrators can **explicitly allow a controlled set of HTTP headers** to be sent with fetch requests, based on the target URL. This enables integrations with authenticated services (e.g. APIs requiring `Authorization` headers) while maintaining strict security boundaries. + +This document explains **how to safely enable HTTP headers for fetch requests**, how the allow‑list mechanism works, and how to configure it. + +## Why Header Allow‑Listing Is Required + +Allowing arbitrary headers in server‑side HTTP requests is dangerous. Without restrictions, users could: + +- Access internal services (SSRF attacks) +- Exfiltrate credentials via forwarded headers +- Abuse Galaxy as a proxy to privileged networks + +To prevent this, Galaxy implements **explicit header allow‑listing with URL pattern matching**: + +- **No headers are allowed by default** +- Each allowed header must be explicitly configured +- Headers are only sent to URLs that match defined patterns +- Sensitive headers can be stored securely using Galaxy’s Vault + +## Configuration Overview + +Header forwarding for fetch requests is controlled via a dedicated configuration file: + +```yaml +galaxy: + url_headers_config_file: url_headers_conf.yml +``` + +This file defines: + +- Which **HTTP headers** are allowed +- For which **URL patterns** they may be sent +- Whether headers are **sensitive** (stored encrypted in the Vault) + +If this configuration file is **not set or empty**, **no headers will ever be forwarded**. + +## url_headers_conf.yml Format + +The configuration file is a YAML list of rules. Each rule applies to one or more URL patterns. + +### Basic Structure + +```yaml +- url_pattern: "https://api.example.org/.*" + headers: + - name: Authorization + sensitive: true + - name: X-API-Key + sensitive: true +``` + +### Fields + +| Field | Description | +| --------------------- | -------------------------------------------------------- | +| `url_pattern` | A regular expression matched against the full URL | +| `headers` | List of allowed HTTP headers for matching URLs | +| `headers[].name` | Exact HTTP header name (case‑insensitive) | +| `headers[].sensitive` | Whether the header value is stored securely in the Vault | + +## Sensitive vs Non‑Sensitive Headers + +### Sensitive Headers + +Sensitive headers (for example `Authorization`, `X-API-Key`, `Cookie`) are: + +- **Encrypted and stored in the Galaxy Vault** +- Never logged or exposed in plaintext +- Managed through Galaxy’s secure secrets infrastructure + +Example: + +```yaml +- url_pattern: "https://protected.example.com/.*" + headers: + - name: Authorization + sensitive: true +``` + +### Non‑Sensitive Headers + +Non‑sensitive headers may be stored in plain configuration and are typically used for: + +- Feature flags +- API versioning +- Public metadata headers + +Example: + +```yaml +- url_pattern: "https://public.example.com/.*" + headers: + - name: X-Client-Version + sensitive: false +``` + +## Multiple Rules and URL Matching + +Multiple rules may be defined. The first rule whose `url_pattern` matches the request URL is applied. + +```yaml +- url_pattern: "https://api.github.com/.*" + headers: + - name: Authorization + sensitive: true + +- url_pattern: "https://raw.githubusercontent.com/.*" + headers: + - name: X-Client-Version + sensitive: false +``` + +```{note} +Rules are evaluated in order. Be careful with overly broad patterns such as `.*`. +``` + +## Using Headers in Practice + +Once configured, users (or tools) may provide header values when performing fetch operations. Galaxy will: + +1. Validate the target URL against the allow‑list +2. Filter headers to the allowed set +3. Securely inject sensitive headers at request time + +Headers not explicitly allowed **will be silently dropped**. + +## Security Best Practices + +```{warning} +Only allow headers and URL patterns that are strictly necessary. +``` + +Recommended practices: + +- Prefer **narrow URL patterns** over wildcards +- Mark authentication headers as `sensitive: true` +- Avoid allowing `Cookie` headers unless absolutely required +- Never allow headers for internal or private network ranges + +## Troubleshooting + +If headers are not being forwarded as expected: + +1. Verify `url_headers_config_file` is configured in `galaxy.yml` +2. Confirm the URL matches the configured `url_pattern` +3. Check that the header name matches exactly +4. Ensure Galaxy has access to the configured Vault diff --git a/doc/source/admin/index.rst b/doc/source/admin/index.rst index 491a492d53e5..385130b374ec 100644 --- a/doc/source/admin/index.rst +++ b/doc/source/admin/index.rst @@ -18,6 +18,7 @@ Galaxy Deployment & Administration jobs job_metrics authentication + enable_headers_in_fetch_requests tool_panel data_tables mq