Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e54881d
Enhance adapter validation by adding URL security checks in AdapterPr…
gaya3-zipstack Sep 10, 2025
0963f8d
Add Whitelist to sample.env
gaya3-zipstack Sep 10, 2025
21320be
Update backend/adapter_processor_v2/views.py
gaya3-zipstack Sep 11, 2025
4307bf6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 11, 2025
c5e6359
Add url validation for PUT API
gaya3-zipstack Sep 11, 2025
1d90cba
Merge branch 'fix/url_safety_net' of https://github.com/Zipstack/unst…
gaya3-zipstack Sep 11, 2025
5196608
Update backend/adapter_processor_v2/views.py
gaya3-zipstack Sep 11, 2025
7fbcc33
Keep exception handling uniform across CREATE and UPDATE APIS
gaya3-zipstack Sep 11, 2025
9d6ce54
Keep exception handling uniform across CREATE and UPDATE APIS
gaya3-zipstack Sep 11, 2025
fb1ea01
Merge branch 'fix/url_safety_net' of https://github.com/Zipstack/unst…
gaya3-zipstack Sep 11, 2025
6a7bb7c
Fix indentation errors
gaya3-zipstack Sep 11, 2025
08c5044
Fix logical condition
gaya3-zipstack Sep 11, 2025
db3acff
Update backend/adapter_processor_v2/views.py
gaya3-zipstack Sep 11, 2025
a756ddb
Reafctorinf for SONAR issue fixes
gaya3-zipstack Sep 12, 2025
9310c68
Resolve conflict
gaya3-zipstack Sep 12, 2025
ea273a1
Fix SONAR issues - unused variable definition
gaya3-zipstack Sep 12, 2025
9fbb82d
Merge branch 'main' into fix/url_safety_net
gaya3-zipstack Sep 15, 2025
7588621
pass validate_urls=true
gaya3-zipstack Sep 15, 2025
6985184
Merge branch 'fix/url_safety_net' of https://github.com/Zipstack/unst…
gaya3-zipstack Sep 15, 2025
082f56f
Update backend/adapter_processor_v2/views.py
gaya3-zipstack Sep 16, 2025
d78e5fb
Revert changes in previous commit for coderabbit issue as it is not a…
gaya3-zipstack Sep 16, 2025
c217694
Merge branch 'main' into fix/url_safety_net
gaya3-zipstack Sep 16, 2025
534a577
Add validation of endpoints for API/ETL and postprocessor hook
gaya3-zipstack Sep 17, 2025
0b74f6f
Merge branch 'fix/url_safety_net' of https://github.com/Zipstack/unst…
gaya3-zipstack Sep 17, 2025
ac1d5c5
Update backend/notification_v2/provider/webhook/webhook.py
gaya3-zipstack Sep 18, 2025
cd129cf
URL validation in variable replacement
gaya3-zipstack Sep 22, 2025
2f5213f
Add sample env variable
gaya3-zipstack Sep 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 50 additions & 6 deletions backend/adapter_processor_v2/adapter_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@
from cryptography.fernet import Fernet
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from platform_settings_v2.platform_auth_service import PlatformAuthenticationService
from tenant_account_v2.organization_member_service import OrganizationMemberService
from platform_settings_v2.platform_auth_service import (
PlatformAuthenticationService,
)
from rest_framework.exceptions import ValidationError
from tenant_account_v2.organization_member_service import (
OrganizationMemberService,
)

from adapter_processor_v2.constants import AdapterKeys, AllowedDomains
from adapter_processor_v2.exceptions import (
Expand All @@ -27,7 +32,9 @@
logger = logging.getLogger(__name__)

try:
from plugins.subscription.time_trials.subscription_adapter import add_unstract_key
from plugins.subscription.time_trials.subscription_adapter import (
add_unstract_key,
)
except ImportError:
add_unstract_key = None

Expand Down Expand Up @@ -92,8 +99,6 @@ def get_adapter_data_with_key(adapter_id: str, key_value: str) -> Any:
def test_adapter(adapter_id: str, adapter_metadata: dict[str, Any]) -> bool:
logger.info(f"Testing adapter: {adapter_id}")
try:
adapter_class = Adapterkit().get_adapter_class_by_adapter_id(adapter_id)

if adapter_metadata.pop(AdapterKeys.ADAPTER_TYPE) == AdapterKeys.X2TEXT:
if (
adapter_metadata.get(AdapterKeys.PLATFORM_PROVIDED_UNSTRACT_KEY)
Expand All @@ -107,7 +112,16 @@ def test_adapter(adapter_id: str, adapter_metadata: dict[str, Any]) -> bool:
platform_key.key
)

adapter_instance = adapter_class(adapter_metadata)
# Validate URLs for this adapter configuration
try:
adapter_instance = AdapterProcessor.validate_adapter_urls(
adapter_id, adapter_metadata
)
except Exception as e:
# Format error message similar to test adapter API
adapter_name = adapter_metadata.get(AdapterKeys.ADAPTER_NAME, "adapter")
error_detail = f"Error testing '{adapter_name}'. {e!s}"
raise ValidationError(error_detail) from e
test_result: bool = adapter_instance.test_connection()
return test_result
except SdkError as e:
Expand All @@ -130,6 +144,36 @@ def update_adapter_metadata(adapter_metadata_b: Any, **kwargs) -> Any:
return adapter_metadata_b
return adapter_metadata_b

@staticmethod
def validate_adapter_urls(adapter_id: str, adapter_metadata: dict) -> Adapter:
"""Validate URLs for an adapter configuration without full connection test.

This method only validates URLs for security (SSRF protection) without
attempting actual network connections.

Args:
adapter_id: The adapter ID (e.g., "postgres|70ab6cc2...")
adapter_metadata: The adapter configuration metadata

Returns:
Adapter: The adapter instance if validation passes

Raises:
AdapterError: If URL validation fails due to security violations
"""
try:
# Get the adapter class
adapterkit = Adapterkit()
adapter_class = adapterkit.get_adapter_class_by_adapter_id(adapter_id)

# Create a temporary instance just to get configured URLs
# This will trigger URL validation in __init__ but not full connection test
return adapter_class(adapter_metadata)

except Exception as e:
logger.error(f"URL validation failed for adapter {adapter_id}: {str(e)}")
raise

@staticmethod
def __fetch_adapters_by_key_value(key: str, value: Any) -> Adapter:
"""Fetches a list of adapters that have an attribute matching key and
Expand Down
223 changes: 142 additions & 81 deletions backend/adapter_processor_v2/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import json
import logging
import uuid
from typing import Any

from cryptography.fernet import Fernet
from django.conf import settings
from django.db import IntegrityError
from django.db.models import ProtectedError, QuerySet
from django.http import HttpRequest
Expand All @@ -14,12 +17,15 @@
)
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.versioning import URLPathVersioning
from rest_framework.viewsets import GenericViewSet, ModelViewSet
from tenant_account_v2.organization_member_service import OrganizationMemberService
from tenant_account_v2.organization_member_service import (
OrganizationMemberService,
)
from utils.filtering import FilterHelper

from adapter_processor_v2.adapter_processor import AdapterProcessor
Expand Down Expand Up @@ -166,75 +172,140 @@ def get_serializer_class(
return AdapterListSerializer
return AdapterInstanceSerializer

def create(self, request: Any) -> Response:
serializer = self.get_serializer(data=request.data)
def _decrypt_and_validate_metadata(self, adapter_metadata_b: bytes) -> dict[str, Any]:
"""Decrypt adapter metadata and validate its format."""
if not adapter_metadata_b:
raise ValidationError("Missing adapter metadata for validation.")

use_platform_unstract_key = False
adapter_metadata = request.data.get(AdapterKeys.ADAPTER_METADATA)
if adapter_metadata and adapter_metadata.get(
AdapterKeys.PLATFORM_PROVIDED_UNSTRACT_KEY, False
):
use_platform_unstract_key = True

serializer.is_valid(raise_exception=True)
try:
adapter_type = serializer.validated_data.get(AdapterKeys.ADAPTER_TYPE)
fernet = Fernet(settings.ENCRYPTION_KEY.encode("utf-8"))
decrypted_json = fernet.decrypt(adapter_metadata_b)
decrypted_metadata = json.loads(decrypted_json.decode("utf-8"))

if adapter_type == AdapterKeys.X2TEXT and use_platform_unstract_key:
adapter_metadata_b = serializer.validated_data.get(
AdapterKeys.ADAPTER_METADATA_B
)
adapter_metadata_b = AdapterProcessor.update_adapter_metadata(
adapter_metadata_b
)
# Update the validated data with the new adapter_metadata
serializer.validated_data[AdapterKeys.ADAPTER_METADATA_B] = (
adapter_metadata_b
if not isinstance(decrypted_metadata, dict):
raise ValidationError(
"Invalid adapter metadata format: expected JSON object."
)
return decrypted_metadata
except Exception as e:
raise ValidationError("Invalid adapter metadata.") from e

def _validate_adapter_urls(
self, adapter_id: str, decrypted_metadata: dict[str, Any]
) -> None:
"""Validate URLs for adapter configuration."""
try:
AdapterProcessor.validate_adapter_urls(adapter_id, decrypted_metadata)
except Exception as e:
adapter_name = decrypted_metadata.get(AdapterKeys.ADAPTER_NAME, "adapter")
error_detail = f"Error testing '{adapter_name}'. {e!s}"
raise ValidationError(error_detail) from e

def _check_platform_key_usage(self, request_data: dict[str, Any]) -> bool:
"""Check if platform unstract key should be used."""
adapter_metadata = request_data.get(AdapterKeys.ADAPTER_METADATA)
return bool(
adapter_metadata
and adapter_metadata.get(AdapterKeys.PLATFORM_PROVIDED_UNSTRACT_KEY, False)
)

instance = serializer.save()
organization_member = OrganizationMemberService.get_user_by_id(
request.user.id
def _update_metadata_for_platform_key(
self,
serializer_validated_data: dict[str, Any],
adapter_type: str,
is_paid_subscription: bool = False,
) -> None:
"""Update adapter metadata when using platform key."""
if adapter_type == AdapterKeys.X2TEXT:
adapter_metadata_b = serializer_validated_data.get(
AdapterKeys.ADAPTER_METADATA_B
)

# Check to see if there is a default configured
# for this adapter_type and for the current user
(
user_default_adapter,
created,
) = UserDefaultAdapter.objects.get_or_create(
organization_member=organization_member
updated_metadata_b = AdapterProcessor.update_adapter_metadata(
adapter_metadata_b, is_paid_subscription=is_paid_subscription
)
serializer_validated_data[AdapterKeys.ADAPTER_METADATA_B] = updated_metadata_b

def _set_default_adapter_if_needed(
self, adapter_instance: AdapterInstance, adapter_type: str, user_id: int
) -> None:
"""Set adapter as default if no default exists for this type."""
organization_member = OrganizationMemberService.get_user_by_id(user_id)
user_default_adapter, created = UserDefaultAdapter.objects.get_or_create(
organization_member=organization_member
)

if (adapter_type == AdapterKeys.LLM) and (
not user_default_adapter.default_llm_adapter
):
user_default_adapter.default_llm_adapter = instance
# Map adapter types to their default fields
adapter_type_mapping = {
AdapterKeys.LLM: "default_llm_adapter",
AdapterKeys.EMBEDDING: "default_embedding_adapter",
AdapterKeys.VECTOR_DB: "default_vector_db_adapter",
AdapterKeys.X2TEXT: "default_x2text_adapter",
}

if adapter_type in adapter_type_mapping:
field_name = adapter_type_mapping[adapter_type]
if not getattr(user_default_adapter, field_name):
setattr(user_default_adapter, field_name, adapter_instance)
user_default_adapter.organization_member = organization_member
user_default_adapter.save()

def _validate_update_metadata(
self,
serializer_validated_data: dict[str, Any],
current_adapter: AdapterInstance,
) -> tuple[str | None, dict[str, Any] | None]:
"""Validate metadata for update operations."""
if AdapterKeys.ADAPTER_METADATA_B not in serializer_validated_data:
return None, None

adapter_id = (
serializer_validated_data.get(AdapterKeys.ADAPTER_ID)
or current_adapter.adapter_id
)
adapter_metadata_b = serializer_validated_data.get(AdapterKeys.ADAPTER_METADATA_B)

elif (adapter_type == AdapterKeys.EMBEDDING) and (
not user_default_adapter.default_embedding_adapter
):
user_default_adapter.default_embedding_adapter = instance
elif (adapter_type == AdapterKeys.VECTOR_DB) and (
not user_default_adapter.default_vector_db_adapter
):
user_default_adapter.default_vector_db_adapter = instance
elif (adapter_type == AdapterKeys.X2TEXT) and (
not user_default_adapter.default_x2text_adapter
):
user_default_adapter.default_x2text_adapter = instance
if not adapter_id or not adapter_metadata_b:
raise ValidationError("Missing adapter metadata for validation.")

organization_member = OrganizationMemberService.get_user_by_id(
request.user.id
)
user_default_adapter.organization_member = organization_member
decrypted_metadata = self._decrypt_and_validate_metadata(adapter_metadata_b)
self._validate_adapter_urls(adapter_id, decrypted_metadata)

user_default_adapter.save()
return adapter_id, decrypted_metadata

def create(self, request: Any) -> Response:
serializer = self.get_serializer(data=request.data)
use_platform_unstract_key = self._check_platform_key_usage(request.data)

serializer.is_valid(raise_exception=True)

# Extract and validate metadata
adapter_id = serializer.validated_data.get(AdapterKeys.ADAPTER_ID)
adapter_metadata_b = serializer.validated_data.get(AdapterKeys.ADAPTER_METADATA_B)
decrypted_metadata = self._decrypt_and_validate_metadata(adapter_metadata_b)

# Validate URLs for security
self._validate_adapter_urls(adapter_id, decrypted_metadata)

try:
adapter_type = serializer.validated_data.get(AdapterKeys.ADAPTER_TYPE)

# Update metadata if using platform key
if use_platform_unstract_key:
self._update_metadata_for_platform_key(
serializer.validated_data, adapter_type
)

# Save the adapter instance
instance = serializer.save()

# Set as default adapter if needed
self._set_default_adapter_if_needed(instance, adapter_type, request.user.id)

except IntegrityError:
raise DuplicateAdapterNameError(
name=serializer.validated_data.get(AdapterKeys.ADAPTER_NAME)
)

headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

Expand Down Expand Up @@ -346,42 +417,32 @@ def list_of_shared_users(self, request: HttpRequest, pk: Any = None) -> Response
def update(
self, request: Request, *args: tuple[Any], **kwargs: dict[str, Any]
) -> Response:
# Check if adapter metadata is being updated and contains the platform key flag
use_platform_unstract_key = False
adapter_metadata = request.data.get(AdapterKeys.ADAPTER_METADATA)
use_platform_unstract_key = self._check_platform_key_usage(request.data)
adapter = self.get_object()

if adapter_metadata and adapter_metadata.get(
AdapterKeys.PLATFORM_PROVIDED_UNSTRACT_KEY, False
):
use_platform_unstract_key = True
logger.error(f"Platform key flag detected: {use_platform_unstract_key}")
# Get serializer and validate data
serializer = self.get_serializer(adapter, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)

# Get the adapter instance for update
adapter = self.get_object()
# Validate metadata if being updated
adapter_id, decrypted_metadata = self._validate_update_metadata(
serializer.validated_data, adapter
)

# Handle platform key updates
if use_platform_unstract_key:
logger.error("Processing adapter with platform key")
serializer = self.get_serializer(adapter, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)

# Get adapter_type from validated data (consistent with create method)
adapter_type = serializer.validated_data.get(AdapterKeys.ADAPTER_TYPE)
logger.error(f"Adapter type from validated data: {adapter_type}")

if adapter_type == AdapterKeys.X2TEXT:
logger.error("Processing X2TEXT adapter with platform key")
adapter_metadata_b = serializer.validated_data.get(
AdapterKeys.ADAPTER_METADATA_B
)
adapter_metadata_b = AdapterProcessor.update_adapter_metadata(
adapter_metadata_b, is_paid_subscription=True
)
# Update the validated data with the new adapter_metadata
serializer.validated_data[AdapterKeys.ADAPTER_METADATA_B] = (
adapter_metadata_b
)
# Update metadata for platform key usage
self._update_metadata_for_platform_key(
serializer.validated_data,
adapter_type,
is_paid_subscription=True,
)

# Save the instance with updated metadata
# Save and return updated instance
serializer.save()
return Response(serializer.data)

Expand Down
5 changes: 5 additions & 0 deletions backend/sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,8 @@ RUNNER_POLLING_INTERVAL_SECONDS=2
# Default: 1800 seconds (30 minutes)
# Examples: 900 (15 min), 1800 (30 min), 3600 (60 min)
MIN_SCHEDULE_INTERVAL_SECONDS=1800

# WHitelisted adapter URLs to allow user to connect to locally hosted adapters.
# Whitelisting 10.68.0.10 to allow frictionless adapter connection to
# managed Postgres for VectorDB
ALLOWED_ADAPTER_PRIVATE_ENDPOINTS="127.0.0.1, 10.68.0.10"