diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index cdb3fbeeab..70013fe395 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -90,12 +90,16 @@ jobs: RELATIVE_FILES=$(echo "$CHANGED_FILES" | sed 's|^src/frontend/||') cd src/frontend + # NUL-delimit so paths containing spaces (e.g. starter-project + # specs like "Basic Prompting.spec.ts") aren't split by xargs. if ${{ inputs.allow-failure || 'false' }}; then - echo "$RELATIVE_FILES" | xargs npx @biomejs/biome check \ - --files-ignore-unknown=true \ - --diagnostic-level=error || echo "Biome found errors (non-blocking for this run)." + printf '%s\n' "$RELATIVE_FILES" | tr '\n' '\0' \ + | xargs -0 npx @biomejs/biome check \ + --files-ignore-unknown=true \ + --diagnostic-level=error || echo "Biome found errors (non-blocking for this run)." else - echo "$RELATIVE_FILES" | xargs npx @biomejs/biome check \ - --files-ignore-unknown=true \ - --diagnostic-level=error + printf '%s\n' "$RELATIVE_FILES" | tr '\n' '\0' \ + | xargs -0 npx @biomejs/biome check \ + --files-ignore-unknown=true \ + --diagnostic-level=error fi diff --git a/.secrets.baseline b/.secrets.baseline index c65c0b72a0..1a0a677b07 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -2817,7 +2817,7 @@ "filename": "src/backend/base/langflow/services/auth/utils.py", "hashed_secret": "b894b81be94cf8fa8d7536475aaec876addf05c8", "is_verified": false, - "line_number": 59, + "line_number": 75, "is_secret": false } ], @@ -2827,7 +2827,7 @@ "filename": "src/backend/base/langflow/services/database/models/api_key/crud.py", "hashed_secret": "920f8f5815b381ea692e9e7c2f7119f2b1aa620a", "is_verified": false, - "line_number": 112, + "line_number": 134, "is_secret": false } ], @@ -4007,7 +4007,7 @@ "filename": "src/backend/tests/unit/test_login.py", "hashed_secret": "8bb6118f8fd6935ad0876a3be34a717d32708ffd", "is_verified": false, - "line_number": 26, + "line_number": 97, "is_secret": false }, { @@ -4015,7 +4015,7 @@ "filename": "src/backend/tests/unit/test_login.py", "hashed_secret": "d8ecf7db8fc9ec9c31bc5c9ae2929cc599c75f8d", "is_verified": false, - "line_number": 42, + "line_number": 113, "is_secret": false } ], @@ -9287,5 +9287,5 @@ } ] }, - "generated_at": "2026-06-10T18:13:35Z" + "generated_at": "2026-06-17T19:13:55Z" } diff --git a/src/backend/base/langflow/api/v1/api_key.py b/src/backend/base/langflow/api/v1/api_key.py index 83570c6f87..8f65cf3f63 100644 --- a/src/backend/base/langflow/api/v1/api_key.py +++ b/src/backend/base/langflow/api/v1/api_key.py @@ -36,6 +36,8 @@ async def create_api_key_route( try: user_id = current_user.id return await create_api_key(db, req, user_id=user_id) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e except Exception as e: raise HTTPException(status_code=400, detail=str(e)) from e diff --git a/src/backend/base/langflow/api/v1/authz_me.py b/src/backend/base/langflow/api/v1/authz_me.py index cb0ff032f5..5dd0874842 100644 --- a/src/backend/base/langflow/api/v1/authz_me.py +++ b/src/backend/base/langflow/api/v1/authz_me.py @@ -17,6 +17,8 @@ from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel, Field, field_validator from langflow.api.utils import CurrentActiveUser +from langflow.services.auth.context import current_auth_context_for_authz +from langflow.services.authorization.access_ceiling import filter_actions_by_external_access_ceiling from langflow.services.deps import get_authorization_service router = APIRouter(prefix="/authz/me", tags=["Authorization"]) @@ -127,8 +129,15 @@ async def get_effective_permissions( resource_ids=body.resource_ids, actions=actions, domain=body.domain, - context={"is_superuser": getattr(current_user, "is_superuser", False)}, + context={ + **current_auth_context_for_authz(), + "is_superuser": current_user.is_superuser, + }, ) + permissions = { + resource_id: filter_actions_by_external_access_ceiling(allowed_actions) + for resource_id, allowed_actions in permissions.items() + } return EffectivePermissionsResponse( resource_type=body.resource_type, permissions=permissions, diff --git a/src/backend/base/langflow/api/v1/chat.py b/src/backend/base/langflow/api/v1/chat.py index bdcfcd847b..773eab975b 100644 --- a/src/backend/base/langflow/api/v1/chat.py +++ b/src/backend/base/langflow/api/v1/chat.py @@ -887,9 +887,26 @@ async def build_public_tmp( queue_service=queue_service, flow_name=flow_name or f"{authenticated_user_id or client_id}_{flow_id}", ) + # Gate the public events/cancel endpoints to jobs that were actually + # started through this public build path, preventing unauthenticated + # callers from reading or cancelling private-flow builds by job_id. + await queue_service.register_public_job(job_id) except CustomComponentValidationError as exc: await logger.awarning(f"Public flow validation failed: {exc}") raise HTTPException(status_code=400, detail="This flow cannot be executed.") from exc + except JobQueueBackendUnavailableError as exc: + # The public marker could not be persisted to the shared (Redis) backend. + # Returning the job_id anyway would hand back an un-shareable id: on a + # multi-worker deployment every other worker's public events/cancel + # endpoints would 404 it. Cancel the just-started build (best-effort) and + # surface a clean 503 instead of a 500 / an unusable job_id. + try: + await queue_service.cancel_job(job_id) + except Exception as cancel_exc: # noqa: BLE001 + await logger.awarning( + f"Failed to cancel public job {job_id} after marker persistence failed: {cancel_exc!r}" + ) + raise HTTPException(status_code=503, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: @@ -906,6 +923,20 @@ async def build_public_tmp( ) +async def _assert_public_job(job_id: str, queue_service: JobQueueService) -> None: + """Raise HTTP 404 if job_id was not registered through the public build endpoint. + + Prevents unauthenticated callers from reading or cancelling private-flow + builds by guessing or leaking a job_id. + + Why 404 not 403: returning 403 would confirm the job exists under a different + access tier, leaking information about private builds. 404 is neutral. + """ + if not await queue_service.is_public_job_async(job_id): + # Static detail — do not reflect job_id back; avoid confirming which IDs exist. + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") + + @router.get("/build_public_tmp/{job_id}/events") async def get_build_events_public( job_id: str, @@ -918,6 +949,7 @@ async def get_build_events_public( This endpoint does not require authentication, matching the public build endpoint. It is used by the shareable playground to consume build events. """ + await _assert_public_job(job_id, queue_service) return await get_flow_events_response( job_id=job_id, queue_service=queue_service, @@ -938,6 +970,7 @@ async def cancel_build_public( This endpoint does not require authentication, matching the public build endpoint. It is used by the shareable playground to cancel builds. """ + await _assert_public_job(job_id, queue_service) try: cancellation_success = await cancel_flow_build(job_id=job_id, queue_service=queue_service) diff --git a/src/backend/base/langflow/api/v1/endpoints.py b/src/backend/base/langflow/api/v1/endpoints.py index 054cc0d3e9..7d0fe4082c 100644 --- a/src/backend/base/langflow/api/v1/endpoints.py +++ b/src/backend/base/langflow/api/v1/endpoints.py @@ -61,6 +61,10 @@ from langflow.services.auth.utils import ( get_optional_user, ) from langflow.services.authorization import FlowAction, ensure_flow_permission +from langflow.services.authorization.access_ceiling import ( + external_access_allows, + get_current_external_access_context, +) from langflow.services.cache.utils import save_uploaded_file from langflow.services.database.models.flow.model import Flow, FlowRead from langflow.services.database.models.flow.utils import get_all_webhook_components_in_flow @@ -1226,6 +1230,7 @@ async def get_task_status(_task_id: str) -> TaskStatusResponse: async def create_upload_file( file: UploadFile, flow: Annotated[Flow, Depends(get_flow)], + current_user: CurrentActiveUser, settings_service: Annotated[SettingsService, Depends(get_settings_service)], ) -> UploadFileResponse: """Upload a file for a specific flow (Deprecated). @@ -1237,6 +1242,17 @@ async def create_upload_file( ``/api/v1/files/upload/{flow_id}`` so authenticated callers can't fill disk through this route either. """ + # Writing a file to a flow's storage is a flow mutation: enforce WRITE so + # the external access ceiling (e.g. a "viewer") cannot upload via this + # deprecated route. Mirrors the non-deprecated twin in files.py. + await ensure_flow_permission( + current_user, + FlowAction.WRITE, + flow_id=flow.id, + flow_user_id=flow.user_id, + workspace_id=flow.workspace_id, + folder_id=flow.folder_id, + ) try: max_file_size_upload = settings_service.settings.max_file_size_upload except Exception as exc: @@ -1273,6 +1289,19 @@ async def custom_component( user: CurrentActiveUser, request: Request, ) -> CustomComponentResponse: + # Building a custom component instantiates (and partially executes) posted + # code. That is a create/write-class action, so enforce the external access + # ceiling directly: a "viewer" external identity is denied while + # editor/admin (and all non-external users) pass unchanged. This route is + # not tied to a single owned resource, so the deny-only primitive is used + # instead of an ``ensure_*_permission`` guard. + external_context = get_current_external_access_context() + if external_context is not None and not external_access_allows("create", external_context): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="External credentials do not allow this action", + ) + settings_service = get_settings_service() settings = settings_service.settings all_known = None @@ -1348,6 +1377,17 @@ async def custom_component_update( HTTPException: If an error occurs during component building or updating. SerializationError: If serialization of the updated component node fails. """ + # Updating a custom component instantiates (and partially executes) posted + # code, a create/write-class action. Enforce the external access ceiling + # directly so a "viewer" external identity is denied; editor/admin (and all + # non-external users) pass unchanged. Same action string as ``custom_component``. + external_context = get_current_external_access_context() + if external_context is not None and not external_access_allows("create", external_context): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="External credentials do not allow this action", + ) + settings_service = get_settings_service() all_known = None if _requires_component_hash_lookups(settings_service.settings, user): diff --git a/src/backend/base/langflow/api/v1/files.py b/src/backend/base/langflow/api/v1/files.py index a2d1cdb98f..5e2b521f21 100644 --- a/src/backend/base/langflow/api/v1/files.py +++ b/src/backend/base/langflow/api/v1/files.py @@ -21,6 +21,7 @@ from langflow.api.utils import ( build_content_disposition, ) from langflow.api.v1.schemas import UploadFileResponse +from langflow.services.authorization import FlowAction, ensure_flow_permission from langflow.services.database.models.flow.model import Flow from langflow.services.deps import get_settings_service, get_storage_service from langflow.services.storage.service import StorageService @@ -84,9 +85,20 @@ async def upload_file( *, file: UploadFile, flow: Annotated[Flow, Depends(get_flow)], + current_user: CurrentActiveUser, storage_service: Annotated[StorageService, Depends(get_storage_service)], settings_service: Annotated[SettingsService, Depends(get_settings_service)], ) -> UploadFileResponse: + # Writing a file to a flow's storage is a flow mutation: enforce WRITE so + # the external access ceiling (e.g. a "viewer") cannot upload via this route. + await ensure_flow_permission( + current_user, + FlowAction.WRITE, + flow_id=flow.id, + flow_user_id=flow.user_id, + workspace_id=flow.workspace_id, + folder_id=flow.folder_id, + ) try: max_file_size_upload = settings_service.settings.max_file_size_upload except Exception as e: @@ -290,8 +302,19 @@ async def list_files( async def delete_file( file_name: ValidatedFileName, flow: Annotated[Flow, Depends(get_flow)], + current_user: CurrentActiveUser, storage_service: Annotated[StorageService, Depends(get_storage_service)], ): + # Deleting a file from a flow's storage mutates the flow's attachments; + # enforce WRITE so the external access ceiling (e.g. a "viewer") is honored. + await ensure_flow_permission( + current_user, + FlowAction.WRITE, + flow_id=flow.id, + flow_user_id=flow.user_id, + workspace_id=flow.workspace_id, + folder_id=flow.folder_id, + ) try: await storage_service.delete_file(flow_id=str(flow.id), file_name=file_name) except Exception as e: diff --git a/src/backend/base/langflow/api/v1/flow_version.py b/src/backend/base/langflow/api/v1/flow_version.py index 218789675b..c0d249761e 100644 --- a/src/backend/base/langflow/api/v1/flow_version.py +++ b/src/backend/base/langflow/api/v1/flow_version.py @@ -14,6 +14,7 @@ from langflow.api.utils import CurrentActiveUser, DbSession from langflow.api.utils.core import remove_api_keys from langflow.api.v1.mappers.deployments.helpers import get_owned_provider_account_or_404 from langflow.api.v1.mappers.deployments.sync import sync_flow_version_attachments +from langflow.services.authorization import FlowAction, ensure_flow_permission from langflow.services.database.models.flow.model import Flow, FlowRead from langflow.services.database.models.flow_version.crud import ( create_flow_version_entry, @@ -201,6 +202,14 @@ async def create_snapshot( body: FlowVersionCreate | None = None, ) -> FlowVersionRead: flow = await _get_user_flow(session, flow_id, current_user.id) + await ensure_flow_permission( + current_user, + FlowAction.WRITE, + flow_id=flow.id, + flow_user_id=flow.user_id, + workspace_id=flow.workspace_id, + folder_id=flow.folder_id, + ) description = body.description if body else None try: @@ -234,6 +243,14 @@ async def activate_version( save_draft: Annotated[bool, Query()] = True, ) -> FlowRead: flow = await _get_user_flow(session, flow_id, current_user.id) + await ensure_flow_permission( + current_user, + FlowAction.WRITE, + flow_id=flow.id, + flow_user_id=flow.user_id, + workspace_id=flow.workspace_id, + folder_id=flow.folder_id, + ) # Verify version entry belongs to this flow try: @@ -299,7 +316,15 @@ async def delete_version_entry( current_user: CurrentActiveUser, session: DbSession, ) -> None: - await _get_user_flow(session, flow_id, current_user.id) + flow = await _get_user_flow(session, flow_id, current_user.id) + await ensure_flow_permission( + current_user, + FlowAction.DELETE, + flow_id=flow.id, + flow_user_id=flow.user_id, + workspace_id=flow.workspace_id, + folder_id=flow.folder_id, + ) # Verify entry belongs to this flow, then delete try: diff --git a/src/backend/base/langflow/api/v1/login.py b/src/backend/base/langflow/api/v1/login.py index d74ec9c2e8..adafcf38ef 100644 --- a/src/backend/base/langflow/api/v1/login.py +++ b/src/backend/base/langflow/api/v1/login.py @@ -12,6 +12,7 @@ from slowapi.wrappers import Limit from langflow.api.utils import DbSession from langflow.api.v1.schemas import Token from langflow.initial_setup.setup import get_or_create_default_folder +from langflow.services.auth.exceptions import AuthenticationError from langflow.services.database.models.user.crud import get_user_by_id from langflow.services.database.models.user.model import UserRead from langflow.services.deps import get_auth_service, get_settings_service, get_variable_service @@ -235,16 +236,22 @@ async def get_session( It does not raise an error if unauthenticated, allowing the frontend to gracefully handle the session state. """ - from langflow.services.auth.utils import oauth2_login + from langflow.services.auth.utils import _get_external_token, oauth2_login # Try to get the token from the request (cookie or Authorization header) try: token = await oauth2_login(request) - if not token: + # Extract the external credential separately so a present-but-invalid + # native cookie can't shadow a valid external one (mirrors get_current_user + # and the WS/SSE paths). oauth2_login may already have collapsed to the + # external credential, in which case the service's dedup guard makes the + # fallback a no-op. + external_token = _get_external_token(request.headers, request.cookies) + if not token and not external_token: return SessionResponse(authenticated=False) # Validate the token and get user - user = await get_auth_service().get_current_user_from_access_token(token, db) + user = await get_auth_service().get_current_user_from_access_token(token, db, external_token=external_token) if not user or not user.is_active: return SessionResponse(authenticated=False) @@ -252,7 +259,7 @@ async def get_session( authenticated=True, user=UserRead.model_validate(user, from_attributes=True), ) - except (HTTPException, ValueError) as _: + except (AuthenticationError, HTTPException, ValueError) as _: # Any authentication error means not authenticated return SessionResponse(authenticated=False) diff --git a/src/backend/base/langflow/api/v1/mcp_projects.py b/src/backend/base/langflow/api/v1/mcp_projects.py index 3d1b44049f..527f98e74d 100644 --- a/src/backend/base/langflow/api/v1/mcp_projects.py +++ b/src/backend/base/langflow/api/v1/mcp_projects.py @@ -67,9 +67,17 @@ from langflow.api.v1.schemas import ( MCPSettings, ) from langflow.services.auth.constants import AUTO_LOGIN_WARNING +from langflow.services.auth.context import ( + AUTH_METHOD_AUTO_LOGIN, + AuthCredentialContext, + clear_current_auth_context, + set_current_auth_context, +) from langflow.services.auth.mcp_encryption import decrypt_auth_settings, encrypt_auth_settings +from langflow.services.authorization import ProjectAction, ensure_project_permission +from langflow.services.authorization.access_ceiling import clear_current_external_access_context from langflow.services.database.models import Flow, Folder -from langflow.services.database.models.api_key.crud import check_key, create_api_key +from langflow.services.database.models.api_key.crud import authenticate_api_key, create_api_key from langflow.services.database.models.api_key.model import ApiKeyCreate from langflow.services.database.models.user.crud import get_user_by_username from langflow.services.database.models.user.model import User @@ -93,6 +101,12 @@ async def verify_project_auth( This function provides authentication for MCP endpoints when using MCP Composer and no API key is provided, or checks if the API key is valid. """ + # Mirror the service.py auth entrypoints: reset request-local credential metadata at entry so a + # later branch (e.g. the composer-token fast path) never inherits stale context from a prior call. + clear_current_auth_context() + # Defensive invariant: drop any stale external access ceiling so it can't carry into MCP project auth. + clear_current_external_access_context() + settings_service = get_settings_service() project = (await db.exec(select(Folder).where(Folder.id == project_id))).first() @@ -144,9 +158,11 @@ async def verify_project_auth( ) # Validate the API key - user = await check_key(db, api_key) - if not user: + api_key_result = await authenticate_api_key(db, api_key) + if not api_key_result: raise HTTPException(status_code=401, detail="Invalid API key") + set_current_auth_context(AuthCredentialContext.from_api_key_result(api_key_result)) + user = api_key_result.user # Verify user has access to the project project_access = ( @@ -171,6 +187,7 @@ async def _superuser_fallback(db: AsyncSession, settings_service) -> User: result = await get_user_by_username(db, settings_service.auth_settings.SUPERUSER) if result: logger.warning(AUTO_LOGIN_WARNING) + set_current_auth_context(AuthCredentialContext(method=AUTH_METHOD_AUTO_LOGIN)) return result raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -546,6 +563,18 @@ async def update_project_mcp_settings( if not project: raise HTTPException(status_code=404, detail="Project not found") + # Mutating flow MCP exposure + project MCP auth settings is a project + # WRITE: enforce so the external access ceiling (e.g. a "viewer") + # cannot change MCP settings. The owner with no ceiling fast-paths via + # owner-override; behavior is unchanged when the feature is off. + await ensure_project_permission( + current_user, + ProjectAction.WRITE, + project_id=project_id, + project_user_id=project.user_id, + workspace_id=project.workspace_id, + ) + # Track if MCP Composer needs to be started or stopped should_handle_mcp_composer = False should_start_composer = False diff --git a/src/backend/base/langflow/api/v1/mcp_utils.py b/src/backend/base/langflow/api/v1/mcp_utils.py index cdbb8a6255..9c444b89fd 100644 --- a/src/backend/base/langflow/api/v1/mcp_utils.py +++ b/src/backend/base/langflow/api/v1/mcp_utils.py @@ -25,6 +25,7 @@ from langflow.api.v1.endpoints import simple_run_flow from langflow.api.v1.schemas import SimplifiedAPIRequest from langflow.helpers.flow import json_schema_from_flow from langflow.schema.message import Message +from langflow.services.authorization import FlowAction, ensure_flow_permission from langflow.services.database.models import Flow from langflow.services.database.models.file.model import File as UserFile from langflow.services.database.models.user.model import User @@ -287,6 +288,18 @@ async def handle_call_tool( msg = f"Flow '{name}' not found in project {project_id}" raise ValueError(msg) + # Enforce execute permission (owner override + external access ceiling) + # before running the flow. Without this an external "viewer" could run a + # flow as a tool, escaping the deny-only access ceiling. + await ensure_flow_permission( + current_user, + FlowAction.EXECUTE, + flow_id=flow.id, + flow_user_id=flow.user_id, + workspace_id=flow.workspace_id, + folder_id=flow.folder_id, + ) + # Process inputs processed_inputs = dict(arguments) diff --git a/src/backend/base/langflow/api/v1/memories.py b/src/backend/base/langflow/api/v1/memories.py index c4b020b12d..1498e566b6 100644 --- a/src/backend/base/langflow/api/v1/memories.py +++ b/src/backend/base/langflow/api/v1/memories.py @@ -32,6 +32,7 @@ from pydantic import BaseModel from sqlmodel import select from langflow.api.utils import CurrentActiveUser +from langflow.services.authorization import KnowledgeBaseAction, ensure_knowledge_base_permission from langflow.services.database.models.memory_base.model import ( MemoryBase, MemoryBaseCreate, @@ -101,6 +102,15 @@ async def create_memory_base( - Returns 409 if a Memory Base with the same name already exists for this user. - Returns 422 if preprocessing=true but preproc_model is missing. """ + # Memory bases are knowledge-base-backed resources; guard via the KB family. + # Passing the actor as owner makes the owner-override path return early when + # no access ceiling is set, preserving existing behavior, while a deny-only + # external ceiling (e.g. a "viewer") is still enforced. + await ensure_knowledge_base_permission( + current_user, + KnowledgeBaseAction.CREATE, + kb_user_id=current_user.id, + ) try: mb = await get_memory_base_service().create(payload, user_id=current_user.id) except PermissionError as exc: @@ -267,6 +277,19 @@ async def update_memory_base( Preprocessing fields (preprocessing, preproc_model, preproc_instructions, preproc_kill_phrase) are immutable after creation and cannot be patched. """ + # Resolve the memory base so the guard receives the REAL owner / kb identity: + # owner-override then fires only for genuine owners, a non-owner reaches the + # registered plugin's enforce path, and audit rows carry the kb id. + mb = await get_memory_base_service().get(memory_base_id, user_id=current_user.id) + if mb is None: + raise HTTPException(status_code=404, detail="Memory base not found") + await ensure_knowledge_base_permission( + current_user, + KnowledgeBaseAction.WRITE, + kb_id=memory_base_id, + kb_user_id=mb.user_id, + kb_name=mb.kb_name, + ) try: mb = await get_memory_base_service().update(memory_base_id, user_id=current_user.id, patch=patch) except PreprocessingValidationError as exc: @@ -287,6 +310,18 @@ async def delete_memory_base( removed. The associated KB directory is deleted from disk afterwards (best-effort — a disk failure will not affect the 204 response). """ + # Resolve the memory base so the guard receives the REAL owner / kb identity + # (owner-override only for genuine owners; non-owners hit plugin enforce). + mb = await get_memory_base_service().get(memory_base_id, user_id=current_user.id) + if mb is None: + raise HTTPException(status_code=404, detail="Memory base not found") + await ensure_knowledge_base_permission( + current_user, + KnowledgeBaseAction.DELETE, + kb_id=memory_base_id, + kb_user_id=mb.user_id, + kb_name=mb.kb_name, + ) deleted = await get_memory_base_service().delete(memory_base_id, user_id=current_user.id) if not deleted: raise HTTPException(status_code=404, detail="Memory base not found") @@ -308,6 +343,18 @@ async def flush_memory_base( Returns 409 Conflict if an ingestion task is already in progress for the given (memory_base_id, session_id) pair to prevent concurrent indexing. """ + # Resolve the memory base so the guard receives the REAL owner / kb identity + # (owner-override only for genuine owners; non-owners hit plugin enforce). + mb = await get_memory_base_service().get(memory_base_id, user_id=current_user.id) + if mb is None: + raise HTTPException(status_code=404, detail="Memory base not found") + await ensure_knowledge_base_permission( + current_user, + KnowledgeBaseAction.INGEST, + kb_id=memory_base_id, + kb_user_id=mb.user_id, + kb_name=mb.kb_name, + ) try: job_id = await get_memory_base_service().trigger_ingestion( memory_base_id=memory_base_id, @@ -355,6 +402,18 @@ async def regenerate_memory_base( Use this to recover from external Chroma directory deletions or vector DB corruption. All MemoryBaseSession.cursor_id values are set to None before re-running ingestion. """ + # Resolve the memory base so the guard receives the REAL owner / kb identity + # (owner-override only for genuine owners; non-owners hit plugin enforce). + mb = await get_memory_base_service().get(memory_base_id, user_id=current_user.id) + if mb is None: + raise HTTPException(status_code=404, detail="Memory base not found") + await ensure_knowledge_base_permission( + current_user, + KnowledgeBaseAction.INGEST, + kb_id=memory_base_id, + kb_user_id=mb.user_id, + kb_name=mb.kb_name, + ) try: job_ids = await get_memory_base_service().regenerate(memory_base_id, user_id=current_user.id) except ValueError as exc: diff --git a/src/backend/base/langflow/api/v1/models.py b/src/backend/base/langflow/api/v1/models.py index a704bc0ff1..968a4545bd 100644 --- a/src/backend/base/langflow/api/v1/models.py +++ b/src/backend/base/langflow/api/v1/models.py @@ -17,6 +17,7 @@ from pydantic import BaseModel, field_validator from langflow.api.utils import CurrentActiveUser, DbSession from langflow.services.auth.utils import get_current_active_user +from langflow.services.authorization import VariableAction, ensure_variable_permission from langflow.services.deps import get_variable_service from langflow.services.variable.constants import GENERIC_TYPE from langflow.services.variable.service import DatabaseVariableService @@ -614,6 +615,14 @@ async def update_enabled_models( Accepts a list of model IDs with their desired enabled status. This only affects model-level enablement - provider credentials must still be configured. """ + # Persists the enabled/disabled model lists as the user's own Variables: a + # variable WRITE. Enforce so the external access ceiling caps a "viewer"; + # the owner with no ceiling fast-paths via owner-override. + await ensure_variable_permission( + current_user, + VariableAction.WRITE, + variable_user_id=current_user.id, + ) variable_service = get_variable_service() if not isinstance(variable_service, DatabaseVariableService): raise HTTPException( @@ -755,6 +764,14 @@ async def set_default_model( request: DefaultModelRequest, ): """Set the default model for the current user.""" + # Creating/updating the default-model Variable is a variable WRITE. Enforce + # so the external access ceiling caps a "viewer"; the owner with no ceiling + # fast-paths via owner-override. + await ensure_variable_permission( + current_user, + VariableAction.WRITE, + variable_user_id=current_user.id, + ) variable_service = get_variable_service() if not isinstance(variable_service, DatabaseVariableService): raise HTTPException( @@ -830,6 +847,14 @@ async def clear_default_model( model_type: Annotated[str, Query(description="Type of model: 'language' or 'embedding'")] = "language", ): """Clear the default model for the current user.""" + # Deleting the default-model Variable is a variable DELETE. Enforce so the + # external access ceiling caps a "viewer"; the owner with no ceiling + # fast-paths via owner-override. + await ensure_variable_permission( + current_user, + VariableAction.DELETE, + variable_user_id=current_user.id, + ) variable_service = get_variable_service() if not isinstance(variable_service, DatabaseVariableService): raise HTTPException( diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Blog Writer.json b/src/backend/base/langflow/initial_setup/starter_projects/Blog Writer.json index f71c5bcbbd..8e4cceb967 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Blog Writer.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Blog Writer.json @@ -850,6 +850,7 @@ "max_depth", "prevent_outside", "use_async", + "follow_redirects", "format", "timeout", "headers", @@ -864,7 +865,7 @@ "legacy": false, "lf_version": "1.4.2", "metadata": { - "code_hash": "2e225a90043a", + "code_hash": "52b5bd602242", "dependencies": { "dependencies": [ { @@ -976,7 +977,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import importlib.util\nimport io\nimport re\nfrom urllib.parse import urljoin, urlparse\n\nimport httpx\nfrom bs4 import BeautifulSoup\nfrom markitdown import MarkItDown\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.data import safe_convert\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageTextInput, Output, SliderInput, TableInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.utils.request_utils import get_user_agent\nfrom lfx.utils.ssrf_protection import SSRFProtectionError, is_ssrf_protection_enabled, validate_and_resolve_url\nfrom lfx.utils.ssrf_transport import create_ssrf_protected_client\n\n# Constants\nDEFAULT_TIMEOUT = 30\nDEFAULT_MAX_DEPTH = 1\nDEFAULT_FORMAT = \"Text\"\n\n\nURL_REGEX = re.compile(\n r\"^(https?:\\/\\/)?\" r\"(www\\.)?\" r\"([a-zA-Z0-9.-]+)\" r\"(\\.[a-zA-Z]{2,})?\" r\"(:\\d+)?\" r\"(\\/[^\\s]*)?$\",\n re.IGNORECASE,\n)\n\nUSER_AGENT = None\n# Check if langflow is installed using importlib.util.find_spec(name))\nif importlib.util.find_spec(\"langflow\"):\n langflow_installed = True\n USER_AGENT = get_user_agent()\nelse:\n langflow_installed = False\n USER_AGENT = \"lfx\"\n\n\nclass URLComponent(Component):\n \"\"\"A component that loads and parses content from web pages recursively.\n\n This component allows fetching content from one or more URLs, with options to:\n - Control crawl depth\n - Prevent crawling outside the root domain\n - Use async loading for better performance\n - Extract either raw HTML or clean text\n - Configure request headers and timeouts\n \"\"\"\n\n display_name = \"URL\"\n description = \"Fetch content from one or more web pages, following links recursively.\"\n documentation: str = \"https://docs.langflow.org/url\"\n icon = \"layout-template\"\n name = \"URLComponent\"\n\n inputs = [\n MessageTextInput(\n name=\"urls\",\n display_name=\"URLs\",\n info=\"Enter one or more URLs to crawl recursively, by clicking the '+' button.\",\n is_list=True,\n tool_mode=True,\n placeholder=\"Enter a URL...\",\n list_add_label=\"Add URL\",\n input_types=[\"Message\"],\n ),\n SliderInput(\n name=\"max_depth\",\n display_name=\"Depth\",\n info=(\n \"Controls how many 'clicks' away from the initial page the crawler will go:\\n\"\n \"- depth 1: only the initial page\\n\"\n \"- depth 2: initial page + all pages linked directly from it\\n\"\n \"- depth 3: initial page + direct links + links found on those direct link pages\\n\"\n \"Note: This is about link traversal, not URL path depth.\"\n ),\n value=DEFAULT_MAX_DEPTH,\n range_spec=RangeSpec(min=1, max=5, step=1),\n required=False,\n min_label=\" \",\n max_label=\" \",\n min_label_icon=\"None\",\n max_label_icon=\"None\",\n # slider_input=True\n ),\n BoolInput(\n name=\"prevent_outside\",\n display_name=\"Prevent Outside\",\n info=(\n \"If enabled, only crawls URLs within the same domain as the root URL. \"\n \"This helps prevent the crawler from going to external websites.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"use_async\",\n display_name=\"Use Async\",\n info=(\n \"If enabled, uses asynchronous loading which can be significantly faster \"\n \"but might use more system resources.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=(\n \"Output Format. Use 'Text' to extract the text from the HTML, \"\n \"'Markdown' to parse the HTML into Markdown format, or 'HTML' \"\n \"for the raw HTML content.\"\n ),\n options=[\"Text\", \"HTML\", \"Markdown\"],\n value=DEFAULT_FORMAT,\n advanced=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"Timeout for the request in seconds.\",\n value=DEFAULT_TIMEOUT,\n required=False,\n advanced=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": USER_AGENT}],\n advanced=True,\n input_types=[\"DataFrame\", \"Table\"],\n ),\n BoolInput(\n name=\"filter_text_html\",\n display_name=\"Filter Text/HTML\",\n info=\"If enabled, filters out text/css content type from the results.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"continue_on_failure\",\n display_name=\"Continue on Failure\",\n info=\"If enabled, continues crawling even if some requests fail.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"check_response_status\",\n display_name=\"Check Response Status\",\n info=\"If enabled, checks the response status of the request.\",\n value=False,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"autoset_encoding\",\n display_name=\"Autoset Encoding\",\n info=\"If enabled, automatically sets the encoding of the request.\",\n value=True,\n required=False,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Extracted Pages\", name=\"page_results\", method=\"fetch_content\"),\n Output(display_name=\"Raw Content\", name=\"raw_results\", method=\"fetch_content_as_message\", tool_mode=False),\n ]\n\n @staticmethod\n def _html_extractor(x: str) -> str:\n \"\"\"Extract raw HTML content.\"\"\"\n return x\n\n @staticmethod\n def _text_extractor(x: str) -> str:\n \"\"\"Extract clean text from HTML.\"\"\"\n return BeautifulSoup(x, \"lxml\").get_text()\n\n @staticmethod\n def _markdown_extractor(x: str) -> str:\n \"\"\"Convert HTML to Markdown format.\"\"\"\n stream = io.BytesIO(x.encode(\"utf-8\"))\n result = MarkItDown(enable_plugins=False).convert_stream(stream)\n return result.markdown\n\n @staticmethod\n def validate_url(url: str) -> bool:\n \"\"\"Validates if the given string matches URL pattern.\n\n Args:\n url: The URL string to validate\n\n Returns:\n bool: True if the URL is valid, False otherwise\n \"\"\"\n return bool(URL_REGEX.match(url))\n\n def ensure_url(self, url: str) -> tuple[str, list[str]]:\n \"\"\"Ensures the given string is a valid URL and returns validated IPs for DNS pinning.\n\n Args:\n url: The URL string to validate and normalize\n\n Returns:\n tuple[str, list[str]]: The normalized URL and list of validated IPs for DNS pinning\n\n Raises:\n ValueError: If the URL is invalid or blocked by SSRF protection\n \"\"\"\n url = url.strip()\n if not url.startswith((\"http://\", \"https://\")):\n url = \"https://\" + url\n\n if not self.validate_url(url):\n msg = f\"Invalid URL: {url}\"\n raise ValueError(msg)\n\n # ============================================================================\n # SSRF Protection with DNS Pinning\n # ============================================================================\n # This prevents DNS rebinding attacks by:\n # 1. Resolving DNS and validating IPs during security check\n # 2. Returning the validated IP addresses for DNS pinning\n # 3. Using a custom HTTP transport that forces use of the pinned IPs\n # 4. Ignoring any new DNS resolutions (prevents rebinding)\n #\n # Without DNS pinning, an attacker could:\n # - First DNS lookup: returns public IP (passes validation)\n # - Second DNS lookup: returns internal IP (bypasses protection)\n # - Attack succeeds: accesses internal services\n #\n # With DNS pinning:\n # - First DNS lookup: returns public IP (passes validation)\n # - IPs are pinned and returned\n # - HTTP request: uses pinned IPs directly (no new DNS lookup)\n # - Attack fails: even if DNS changes, we use the validated IPs\n # ============================================================================\n try:\n _validated_url, validated_ips = validate_and_resolve_url(url)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n else:\n # Log DNS pinning information for security auditing\n if validated_ips:\n logger.debug(f\"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s) for {url}\")\n\n return url, validated_ips\n\n def _build_http_client(self, url: str, validated_ips: list[str]) -> httpx.AsyncClient:\n \"\"\"Create an HTTP client with DNS pinning for SSRF protection.\n\n Args:\n url: The request URL whose hostname will be pinned\n validated_ips: IPs validated by validate_and_resolve_url for this URL\n\n Returns:\n httpx.AsyncClient: A client with DNS pinning when SSRF protection is enabled\n \"\"\"\n if is_ssrf_protection_enabled() and validated_ips:\n hostname = urlparse(url).hostname\n if hostname:\n return create_ssrf_protected_client(hostname=hostname, validated_ips=validated_ips)\n return httpx.AsyncClient()\n\n async def _fetch_url_with_pinning(self, url: str, validated_ips: list[str], headers: dict) -> tuple[str, dict]:\n \"\"\"Fetch a single URL with DNS pinning protection.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n async with self._build_http_client(url, validated_ips) as client:\n try:\n response = await client.get(url, headers=headers, timeout=self.timeout, follow_redirects=False)\n\n if self.check_response_status:\n response.raise_for_status()\n\n content_type = response.headers.get(\"content-type\", \"\").lower()\n\n # Filter out CSS files if requested\n if self.filter_text_html and \"text/css\" in content_type:\n logger.debug(f\"Skipping CSS file: {url}\")\n return \"\", {}\n\n # Get the HTML content\n html_content = response.text\n\n # Extract metadata\n metadata = {\n \"source\": str(response.url),\n \"title\": \"\",\n \"description\": \"\",\n \"content_type\": content_type,\n \"language\": \"\",\n }\n\n # Try to extract title and description from HTML\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - metadata extraction is optional\n # and we don't want to fail the entire request if it fails\n logger.debug(f\"Failed to extract metadata from {url}\")\n else:\n if soup.title:\n metadata[\"title\"] = soup.title.string or \"\"\n\n # Try to get description from meta tags\n meta_desc = soup.find(\"meta\", attrs={\"name\": \"description\"})\n if meta_desc and meta_desc.get(\"content\"):\n metadata[\"description\"] = meta_desc[\"content\"]\n\n # Try to get language\n html_tag = soup.find(\"html\")\n if html_tag and html_tag.get(\"lang\"):\n metadata[\"language\"] = html_tag[\"lang\"]\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Failed to fetch {url}: {e}\")\n return \"\", {}\n raise\n else:\n return html_content, metadata\n\n async def _crawl_recursive(\n self, start_url: str, validated_ips: list[str], headers: dict, visited: set, depth: int = 0\n ) -> list[dict]:\n \"\"\"Recursively crawl URLs with DNS pinning protection.\n\n Args:\n start_url: The URL to start crawling from\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n visited: Set of already visited URLs\n depth: Current crawl depth\n\n Returns:\n list[dict]: List of documents with content and metadata\n \"\"\"\n if depth >= self.max_depth or start_url in visited:\n return []\n\n visited.add(start_url)\n documents = []\n\n # Fetch the current URL\n html_content, metadata = await self._fetch_url_with_pinning(start_url, validated_ips, headers)\n\n if not html_content:\n return documents\n\n # Extract content based on format\n extractors = {\n \"HTML\": self._html_extractor,\n \"Markdown\": self._markdown_extractor,\n \"Text\": self._text_extractor,\n }\n extractor = extractors.get(self.format, self._text_extractor)\n extracted_content = extractor(html_content)\n\n # Add the document\n documents.append(\n {\n \"page_content\": extracted_content,\n \"metadata\": metadata,\n }\n )\n\n # If we haven't reached max depth, extract and follow links\n if depth < self.max_depth - 1:\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n links = soup.find_all(\"a\", href=True)\n\n for link in links:\n href = link[\"href\"]\n # Resolve relative URLs\n absolute_url = urljoin(start_url, href)\n\n # Skip if already visited\n if absolute_url in visited:\n continue\n\n # Check if we should prevent going outside the domain\n if self.prevent_outside:\n start_domain = urlparse(start_url).netloc\n link_domain = urlparse(absolute_url).netloc\n if start_domain != link_domain:\n continue\n\n # Validate and crawl the linked URL\n try:\n _, link_validated_ips = self.ensure_url(absolute_url)\n sub_docs = await self._crawl_recursive(\n absolute_url, link_validated_ips, headers, visited, depth + 1\n )\n documents.extend(sub_docs)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping {absolute_url}: {e}\")\n continue\n raise\n\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - link extraction is optional\n # and we don't want to fail the entire crawl if one page has issues\n logger.debug(f\"Failed to extract links from {start_url}\")\n\n return documents\n\n async def fetch_url_contents(self) -> list[dict]:\n \"\"\"Load documents from the configured URLs with SSRF protection and DNS pinning.\n\n This method implements comprehensive SSRF (Server-Side Request Forgery) protection\n using DNS pinning to prevent DNS rebinding attacks. Each URL is validated and its\n DNS resolution is pinned before any HTTP requests are made.\n\n Returns:\n list[dict]: List of documents with content and metadata\n\n Raises:\n ValueError: If no valid URLs are provided or if there's an error loading documents\n \"\"\"\n try:\n # Validate all URLs and get their validated IPs for DNS pinning\n validated_urls = []\n first_validation_error: Exception | None = None\n for url in self.urls:\n if not url.strip():\n continue\n try:\n normalized_url, validated_ips = self.ensure_url(url)\n validated_urls.append((normalized_url, validated_ips))\n except (ValueError, SSRFProtectionError) as e:\n # Remember the first rejection so its real cause (for example an\n # SSRF block) can be surfaced if no URL survives validation,\n # instead of being hidden behind \"No valid URLs provided\".\n if first_validation_error is None:\n first_validation_error = e\n if self.continue_on_failure:\n logger.warning(f\"Skipping invalid URL {url}: {e}\")\n continue\n raise\n\n # Remove duplicates while preserving order\n seen = set()\n unique_validated_urls = []\n for url, ips in validated_urls:\n if url not in seen:\n seen.add(url)\n unique_validated_urls.append((url, ips))\n\n logger.debug(f\"Validated {len(unique_validated_urls)} unique URL(s)\")\n\n if not unique_validated_urls:\n # Surface the actual reason every candidate URL was rejected (e.g. an\n # SSRF block) rather than a generic message, so a security failure is\n # not silently swallowed when continue_on_failure is enabled.\n if first_validation_error is not None:\n raise first_validation_error\n msg = \"No valid URLs provided.\"\n raise ValueError(msg)\n\n # Prepare headers\n headers_dict = {header[\"key\"]: header[\"value\"] for header in self.headers if header[\"value\"] is not None}\n\n # Crawl all URLs\n all_docs = []\n for url, validated_ips in unique_validated_urls:\n logger.debug(f\"Crawling {url} with max_depth={self.max_depth}\")\n\n try:\n visited = set()\n docs = await self._crawl_recursive(url, validated_ips, headers_dict, visited, depth=0)\n\n if not docs:\n logger.warning(f\"No documents found for {url}\")\n continue\n\n logger.debug(f\"Found {len(docs)} document(s) from {url}\")\n all_docs.extend(docs)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Error loading documents from {url}: {e}\")\n continue\n msg = f\"Error loading documents from {url}: {e}\"\n raise ValueError(msg) from e\n\n if not all_docs:\n msg = \"No documents were successfully loaded from any URL\"\n raise ValueError(msg)\n\n # Convert to output format\n return [\n {\n \"text\": safe_convert(doc[\"page_content\"], clean_data=True),\n \"url\": doc[\"metadata\"].get(\"source\", \"\"),\n \"title\": doc[\"metadata\"].get(\"title\", \"\"),\n \"description\": doc[\"metadata\"].get(\"description\", \"\"),\n \"content_type\": doc[\"metadata\"].get(\"content_type\", \"\"),\n \"language\": doc[\"metadata\"].get(\"language\", \"\"),\n }\n for doc in all_docs\n ]\n\n except Exception as e:\n error_msg = e.message if hasattr(e, \"message\") else str(e)\n msg = f\"Error loading documents: {error_msg}\"\n logger.exception(msg)\n raise ValueError(msg) from e\n\n async def fetch_content(self) -> DataFrame:\n \"\"\"Convert the documents to a DataFrame.\"\"\"\n url_contents = await self.fetch_url_contents()\n return DataFrame(data=url_contents)\n\n async def fetch_content_as_message(self) -> Message:\n \"\"\"Convert the documents to a Message.\"\"\"\n url_contents = await self.fetch_url_contents()\n return Message(text=\"\\n\\n\".join([x[\"text\"] for x in url_contents]), data={\"data\": url_contents})\n" + "value": "import importlib.util\nimport io\nimport re\nfrom urllib.parse import urljoin, urlparse\n\nimport httpx\nfrom bs4 import BeautifulSoup\nfrom markitdown import MarkItDown\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.data import safe_convert\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageTextInput, Output, SliderInput, TableInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.utils.request_utils import get_user_agent\nfrom lfx.utils.ssrf_protection import SSRFProtectionError, is_ssrf_protection_enabled, validate_and_resolve_url\nfrom lfx.utils.ssrf_transport import create_ssrf_protected_client\n\n# Constants\nDEFAULT_TIMEOUT = 30\nDEFAULT_MAX_DEPTH = 1\nDEFAULT_FORMAT = \"Text\"\n\n# HTTP status codes that carry a redirect Location header (RFC 9110).\nREDIRECT_STATUS_CODES = frozenset({301, 302, 303, 307, 308})\n\n# Maximum number of redirects to follow. Each hop is re-validated for SSRF safety;\n# matches httpx's default and the API Request component.\nMAX_REDIRECTS = 20\n\n# Default ports per scheme, used to compare redirect origins.\nDEFAULT_SCHEME_PORTS = {\"http\": 80, \"https\": 443}\n\n\nURL_REGEX = re.compile(\n r\"^(https?:\\/\\/)?\" r\"(www\\.)?\" r\"([a-zA-Z0-9.-]+)\" r\"(\\.[a-zA-Z]{2,})?\" r\"(:\\d+)?\" r\"(\\/[^\\s]*)?$\",\n re.IGNORECASE,\n)\n\nUSER_AGENT = None\n# Check if langflow is installed using importlib.util.find_spec(name))\nif importlib.util.find_spec(\"langflow\"):\n langflow_installed = True\n USER_AGENT = get_user_agent()\nelse:\n langflow_installed = False\n USER_AGENT = \"lfx\"\n\n\nclass URLComponent(Component):\n \"\"\"A component that loads and parses content from web pages recursively.\n\n This component allows fetching content from one or more URLs, with options to:\n - Control crawl depth\n - Prevent crawling outside the root domain\n - Use async loading for better performance\n - Extract either raw HTML or clean text\n - Configure request headers and timeouts\n \"\"\"\n\n display_name = \"URL\"\n description = \"Fetch content from one or more web pages, following links recursively.\"\n documentation: str = \"https://docs.langflow.org/url\"\n icon = \"layout-template\"\n name = \"URLComponent\"\n\n inputs = [\n MessageTextInput(\n name=\"urls\",\n display_name=\"URLs\",\n info=\"Enter one or more URLs to crawl recursively, by clicking the '+' button.\",\n is_list=True,\n tool_mode=True,\n placeholder=\"Enter a URL...\",\n list_add_label=\"Add URL\",\n input_types=[\"Message\"],\n ),\n SliderInput(\n name=\"max_depth\",\n display_name=\"Depth\",\n info=(\n \"Controls how many 'clicks' away from the initial page the crawler will go:\\n\"\n \"- depth 1: only the initial page\\n\"\n \"- depth 2: initial page + all pages linked directly from it\\n\"\n \"- depth 3: initial page + direct links + links found on those direct link pages\\n\"\n \"Note: This is about link traversal, not URL path depth.\"\n ),\n value=DEFAULT_MAX_DEPTH,\n range_spec=RangeSpec(min=1, max=5, step=1),\n required=False,\n min_label=\" \",\n max_label=\" \",\n min_label_icon=\"None\",\n max_label_icon=\"None\",\n # slider_input=True\n ),\n BoolInput(\n name=\"prevent_outside\",\n display_name=\"Prevent Outside\",\n info=(\n \"If enabled, only crawls URLs within the same domain as the root URL. \"\n \"This helps prevent the crawler from going to external websites.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"use_async\",\n display_name=\"Use Async\",\n info=(\n \"If enabled, uses asynchronous loading which can be significantly faster \"\n \"but might use more system resources.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"follow_redirects\",\n display_name=\"Follow Redirects\",\n info=(\n \"If enabled, follows HTTP redirects such as http→https or www/non-www \"\n \"normalization, which most sites rely on to serve their content. When SSRF \"\n \"protection is enabled (the default), every redirect hop is re-validated against \"\n \"the same blocked-IP denylist and DNS-pinned before it is fetched, so following \"\n \"redirects cannot be used to reach internal resources. Disable to capture the \"\n \"first response as-is without following redirects.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=(\n \"Output Format. Use 'Text' to extract the text from the HTML, \"\n \"'Markdown' to parse the HTML into Markdown format, or 'HTML' \"\n \"for the raw HTML content.\"\n ),\n options=[\"Text\", \"HTML\", \"Markdown\"],\n value=DEFAULT_FORMAT,\n advanced=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"Timeout for the request in seconds.\",\n value=DEFAULT_TIMEOUT,\n required=False,\n advanced=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": USER_AGENT}],\n advanced=True,\n input_types=[\"DataFrame\", \"Table\"],\n ),\n BoolInput(\n name=\"filter_text_html\",\n display_name=\"Filter Text/HTML\",\n info=\"If enabled, filters out text/css content type from the results.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"continue_on_failure\",\n display_name=\"Continue on Failure\",\n info=\"If enabled, continues crawling even if some requests fail.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"check_response_status\",\n display_name=\"Check Response Status\",\n info=\"If enabled, checks the response status of the request.\",\n value=False,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"autoset_encoding\",\n display_name=\"Autoset Encoding\",\n info=\"If enabled, automatically sets the encoding of the request.\",\n value=True,\n required=False,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Extracted Pages\", name=\"page_results\", method=\"fetch_content\"),\n Output(display_name=\"Raw Content\", name=\"raw_results\", method=\"fetch_content_as_message\", tool_mode=False),\n ]\n\n @staticmethod\n def _html_extractor(x: str) -> str:\n \"\"\"Extract raw HTML content.\"\"\"\n return x\n\n @staticmethod\n def _text_extractor(x: str) -> str:\n \"\"\"Extract clean text from HTML.\"\"\"\n return BeautifulSoup(x, \"lxml\").get_text()\n\n @staticmethod\n def _markdown_extractor(x: str) -> str:\n \"\"\"Convert HTML to Markdown format.\"\"\"\n stream = io.BytesIO(x.encode(\"utf-8\"))\n result = MarkItDown(enable_plugins=False).convert_stream(stream)\n return result.markdown\n\n @staticmethod\n def validate_url(url: str) -> bool:\n \"\"\"Validates if the given string matches URL pattern.\n\n Args:\n url: The URL string to validate\n\n Returns:\n bool: True if the URL is valid, False otherwise\n \"\"\"\n return bool(URL_REGEX.match(url))\n\n def ensure_url(self, url: str) -> tuple[str, list[str]]:\n \"\"\"Ensures the given string is a valid URL and returns validated IPs for DNS pinning.\n\n Args:\n url: The URL string to validate and normalize\n\n Returns:\n tuple[str, list[str]]: The normalized URL and list of validated IPs for DNS pinning\n\n Raises:\n ValueError: If the URL is invalid or blocked by SSRF protection\n \"\"\"\n url = url.strip()\n if not url.startswith((\"http://\", \"https://\")):\n url = \"https://\" + url\n\n if not self.validate_url(url):\n msg = f\"Invalid URL: {url}\"\n raise ValueError(msg)\n\n # ============================================================================\n # SSRF Protection with DNS Pinning\n # ============================================================================\n # This prevents DNS rebinding attacks by:\n # 1. Resolving DNS and validating IPs during security check\n # 2. Returning the validated IP addresses for DNS pinning\n # 3. Using a custom HTTP transport that forces use of the pinned IPs\n # 4. Ignoring any new DNS resolutions (prevents rebinding)\n #\n # Without DNS pinning, an attacker could:\n # - First DNS lookup: returns public IP (passes validation)\n # - Second DNS lookup: returns internal IP (bypasses protection)\n # - Attack succeeds: accesses internal services\n #\n # With DNS pinning:\n # - First DNS lookup: returns public IP (passes validation)\n # - IPs are pinned and returned\n # - HTTP request: uses pinned IPs directly (no new DNS lookup)\n # - Attack fails: even if DNS changes, we use the validated IPs\n # ============================================================================\n try:\n _validated_url, validated_ips = validate_and_resolve_url(url)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n else:\n # Log DNS pinning information for security auditing\n if validated_ips:\n logger.debug(f\"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s) for {url}\")\n\n return url, validated_ips\n\n def _build_http_client(self, url: str, validated_ips: list[str]) -> httpx.AsyncClient:\n \"\"\"Create an HTTP client with DNS pinning for SSRF protection.\n\n Args:\n url: The request URL whose hostname will be pinned\n validated_ips: IPs validated by validate_and_resolve_url for this URL\n\n Returns:\n httpx.AsyncClient: A client with DNS pinning when SSRF protection is enabled\n \"\"\"\n if is_ssrf_protection_enabled() and validated_ips:\n hostname = urlparse(url).hostname\n if hostname:\n return create_ssrf_protected_client(hostname=hostname, validated_ips=validated_ips)\n return httpx.AsyncClient()\n\n @staticmethod\n def _headers_for_redirect(headers: dict | None, current_url: str, next_url: str) -> dict | None:\n \"\"\"Drop sensitive headers when a redirect crosses to a different origin.\n\n Mirrors httpx's auto-follow behavior so manually following redirects does not\n leak credentials (Authorization / Cookie) to an origin other than the one the\n caller intended them for. Headers are kept only when the redirect stays on the\n same origin (scheme, host, port) or is a direct https upgrade of the same host\n on default ports - the exact cases where httpx keeps the Authorization header.\n \"\"\"\n if not headers:\n return headers\n current, nxt = urlparse(current_url), urlparse(next_url)\n current_port = current.port or DEFAULT_SCHEME_PORTS.get(current.scheme)\n next_port = nxt.port or DEFAULT_SCHEME_PORTS.get(nxt.scheme)\n same_origin = (current.scheme, current.hostname, current_port) == (nxt.scheme, nxt.hostname, next_port)\n https_upgrade = (\n current.hostname == nxt.hostname\n and current.scheme == \"http\"\n and nxt.scheme == \"https\"\n and current_port == DEFAULT_SCHEME_PORTS[\"http\"]\n and next_port == DEFAULT_SCHEME_PORTS[\"https\"]\n )\n if same_origin or https_upgrade:\n return headers\n sensitive = {\"authorization\", \"proxy-authorization\", \"cookie\"}\n return {k: v for k, v in headers.items() if k.lower() not in sensitive}\n\n def _process_response(self, response: httpx.Response) -> tuple[str, dict]:\n \"\"\"Turn a final (non-redirect) response into its HTML content and metadata.\n\n Args:\n response: The HTTP response to process\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n if self.check_response_status:\n response.raise_for_status()\n\n content_type = response.headers.get(\"content-type\", \"\").lower()\n\n # Filter out CSS files if requested\n if self.filter_text_html and \"text/css\" in content_type:\n logger.debug(f\"Skipping CSS file: {response.url}\")\n return \"\", {}\n\n # Get the HTML content\n html_content = response.text\n\n # Extract metadata\n metadata = {\n \"source\": str(response.url),\n \"title\": \"\",\n \"description\": \"\",\n \"content_type\": content_type,\n \"language\": \"\",\n }\n\n # Try to extract title and description from HTML\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - metadata extraction is optional\n # and we don't want to fail the entire request if it fails\n logger.debug(f\"Failed to extract metadata from {response.url}\")\n else:\n if soup.title:\n metadata[\"title\"] = soup.title.string or \"\"\n\n # Try to get description from meta tags\n meta_desc = soup.find(\"meta\", attrs={\"name\": \"description\"})\n if meta_desc and meta_desc.get(\"content\"):\n metadata[\"description\"] = meta_desc[\"content\"]\n\n # Try to get language\n html_tag = soup.find(\"html\")\n if html_tag and html_tag.get(\"lang\"):\n metadata[\"language\"] = html_tag[\"lang\"]\n\n return html_content, metadata\n\n async def _fetch_with_revalidated_redirects(\n self, url: str, validated_ips: list[str], headers: dict\n ) -> tuple[str, dict]:\n \"\"\"Fetch ``url``, following redirects manually and re-validating every hop.\n\n This closes an SSRF bypass: with redirects enabled, httpx would otherwise\n auto-follow a redirect from a validated public URL to an internal address that\n the pinned transport never checked (the redirect target is a different host, so\n it is not in the pin map). Each redirect ``Location`` is resolved (relative\n locations included) and re-validated with ``ensure_url`` - the same private/\n loopback/link-local denylist and DNS pinning applied to the initial request -\n before any connection to it is made. A blocked hop raises ``ValueError`` (unless\n ``continue_on_failure``); the chain is capped at ``MAX_REDIRECTS`` hops.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning of the initial URL\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n current_url = url\n current_ips = validated_ips\n\n for _ in range(MAX_REDIRECTS + 1):\n async with self._build_http_client(current_url, current_ips) as client:\n response = await client.get(current_url, headers=headers, timeout=self.timeout, follow_redirects=False)\n\n location = response.headers.get(\"location\")\n if response.status_code in REDIRECT_STATUS_CODES and location:\n # Resolve relative redirects against the current URL.\n next_url = urljoin(current_url, location)\n\n # Re-validate the redirect target with the same SSRF denylist + DNS pinning.\n try:\n validated_next_url, current_ips = self.ensure_url(next_url)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping blocked or invalid redirect to {next_url}: {e}\")\n return \"\", {}\n msg = f\"SSRF Protection: blocked redirect to {next_url}: {e}\"\n raise ValueError(msg) from e\n\n headers = self._headers_for_redirect(headers, current_url, next_url)\n current_url = validated_next_url\n continue\n\n # Not a redirect (or no Location header) - this is the final response.\n return self._process_response(response)\n\n # Exhausted the redirect budget.\n if self.continue_on_failure:\n logger.warning(f\"Exceeded the maximum of {MAX_REDIRECTS} redirects while requesting {url}\")\n return \"\", {}\n msg = f\"SSRF Protection: exceeded the maximum of {MAX_REDIRECTS} redirects while requesting {url}\"\n raise ValueError(msg)\n\n async def _fetch_url_with_pinning(self, url: str, validated_ips: list[str], headers: dict) -> tuple[str, dict]:\n \"\"\"Fetch a single URL with DNS pinning protection, following redirects safely.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n try:\n # When SSRF protection is enabled we must follow redirects manually so each hop\n # is re-validated and DNS-pinned; letting httpx auto-follow would connect to the\n # redirect target without pinning (different host, not in the pin map) and re-open\n # the SSRF hole that DNS pinning closes. With protection disabled there is no pin\n # to bypass, so httpx can follow redirects natively.\n if self.follow_redirects and is_ssrf_protection_enabled():\n return await self._fetch_with_revalidated_redirects(url, validated_ips, headers)\n\n async with self._build_http_client(url, validated_ips) as client:\n response = await client.get(\n url, headers=headers, timeout=self.timeout, follow_redirects=self.follow_redirects\n )\n return self._process_response(response)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Failed to fetch {url}: {e}\")\n return \"\", {}\n raise\n\n async def _crawl_recursive(\n self, start_url: str, validated_ips: list[str], headers: dict, visited: set, depth: int = 0\n ) -> list[dict]:\n \"\"\"Recursively crawl URLs with DNS pinning protection.\n\n Args:\n start_url: The URL to start crawling from\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n visited: Set of already visited URLs\n depth: Current crawl depth\n\n Returns:\n list[dict]: List of documents with content and metadata\n \"\"\"\n if depth >= self.max_depth or start_url in visited:\n return []\n\n visited.add(start_url)\n documents = []\n\n # Fetch the current URL\n html_content, metadata = await self._fetch_url_with_pinning(start_url, validated_ips, headers)\n\n # Redirects may have landed on a different canonical URL (http->https, www\n # normalization). Resolve relative links and the prevent_outside check against\n # the URL the content actually came from, and mark it visited so links back to\n # the canonical form are not re-crawled.\n base_url = metadata.get(\"source\") or start_url\n visited.add(base_url)\n\n if not html_content:\n return documents\n\n # Extract content based on format\n extractors = {\n \"HTML\": self._html_extractor,\n \"Markdown\": self._markdown_extractor,\n \"Text\": self._text_extractor,\n }\n extractor = extractors.get(self.format, self._text_extractor)\n extracted_content = extractor(html_content)\n\n # Add the document\n documents.append(\n {\n \"page_content\": extracted_content,\n \"metadata\": metadata,\n }\n )\n\n # If we haven't reached max depth, extract and follow links\n if depth < self.max_depth - 1:\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n links = soup.find_all(\"a\", href=True)\n\n for link in links:\n href = link[\"href\"]\n # Resolve relative URLs\n absolute_url = urljoin(base_url, href)\n\n # Skip if already visited\n if absolute_url in visited:\n continue\n\n # Check if we should prevent going outside the domain\n if self.prevent_outside:\n start_domain = urlparse(base_url).netloc\n link_domain = urlparse(absolute_url).netloc\n if start_domain != link_domain:\n continue\n\n # Validate and crawl the linked URL\n try:\n _, link_validated_ips = self.ensure_url(absolute_url)\n sub_docs = await self._crawl_recursive(\n absolute_url, link_validated_ips, headers, visited, depth + 1\n )\n documents.extend(sub_docs)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping {absolute_url}: {e}\")\n continue\n raise\n\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - link extraction is optional\n # and we don't want to fail the entire crawl if one page has issues\n logger.debug(f\"Failed to extract links from {start_url}\")\n\n return documents\n\n async def fetch_url_contents(self) -> list[dict]:\n \"\"\"Load documents from the configured URLs with SSRF protection and DNS pinning.\n\n This method implements comprehensive SSRF (Server-Side Request Forgery) protection\n using DNS pinning to prevent DNS rebinding attacks. Each URL is validated and its\n DNS resolution is pinned before any HTTP requests are made.\n\n Returns:\n list[dict]: List of documents with content and metadata\n\n Raises:\n ValueError: If no valid URLs are provided or if there's an error loading documents\n \"\"\"\n try:\n # Validate all URLs and get their validated IPs for DNS pinning\n validated_urls = []\n first_validation_error: Exception | None = None\n for url in self.urls:\n if not url.strip():\n continue\n try:\n normalized_url, validated_ips = self.ensure_url(url)\n validated_urls.append((normalized_url, validated_ips))\n except (ValueError, SSRFProtectionError) as e:\n # Remember the first rejection so its real cause (for example an\n # SSRF block) can be surfaced if no URL survives validation,\n # instead of being hidden behind \"No valid URLs provided\".\n if first_validation_error is None:\n first_validation_error = e\n if self.continue_on_failure:\n logger.warning(f\"Skipping invalid URL {url}: {e}\")\n continue\n raise\n\n # Remove duplicates while preserving order\n seen = set()\n unique_validated_urls = []\n for url, ips in validated_urls:\n if url not in seen:\n seen.add(url)\n unique_validated_urls.append((url, ips))\n\n logger.debug(f\"Validated {len(unique_validated_urls)} unique URL(s)\")\n\n if not unique_validated_urls:\n # Surface the actual reason every candidate URL was rejected (e.g. an\n # SSRF block) rather than a generic message, so a security failure is\n # not silently swallowed when continue_on_failure is enabled.\n if first_validation_error is not None:\n raise first_validation_error\n msg = \"No valid URLs provided.\"\n raise ValueError(msg)\n\n # Prepare headers\n headers_dict = {header[\"key\"]: header[\"value\"] for header in self.headers if header[\"value\"] is not None}\n\n # Crawl all URLs\n all_docs = []\n for url, validated_ips in unique_validated_urls:\n logger.debug(f\"Crawling {url} with max_depth={self.max_depth}\")\n\n try:\n visited = set()\n docs = await self._crawl_recursive(url, validated_ips, headers_dict, visited, depth=0)\n\n if not docs:\n logger.warning(f\"No documents found for {url}\")\n continue\n\n logger.debug(f\"Found {len(docs)} document(s) from {url}\")\n all_docs.extend(docs)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Error loading documents from {url}: {e}\")\n continue\n msg = f\"Error loading documents from {url}: {e}\"\n raise ValueError(msg) from e\n\n if not all_docs:\n msg = \"No documents were successfully loaded from any URL\"\n raise ValueError(msg)\n\n # Convert to output format\n return [\n {\n \"text\": safe_convert(doc[\"page_content\"], clean_data=True),\n \"url\": doc[\"metadata\"].get(\"source\", \"\"),\n \"title\": doc[\"metadata\"].get(\"title\", \"\"),\n \"description\": doc[\"metadata\"].get(\"description\", \"\"),\n \"content_type\": doc[\"metadata\"].get(\"content_type\", \"\"),\n \"language\": doc[\"metadata\"].get(\"language\", \"\"),\n }\n for doc in all_docs\n ]\n\n except Exception as e:\n error_msg = e.message if hasattr(e, \"message\") else str(e)\n msg = f\"Error loading documents: {error_msg}\"\n logger.exception(msg)\n raise ValueError(msg) from e\n\n async def fetch_content(self) -> DataFrame:\n \"\"\"Convert the documents to a DataFrame.\"\"\"\n url_contents = await self.fetch_url_contents()\n return DataFrame(data=url_contents)\n\n async def fetch_content_as_message(self) -> Message:\n \"\"\"Convert the documents to a Message.\"\"\"\n url_contents = await self.fetch_url_contents()\n return Message(text=\"\\n\\n\".join([x[\"text\"] for x in url_contents]), data={\"data\": url_contents})\n" }, "continue_on_failure": { "_input_type": "BoolInput", @@ -1014,6 +1015,26 @@ "type": "bool", "value": true }, + "follow_redirects": { + "_input_type": "BoolInput", + "advanced": true, + "display_name": "Follow Redirects", + "dynamic": false, + "info": "If enabled, follows HTTP redirects such as http→https or www/non-www normalization, which most sites rely on to serve their content. When SSRF protection is enabled (the default), every redirect hop is re-validated against the same blocked-IP denylist and DNS-pinned before it is fetched, so following redirects cannot be used to reach internal resources. Disable to capture the first response as-is without following redirects.", + "list": false, + "list_add_label": "Add More", + "name": "follow_redirects", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + }, "format": { "_input_type": "DropdownInput", "advanced": true, diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Custom Component Generator.json b/src/backend/base/langflow/initial_setup/starter_projects/Custom Component Generator.json index 233fcaee9e..9eb3659e04 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Custom Component Generator.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Custom Component Generator.json @@ -908,6 +908,7 @@ "max_depth", "prevent_outside", "use_async", + "follow_redirects", "format", "timeout", "headers", @@ -921,7 +922,7 @@ "legacy": false, "lf_version": "1.6.0", "metadata": { - "code_hash": "2e225a90043a", + "code_hash": "52b5bd602242", "dependencies": { "dependencies": [ { @@ -1031,7 +1032,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import importlib.util\nimport io\nimport re\nfrom urllib.parse import urljoin, urlparse\n\nimport httpx\nfrom bs4 import BeautifulSoup\nfrom markitdown import MarkItDown\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.data import safe_convert\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageTextInput, Output, SliderInput, TableInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.utils.request_utils import get_user_agent\nfrom lfx.utils.ssrf_protection import SSRFProtectionError, is_ssrf_protection_enabled, validate_and_resolve_url\nfrom lfx.utils.ssrf_transport import create_ssrf_protected_client\n\n# Constants\nDEFAULT_TIMEOUT = 30\nDEFAULT_MAX_DEPTH = 1\nDEFAULT_FORMAT = \"Text\"\n\n\nURL_REGEX = re.compile(\n r\"^(https?:\\/\\/)?\" r\"(www\\.)?\" r\"([a-zA-Z0-9.-]+)\" r\"(\\.[a-zA-Z]{2,})?\" r\"(:\\d+)?\" r\"(\\/[^\\s]*)?$\",\n re.IGNORECASE,\n)\n\nUSER_AGENT = None\n# Check if langflow is installed using importlib.util.find_spec(name))\nif importlib.util.find_spec(\"langflow\"):\n langflow_installed = True\n USER_AGENT = get_user_agent()\nelse:\n langflow_installed = False\n USER_AGENT = \"lfx\"\n\n\nclass URLComponent(Component):\n \"\"\"A component that loads and parses content from web pages recursively.\n\n This component allows fetching content from one or more URLs, with options to:\n - Control crawl depth\n - Prevent crawling outside the root domain\n - Use async loading for better performance\n - Extract either raw HTML or clean text\n - Configure request headers and timeouts\n \"\"\"\n\n display_name = \"URL\"\n description = \"Fetch content from one or more web pages, following links recursively.\"\n documentation: str = \"https://docs.langflow.org/url\"\n icon = \"layout-template\"\n name = \"URLComponent\"\n\n inputs = [\n MessageTextInput(\n name=\"urls\",\n display_name=\"URLs\",\n info=\"Enter one or more URLs to crawl recursively, by clicking the '+' button.\",\n is_list=True,\n tool_mode=True,\n placeholder=\"Enter a URL...\",\n list_add_label=\"Add URL\",\n input_types=[\"Message\"],\n ),\n SliderInput(\n name=\"max_depth\",\n display_name=\"Depth\",\n info=(\n \"Controls how many 'clicks' away from the initial page the crawler will go:\\n\"\n \"- depth 1: only the initial page\\n\"\n \"- depth 2: initial page + all pages linked directly from it\\n\"\n \"- depth 3: initial page + direct links + links found on those direct link pages\\n\"\n \"Note: This is about link traversal, not URL path depth.\"\n ),\n value=DEFAULT_MAX_DEPTH,\n range_spec=RangeSpec(min=1, max=5, step=1),\n required=False,\n min_label=\" \",\n max_label=\" \",\n min_label_icon=\"None\",\n max_label_icon=\"None\",\n # slider_input=True\n ),\n BoolInput(\n name=\"prevent_outside\",\n display_name=\"Prevent Outside\",\n info=(\n \"If enabled, only crawls URLs within the same domain as the root URL. \"\n \"This helps prevent the crawler from going to external websites.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"use_async\",\n display_name=\"Use Async\",\n info=(\n \"If enabled, uses asynchronous loading which can be significantly faster \"\n \"but might use more system resources.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=(\n \"Output Format. Use 'Text' to extract the text from the HTML, \"\n \"'Markdown' to parse the HTML into Markdown format, or 'HTML' \"\n \"for the raw HTML content.\"\n ),\n options=[\"Text\", \"HTML\", \"Markdown\"],\n value=DEFAULT_FORMAT,\n advanced=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"Timeout for the request in seconds.\",\n value=DEFAULT_TIMEOUT,\n required=False,\n advanced=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": USER_AGENT}],\n advanced=True,\n input_types=[\"DataFrame\", \"Table\"],\n ),\n BoolInput(\n name=\"filter_text_html\",\n display_name=\"Filter Text/HTML\",\n info=\"If enabled, filters out text/css content type from the results.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"continue_on_failure\",\n display_name=\"Continue on Failure\",\n info=\"If enabled, continues crawling even if some requests fail.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"check_response_status\",\n display_name=\"Check Response Status\",\n info=\"If enabled, checks the response status of the request.\",\n value=False,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"autoset_encoding\",\n display_name=\"Autoset Encoding\",\n info=\"If enabled, automatically sets the encoding of the request.\",\n value=True,\n required=False,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Extracted Pages\", name=\"page_results\", method=\"fetch_content\"),\n Output(display_name=\"Raw Content\", name=\"raw_results\", method=\"fetch_content_as_message\", tool_mode=False),\n ]\n\n @staticmethod\n def _html_extractor(x: str) -> str:\n \"\"\"Extract raw HTML content.\"\"\"\n return x\n\n @staticmethod\n def _text_extractor(x: str) -> str:\n \"\"\"Extract clean text from HTML.\"\"\"\n return BeautifulSoup(x, \"lxml\").get_text()\n\n @staticmethod\n def _markdown_extractor(x: str) -> str:\n \"\"\"Convert HTML to Markdown format.\"\"\"\n stream = io.BytesIO(x.encode(\"utf-8\"))\n result = MarkItDown(enable_plugins=False).convert_stream(stream)\n return result.markdown\n\n @staticmethod\n def validate_url(url: str) -> bool:\n \"\"\"Validates if the given string matches URL pattern.\n\n Args:\n url: The URL string to validate\n\n Returns:\n bool: True if the URL is valid, False otherwise\n \"\"\"\n return bool(URL_REGEX.match(url))\n\n def ensure_url(self, url: str) -> tuple[str, list[str]]:\n \"\"\"Ensures the given string is a valid URL and returns validated IPs for DNS pinning.\n\n Args:\n url: The URL string to validate and normalize\n\n Returns:\n tuple[str, list[str]]: The normalized URL and list of validated IPs for DNS pinning\n\n Raises:\n ValueError: If the URL is invalid or blocked by SSRF protection\n \"\"\"\n url = url.strip()\n if not url.startswith((\"http://\", \"https://\")):\n url = \"https://\" + url\n\n if not self.validate_url(url):\n msg = f\"Invalid URL: {url}\"\n raise ValueError(msg)\n\n # ============================================================================\n # SSRF Protection with DNS Pinning\n # ============================================================================\n # This prevents DNS rebinding attacks by:\n # 1. Resolving DNS and validating IPs during security check\n # 2. Returning the validated IP addresses for DNS pinning\n # 3. Using a custom HTTP transport that forces use of the pinned IPs\n # 4. Ignoring any new DNS resolutions (prevents rebinding)\n #\n # Without DNS pinning, an attacker could:\n # - First DNS lookup: returns public IP (passes validation)\n # - Second DNS lookup: returns internal IP (bypasses protection)\n # - Attack succeeds: accesses internal services\n #\n # With DNS pinning:\n # - First DNS lookup: returns public IP (passes validation)\n # - IPs are pinned and returned\n # - HTTP request: uses pinned IPs directly (no new DNS lookup)\n # - Attack fails: even if DNS changes, we use the validated IPs\n # ============================================================================\n try:\n _validated_url, validated_ips = validate_and_resolve_url(url)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n else:\n # Log DNS pinning information for security auditing\n if validated_ips:\n logger.debug(f\"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s) for {url}\")\n\n return url, validated_ips\n\n def _build_http_client(self, url: str, validated_ips: list[str]) -> httpx.AsyncClient:\n \"\"\"Create an HTTP client with DNS pinning for SSRF protection.\n\n Args:\n url: The request URL whose hostname will be pinned\n validated_ips: IPs validated by validate_and_resolve_url for this URL\n\n Returns:\n httpx.AsyncClient: A client with DNS pinning when SSRF protection is enabled\n \"\"\"\n if is_ssrf_protection_enabled() and validated_ips:\n hostname = urlparse(url).hostname\n if hostname:\n return create_ssrf_protected_client(hostname=hostname, validated_ips=validated_ips)\n return httpx.AsyncClient()\n\n async def _fetch_url_with_pinning(self, url: str, validated_ips: list[str], headers: dict) -> tuple[str, dict]:\n \"\"\"Fetch a single URL with DNS pinning protection.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n async with self._build_http_client(url, validated_ips) as client:\n try:\n response = await client.get(url, headers=headers, timeout=self.timeout, follow_redirects=False)\n\n if self.check_response_status:\n response.raise_for_status()\n\n content_type = response.headers.get(\"content-type\", \"\").lower()\n\n # Filter out CSS files if requested\n if self.filter_text_html and \"text/css\" in content_type:\n logger.debug(f\"Skipping CSS file: {url}\")\n return \"\", {}\n\n # Get the HTML content\n html_content = response.text\n\n # Extract metadata\n metadata = {\n \"source\": str(response.url),\n \"title\": \"\",\n \"description\": \"\",\n \"content_type\": content_type,\n \"language\": \"\",\n }\n\n # Try to extract title and description from HTML\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - metadata extraction is optional\n # and we don't want to fail the entire request if it fails\n logger.debug(f\"Failed to extract metadata from {url}\")\n else:\n if soup.title:\n metadata[\"title\"] = soup.title.string or \"\"\n\n # Try to get description from meta tags\n meta_desc = soup.find(\"meta\", attrs={\"name\": \"description\"})\n if meta_desc and meta_desc.get(\"content\"):\n metadata[\"description\"] = meta_desc[\"content\"]\n\n # Try to get language\n html_tag = soup.find(\"html\")\n if html_tag and html_tag.get(\"lang\"):\n metadata[\"language\"] = html_tag[\"lang\"]\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Failed to fetch {url}: {e}\")\n return \"\", {}\n raise\n else:\n return html_content, metadata\n\n async def _crawl_recursive(\n self, start_url: str, validated_ips: list[str], headers: dict, visited: set, depth: int = 0\n ) -> list[dict]:\n \"\"\"Recursively crawl URLs with DNS pinning protection.\n\n Args:\n start_url: The URL to start crawling from\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n visited: Set of already visited URLs\n depth: Current crawl depth\n\n Returns:\n list[dict]: List of documents with content and metadata\n \"\"\"\n if depth >= self.max_depth or start_url in visited:\n return []\n\n visited.add(start_url)\n documents = []\n\n # Fetch the current URL\n html_content, metadata = await self._fetch_url_with_pinning(start_url, validated_ips, headers)\n\n if not html_content:\n return documents\n\n # Extract content based on format\n extractors = {\n \"HTML\": self._html_extractor,\n \"Markdown\": self._markdown_extractor,\n \"Text\": self._text_extractor,\n }\n extractor = extractors.get(self.format, self._text_extractor)\n extracted_content = extractor(html_content)\n\n # Add the document\n documents.append(\n {\n \"page_content\": extracted_content,\n \"metadata\": metadata,\n }\n )\n\n # If we haven't reached max depth, extract and follow links\n if depth < self.max_depth - 1:\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n links = soup.find_all(\"a\", href=True)\n\n for link in links:\n href = link[\"href\"]\n # Resolve relative URLs\n absolute_url = urljoin(start_url, href)\n\n # Skip if already visited\n if absolute_url in visited:\n continue\n\n # Check if we should prevent going outside the domain\n if self.prevent_outside:\n start_domain = urlparse(start_url).netloc\n link_domain = urlparse(absolute_url).netloc\n if start_domain != link_domain:\n continue\n\n # Validate and crawl the linked URL\n try:\n _, link_validated_ips = self.ensure_url(absolute_url)\n sub_docs = await self._crawl_recursive(\n absolute_url, link_validated_ips, headers, visited, depth + 1\n )\n documents.extend(sub_docs)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping {absolute_url}: {e}\")\n continue\n raise\n\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - link extraction is optional\n # and we don't want to fail the entire crawl if one page has issues\n logger.debug(f\"Failed to extract links from {start_url}\")\n\n return documents\n\n async def fetch_url_contents(self) -> list[dict]:\n \"\"\"Load documents from the configured URLs with SSRF protection and DNS pinning.\n\n This method implements comprehensive SSRF (Server-Side Request Forgery) protection\n using DNS pinning to prevent DNS rebinding attacks. Each URL is validated and its\n DNS resolution is pinned before any HTTP requests are made.\n\n Returns:\n list[dict]: List of documents with content and metadata\n\n Raises:\n ValueError: If no valid URLs are provided or if there's an error loading documents\n \"\"\"\n try:\n # Validate all URLs and get their validated IPs for DNS pinning\n validated_urls = []\n first_validation_error: Exception | None = None\n for url in self.urls:\n if not url.strip():\n continue\n try:\n normalized_url, validated_ips = self.ensure_url(url)\n validated_urls.append((normalized_url, validated_ips))\n except (ValueError, SSRFProtectionError) as e:\n # Remember the first rejection so its real cause (for example an\n # SSRF block) can be surfaced if no URL survives validation,\n # instead of being hidden behind \"No valid URLs provided\".\n if first_validation_error is None:\n first_validation_error = e\n if self.continue_on_failure:\n logger.warning(f\"Skipping invalid URL {url}: {e}\")\n continue\n raise\n\n # Remove duplicates while preserving order\n seen = set()\n unique_validated_urls = []\n for url, ips in validated_urls:\n if url not in seen:\n seen.add(url)\n unique_validated_urls.append((url, ips))\n\n logger.debug(f\"Validated {len(unique_validated_urls)} unique URL(s)\")\n\n if not unique_validated_urls:\n # Surface the actual reason every candidate URL was rejected (e.g. an\n # SSRF block) rather than a generic message, so a security failure is\n # not silently swallowed when continue_on_failure is enabled.\n if first_validation_error is not None:\n raise first_validation_error\n msg = \"No valid URLs provided.\"\n raise ValueError(msg)\n\n # Prepare headers\n headers_dict = {header[\"key\"]: header[\"value\"] for header in self.headers if header[\"value\"] is not None}\n\n # Crawl all URLs\n all_docs = []\n for url, validated_ips in unique_validated_urls:\n logger.debug(f\"Crawling {url} with max_depth={self.max_depth}\")\n\n try:\n visited = set()\n docs = await self._crawl_recursive(url, validated_ips, headers_dict, visited, depth=0)\n\n if not docs:\n logger.warning(f\"No documents found for {url}\")\n continue\n\n logger.debug(f\"Found {len(docs)} document(s) from {url}\")\n all_docs.extend(docs)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Error loading documents from {url}: {e}\")\n continue\n msg = f\"Error loading documents from {url}: {e}\"\n raise ValueError(msg) from e\n\n if not all_docs:\n msg = \"No documents were successfully loaded from any URL\"\n raise ValueError(msg)\n\n # Convert to output format\n return [\n {\n \"text\": safe_convert(doc[\"page_content\"], clean_data=True),\n \"url\": doc[\"metadata\"].get(\"source\", \"\"),\n \"title\": doc[\"metadata\"].get(\"title\", \"\"),\n \"description\": doc[\"metadata\"].get(\"description\", \"\"),\n \"content_type\": doc[\"metadata\"].get(\"content_type\", \"\"),\n \"language\": doc[\"metadata\"].get(\"language\", \"\"),\n }\n for doc in all_docs\n ]\n\n except Exception as e:\n error_msg = e.message if hasattr(e, \"message\") else str(e)\n msg = f\"Error loading documents: {error_msg}\"\n logger.exception(msg)\n raise ValueError(msg) from e\n\n async def fetch_content(self) -> DataFrame:\n \"\"\"Convert the documents to a DataFrame.\"\"\"\n url_contents = await self.fetch_url_contents()\n return DataFrame(data=url_contents)\n\n async def fetch_content_as_message(self) -> Message:\n \"\"\"Convert the documents to a Message.\"\"\"\n url_contents = await self.fetch_url_contents()\n return Message(text=\"\\n\\n\".join([x[\"text\"] for x in url_contents]), data={\"data\": url_contents})\n" + "value": "import importlib.util\nimport io\nimport re\nfrom urllib.parse import urljoin, urlparse\n\nimport httpx\nfrom bs4 import BeautifulSoup\nfrom markitdown import MarkItDown\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.data import safe_convert\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageTextInput, Output, SliderInput, TableInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.utils.request_utils import get_user_agent\nfrom lfx.utils.ssrf_protection import SSRFProtectionError, is_ssrf_protection_enabled, validate_and_resolve_url\nfrom lfx.utils.ssrf_transport import create_ssrf_protected_client\n\n# Constants\nDEFAULT_TIMEOUT = 30\nDEFAULT_MAX_DEPTH = 1\nDEFAULT_FORMAT = \"Text\"\n\n# HTTP status codes that carry a redirect Location header (RFC 9110).\nREDIRECT_STATUS_CODES = frozenset({301, 302, 303, 307, 308})\n\n# Maximum number of redirects to follow. Each hop is re-validated for SSRF safety;\n# matches httpx's default and the API Request component.\nMAX_REDIRECTS = 20\n\n# Default ports per scheme, used to compare redirect origins.\nDEFAULT_SCHEME_PORTS = {\"http\": 80, \"https\": 443}\n\n\nURL_REGEX = re.compile(\n r\"^(https?:\\/\\/)?\" r\"(www\\.)?\" r\"([a-zA-Z0-9.-]+)\" r\"(\\.[a-zA-Z]{2,})?\" r\"(:\\d+)?\" r\"(\\/[^\\s]*)?$\",\n re.IGNORECASE,\n)\n\nUSER_AGENT = None\n# Check if langflow is installed using importlib.util.find_spec(name))\nif importlib.util.find_spec(\"langflow\"):\n langflow_installed = True\n USER_AGENT = get_user_agent()\nelse:\n langflow_installed = False\n USER_AGENT = \"lfx\"\n\n\nclass URLComponent(Component):\n \"\"\"A component that loads and parses content from web pages recursively.\n\n This component allows fetching content from one or more URLs, with options to:\n - Control crawl depth\n - Prevent crawling outside the root domain\n - Use async loading for better performance\n - Extract either raw HTML or clean text\n - Configure request headers and timeouts\n \"\"\"\n\n display_name = \"URL\"\n description = \"Fetch content from one or more web pages, following links recursively.\"\n documentation: str = \"https://docs.langflow.org/url\"\n icon = \"layout-template\"\n name = \"URLComponent\"\n\n inputs = [\n MessageTextInput(\n name=\"urls\",\n display_name=\"URLs\",\n info=\"Enter one or more URLs to crawl recursively, by clicking the '+' button.\",\n is_list=True,\n tool_mode=True,\n placeholder=\"Enter a URL...\",\n list_add_label=\"Add URL\",\n input_types=[\"Message\"],\n ),\n SliderInput(\n name=\"max_depth\",\n display_name=\"Depth\",\n info=(\n \"Controls how many 'clicks' away from the initial page the crawler will go:\\n\"\n \"- depth 1: only the initial page\\n\"\n \"- depth 2: initial page + all pages linked directly from it\\n\"\n \"- depth 3: initial page + direct links + links found on those direct link pages\\n\"\n \"Note: This is about link traversal, not URL path depth.\"\n ),\n value=DEFAULT_MAX_DEPTH,\n range_spec=RangeSpec(min=1, max=5, step=1),\n required=False,\n min_label=\" \",\n max_label=\" \",\n min_label_icon=\"None\",\n max_label_icon=\"None\",\n # slider_input=True\n ),\n BoolInput(\n name=\"prevent_outside\",\n display_name=\"Prevent Outside\",\n info=(\n \"If enabled, only crawls URLs within the same domain as the root URL. \"\n \"This helps prevent the crawler from going to external websites.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"use_async\",\n display_name=\"Use Async\",\n info=(\n \"If enabled, uses asynchronous loading which can be significantly faster \"\n \"but might use more system resources.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"follow_redirects\",\n display_name=\"Follow Redirects\",\n info=(\n \"If enabled, follows HTTP redirects such as http→https or www/non-www \"\n \"normalization, which most sites rely on to serve their content. When SSRF \"\n \"protection is enabled (the default), every redirect hop is re-validated against \"\n \"the same blocked-IP denylist and DNS-pinned before it is fetched, so following \"\n \"redirects cannot be used to reach internal resources. Disable to capture the \"\n \"first response as-is without following redirects.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=(\n \"Output Format. Use 'Text' to extract the text from the HTML, \"\n \"'Markdown' to parse the HTML into Markdown format, or 'HTML' \"\n \"for the raw HTML content.\"\n ),\n options=[\"Text\", \"HTML\", \"Markdown\"],\n value=DEFAULT_FORMAT,\n advanced=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"Timeout for the request in seconds.\",\n value=DEFAULT_TIMEOUT,\n required=False,\n advanced=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": USER_AGENT}],\n advanced=True,\n input_types=[\"DataFrame\", \"Table\"],\n ),\n BoolInput(\n name=\"filter_text_html\",\n display_name=\"Filter Text/HTML\",\n info=\"If enabled, filters out text/css content type from the results.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"continue_on_failure\",\n display_name=\"Continue on Failure\",\n info=\"If enabled, continues crawling even if some requests fail.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"check_response_status\",\n display_name=\"Check Response Status\",\n info=\"If enabled, checks the response status of the request.\",\n value=False,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"autoset_encoding\",\n display_name=\"Autoset Encoding\",\n info=\"If enabled, automatically sets the encoding of the request.\",\n value=True,\n required=False,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Extracted Pages\", name=\"page_results\", method=\"fetch_content\"),\n Output(display_name=\"Raw Content\", name=\"raw_results\", method=\"fetch_content_as_message\", tool_mode=False),\n ]\n\n @staticmethod\n def _html_extractor(x: str) -> str:\n \"\"\"Extract raw HTML content.\"\"\"\n return x\n\n @staticmethod\n def _text_extractor(x: str) -> str:\n \"\"\"Extract clean text from HTML.\"\"\"\n return BeautifulSoup(x, \"lxml\").get_text()\n\n @staticmethod\n def _markdown_extractor(x: str) -> str:\n \"\"\"Convert HTML to Markdown format.\"\"\"\n stream = io.BytesIO(x.encode(\"utf-8\"))\n result = MarkItDown(enable_plugins=False).convert_stream(stream)\n return result.markdown\n\n @staticmethod\n def validate_url(url: str) -> bool:\n \"\"\"Validates if the given string matches URL pattern.\n\n Args:\n url: The URL string to validate\n\n Returns:\n bool: True if the URL is valid, False otherwise\n \"\"\"\n return bool(URL_REGEX.match(url))\n\n def ensure_url(self, url: str) -> tuple[str, list[str]]:\n \"\"\"Ensures the given string is a valid URL and returns validated IPs for DNS pinning.\n\n Args:\n url: The URL string to validate and normalize\n\n Returns:\n tuple[str, list[str]]: The normalized URL and list of validated IPs for DNS pinning\n\n Raises:\n ValueError: If the URL is invalid or blocked by SSRF protection\n \"\"\"\n url = url.strip()\n if not url.startswith((\"http://\", \"https://\")):\n url = \"https://\" + url\n\n if not self.validate_url(url):\n msg = f\"Invalid URL: {url}\"\n raise ValueError(msg)\n\n # ============================================================================\n # SSRF Protection with DNS Pinning\n # ============================================================================\n # This prevents DNS rebinding attacks by:\n # 1. Resolving DNS and validating IPs during security check\n # 2. Returning the validated IP addresses for DNS pinning\n # 3. Using a custom HTTP transport that forces use of the pinned IPs\n # 4. Ignoring any new DNS resolutions (prevents rebinding)\n #\n # Without DNS pinning, an attacker could:\n # - First DNS lookup: returns public IP (passes validation)\n # - Second DNS lookup: returns internal IP (bypasses protection)\n # - Attack succeeds: accesses internal services\n #\n # With DNS pinning:\n # - First DNS lookup: returns public IP (passes validation)\n # - IPs are pinned and returned\n # - HTTP request: uses pinned IPs directly (no new DNS lookup)\n # - Attack fails: even if DNS changes, we use the validated IPs\n # ============================================================================\n try:\n _validated_url, validated_ips = validate_and_resolve_url(url)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n else:\n # Log DNS pinning information for security auditing\n if validated_ips:\n logger.debug(f\"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s) for {url}\")\n\n return url, validated_ips\n\n def _build_http_client(self, url: str, validated_ips: list[str]) -> httpx.AsyncClient:\n \"\"\"Create an HTTP client with DNS pinning for SSRF protection.\n\n Args:\n url: The request URL whose hostname will be pinned\n validated_ips: IPs validated by validate_and_resolve_url for this URL\n\n Returns:\n httpx.AsyncClient: A client with DNS pinning when SSRF protection is enabled\n \"\"\"\n if is_ssrf_protection_enabled() and validated_ips:\n hostname = urlparse(url).hostname\n if hostname:\n return create_ssrf_protected_client(hostname=hostname, validated_ips=validated_ips)\n return httpx.AsyncClient()\n\n @staticmethod\n def _headers_for_redirect(headers: dict | None, current_url: str, next_url: str) -> dict | None:\n \"\"\"Drop sensitive headers when a redirect crosses to a different origin.\n\n Mirrors httpx's auto-follow behavior so manually following redirects does not\n leak credentials (Authorization / Cookie) to an origin other than the one the\n caller intended them for. Headers are kept only when the redirect stays on the\n same origin (scheme, host, port) or is a direct https upgrade of the same host\n on default ports - the exact cases where httpx keeps the Authorization header.\n \"\"\"\n if not headers:\n return headers\n current, nxt = urlparse(current_url), urlparse(next_url)\n current_port = current.port or DEFAULT_SCHEME_PORTS.get(current.scheme)\n next_port = nxt.port or DEFAULT_SCHEME_PORTS.get(nxt.scheme)\n same_origin = (current.scheme, current.hostname, current_port) == (nxt.scheme, nxt.hostname, next_port)\n https_upgrade = (\n current.hostname == nxt.hostname\n and current.scheme == \"http\"\n and nxt.scheme == \"https\"\n and current_port == DEFAULT_SCHEME_PORTS[\"http\"]\n and next_port == DEFAULT_SCHEME_PORTS[\"https\"]\n )\n if same_origin or https_upgrade:\n return headers\n sensitive = {\"authorization\", \"proxy-authorization\", \"cookie\"}\n return {k: v for k, v in headers.items() if k.lower() not in sensitive}\n\n def _process_response(self, response: httpx.Response) -> tuple[str, dict]:\n \"\"\"Turn a final (non-redirect) response into its HTML content and metadata.\n\n Args:\n response: The HTTP response to process\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n if self.check_response_status:\n response.raise_for_status()\n\n content_type = response.headers.get(\"content-type\", \"\").lower()\n\n # Filter out CSS files if requested\n if self.filter_text_html and \"text/css\" in content_type:\n logger.debug(f\"Skipping CSS file: {response.url}\")\n return \"\", {}\n\n # Get the HTML content\n html_content = response.text\n\n # Extract metadata\n metadata = {\n \"source\": str(response.url),\n \"title\": \"\",\n \"description\": \"\",\n \"content_type\": content_type,\n \"language\": \"\",\n }\n\n # Try to extract title and description from HTML\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - metadata extraction is optional\n # and we don't want to fail the entire request if it fails\n logger.debug(f\"Failed to extract metadata from {response.url}\")\n else:\n if soup.title:\n metadata[\"title\"] = soup.title.string or \"\"\n\n # Try to get description from meta tags\n meta_desc = soup.find(\"meta\", attrs={\"name\": \"description\"})\n if meta_desc and meta_desc.get(\"content\"):\n metadata[\"description\"] = meta_desc[\"content\"]\n\n # Try to get language\n html_tag = soup.find(\"html\")\n if html_tag and html_tag.get(\"lang\"):\n metadata[\"language\"] = html_tag[\"lang\"]\n\n return html_content, metadata\n\n async def _fetch_with_revalidated_redirects(\n self, url: str, validated_ips: list[str], headers: dict\n ) -> tuple[str, dict]:\n \"\"\"Fetch ``url``, following redirects manually and re-validating every hop.\n\n This closes an SSRF bypass: with redirects enabled, httpx would otherwise\n auto-follow a redirect from a validated public URL to an internal address that\n the pinned transport never checked (the redirect target is a different host, so\n it is not in the pin map). Each redirect ``Location`` is resolved (relative\n locations included) and re-validated with ``ensure_url`` - the same private/\n loopback/link-local denylist and DNS pinning applied to the initial request -\n before any connection to it is made. A blocked hop raises ``ValueError`` (unless\n ``continue_on_failure``); the chain is capped at ``MAX_REDIRECTS`` hops.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning of the initial URL\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n current_url = url\n current_ips = validated_ips\n\n for _ in range(MAX_REDIRECTS + 1):\n async with self._build_http_client(current_url, current_ips) as client:\n response = await client.get(current_url, headers=headers, timeout=self.timeout, follow_redirects=False)\n\n location = response.headers.get(\"location\")\n if response.status_code in REDIRECT_STATUS_CODES and location:\n # Resolve relative redirects against the current URL.\n next_url = urljoin(current_url, location)\n\n # Re-validate the redirect target with the same SSRF denylist + DNS pinning.\n try:\n validated_next_url, current_ips = self.ensure_url(next_url)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping blocked or invalid redirect to {next_url}: {e}\")\n return \"\", {}\n msg = f\"SSRF Protection: blocked redirect to {next_url}: {e}\"\n raise ValueError(msg) from e\n\n headers = self._headers_for_redirect(headers, current_url, next_url)\n current_url = validated_next_url\n continue\n\n # Not a redirect (or no Location header) - this is the final response.\n return self._process_response(response)\n\n # Exhausted the redirect budget.\n if self.continue_on_failure:\n logger.warning(f\"Exceeded the maximum of {MAX_REDIRECTS} redirects while requesting {url}\")\n return \"\", {}\n msg = f\"SSRF Protection: exceeded the maximum of {MAX_REDIRECTS} redirects while requesting {url}\"\n raise ValueError(msg)\n\n async def _fetch_url_with_pinning(self, url: str, validated_ips: list[str], headers: dict) -> tuple[str, dict]:\n \"\"\"Fetch a single URL with DNS pinning protection, following redirects safely.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n try:\n # When SSRF protection is enabled we must follow redirects manually so each hop\n # is re-validated and DNS-pinned; letting httpx auto-follow would connect to the\n # redirect target without pinning (different host, not in the pin map) and re-open\n # the SSRF hole that DNS pinning closes. With protection disabled there is no pin\n # to bypass, so httpx can follow redirects natively.\n if self.follow_redirects and is_ssrf_protection_enabled():\n return await self._fetch_with_revalidated_redirects(url, validated_ips, headers)\n\n async with self._build_http_client(url, validated_ips) as client:\n response = await client.get(\n url, headers=headers, timeout=self.timeout, follow_redirects=self.follow_redirects\n )\n return self._process_response(response)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Failed to fetch {url}: {e}\")\n return \"\", {}\n raise\n\n async def _crawl_recursive(\n self, start_url: str, validated_ips: list[str], headers: dict, visited: set, depth: int = 0\n ) -> list[dict]:\n \"\"\"Recursively crawl URLs with DNS pinning protection.\n\n Args:\n start_url: The URL to start crawling from\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n visited: Set of already visited URLs\n depth: Current crawl depth\n\n Returns:\n list[dict]: List of documents with content and metadata\n \"\"\"\n if depth >= self.max_depth or start_url in visited:\n return []\n\n visited.add(start_url)\n documents = []\n\n # Fetch the current URL\n html_content, metadata = await self._fetch_url_with_pinning(start_url, validated_ips, headers)\n\n # Redirects may have landed on a different canonical URL (http->https, www\n # normalization). Resolve relative links and the prevent_outside check against\n # the URL the content actually came from, and mark it visited so links back to\n # the canonical form are not re-crawled.\n base_url = metadata.get(\"source\") or start_url\n visited.add(base_url)\n\n if not html_content:\n return documents\n\n # Extract content based on format\n extractors = {\n \"HTML\": self._html_extractor,\n \"Markdown\": self._markdown_extractor,\n \"Text\": self._text_extractor,\n }\n extractor = extractors.get(self.format, self._text_extractor)\n extracted_content = extractor(html_content)\n\n # Add the document\n documents.append(\n {\n \"page_content\": extracted_content,\n \"metadata\": metadata,\n }\n )\n\n # If we haven't reached max depth, extract and follow links\n if depth < self.max_depth - 1:\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n links = soup.find_all(\"a\", href=True)\n\n for link in links:\n href = link[\"href\"]\n # Resolve relative URLs\n absolute_url = urljoin(base_url, href)\n\n # Skip if already visited\n if absolute_url in visited:\n continue\n\n # Check if we should prevent going outside the domain\n if self.prevent_outside:\n start_domain = urlparse(base_url).netloc\n link_domain = urlparse(absolute_url).netloc\n if start_domain != link_domain:\n continue\n\n # Validate and crawl the linked URL\n try:\n _, link_validated_ips = self.ensure_url(absolute_url)\n sub_docs = await self._crawl_recursive(\n absolute_url, link_validated_ips, headers, visited, depth + 1\n )\n documents.extend(sub_docs)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping {absolute_url}: {e}\")\n continue\n raise\n\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - link extraction is optional\n # and we don't want to fail the entire crawl if one page has issues\n logger.debug(f\"Failed to extract links from {start_url}\")\n\n return documents\n\n async def fetch_url_contents(self) -> list[dict]:\n \"\"\"Load documents from the configured URLs with SSRF protection and DNS pinning.\n\n This method implements comprehensive SSRF (Server-Side Request Forgery) protection\n using DNS pinning to prevent DNS rebinding attacks. Each URL is validated and its\n DNS resolution is pinned before any HTTP requests are made.\n\n Returns:\n list[dict]: List of documents with content and metadata\n\n Raises:\n ValueError: If no valid URLs are provided or if there's an error loading documents\n \"\"\"\n try:\n # Validate all URLs and get their validated IPs for DNS pinning\n validated_urls = []\n first_validation_error: Exception | None = None\n for url in self.urls:\n if not url.strip():\n continue\n try:\n normalized_url, validated_ips = self.ensure_url(url)\n validated_urls.append((normalized_url, validated_ips))\n except (ValueError, SSRFProtectionError) as e:\n # Remember the first rejection so its real cause (for example an\n # SSRF block) can be surfaced if no URL survives validation,\n # instead of being hidden behind \"No valid URLs provided\".\n if first_validation_error is None:\n first_validation_error = e\n if self.continue_on_failure:\n logger.warning(f\"Skipping invalid URL {url}: {e}\")\n continue\n raise\n\n # Remove duplicates while preserving order\n seen = set()\n unique_validated_urls = []\n for url, ips in validated_urls:\n if url not in seen:\n seen.add(url)\n unique_validated_urls.append((url, ips))\n\n logger.debug(f\"Validated {len(unique_validated_urls)} unique URL(s)\")\n\n if not unique_validated_urls:\n # Surface the actual reason every candidate URL was rejected (e.g. an\n # SSRF block) rather than a generic message, so a security failure is\n # not silently swallowed when continue_on_failure is enabled.\n if first_validation_error is not None:\n raise first_validation_error\n msg = \"No valid URLs provided.\"\n raise ValueError(msg)\n\n # Prepare headers\n headers_dict = {header[\"key\"]: header[\"value\"] for header in self.headers if header[\"value\"] is not None}\n\n # Crawl all URLs\n all_docs = []\n for url, validated_ips in unique_validated_urls:\n logger.debug(f\"Crawling {url} with max_depth={self.max_depth}\")\n\n try:\n visited = set()\n docs = await self._crawl_recursive(url, validated_ips, headers_dict, visited, depth=0)\n\n if not docs:\n logger.warning(f\"No documents found for {url}\")\n continue\n\n logger.debug(f\"Found {len(docs)} document(s) from {url}\")\n all_docs.extend(docs)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Error loading documents from {url}: {e}\")\n continue\n msg = f\"Error loading documents from {url}: {e}\"\n raise ValueError(msg) from e\n\n if not all_docs:\n msg = \"No documents were successfully loaded from any URL\"\n raise ValueError(msg)\n\n # Convert to output format\n return [\n {\n \"text\": safe_convert(doc[\"page_content\"], clean_data=True),\n \"url\": doc[\"metadata\"].get(\"source\", \"\"),\n \"title\": doc[\"metadata\"].get(\"title\", \"\"),\n \"description\": doc[\"metadata\"].get(\"description\", \"\"),\n \"content_type\": doc[\"metadata\"].get(\"content_type\", \"\"),\n \"language\": doc[\"metadata\"].get(\"language\", \"\"),\n }\n for doc in all_docs\n ]\n\n except Exception as e:\n error_msg = e.message if hasattr(e, \"message\") else str(e)\n msg = f\"Error loading documents: {error_msg}\"\n logger.exception(msg)\n raise ValueError(msg) from e\n\n async def fetch_content(self) -> DataFrame:\n \"\"\"Convert the documents to a DataFrame.\"\"\"\n url_contents = await self.fetch_url_contents()\n return DataFrame(data=url_contents)\n\n async def fetch_content_as_message(self) -> Message:\n \"\"\"Convert the documents to a Message.\"\"\"\n url_contents = await self.fetch_url_contents()\n return Message(text=\"\\n\\n\".join([x[\"text\"] for x in url_contents]), data={\"data\": url_contents})\n" }, "continue_on_failure": { "_input_type": "BoolInput", @@ -1069,6 +1070,26 @@ "type": "bool", "value": true }, + "follow_redirects": { + "_input_type": "BoolInput", + "advanced": true, + "display_name": "Follow Redirects", + "dynamic": false, + "info": "If enabled, follows HTTP redirects such as http→https or www/non-www normalization, which most sites rely on to serve their content. When SSRF protection is enabled (the default), every redirect hop is re-validated against the same blocked-IP denylist and DNS-pinned before it is fetched, so following redirects cannot be used to reach internal resources. Disable to capture the first response as-is without following redirects.", + "list": false, + "list_add_label": "Add More", + "name": "follow_redirects", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + }, "format": { "_input_type": "DropdownInput", "advanced": true, @@ -1301,6 +1322,7 @@ "max_depth", "prevent_outside", "use_async", + "follow_redirects", "format", "timeout", "headers", @@ -1314,7 +1336,7 @@ "legacy": false, "lf_version": "1.6.0", "metadata": { - "code_hash": "2e225a90043a", + "code_hash": "52b5bd602242", "dependencies": { "dependencies": [ { @@ -1424,7 +1446,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import importlib.util\nimport io\nimport re\nfrom urllib.parse import urljoin, urlparse\n\nimport httpx\nfrom bs4 import BeautifulSoup\nfrom markitdown import MarkItDown\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.data import safe_convert\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageTextInput, Output, SliderInput, TableInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.utils.request_utils import get_user_agent\nfrom lfx.utils.ssrf_protection import SSRFProtectionError, is_ssrf_protection_enabled, validate_and_resolve_url\nfrom lfx.utils.ssrf_transport import create_ssrf_protected_client\n\n# Constants\nDEFAULT_TIMEOUT = 30\nDEFAULT_MAX_DEPTH = 1\nDEFAULT_FORMAT = \"Text\"\n\n\nURL_REGEX = re.compile(\n r\"^(https?:\\/\\/)?\" r\"(www\\.)?\" r\"([a-zA-Z0-9.-]+)\" r\"(\\.[a-zA-Z]{2,})?\" r\"(:\\d+)?\" r\"(\\/[^\\s]*)?$\",\n re.IGNORECASE,\n)\n\nUSER_AGENT = None\n# Check if langflow is installed using importlib.util.find_spec(name))\nif importlib.util.find_spec(\"langflow\"):\n langflow_installed = True\n USER_AGENT = get_user_agent()\nelse:\n langflow_installed = False\n USER_AGENT = \"lfx\"\n\n\nclass URLComponent(Component):\n \"\"\"A component that loads and parses content from web pages recursively.\n\n This component allows fetching content from one or more URLs, with options to:\n - Control crawl depth\n - Prevent crawling outside the root domain\n - Use async loading for better performance\n - Extract either raw HTML or clean text\n - Configure request headers and timeouts\n \"\"\"\n\n display_name = \"URL\"\n description = \"Fetch content from one or more web pages, following links recursively.\"\n documentation: str = \"https://docs.langflow.org/url\"\n icon = \"layout-template\"\n name = \"URLComponent\"\n\n inputs = [\n MessageTextInput(\n name=\"urls\",\n display_name=\"URLs\",\n info=\"Enter one or more URLs to crawl recursively, by clicking the '+' button.\",\n is_list=True,\n tool_mode=True,\n placeholder=\"Enter a URL...\",\n list_add_label=\"Add URL\",\n input_types=[\"Message\"],\n ),\n SliderInput(\n name=\"max_depth\",\n display_name=\"Depth\",\n info=(\n \"Controls how many 'clicks' away from the initial page the crawler will go:\\n\"\n \"- depth 1: only the initial page\\n\"\n \"- depth 2: initial page + all pages linked directly from it\\n\"\n \"- depth 3: initial page + direct links + links found on those direct link pages\\n\"\n \"Note: This is about link traversal, not URL path depth.\"\n ),\n value=DEFAULT_MAX_DEPTH,\n range_spec=RangeSpec(min=1, max=5, step=1),\n required=False,\n min_label=\" \",\n max_label=\" \",\n min_label_icon=\"None\",\n max_label_icon=\"None\",\n # slider_input=True\n ),\n BoolInput(\n name=\"prevent_outside\",\n display_name=\"Prevent Outside\",\n info=(\n \"If enabled, only crawls URLs within the same domain as the root URL. \"\n \"This helps prevent the crawler from going to external websites.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"use_async\",\n display_name=\"Use Async\",\n info=(\n \"If enabled, uses asynchronous loading which can be significantly faster \"\n \"but might use more system resources.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=(\n \"Output Format. Use 'Text' to extract the text from the HTML, \"\n \"'Markdown' to parse the HTML into Markdown format, or 'HTML' \"\n \"for the raw HTML content.\"\n ),\n options=[\"Text\", \"HTML\", \"Markdown\"],\n value=DEFAULT_FORMAT,\n advanced=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"Timeout for the request in seconds.\",\n value=DEFAULT_TIMEOUT,\n required=False,\n advanced=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": USER_AGENT}],\n advanced=True,\n input_types=[\"DataFrame\", \"Table\"],\n ),\n BoolInput(\n name=\"filter_text_html\",\n display_name=\"Filter Text/HTML\",\n info=\"If enabled, filters out text/css content type from the results.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"continue_on_failure\",\n display_name=\"Continue on Failure\",\n info=\"If enabled, continues crawling even if some requests fail.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"check_response_status\",\n display_name=\"Check Response Status\",\n info=\"If enabled, checks the response status of the request.\",\n value=False,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"autoset_encoding\",\n display_name=\"Autoset Encoding\",\n info=\"If enabled, automatically sets the encoding of the request.\",\n value=True,\n required=False,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Extracted Pages\", name=\"page_results\", method=\"fetch_content\"),\n Output(display_name=\"Raw Content\", name=\"raw_results\", method=\"fetch_content_as_message\", tool_mode=False),\n ]\n\n @staticmethod\n def _html_extractor(x: str) -> str:\n \"\"\"Extract raw HTML content.\"\"\"\n return x\n\n @staticmethod\n def _text_extractor(x: str) -> str:\n \"\"\"Extract clean text from HTML.\"\"\"\n return BeautifulSoup(x, \"lxml\").get_text()\n\n @staticmethod\n def _markdown_extractor(x: str) -> str:\n \"\"\"Convert HTML to Markdown format.\"\"\"\n stream = io.BytesIO(x.encode(\"utf-8\"))\n result = MarkItDown(enable_plugins=False).convert_stream(stream)\n return result.markdown\n\n @staticmethod\n def validate_url(url: str) -> bool:\n \"\"\"Validates if the given string matches URL pattern.\n\n Args:\n url: The URL string to validate\n\n Returns:\n bool: True if the URL is valid, False otherwise\n \"\"\"\n return bool(URL_REGEX.match(url))\n\n def ensure_url(self, url: str) -> tuple[str, list[str]]:\n \"\"\"Ensures the given string is a valid URL and returns validated IPs for DNS pinning.\n\n Args:\n url: The URL string to validate and normalize\n\n Returns:\n tuple[str, list[str]]: The normalized URL and list of validated IPs for DNS pinning\n\n Raises:\n ValueError: If the URL is invalid or blocked by SSRF protection\n \"\"\"\n url = url.strip()\n if not url.startswith((\"http://\", \"https://\")):\n url = \"https://\" + url\n\n if not self.validate_url(url):\n msg = f\"Invalid URL: {url}\"\n raise ValueError(msg)\n\n # ============================================================================\n # SSRF Protection with DNS Pinning\n # ============================================================================\n # This prevents DNS rebinding attacks by:\n # 1. Resolving DNS and validating IPs during security check\n # 2. Returning the validated IP addresses for DNS pinning\n # 3. Using a custom HTTP transport that forces use of the pinned IPs\n # 4. Ignoring any new DNS resolutions (prevents rebinding)\n #\n # Without DNS pinning, an attacker could:\n # - First DNS lookup: returns public IP (passes validation)\n # - Second DNS lookup: returns internal IP (bypasses protection)\n # - Attack succeeds: accesses internal services\n #\n # With DNS pinning:\n # - First DNS lookup: returns public IP (passes validation)\n # - IPs are pinned and returned\n # - HTTP request: uses pinned IPs directly (no new DNS lookup)\n # - Attack fails: even if DNS changes, we use the validated IPs\n # ============================================================================\n try:\n _validated_url, validated_ips = validate_and_resolve_url(url)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n else:\n # Log DNS pinning information for security auditing\n if validated_ips:\n logger.debug(f\"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s) for {url}\")\n\n return url, validated_ips\n\n def _build_http_client(self, url: str, validated_ips: list[str]) -> httpx.AsyncClient:\n \"\"\"Create an HTTP client with DNS pinning for SSRF protection.\n\n Args:\n url: The request URL whose hostname will be pinned\n validated_ips: IPs validated by validate_and_resolve_url for this URL\n\n Returns:\n httpx.AsyncClient: A client with DNS pinning when SSRF protection is enabled\n \"\"\"\n if is_ssrf_protection_enabled() and validated_ips:\n hostname = urlparse(url).hostname\n if hostname:\n return create_ssrf_protected_client(hostname=hostname, validated_ips=validated_ips)\n return httpx.AsyncClient()\n\n async def _fetch_url_with_pinning(self, url: str, validated_ips: list[str], headers: dict) -> tuple[str, dict]:\n \"\"\"Fetch a single URL with DNS pinning protection.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n async with self._build_http_client(url, validated_ips) as client:\n try:\n response = await client.get(url, headers=headers, timeout=self.timeout, follow_redirects=False)\n\n if self.check_response_status:\n response.raise_for_status()\n\n content_type = response.headers.get(\"content-type\", \"\").lower()\n\n # Filter out CSS files if requested\n if self.filter_text_html and \"text/css\" in content_type:\n logger.debug(f\"Skipping CSS file: {url}\")\n return \"\", {}\n\n # Get the HTML content\n html_content = response.text\n\n # Extract metadata\n metadata = {\n \"source\": str(response.url),\n \"title\": \"\",\n \"description\": \"\",\n \"content_type\": content_type,\n \"language\": \"\",\n }\n\n # Try to extract title and description from HTML\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - metadata extraction is optional\n # and we don't want to fail the entire request if it fails\n logger.debug(f\"Failed to extract metadata from {url}\")\n else:\n if soup.title:\n metadata[\"title\"] = soup.title.string or \"\"\n\n # Try to get description from meta tags\n meta_desc = soup.find(\"meta\", attrs={\"name\": \"description\"})\n if meta_desc and meta_desc.get(\"content\"):\n metadata[\"description\"] = meta_desc[\"content\"]\n\n # Try to get language\n html_tag = soup.find(\"html\")\n if html_tag and html_tag.get(\"lang\"):\n metadata[\"language\"] = html_tag[\"lang\"]\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Failed to fetch {url}: {e}\")\n return \"\", {}\n raise\n else:\n return html_content, metadata\n\n async def _crawl_recursive(\n self, start_url: str, validated_ips: list[str], headers: dict, visited: set, depth: int = 0\n ) -> list[dict]:\n \"\"\"Recursively crawl URLs with DNS pinning protection.\n\n Args:\n start_url: The URL to start crawling from\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n visited: Set of already visited URLs\n depth: Current crawl depth\n\n Returns:\n list[dict]: List of documents with content and metadata\n \"\"\"\n if depth >= self.max_depth or start_url in visited:\n return []\n\n visited.add(start_url)\n documents = []\n\n # Fetch the current URL\n html_content, metadata = await self._fetch_url_with_pinning(start_url, validated_ips, headers)\n\n if not html_content:\n return documents\n\n # Extract content based on format\n extractors = {\n \"HTML\": self._html_extractor,\n \"Markdown\": self._markdown_extractor,\n \"Text\": self._text_extractor,\n }\n extractor = extractors.get(self.format, self._text_extractor)\n extracted_content = extractor(html_content)\n\n # Add the document\n documents.append(\n {\n \"page_content\": extracted_content,\n \"metadata\": metadata,\n }\n )\n\n # If we haven't reached max depth, extract and follow links\n if depth < self.max_depth - 1:\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n links = soup.find_all(\"a\", href=True)\n\n for link in links:\n href = link[\"href\"]\n # Resolve relative URLs\n absolute_url = urljoin(start_url, href)\n\n # Skip if already visited\n if absolute_url in visited:\n continue\n\n # Check if we should prevent going outside the domain\n if self.prevent_outside:\n start_domain = urlparse(start_url).netloc\n link_domain = urlparse(absolute_url).netloc\n if start_domain != link_domain:\n continue\n\n # Validate and crawl the linked URL\n try:\n _, link_validated_ips = self.ensure_url(absolute_url)\n sub_docs = await self._crawl_recursive(\n absolute_url, link_validated_ips, headers, visited, depth + 1\n )\n documents.extend(sub_docs)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping {absolute_url}: {e}\")\n continue\n raise\n\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - link extraction is optional\n # and we don't want to fail the entire crawl if one page has issues\n logger.debug(f\"Failed to extract links from {start_url}\")\n\n return documents\n\n async def fetch_url_contents(self) -> list[dict]:\n \"\"\"Load documents from the configured URLs with SSRF protection and DNS pinning.\n\n This method implements comprehensive SSRF (Server-Side Request Forgery) protection\n using DNS pinning to prevent DNS rebinding attacks. Each URL is validated and its\n DNS resolution is pinned before any HTTP requests are made.\n\n Returns:\n list[dict]: List of documents with content and metadata\n\n Raises:\n ValueError: If no valid URLs are provided or if there's an error loading documents\n \"\"\"\n try:\n # Validate all URLs and get their validated IPs for DNS pinning\n validated_urls = []\n first_validation_error: Exception | None = None\n for url in self.urls:\n if not url.strip():\n continue\n try:\n normalized_url, validated_ips = self.ensure_url(url)\n validated_urls.append((normalized_url, validated_ips))\n except (ValueError, SSRFProtectionError) as e:\n # Remember the first rejection so its real cause (for example an\n # SSRF block) can be surfaced if no URL survives validation,\n # instead of being hidden behind \"No valid URLs provided\".\n if first_validation_error is None:\n first_validation_error = e\n if self.continue_on_failure:\n logger.warning(f\"Skipping invalid URL {url}: {e}\")\n continue\n raise\n\n # Remove duplicates while preserving order\n seen = set()\n unique_validated_urls = []\n for url, ips in validated_urls:\n if url not in seen:\n seen.add(url)\n unique_validated_urls.append((url, ips))\n\n logger.debug(f\"Validated {len(unique_validated_urls)} unique URL(s)\")\n\n if not unique_validated_urls:\n # Surface the actual reason every candidate URL was rejected (e.g. an\n # SSRF block) rather than a generic message, so a security failure is\n # not silently swallowed when continue_on_failure is enabled.\n if first_validation_error is not None:\n raise first_validation_error\n msg = \"No valid URLs provided.\"\n raise ValueError(msg)\n\n # Prepare headers\n headers_dict = {header[\"key\"]: header[\"value\"] for header in self.headers if header[\"value\"] is not None}\n\n # Crawl all URLs\n all_docs = []\n for url, validated_ips in unique_validated_urls:\n logger.debug(f\"Crawling {url} with max_depth={self.max_depth}\")\n\n try:\n visited = set()\n docs = await self._crawl_recursive(url, validated_ips, headers_dict, visited, depth=0)\n\n if not docs:\n logger.warning(f\"No documents found for {url}\")\n continue\n\n logger.debug(f\"Found {len(docs)} document(s) from {url}\")\n all_docs.extend(docs)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Error loading documents from {url}: {e}\")\n continue\n msg = f\"Error loading documents from {url}: {e}\"\n raise ValueError(msg) from e\n\n if not all_docs:\n msg = \"No documents were successfully loaded from any URL\"\n raise ValueError(msg)\n\n # Convert to output format\n return [\n {\n \"text\": safe_convert(doc[\"page_content\"], clean_data=True),\n \"url\": doc[\"metadata\"].get(\"source\", \"\"),\n \"title\": doc[\"metadata\"].get(\"title\", \"\"),\n \"description\": doc[\"metadata\"].get(\"description\", \"\"),\n \"content_type\": doc[\"metadata\"].get(\"content_type\", \"\"),\n \"language\": doc[\"metadata\"].get(\"language\", \"\"),\n }\n for doc in all_docs\n ]\n\n except Exception as e:\n error_msg = e.message if hasattr(e, \"message\") else str(e)\n msg = f\"Error loading documents: {error_msg}\"\n logger.exception(msg)\n raise ValueError(msg) from e\n\n async def fetch_content(self) -> DataFrame:\n \"\"\"Convert the documents to a DataFrame.\"\"\"\n url_contents = await self.fetch_url_contents()\n return DataFrame(data=url_contents)\n\n async def fetch_content_as_message(self) -> Message:\n \"\"\"Convert the documents to a Message.\"\"\"\n url_contents = await self.fetch_url_contents()\n return Message(text=\"\\n\\n\".join([x[\"text\"] for x in url_contents]), data={\"data\": url_contents})\n" + "value": "import importlib.util\nimport io\nimport re\nfrom urllib.parse import urljoin, urlparse\n\nimport httpx\nfrom bs4 import BeautifulSoup\nfrom markitdown import MarkItDown\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.data import safe_convert\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageTextInput, Output, SliderInput, TableInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.utils.request_utils import get_user_agent\nfrom lfx.utils.ssrf_protection import SSRFProtectionError, is_ssrf_protection_enabled, validate_and_resolve_url\nfrom lfx.utils.ssrf_transport import create_ssrf_protected_client\n\n# Constants\nDEFAULT_TIMEOUT = 30\nDEFAULT_MAX_DEPTH = 1\nDEFAULT_FORMAT = \"Text\"\n\n# HTTP status codes that carry a redirect Location header (RFC 9110).\nREDIRECT_STATUS_CODES = frozenset({301, 302, 303, 307, 308})\n\n# Maximum number of redirects to follow. Each hop is re-validated for SSRF safety;\n# matches httpx's default and the API Request component.\nMAX_REDIRECTS = 20\n\n# Default ports per scheme, used to compare redirect origins.\nDEFAULT_SCHEME_PORTS = {\"http\": 80, \"https\": 443}\n\n\nURL_REGEX = re.compile(\n r\"^(https?:\\/\\/)?\" r\"(www\\.)?\" r\"([a-zA-Z0-9.-]+)\" r\"(\\.[a-zA-Z]{2,})?\" r\"(:\\d+)?\" r\"(\\/[^\\s]*)?$\",\n re.IGNORECASE,\n)\n\nUSER_AGENT = None\n# Check if langflow is installed using importlib.util.find_spec(name))\nif importlib.util.find_spec(\"langflow\"):\n langflow_installed = True\n USER_AGENT = get_user_agent()\nelse:\n langflow_installed = False\n USER_AGENT = \"lfx\"\n\n\nclass URLComponent(Component):\n \"\"\"A component that loads and parses content from web pages recursively.\n\n This component allows fetching content from one or more URLs, with options to:\n - Control crawl depth\n - Prevent crawling outside the root domain\n - Use async loading for better performance\n - Extract either raw HTML or clean text\n - Configure request headers and timeouts\n \"\"\"\n\n display_name = \"URL\"\n description = \"Fetch content from one or more web pages, following links recursively.\"\n documentation: str = \"https://docs.langflow.org/url\"\n icon = \"layout-template\"\n name = \"URLComponent\"\n\n inputs = [\n MessageTextInput(\n name=\"urls\",\n display_name=\"URLs\",\n info=\"Enter one or more URLs to crawl recursively, by clicking the '+' button.\",\n is_list=True,\n tool_mode=True,\n placeholder=\"Enter a URL...\",\n list_add_label=\"Add URL\",\n input_types=[\"Message\"],\n ),\n SliderInput(\n name=\"max_depth\",\n display_name=\"Depth\",\n info=(\n \"Controls how many 'clicks' away from the initial page the crawler will go:\\n\"\n \"- depth 1: only the initial page\\n\"\n \"- depth 2: initial page + all pages linked directly from it\\n\"\n \"- depth 3: initial page + direct links + links found on those direct link pages\\n\"\n \"Note: This is about link traversal, not URL path depth.\"\n ),\n value=DEFAULT_MAX_DEPTH,\n range_spec=RangeSpec(min=1, max=5, step=1),\n required=False,\n min_label=\" \",\n max_label=\" \",\n min_label_icon=\"None\",\n max_label_icon=\"None\",\n # slider_input=True\n ),\n BoolInput(\n name=\"prevent_outside\",\n display_name=\"Prevent Outside\",\n info=(\n \"If enabled, only crawls URLs within the same domain as the root URL. \"\n \"This helps prevent the crawler from going to external websites.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"use_async\",\n display_name=\"Use Async\",\n info=(\n \"If enabled, uses asynchronous loading which can be significantly faster \"\n \"but might use more system resources.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"follow_redirects\",\n display_name=\"Follow Redirects\",\n info=(\n \"If enabled, follows HTTP redirects such as http→https or www/non-www \"\n \"normalization, which most sites rely on to serve their content. When SSRF \"\n \"protection is enabled (the default), every redirect hop is re-validated against \"\n \"the same blocked-IP denylist and DNS-pinned before it is fetched, so following \"\n \"redirects cannot be used to reach internal resources. Disable to capture the \"\n \"first response as-is without following redirects.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=(\n \"Output Format. Use 'Text' to extract the text from the HTML, \"\n \"'Markdown' to parse the HTML into Markdown format, or 'HTML' \"\n \"for the raw HTML content.\"\n ),\n options=[\"Text\", \"HTML\", \"Markdown\"],\n value=DEFAULT_FORMAT,\n advanced=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"Timeout for the request in seconds.\",\n value=DEFAULT_TIMEOUT,\n required=False,\n advanced=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": USER_AGENT}],\n advanced=True,\n input_types=[\"DataFrame\", \"Table\"],\n ),\n BoolInput(\n name=\"filter_text_html\",\n display_name=\"Filter Text/HTML\",\n info=\"If enabled, filters out text/css content type from the results.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"continue_on_failure\",\n display_name=\"Continue on Failure\",\n info=\"If enabled, continues crawling even if some requests fail.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"check_response_status\",\n display_name=\"Check Response Status\",\n info=\"If enabled, checks the response status of the request.\",\n value=False,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"autoset_encoding\",\n display_name=\"Autoset Encoding\",\n info=\"If enabled, automatically sets the encoding of the request.\",\n value=True,\n required=False,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Extracted Pages\", name=\"page_results\", method=\"fetch_content\"),\n Output(display_name=\"Raw Content\", name=\"raw_results\", method=\"fetch_content_as_message\", tool_mode=False),\n ]\n\n @staticmethod\n def _html_extractor(x: str) -> str:\n \"\"\"Extract raw HTML content.\"\"\"\n return x\n\n @staticmethod\n def _text_extractor(x: str) -> str:\n \"\"\"Extract clean text from HTML.\"\"\"\n return BeautifulSoup(x, \"lxml\").get_text()\n\n @staticmethod\n def _markdown_extractor(x: str) -> str:\n \"\"\"Convert HTML to Markdown format.\"\"\"\n stream = io.BytesIO(x.encode(\"utf-8\"))\n result = MarkItDown(enable_plugins=False).convert_stream(stream)\n return result.markdown\n\n @staticmethod\n def validate_url(url: str) -> bool:\n \"\"\"Validates if the given string matches URL pattern.\n\n Args:\n url: The URL string to validate\n\n Returns:\n bool: True if the URL is valid, False otherwise\n \"\"\"\n return bool(URL_REGEX.match(url))\n\n def ensure_url(self, url: str) -> tuple[str, list[str]]:\n \"\"\"Ensures the given string is a valid URL and returns validated IPs for DNS pinning.\n\n Args:\n url: The URL string to validate and normalize\n\n Returns:\n tuple[str, list[str]]: The normalized URL and list of validated IPs for DNS pinning\n\n Raises:\n ValueError: If the URL is invalid or blocked by SSRF protection\n \"\"\"\n url = url.strip()\n if not url.startswith((\"http://\", \"https://\")):\n url = \"https://\" + url\n\n if not self.validate_url(url):\n msg = f\"Invalid URL: {url}\"\n raise ValueError(msg)\n\n # ============================================================================\n # SSRF Protection with DNS Pinning\n # ============================================================================\n # This prevents DNS rebinding attacks by:\n # 1. Resolving DNS and validating IPs during security check\n # 2. Returning the validated IP addresses for DNS pinning\n # 3. Using a custom HTTP transport that forces use of the pinned IPs\n # 4. Ignoring any new DNS resolutions (prevents rebinding)\n #\n # Without DNS pinning, an attacker could:\n # - First DNS lookup: returns public IP (passes validation)\n # - Second DNS lookup: returns internal IP (bypasses protection)\n # - Attack succeeds: accesses internal services\n #\n # With DNS pinning:\n # - First DNS lookup: returns public IP (passes validation)\n # - IPs are pinned and returned\n # - HTTP request: uses pinned IPs directly (no new DNS lookup)\n # - Attack fails: even if DNS changes, we use the validated IPs\n # ============================================================================\n try:\n _validated_url, validated_ips = validate_and_resolve_url(url)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n else:\n # Log DNS pinning information for security auditing\n if validated_ips:\n logger.debug(f\"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s) for {url}\")\n\n return url, validated_ips\n\n def _build_http_client(self, url: str, validated_ips: list[str]) -> httpx.AsyncClient:\n \"\"\"Create an HTTP client with DNS pinning for SSRF protection.\n\n Args:\n url: The request URL whose hostname will be pinned\n validated_ips: IPs validated by validate_and_resolve_url for this URL\n\n Returns:\n httpx.AsyncClient: A client with DNS pinning when SSRF protection is enabled\n \"\"\"\n if is_ssrf_protection_enabled() and validated_ips:\n hostname = urlparse(url).hostname\n if hostname:\n return create_ssrf_protected_client(hostname=hostname, validated_ips=validated_ips)\n return httpx.AsyncClient()\n\n @staticmethod\n def _headers_for_redirect(headers: dict | None, current_url: str, next_url: str) -> dict | None:\n \"\"\"Drop sensitive headers when a redirect crosses to a different origin.\n\n Mirrors httpx's auto-follow behavior so manually following redirects does not\n leak credentials (Authorization / Cookie) to an origin other than the one the\n caller intended them for. Headers are kept only when the redirect stays on the\n same origin (scheme, host, port) or is a direct https upgrade of the same host\n on default ports - the exact cases where httpx keeps the Authorization header.\n \"\"\"\n if not headers:\n return headers\n current, nxt = urlparse(current_url), urlparse(next_url)\n current_port = current.port or DEFAULT_SCHEME_PORTS.get(current.scheme)\n next_port = nxt.port or DEFAULT_SCHEME_PORTS.get(nxt.scheme)\n same_origin = (current.scheme, current.hostname, current_port) == (nxt.scheme, nxt.hostname, next_port)\n https_upgrade = (\n current.hostname == nxt.hostname\n and current.scheme == \"http\"\n and nxt.scheme == \"https\"\n and current_port == DEFAULT_SCHEME_PORTS[\"http\"]\n and next_port == DEFAULT_SCHEME_PORTS[\"https\"]\n )\n if same_origin or https_upgrade:\n return headers\n sensitive = {\"authorization\", \"proxy-authorization\", \"cookie\"}\n return {k: v for k, v in headers.items() if k.lower() not in sensitive}\n\n def _process_response(self, response: httpx.Response) -> tuple[str, dict]:\n \"\"\"Turn a final (non-redirect) response into its HTML content and metadata.\n\n Args:\n response: The HTTP response to process\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n if self.check_response_status:\n response.raise_for_status()\n\n content_type = response.headers.get(\"content-type\", \"\").lower()\n\n # Filter out CSS files if requested\n if self.filter_text_html and \"text/css\" in content_type:\n logger.debug(f\"Skipping CSS file: {response.url}\")\n return \"\", {}\n\n # Get the HTML content\n html_content = response.text\n\n # Extract metadata\n metadata = {\n \"source\": str(response.url),\n \"title\": \"\",\n \"description\": \"\",\n \"content_type\": content_type,\n \"language\": \"\",\n }\n\n # Try to extract title and description from HTML\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - metadata extraction is optional\n # and we don't want to fail the entire request if it fails\n logger.debug(f\"Failed to extract metadata from {response.url}\")\n else:\n if soup.title:\n metadata[\"title\"] = soup.title.string or \"\"\n\n # Try to get description from meta tags\n meta_desc = soup.find(\"meta\", attrs={\"name\": \"description\"})\n if meta_desc and meta_desc.get(\"content\"):\n metadata[\"description\"] = meta_desc[\"content\"]\n\n # Try to get language\n html_tag = soup.find(\"html\")\n if html_tag and html_tag.get(\"lang\"):\n metadata[\"language\"] = html_tag[\"lang\"]\n\n return html_content, metadata\n\n async def _fetch_with_revalidated_redirects(\n self, url: str, validated_ips: list[str], headers: dict\n ) -> tuple[str, dict]:\n \"\"\"Fetch ``url``, following redirects manually and re-validating every hop.\n\n This closes an SSRF bypass: with redirects enabled, httpx would otherwise\n auto-follow a redirect from a validated public URL to an internal address that\n the pinned transport never checked (the redirect target is a different host, so\n it is not in the pin map). Each redirect ``Location`` is resolved (relative\n locations included) and re-validated with ``ensure_url`` - the same private/\n loopback/link-local denylist and DNS pinning applied to the initial request -\n before any connection to it is made. A blocked hop raises ``ValueError`` (unless\n ``continue_on_failure``); the chain is capped at ``MAX_REDIRECTS`` hops.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning of the initial URL\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n current_url = url\n current_ips = validated_ips\n\n for _ in range(MAX_REDIRECTS + 1):\n async with self._build_http_client(current_url, current_ips) as client:\n response = await client.get(current_url, headers=headers, timeout=self.timeout, follow_redirects=False)\n\n location = response.headers.get(\"location\")\n if response.status_code in REDIRECT_STATUS_CODES and location:\n # Resolve relative redirects against the current URL.\n next_url = urljoin(current_url, location)\n\n # Re-validate the redirect target with the same SSRF denylist + DNS pinning.\n try:\n validated_next_url, current_ips = self.ensure_url(next_url)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping blocked or invalid redirect to {next_url}: {e}\")\n return \"\", {}\n msg = f\"SSRF Protection: blocked redirect to {next_url}: {e}\"\n raise ValueError(msg) from e\n\n headers = self._headers_for_redirect(headers, current_url, next_url)\n current_url = validated_next_url\n continue\n\n # Not a redirect (or no Location header) - this is the final response.\n return self._process_response(response)\n\n # Exhausted the redirect budget.\n if self.continue_on_failure:\n logger.warning(f\"Exceeded the maximum of {MAX_REDIRECTS} redirects while requesting {url}\")\n return \"\", {}\n msg = f\"SSRF Protection: exceeded the maximum of {MAX_REDIRECTS} redirects while requesting {url}\"\n raise ValueError(msg)\n\n async def _fetch_url_with_pinning(self, url: str, validated_ips: list[str], headers: dict) -> tuple[str, dict]:\n \"\"\"Fetch a single URL with DNS pinning protection, following redirects safely.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n try:\n # When SSRF protection is enabled we must follow redirects manually so each hop\n # is re-validated and DNS-pinned; letting httpx auto-follow would connect to the\n # redirect target without pinning (different host, not in the pin map) and re-open\n # the SSRF hole that DNS pinning closes. With protection disabled there is no pin\n # to bypass, so httpx can follow redirects natively.\n if self.follow_redirects and is_ssrf_protection_enabled():\n return await self._fetch_with_revalidated_redirects(url, validated_ips, headers)\n\n async with self._build_http_client(url, validated_ips) as client:\n response = await client.get(\n url, headers=headers, timeout=self.timeout, follow_redirects=self.follow_redirects\n )\n return self._process_response(response)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Failed to fetch {url}: {e}\")\n return \"\", {}\n raise\n\n async def _crawl_recursive(\n self, start_url: str, validated_ips: list[str], headers: dict, visited: set, depth: int = 0\n ) -> list[dict]:\n \"\"\"Recursively crawl URLs with DNS pinning protection.\n\n Args:\n start_url: The URL to start crawling from\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n visited: Set of already visited URLs\n depth: Current crawl depth\n\n Returns:\n list[dict]: List of documents with content and metadata\n \"\"\"\n if depth >= self.max_depth or start_url in visited:\n return []\n\n visited.add(start_url)\n documents = []\n\n # Fetch the current URL\n html_content, metadata = await self._fetch_url_with_pinning(start_url, validated_ips, headers)\n\n # Redirects may have landed on a different canonical URL (http->https, www\n # normalization). Resolve relative links and the prevent_outside check against\n # the URL the content actually came from, and mark it visited so links back to\n # the canonical form are not re-crawled.\n base_url = metadata.get(\"source\") or start_url\n visited.add(base_url)\n\n if not html_content:\n return documents\n\n # Extract content based on format\n extractors = {\n \"HTML\": self._html_extractor,\n \"Markdown\": self._markdown_extractor,\n \"Text\": self._text_extractor,\n }\n extractor = extractors.get(self.format, self._text_extractor)\n extracted_content = extractor(html_content)\n\n # Add the document\n documents.append(\n {\n \"page_content\": extracted_content,\n \"metadata\": metadata,\n }\n )\n\n # If we haven't reached max depth, extract and follow links\n if depth < self.max_depth - 1:\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n links = soup.find_all(\"a\", href=True)\n\n for link in links:\n href = link[\"href\"]\n # Resolve relative URLs\n absolute_url = urljoin(base_url, href)\n\n # Skip if already visited\n if absolute_url in visited:\n continue\n\n # Check if we should prevent going outside the domain\n if self.prevent_outside:\n start_domain = urlparse(base_url).netloc\n link_domain = urlparse(absolute_url).netloc\n if start_domain != link_domain:\n continue\n\n # Validate and crawl the linked URL\n try:\n _, link_validated_ips = self.ensure_url(absolute_url)\n sub_docs = await self._crawl_recursive(\n absolute_url, link_validated_ips, headers, visited, depth + 1\n )\n documents.extend(sub_docs)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping {absolute_url}: {e}\")\n continue\n raise\n\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - link extraction is optional\n # and we don't want to fail the entire crawl if one page has issues\n logger.debug(f\"Failed to extract links from {start_url}\")\n\n return documents\n\n async def fetch_url_contents(self) -> list[dict]:\n \"\"\"Load documents from the configured URLs with SSRF protection and DNS pinning.\n\n This method implements comprehensive SSRF (Server-Side Request Forgery) protection\n using DNS pinning to prevent DNS rebinding attacks. Each URL is validated and its\n DNS resolution is pinned before any HTTP requests are made.\n\n Returns:\n list[dict]: List of documents with content and metadata\n\n Raises:\n ValueError: If no valid URLs are provided or if there's an error loading documents\n \"\"\"\n try:\n # Validate all URLs and get their validated IPs for DNS pinning\n validated_urls = []\n first_validation_error: Exception | None = None\n for url in self.urls:\n if not url.strip():\n continue\n try:\n normalized_url, validated_ips = self.ensure_url(url)\n validated_urls.append((normalized_url, validated_ips))\n except (ValueError, SSRFProtectionError) as e:\n # Remember the first rejection so its real cause (for example an\n # SSRF block) can be surfaced if no URL survives validation,\n # instead of being hidden behind \"No valid URLs provided\".\n if first_validation_error is None:\n first_validation_error = e\n if self.continue_on_failure:\n logger.warning(f\"Skipping invalid URL {url}: {e}\")\n continue\n raise\n\n # Remove duplicates while preserving order\n seen = set()\n unique_validated_urls = []\n for url, ips in validated_urls:\n if url not in seen:\n seen.add(url)\n unique_validated_urls.append((url, ips))\n\n logger.debug(f\"Validated {len(unique_validated_urls)} unique URL(s)\")\n\n if not unique_validated_urls:\n # Surface the actual reason every candidate URL was rejected (e.g. an\n # SSRF block) rather than a generic message, so a security failure is\n # not silently swallowed when continue_on_failure is enabled.\n if first_validation_error is not None:\n raise first_validation_error\n msg = \"No valid URLs provided.\"\n raise ValueError(msg)\n\n # Prepare headers\n headers_dict = {header[\"key\"]: header[\"value\"] for header in self.headers if header[\"value\"] is not None}\n\n # Crawl all URLs\n all_docs = []\n for url, validated_ips in unique_validated_urls:\n logger.debug(f\"Crawling {url} with max_depth={self.max_depth}\")\n\n try:\n visited = set()\n docs = await self._crawl_recursive(url, validated_ips, headers_dict, visited, depth=0)\n\n if not docs:\n logger.warning(f\"No documents found for {url}\")\n continue\n\n logger.debug(f\"Found {len(docs)} document(s) from {url}\")\n all_docs.extend(docs)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Error loading documents from {url}: {e}\")\n continue\n msg = f\"Error loading documents from {url}: {e}\"\n raise ValueError(msg) from e\n\n if not all_docs:\n msg = \"No documents were successfully loaded from any URL\"\n raise ValueError(msg)\n\n # Convert to output format\n return [\n {\n \"text\": safe_convert(doc[\"page_content\"], clean_data=True),\n \"url\": doc[\"metadata\"].get(\"source\", \"\"),\n \"title\": doc[\"metadata\"].get(\"title\", \"\"),\n \"description\": doc[\"metadata\"].get(\"description\", \"\"),\n \"content_type\": doc[\"metadata\"].get(\"content_type\", \"\"),\n \"language\": doc[\"metadata\"].get(\"language\", \"\"),\n }\n for doc in all_docs\n ]\n\n except Exception as e:\n error_msg = e.message if hasattr(e, \"message\") else str(e)\n msg = f\"Error loading documents: {error_msg}\"\n logger.exception(msg)\n raise ValueError(msg) from e\n\n async def fetch_content(self) -> DataFrame:\n \"\"\"Convert the documents to a DataFrame.\"\"\"\n url_contents = await self.fetch_url_contents()\n return DataFrame(data=url_contents)\n\n async def fetch_content_as_message(self) -> Message:\n \"\"\"Convert the documents to a Message.\"\"\"\n url_contents = await self.fetch_url_contents()\n return Message(text=\"\\n\\n\".join([x[\"text\"] for x in url_contents]), data={\"data\": url_contents})\n" }, "continue_on_failure": { "_input_type": "BoolInput", @@ -1462,6 +1484,26 @@ "type": "bool", "value": true }, + "follow_redirects": { + "_input_type": "BoolInput", + "advanced": true, + "display_name": "Follow Redirects", + "dynamic": false, + "info": "If enabled, follows HTTP redirects such as http→https or www/non-www normalization, which most sites rely on to serve their content. When SSRF protection is enabled (the default), every redirect hop is re-validated against the same blocked-IP denylist and DNS-pinned before it is fetched, so following redirects cannot be used to reach internal resources. Disable to capture the first response as-is without following redirects.", + "list": false, + "list_add_label": "Add More", + "name": "follow_redirects", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + }, "format": { "_input_type": "DropdownInput", "advanced": true, @@ -1700,6 +1742,7 @@ "max_depth", "prevent_outside", "use_async", + "follow_redirects", "format", "timeout", "headers", @@ -1713,7 +1756,7 @@ "legacy": false, "lf_version": "1.6.0", "metadata": { - "code_hash": "2e225a90043a", + "code_hash": "52b5bd602242", "dependencies": { "dependencies": [ { @@ -1823,7 +1866,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import importlib.util\nimport io\nimport re\nfrom urllib.parse import urljoin, urlparse\n\nimport httpx\nfrom bs4 import BeautifulSoup\nfrom markitdown import MarkItDown\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.data import safe_convert\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageTextInput, Output, SliderInput, TableInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.utils.request_utils import get_user_agent\nfrom lfx.utils.ssrf_protection import SSRFProtectionError, is_ssrf_protection_enabled, validate_and_resolve_url\nfrom lfx.utils.ssrf_transport import create_ssrf_protected_client\n\n# Constants\nDEFAULT_TIMEOUT = 30\nDEFAULT_MAX_DEPTH = 1\nDEFAULT_FORMAT = \"Text\"\n\n\nURL_REGEX = re.compile(\n r\"^(https?:\\/\\/)?\" r\"(www\\.)?\" r\"([a-zA-Z0-9.-]+)\" r\"(\\.[a-zA-Z]{2,})?\" r\"(:\\d+)?\" r\"(\\/[^\\s]*)?$\",\n re.IGNORECASE,\n)\n\nUSER_AGENT = None\n# Check if langflow is installed using importlib.util.find_spec(name))\nif importlib.util.find_spec(\"langflow\"):\n langflow_installed = True\n USER_AGENT = get_user_agent()\nelse:\n langflow_installed = False\n USER_AGENT = \"lfx\"\n\n\nclass URLComponent(Component):\n \"\"\"A component that loads and parses content from web pages recursively.\n\n This component allows fetching content from one or more URLs, with options to:\n - Control crawl depth\n - Prevent crawling outside the root domain\n - Use async loading for better performance\n - Extract either raw HTML or clean text\n - Configure request headers and timeouts\n \"\"\"\n\n display_name = \"URL\"\n description = \"Fetch content from one or more web pages, following links recursively.\"\n documentation: str = \"https://docs.langflow.org/url\"\n icon = \"layout-template\"\n name = \"URLComponent\"\n\n inputs = [\n MessageTextInput(\n name=\"urls\",\n display_name=\"URLs\",\n info=\"Enter one or more URLs to crawl recursively, by clicking the '+' button.\",\n is_list=True,\n tool_mode=True,\n placeholder=\"Enter a URL...\",\n list_add_label=\"Add URL\",\n input_types=[\"Message\"],\n ),\n SliderInput(\n name=\"max_depth\",\n display_name=\"Depth\",\n info=(\n \"Controls how many 'clicks' away from the initial page the crawler will go:\\n\"\n \"- depth 1: only the initial page\\n\"\n \"- depth 2: initial page + all pages linked directly from it\\n\"\n \"- depth 3: initial page + direct links + links found on those direct link pages\\n\"\n \"Note: This is about link traversal, not URL path depth.\"\n ),\n value=DEFAULT_MAX_DEPTH,\n range_spec=RangeSpec(min=1, max=5, step=1),\n required=False,\n min_label=\" \",\n max_label=\" \",\n min_label_icon=\"None\",\n max_label_icon=\"None\",\n # slider_input=True\n ),\n BoolInput(\n name=\"prevent_outside\",\n display_name=\"Prevent Outside\",\n info=(\n \"If enabled, only crawls URLs within the same domain as the root URL. \"\n \"This helps prevent the crawler from going to external websites.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"use_async\",\n display_name=\"Use Async\",\n info=(\n \"If enabled, uses asynchronous loading which can be significantly faster \"\n \"but might use more system resources.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=(\n \"Output Format. Use 'Text' to extract the text from the HTML, \"\n \"'Markdown' to parse the HTML into Markdown format, or 'HTML' \"\n \"for the raw HTML content.\"\n ),\n options=[\"Text\", \"HTML\", \"Markdown\"],\n value=DEFAULT_FORMAT,\n advanced=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"Timeout for the request in seconds.\",\n value=DEFAULT_TIMEOUT,\n required=False,\n advanced=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": USER_AGENT}],\n advanced=True,\n input_types=[\"DataFrame\", \"Table\"],\n ),\n BoolInput(\n name=\"filter_text_html\",\n display_name=\"Filter Text/HTML\",\n info=\"If enabled, filters out text/css content type from the results.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"continue_on_failure\",\n display_name=\"Continue on Failure\",\n info=\"If enabled, continues crawling even if some requests fail.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"check_response_status\",\n display_name=\"Check Response Status\",\n info=\"If enabled, checks the response status of the request.\",\n value=False,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"autoset_encoding\",\n display_name=\"Autoset Encoding\",\n info=\"If enabled, automatically sets the encoding of the request.\",\n value=True,\n required=False,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Extracted Pages\", name=\"page_results\", method=\"fetch_content\"),\n Output(display_name=\"Raw Content\", name=\"raw_results\", method=\"fetch_content_as_message\", tool_mode=False),\n ]\n\n @staticmethod\n def _html_extractor(x: str) -> str:\n \"\"\"Extract raw HTML content.\"\"\"\n return x\n\n @staticmethod\n def _text_extractor(x: str) -> str:\n \"\"\"Extract clean text from HTML.\"\"\"\n return BeautifulSoup(x, \"lxml\").get_text()\n\n @staticmethod\n def _markdown_extractor(x: str) -> str:\n \"\"\"Convert HTML to Markdown format.\"\"\"\n stream = io.BytesIO(x.encode(\"utf-8\"))\n result = MarkItDown(enable_plugins=False).convert_stream(stream)\n return result.markdown\n\n @staticmethod\n def validate_url(url: str) -> bool:\n \"\"\"Validates if the given string matches URL pattern.\n\n Args:\n url: The URL string to validate\n\n Returns:\n bool: True if the URL is valid, False otherwise\n \"\"\"\n return bool(URL_REGEX.match(url))\n\n def ensure_url(self, url: str) -> tuple[str, list[str]]:\n \"\"\"Ensures the given string is a valid URL and returns validated IPs for DNS pinning.\n\n Args:\n url: The URL string to validate and normalize\n\n Returns:\n tuple[str, list[str]]: The normalized URL and list of validated IPs for DNS pinning\n\n Raises:\n ValueError: If the URL is invalid or blocked by SSRF protection\n \"\"\"\n url = url.strip()\n if not url.startswith((\"http://\", \"https://\")):\n url = \"https://\" + url\n\n if not self.validate_url(url):\n msg = f\"Invalid URL: {url}\"\n raise ValueError(msg)\n\n # ============================================================================\n # SSRF Protection with DNS Pinning\n # ============================================================================\n # This prevents DNS rebinding attacks by:\n # 1. Resolving DNS and validating IPs during security check\n # 2. Returning the validated IP addresses for DNS pinning\n # 3. Using a custom HTTP transport that forces use of the pinned IPs\n # 4. Ignoring any new DNS resolutions (prevents rebinding)\n #\n # Without DNS pinning, an attacker could:\n # - First DNS lookup: returns public IP (passes validation)\n # - Second DNS lookup: returns internal IP (bypasses protection)\n # - Attack succeeds: accesses internal services\n #\n # With DNS pinning:\n # - First DNS lookup: returns public IP (passes validation)\n # - IPs are pinned and returned\n # - HTTP request: uses pinned IPs directly (no new DNS lookup)\n # - Attack fails: even if DNS changes, we use the validated IPs\n # ============================================================================\n try:\n _validated_url, validated_ips = validate_and_resolve_url(url)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n else:\n # Log DNS pinning information for security auditing\n if validated_ips:\n logger.debug(f\"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s) for {url}\")\n\n return url, validated_ips\n\n def _build_http_client(self, url: str, validated_ips: list[str]) -> httpx.AsyncClient:\n \"\"\"Create an HTTP client with DNS pinning for SSRF protection.\n\n Args:\n url: The request URL whose hostname will be pinned\n validated_ips: IPs validated by validate_and_resolve_url for this URL\n\n Returns:\n httpx.AsyncClient: A client with DNS pinning when SSRF protection is enabled\n \"\"\"\n if is_ssrf_protection_enabled() and validated_ips:\n hostname = urlparse(url).hostname\n if hostname:\n return create_ssrf_protected_client(hostname=hostname, validated_ips=validated_ips)\n return httpx.AsyncClient()\n\n async def _fetch_url_with_pinning(self, url: str, validated_ips: list[str], headers: dict) -> tuple[str, dict]:\n \"\"\"Fetch a single URL with DNS pinning protection.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n async with self._build_http_client(url, validated_ips) as client:\n try:\n response = await client.get(url, headers=headers, timeout=self.timeout, follow_redirects=False)\n\n if self.check_response_status:\n response.raise_for_status()\n\n content_type = response.headers.get(\"content-type\", \"\").lower()\n\n # Filter out CSS files if requested\n if self.filter_text_html and \"text/css\" in content_type:\n logger.debug(f\"Skipping CSS file: {url}\")\n return \"\", {}\n\n # Get the HTML content\n html_content = response.text\n\n # Extract metadata\n metadata = {\n \"source\": str(response.url),\n \"title\": \"\",\n \"description\": \"\",\n \"content_type\": content_type,\n \"language\": \"\",\n }\n\n # Try to extract title and description from HTML\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - metadata extraction is optional\n # and we don't want to fail the entire request if it fails\n logger.debug(f\"Failed to extract metadata from {url}\")\n else:\n if soup.title:\n metadata[\"title\"] = soup.title.string or \"\"\n\n # Try to get description from meta tags\n meta_desc = soup.find(\"meta\", attrs={\"name\": \"description\"})\n if meta_desc and meta_desc.get(\"content\"):\n metadata[\"description\"] = meta_desc[\"content\"]\n\n # Try to get language\n html_tag = soup.find(\"html\")\n if html_tag and html_tag.get(\"lang\"):\n metadata[\"language\"] = html_tag[\"lang\"]\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Failed to fetch {url}: {e}\")\n return \"\", {}\n raise\n else:\n return html_content, metadata\n\n async def _crawl_recursive(\n self, start_url: str, validated_ips: list[str], headers: dict, visited: set, depth: int = 0\n ) -> list[dict]:\n \"\"\"Recursively crawl URLs with DNS pinning protection.\n\n Args:\n start_url: The URL to start crawling from\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n visited: Set of already visited URLs\n depth: Current crawl depth\n\n Returns:\n list[dict]: List of documents with content and metadata\n \"\"\"\n if depth >= self.max_depth or start_url in visited:\n return []\n\n visited.add(start_url)\n documents = []\n\n # Fetch the current URL\n html_content, metadata = await self._fetch_url_with_pinning(start_url, validated_ips, headers)\n\n if not html_content:\n return documents\n\n # Extract content based on format\n extractors = {\n \"HTML\": self._html_extractor,\n \"Markdown\": self._markdown_extractor,\n \"Text\": self._text_extractor,\n }\n extractor = extractors.get(self.format, self._text_extractor)\n extracted_content = extractor(html_content)\n\n # Add the document\n documents.append(\n {\n \"page_content\": extracted_content,\n \"metadata\": metadata,\n }\n )\n\n # If we haven't reached max depth, extract and follow links\n if depth < self.max_depth - 1:\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n links = soup.find_all(\"a\", href=True)\n\n for link in links:\n href = link[\"href\"]\n # Resolve relative URLs\n absolute_url = urljoin(start_url, href)\n\n # Skip if already visited\n if absolute_url in visited:\n continue\n\n # Check if we should prevent going outside the domain\n if self.prevent_outside:\n start_domain = urlparse(start_url).netloc\n link_domain = urlparse(absolute_url).netloc\n if start_domain != link_domain:\n continue\n\n # Validate and crawl the linked URL\n try:\n _, link_validated_ips = self.ensure_url(absolute_url)\n sub_docs = await self._crawl_recursive(\n absolute_url, link_validated_ips, headers, visited, depth + 1\n )\n documents.extend(sub_docs)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping {absolute_url}: {e}\")\n continue\n raise\n\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - link extraction is optional\n # and we don't want to fail the entire crawl if one page has issues\n logger.debug(f\"Failed to extract links from {start_url}\")\n\n return documents\n\n async def fetch_url_contents(self) -> list[dict]:\n \"\"\"Load documents from the configured URLs with SSRF protection and DNS pinning.\n\n This method implements comprehensive SSRF (Server-Side Request Forgery) protection\n using DNS pinning to prevent DNS rebinding attacks. Each URL is validated and its\n DNS resolution is pinned before any HTTP requests are made.\n\n Returns:\n list[dict]: List of documents with content and metadata\n\n Raises:\n ValueError: If no valid URLs are provided or if there's an error loading documents\n \"\"\"\n try:\n # Validate all URLs and get their validated IPs for DNS pinning\n validated_urls = []\n first_validation_error: Exception | None = None\n for url in self.urls:\n if not url.strip():\n continue\n try:\n normalized_url, validated_ips = self.ensure_url(url)\n validated_urls.append((normalized_url, validated_ips))\n except (ValueError, SSRFProtectionError) as e:\n # Remember the first rejection so its real cause (for example an\n # SSRF block) can be surfaced if no URL survives validation,\n # instead of being hidden behind \"No valid URLs provided\".\n if first_validation_error is None:\n first_validation_error = e\n if self.continue_on_failure:\n logger.warning(f\"Skipping invalid URL {url}: {e}\")\n continue\n raise\n\n # Remove duplicates while preserving order\n seen = set()\n unique_validated_urls = []\n for url, ips in validated_urls:\n if url not in seen:\n seen.add(url)\n unique_validated_urls.append((url, ips))\n\n logger.debug(f\"Validated {len(unique_validated_urls)} unique URL(s)\")\n\n if not unique_validated_urls:\n # Surface the actual reason every candidate URL was rejected (e.g. an\n # SSRF block) rather than a generic message, so a security failure is\n # not silently swallowed when continue_on_failure is enabled.\n if first_validation_error is not None:\n raise first_validation_error\n msg = \"No valid URLs provided.\"\n raise ValueError(msg)\n\n # Prepare headers\n headers_dict = {header[\"key\"]: header[\"value\"] for header in self.headers if header[\"value\"] is not None}\n\n # Crawl all URLs\n all_docs = []\n for url, validated_ips in unique_validated_urls:\n logger.debug(f\"Crawling {url} with max_depth={self.max_depth}\")\n\n try:\n visited = set()\n docs = await self._crawl_recursive(url, validated_ips, headers_dict, visited, depth=0)\n\n if not docs:\n logger.warning(f\"No documents found for {url}\")\n continue\n\n logger.debug(f\"Found {len(docs)} document(s) from {url}\")\n all_docs.extend(docs)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Error loading documents from {url}: {e}\")\n continue\n msg = f\"Error loading documents from {url}: {e}\"\n raise ValueError(msg) from e\n\n if not all_docs:\n msg = \"No documents were successfully loaded from any URL\"\n raise ValueError(msg)\n\n # Convert to output format\n return [\n {\n \"text\": safe_convert(doc[\"page_content\"], clean_data=True),\n \"url\": doc[\"metadata\"].get(\"source\", \"\"),\n \"title\": doc[\"metadata\"].get(\"title\", \"\"),\n \"description\": doc[\"metadata\"].get(\"description\", \"\"),\n \"content_type\": doc[\"metadata\"].get(\"content_type\", \"\"),\n \"language\": doc[\"metadata\"].get(\"language\", \"\"),\n }\n for doc in all_docs\n ]\n\n except Exception as e:\n error_msg = e.message if hasattr(e, \"message\") else str(e)\n msg = f\"Error loading documents: {error_msg}\"\n logger.exception(msg)\n raise ValueError(msg) from e\n\n async def fetch_content(self) -> DataFrame:\n \"\"\"Convert the documents to a DataFrame.\"\"\"\n url_contents = await self.fetch_url_contents()\n return DataFrame(data=url_contents)\n\n async def fetch_content_as_message(self) -> Message:\n \"\"\"Convert the documents to a Message.\"\"\"\n url_contents = await self.fetch_url_contents()\n return Message(text=\"\\n\\n\".join([x[\"text\"] for x in url_contents]), data={\"data\": url_contents})\n" + "value": "import importlib.util\nimport io\nimport re\nfrom urllib.parse import urljoin, urlparse\n\nimport httpx\nfrom bs4 import BeautifulSoup\nfrom markitdown import MarkItDown\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.data import safe_convert\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageTextInput, Output, SliderInput, TableInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.utils.request_utils import get_user_agent\nfrom lfx.utils.ssrf_protection import SSRFProtectionError, is_ssrf_protection_enabled, validate_and_resolve_url\nfrom lfx.utils.ssrf_transport import create_ssrf_protected_client\n\n# Constants\nDEFAULT_TIMEOUT = 30\nDEFAULT_MAX_DEPTH = 1\nDEFAULT_FORMAT = \"Text\"\n\n# HTTP status codes that carry a redirect Location header (RFC 9110).\nREDIRECT_STATUS_CODES = frozenset({301, 302, 303, 307, 308})\n\n# Maximum number of redirects to follow. Each hop is re-validated for SSRF safety;\n# matches httpx's default and the API Request component.\nMAX_REDIRECTS = 20\n\n# Default ports per scheme, used to compare redirect origins.\nDEFAULT_SCHEME_PORTS = {\"http\": 80, \"https\": 443}\n\n\nURL_REGEX = re.compile(\n r\"^(https?:\\/\\/)?\" r\"(www\\.)?\" r\"([a-zA-Z0-9.-]+)\" r\"(\\.[a-zA-Z]{2,})?\" r\"(:\\d+)?\" r\"(\\/[^\\s]*)?$\",\n re.IGNORECASE,\n)\n\nUSER_AGENT = None\n# Check if langflow is installed using importlib.util.find_spec(name))\nif importlib.util.find_spec(\"langflow\"):\n langflow_installed = True\n USER_AGENT = get_user_agent()\nelse:\n langflow_installed = False\n USER_AGENT = \"lfx\"\n\n\nclass URLComponent(Component):\n \"\"\"A component that loads and parses content from web pages recursively.\n\n This component allows fetching content from one or more URLs, with options to:\n - Control crawl depth\n - Prevent crawling outside the root domain\n - Use async loading for better performance\n - Extract either raw HTML or clean text\n - Configure request headers and timeouts\n \"\"\"\n\n display_name = \"URL\"\n description = \"Fetch content from one or more web pages, following links recursively.\"\n documentation: str = \"https://docs.langflow.org/url\"\n icon = \"layout-template\"\n name = \"URLComponent\"\n\n inputs = [\n MessageTextInput(\n name=\"urls\",\n display_name=\"URLs\",\n info=\"Enter one or more URLs to crawl recursively, by clicking the '+' button.\",\n is_list=True,\n tool_mode=True,\n placeholder=\"Enter a URL...\",\n list_add_label=\"Add URL\",\n input_types=[\"Message\"],\n ),\n SliderInput(\n name=\"max_depth\",\n display_name=\"Depth\",\n info=(\n \"Controls how many 'clicks' away from the initial page the crawler will go:\\n\"\n \"- depth 1: only the initial page\\n\"\n \"- depth 2: initial page + all pages linked directly from it\\n\"\n \"- depth 3: initial page + direct links + links found on those direct link pages\\n\"\n \"Note: This is about link traversal, not URL path depth.\"\n ),\n value=DEFAULT_MAX_DEPTH,\n range_spec=RangeSpec(min=1, max=5, step=1),\n required=False,\n min_label=\" \",\n max_label=\" \",\n min_label_icon=\"None\",\n max_label_icon=\"None\",\n # slider_input=True\n ),\n BoolInput(\n name=\"prevent_outside\",\n display_name=\"Prevent Outside\",\n info=(\n \"If enabled, only crawls URLs within the same domain as the root URL. \"\n \"This helps prevent the crawler from going to external websites.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"use_async\",\n display_name=\"Use Async\",\n info=(\n \"If enabled, uses asynchronous loading which can be significantly faster \"\n \"but might use more system resources.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"follow_redirects\",\n display_name=\"Follow Redirects\",\n info=(\n \"If enabled, follows HTTP redirects such as http→https or www/non-www \"\n \"normalization, which most sites rely on to serve their content. When SSRF \"\n \"protection is enabled (the default), every redirect hop is re-validated against \"\n \"the same blocked-IP denylist and DNS-pinned before it is fetched, so following \"\n \"redirects cannot be used to reach internal resources. Disable to capture the \"\n \"first response as-is without following redirects.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=(\n \"Output Format. Use 'Text' to extract the text from the HTML, \"\n \"'Markdown' to parse the HTML into Markdown format, or 'HTML' \"\n \"for the raw HTML content.\"\n ),\n options=[\"Text\", \"HTML\", \"Markdown\"],\n value=DEFAULT_FORMAT,\n advanced=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"Timeout for the request in seconds.\",\n value=DEFAULT_TIMEOUT,\n required=False,\n advanced=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": USER_AGENT}],\n advanced=True,\n input_types=[\"DataFrame\", \"Table\"],\n ),\n BoolInput(\n name=\"filter_text_html\",\n display_name=\"Filter Text/HTML\",\n info=\"If enabled, filters out text/css content type from the results.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"continue_on_failure\",\n display_name=\"Continue on Failure\",\n info=\"If enabled, continues crawling even if some requests fail.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"check_response_status\",\n display_name=\"Check Response Status\",\n info=\"If enabled, checks the response status of the request.\",\n value=False,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"autoset_encoding\",\n display_name=\"Autoset Encoding\",\n info=\"If enabled, automatically sets the encoding of the request.\",\n value=True,\n required=False,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Extracted Pages\", name=\"page_results\", method=\"fetch_content\"),\n Output(display_name=\"Raw Content\", name=\"raw_results\", method=\"fetch_content_as_message\", tool_mode=False),\n ]\n\n @staticmethod\n def _html_extractor(x: str) -> str:\n \"\"\"Extract raw HTML content.\"\"\"\n return x\n\n @staticmethod\n def _text_extractor(x: str) -> str:\n \"\"\"Extract clean text from HTML.\"\"\"\n return BeautifulSoup(x, \"lxml\").get_text()\n\n @staticmethod\n def _markdown_extractor(x: str) -> str:\n \"\"\"Convert HTML to Markdown format.\"\"\"\n stream = io.BytesIO(x.encode(\"utf-8\"))\n result = MarkItDown(enable_plugins=False).convert_stream(stream)\n return result.markdown\n\n @staticmethod\n def validate_url(url: str) -> bool:\n \"\"\"Validates if the given string matches URL pattern.\n\n Args:\n url: The URL string to validate\n\n Returns:\n bool: True if the URL is valid, False otherwise\n \"\"\"\n return bool(URL_REGEX.match(url))\n\n def ensure_url(self, url: str) -> tuple[str, list[str]]:\n \"\"\"Ensures the given string is a valid URL and returns validated IPs for DNS pinning.\n\n Args:\n url: The URL string to validate and normalize\n\n Returns:\n tuple[str, list[str]]: The normalized URL and list of validated IPs for DNS pinning\n\n Raises:\n ValueError: If the URL is invalid or blocked by SSRF protection\n \"\"\"\n url = url.strip()\n if not url.startswith((\"http://\", \"https://\")):\n url = \"https://\" + url\n\n if not self.validate_url(url):\n msg = f\"Invalid URL: {url}\"\n raise ValueError(msg)\n\n # ============================================================================\n # SSRF Protection with DNS Pinning\n # ============================================================================\n # This prevents DNS rebinding attacks by:\n # 1. Resolving DNS and validating IPs during security check\n # 2. Returning the validated IP addresses for DNS pinning\n # 3. Using a custom HTTP transport that forces use of the pinned IPs\n # 4. Ignoring any new DNS resolutions (prevents rebinding)\n #\n # Without DNS pinning, an attacker could:\n # - First DNS lookup: returns public IP (passes validation)\n # - Second DNS lookup: returns internal IP (bypasses protection)\n # - Attack succeeds: accesses internal services\n #\n # With DNS pinning:\n # - First DNS lookup: returns public IP (passes validation)\n # - IPs are pinned and returned\n # - HTTP request: uses pinned IPs directly (no new DNS lookup)\n # - Attack fails: even if DNS changes, we use the validated IPs\n # ============================================================================\n try:\n _validated_url, validated_ips = validate_and_resolve_url(url)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n else:\n # Log DNS pinning information for security auditing\n if validated_ips:\n logger.debug(f\"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s) for {url}\")\n\n return url, validated_ips\n\n def _build_http_client(self, url: str, validated_ips: list[str]) -> httpx.AsyncClient:\n \"\"\"Create an HTTP client with DNS pinning for SSRF protection.\n\n Args:\n url: The request URL whose hostname will be pinned\n validated_ips: IPs validated by validate_and_resolve_url for this URL\n\n Returns:\n httpx.AsyncClient: A client with DNS pinning when SSRF protection is enabled\n \"\"\"\n if is_ssrf_protection_enabled() and validated_ips:\n hostname = urlparse(url).hostname\n if hostname:\n return create_ssrf_protected_client(hostname=hostname, validated_ips=validated_ips)\n return httpx.AsyncClient()\n\n @staticmethod\n def _headers_for_redirect(headers: dict | None, current_url: str, next_url: str) -> dict | None:\n \"\"\"Drop sensitive headers when a redirect crosses to a different origin.\n\n Mirrors httpx's auto-follow behavior so manually following redirects does not\n leak credentials (Authorization / Cookie) to an origin other than the one the\n caller intended them for. Headers are kept only when the redirect stays on the\n same origin (scheme, host, port) or is a direct https upgrade of the same host\n on default ports - the exact cases where httpx keeps the Authorization header.\n \"\"\"\n if not headers:\n return headers\n current, nxt = urlparse(current_url), urlparse(next_url)\n current_port = current.port or DEFAULT_SCHEME_PORTS.get(current.scheme)\n next_port = nxt.port or DEFAULT_SCHEME_PORTS.get(nxt.scheme)\n same_origin = (current.scheme, current.hostname, current_port) == (nxt.scheme, nxt.hostname, next_port)\n https_upgrade = (\n current.hostname == nxt.hostname\n and current.scheme == \"http\"\n and nxt.scheme == \"https\"\n and current_port == DEFAULT_SCHEME_PORTS[\"http\"]\n and next_port == DEFAULT_SCHEME_PORTS[\"https\"]\n )\n if same_origin or https_upgrade:\n return headers\n sensitive = {\"authorization\", \"proxy-authorization\", \"cookie\"}\n return {k: v for k, v in headers.items() if k.lower() not in sensitive}\n\n def _process_response(self, response: httpx.Response) -> tuple[str, dict]:\n \"\"\"Turn a final (non-redirect) response into its HTML content and metadata.\n\n Args:\n response: The HTTP response to process\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n if self.check_response_status:\n response.raise_for_status()\n\n content_type = response.headers.get(\"content-type\", \"\").lower()\n\n # Filter out CSS files if requested\n if self.filter_text_html and \"text/css\" in content_type:\n logger.debug(f\"Skipping CSS file: {response.url}\")\n return \"\", {}\n\n # Get the HTML content\n html_content = response.text\n\n # Extract metadata\n metadata = {\n \"source\": str(response.url),\n \"title\": \"\",\n \"description\": \"\",\n \"content_type\": content_type,\n \"language\": \"\",\n }\n\n # Try to extract title and description from HTML\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - metadata extraction is optional\n # and we don't want to fail the entire request if it fails\n logger.debug(f\"Failed to extract metadata from {response.url}\")\n else:\n if soup.title:\n metadata[\"title\"] = soup.title.string or \"\"\n\n # Try to get description from meta tags\n meta_desc = soup.find(\"meta\", attrs={\"name\": \"description\"})\n if meta_desc and meta_desc.get(\"content\"):\n metadata[\"description\"] = meta_desc[\"content\"]\n\n # Try to get language\n html_tag = soup.find(\"html\")\n if html_tag and html_tag.get(\"lang\"):\n metadata[\"language\"] = html_tag[\"lang\"]\n\n return html_content, metadata\n\n async def _fetch_with_revalidated_redirects(\n self, url: str, validated_ips: list[str], headers: dict\n ) -> tuple[str, dict]:\n \"\"\"Fetch ``url``, following redirects manually and re-validating every hop.\n\n This closes an SSRF bypass: with redirects enabled, httpx would otherwise\n auto-follow a redirect from a validated public URL to an internal address that\n the pinned transport never checked (the redirect target is a different host, so\n it is not in the pin map). Each redirect ``Location`` is resolved (relative\n locations included) and re-validated with ``ensure_url`` - the same private/\n loopback/link-local denylist and DNS pinning applied to the initial request -\n before any connection to it is made. A blocked hop raises ``ValueError`` (unless\n ``continue_on_failure``); the chain is capped at ``MAX_REDIRECTS`` hops.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning of the initial URL\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n current_url = url\n current_ips = validated_ips\n\n for _ in range(MAX_REDIRECTS + 1):\n async with self._build_http_client(current_url, current_ips) as client:\n response = await client.get(current_url, headers=headers, timeout=self.timeout, follow_redirects=False)\n\n location = response.headers.get(\"location\")\n if response.status_code in REDIRECT_STATUS_CODES and location:\n # Resolve relative redirects against the current URL.\n next_url = urljoin(current_url, location)\n\n # Re-validate the redirect target with the same SSRF denylist + DNS pinning.\n try:\n validated_next_url, current_ips = self.ensure_url(next_url)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping blocked or invalid redirect to {next_url}: {e}\")\n return \"\", {}\n msg = f\"SSRF Protection: blocked redirect to {next_url}: {e}\"\n raise ValueError(msg) from e\n\n headers = self._headers_for_redirect(headers, current_url, next_url)\n current_url = validated_next_url\n continue\n\n # Not a redirect (or no Location header) - this is the final response.\n return self._process_response(response)\n\n # Exhausted the redirect budget.\n if self.continue_on_failure:\n logger.warning(f\"Exceeded the maximum of {MAX_REDIRECTS} redirects while requesting {url}\")\n return \"\", {}\n msg = f\"SSRF Protection: exceeded the maximum of {MAX_REDIRECTS} redirects while requesting {url}\"\n raise ValueError(msg)\n\n async def _fetch_url_with_pinning(self, url: str, validated_ips: list[str], headers: dict) -> tuple[str, dict]:\n \"\"\"Fetch a single URL with DNS pinning protection, following redirects safely.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n try:\n # When SSRF protection is enabled we must follow redirects manually so each hop\n # is re-validated and DNS-pinned; letting httpx auto-follow would connect to the\n # redirect target without pinning (different host, not in the pin map) and re-open\n # the SSRF hole that DNS pinning closes. With protection disabled there is no pin\n # to bypass, so httpx can follow redirects natively.\n if self.follow_redirects and is_ssrf_protection_enabled():\n return await self._fetch_with_revalidated_redirects(url, validated_ips, headers)\n\n async with self._build_http_client(url, validated_ips) as client:\n response = await client.get(\n url, headers=headers, timeout=self.timeout, follow_redirects=self.follow_redirects\n )\n return self._process_response(response)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Failed to fetch {url}: {e}\")\n return \"\", {}\n raise\n\n async def _crawl_recursive(\n self, start_url: str, validated_ips: list[str], headers: dict, visited: set, depth: int = 0\n ) -> list[dict]:\n \"\"\"Recursively crawl URLs with DNS pinning protection.\n\n Args:\n start_url: The URL to start crawling from\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n visited: Set of already visited URLs\n depth: Current crawl depth\n\n Returns:\n list[dict]: List of documents with content and metadata\n \"\"\"\n if depth >= self.max_depth or start_url in visited:\n return []\n\n visited.add(start_url)\n documents = []\n\n # Fetch the current URL\n html_content, metadata = await self._fetch_url_with_pinning(start_url, validated_ips, headers)\n\n # Redirects may have landed on a different canonical URL (http->https, www\n # normalization). Resolve relative links and the prevent_outside check against\n # the URL the content actually came from, and mark it visited so links back to\n # the canonical form are not re-crawled.\n base_url = metadata.get(\"source\") or start_url\n visited.add(base_url)\n\n if not html_content:\n return documents\n\n # Extract content based on format\n extractors = {\n \"HTML\": self._html_extractor,\n \"Markdown\": self._markdown_extractor,\n \"Text\": self._text_extractor,\n }\n extractor = extractors.get(self.format, self._text_extractor)\n extracted_content = extractor(html_content)\n\n # Add the document\n documents.append(\n {\n \"page_content\": extracted_content,\n \"metadata\": metadata,\n }\n )\n\n # If we haven't reached max depth, extract and follow links\n if depth < self.max_depth - 1:\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n links = soup.find_all(\"a\", href=True)\n\n for link in links:\n href = link[\"href\"]\n # Resolve relative URLs\n absolute_url = urljoin(base_url, href)\n\n # Skip if already visited\n if absolute_url in visited:\n continue\n\n # Check if we should prevent going outside the domain\n if self.prevent_outside:\n start_domain = urlparse(base_url).netloc\n link_domain = urlparse(absolute_url).netloc\n if start_domain != link_domain:\n continue\n\n # Validate and crawl the linked URL\n try:\n _, link_validated_ips = self.ensure_url(absolute_url)\n sub_docs = await self._crawl_recursive(\n absolute_url, link_validated_ips, headers, visited, depth + 1\n )\n documents.extend(sub_docs)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping {absolute_url}: {e}\")\n continue\n raise\n\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - link extraction is optional\n # and we don't want to fail the entire crawl if one page has issues\n logger.debug(f\"Failed to extract links from {start_url}\")\n\n return documents\n\n async def fetch_url_contents(self) -> list[dict]:\n \"\"\"Load documents from the configured URLs with SSRF protection and DNS pinning.\n\n This method implements comprehensive SSRF (Server-Side Request Forgery) protection\n using DNS pinning to prevent DNS rebinding attacks. Each URL is validated and its\n DNS resolution is pinned before any HTTP requests are made.\n\n Returns:\n list[dict]: List of documents with content and metadata\n\n Raises:\n ValueError: If no valid URLs are provided or if there's an error loading documents\n \"\"\"\n try:\n # Validate all URLs and get their validated IPs for DNS pinning\n validated_urls = []\n first_validation_error: Exception | None = None\n for url in self.urls:\n if not url.strip():\n continue\n try:\n normalized_url, validated_ips = self.ensure_url(url)\n validated_urls.append((normalized_url, validated_ips))\n except (ValueError, SSRFProtectionError) as e:\n # Remember the first rejection so its real cause (for example an\n # SSRF block) can be surfaced if no URL survives validation,\n # instead of being hidden behind \"No valid URLs provided\".\n if first_validation_error is None:\n first_validation_error = e\n if self.continue_on_failure:\n logger.warning(f\"Skipping invalid URL {url}: {e}\")\n continue\n raise\n\n # Remove duplicates while preserving order\n seen = set()\n unique_validated_urls = []\n for url, ips in validated_urls:\n if url not in seen:\n seen.add(url)\n unique_validated_urls.append((url, ips))\n\n logger.debug(f\"Validated {len(unique_validated_urls)} unique URL(s)\")\n\n if not unique_validated_urls:\n # Surface the actual reason every candidate URL was rejected (e.g. an\n # SSRF block) rather than a generic message, so a security failure is\n # not silently swallowed when continue_on_failure is enabled.\n if first_validation_error is not None:\n raise first_validation_error\n msg = \"No valid URLs provided.\"\n raise ValueError(msg)\n\n # Prepare headers\n headers_dict = {header[\"key\"]: header[\"value\"] for header in self.headers if header[\"value\"] is not None}\n\n # Crawl all URLs\n all_docs = []\n for url, validated_ips in unique_validated_urls:\n logger.debug(f\"Crawling {url} with max_depth={self.max_depth}\")\n\n try:\n visited = set()\n docs = await self._crawl_recursive(url, validated_ips, headers_dict, visited, depth=0)\n\n if not docs:\n logger.warning(f\"No documents found for {url}\")\n continue\n\n logger.debug(f\"Found {len(docs)} document(s) from {url}\")\n all_docs.extend(docs)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Error loading documents from {url}: {e}\")\n continue\n msg = f\"Error loading documents from {url}: {e}\"\n raise ValueError(msg) from e\n\n if not all_docs:\n msg = \"No documents were successfully loaded from any URL\"\n raise ValueError(msg)\n\n # Convert to output format\n return [\n {\n \"text\": safe_convert(doc[\"page_content\"], clean_data=True),\n \"url\": doc[\"metadata\"].get(\"source\", \"\"),\n \"title\": doc[\"metadata\"].get(\"title\", \"\"),\n \"description\": doc[\"metadata\"].get(\"description\", \"\"),\n \"content_type\": doc[\"metadata\"].get(\"content_type\", \"\"),\n \"language\": doc[\"metadata\"].get(\"language\", \"\"),\n }\n for doc in all_docs\n ]\n\n except Exception as e:\n error_msg = e.message if hasattr(e, \"message\") else str(e)\n msg = f\"Error loading documents: {error_msg}\"\n logger.exception(msg)\n raise ValueError(msg) from e\n\n async def fetch_content(self) -> DataFrame:\n \"\"\"Convert the documents to a DataFrame.\"\"\"\n url_contents = await self.fetch_url_contents()\n return DataFrame(data=url_contents)\n\n async def fetch_content_as_message(self) -> Message:\n \"\"\"Convert the documents to a Message.\"\"\"\n url_contents = await self.fetch_url_contents()\n return Message(text=\"\\n\\n\".join([x[\"text\"] for x in url_contents]), data={\"data\": url_contents})\n" }, "continue_on_failure": { "_input_type": "BoolInput", @@ -1861,6 +1904,26 @@ "type": "bool", "value": true }, + "follow_redirects": { + "_input_type": "BoolInput", + "advanced": true, + "display_name": "Follow Redirects", + "dynamic": false, + "info": "If enabled, follows HTTP redirects such as http→https or www/non-www normalization, which most sites rely on to serve their content. When SSRF protection is enabled (the default), every redirect hop is re-validated against the same blocked-IP denylist and DNS-pinned before it is fetched, so following redirects cannot be used to reach internal resources. Disable to capture the first response as-is without following redirects.", + "list": false, + "list_add_label": "Add More", + "name": "follow_redirects", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + }, "format": { "_input_type": "DropdownInput", "advanced": true, diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json b/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json index 05ba59a849..3c6f24be8c 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json @@ -771,7 +771,7 @@ "key": "APIRequest", "legacy": false, "metadata": { - "code_hash": "1a052ebb9519", + "code_hash": "716a753c256e", "dependencies": { "dependencies": [ { @@ -889,7 +889,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import json\nimport re\nimport tempfile\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Any\nfrom urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse\n\nimport aiofiles\nimport aiofiles.os as aiofiles_os\nimport httpx\nimport validators\n\nfrom lfx.base.curl.parse import parse_context\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import TabInput\nfrom lfx.io import (\n BoolInput,\n DataInput,\n DropdownInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n Output,\n TableInput,\n)\nfrom lfx.schema.data import Data\nfrom lfx.schema.dotdict import dotdict\nfrom lfx.utils.component_utils import set_current_fields, set_field_advanced, set_field_display\n\n# SSRF Protection imports - for preventing Server-Side Request Forgery attacks\nfrom lfx.utils.ssrf_protection import (\n SSRFProtectionError,\n is_ssrf_protection_enabled,\n validate_and_resolve_url,\n)\nfrom lfx.utils.ssrf_transport import create_ssrf_protected_client\n\n# Define fields for each mode\nMODE_FIELDS = {\n \"URL\": [\n \"url_input\",\n \"method\",\n ],\n \"cURL\": [\"curl_input\"],\n}\n\n# Fields that should always be visible\nDEFAULT_FIELDS = [\"mode\"]\n\n# HTTP redirect status codes (RFC 9110).\nHTTP_MOVED_PERMANENTLY = 301\nHTTP_FOUND = 302\nHTTP_SEE_OTHER = 303\nHTTP_TEMPORARY_REDIRECT = 307\nHTTP_PERMANENT_REDIRECT = 308\n\n# Maximum number of redirects to follow when re-validating each hop (matches httpx's default).\nMAX_REDIRECTS = 20\n\n# HTTP status codes that represent a redirect carrying a Location header.\nREDIRECT_STATUS_CODES = frozenset(\n {\n HTTP_MOVED_PERMANENTLY,\n HTTP_FOUND,\n HTTP_SEE_OTHER,\n HTTP_TEMPORARY_REDIRECT,\n HTTP_PERMANENT_REDIRECT,\n }\n)\n\n\nclass APIRequestComponent(Component):\n display_name = \"API Request\"\n description = \"Make HTTP requests using URL or cURL commands.\"\n documentation: str = \"https://docs.langflow.org/api-request\"\n icon = \"Globe\"\n name = \"APIRequest\"\n\n inputs = [\n MessageTextInput(\n name=\"url_input\",\n display_name=\"URL\",\n info=\"Enter the URL for the request.\",\n advanced=False,\n tool_mode=True,\n ),\n MultilineInput(\n name=\"curl_input\",\n display_name=\"cURL\",\n info=(\n \"Paste a curl command to populate the fields. \"\n \"This will fill in the dictionary fields for headers and body.\"\n ),\n real_time_refresh=True,\n tool_mode=True,\n advanced=True,\n show=False,\n ),\n DropdownInput(\n name=\"method\",\n display_name=\"Method\",\n options=[\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"],\n value=\"GET\",\n info=\"The HTTP method to use.\",\n real_time_refresh=True,\n ),\n TabInput(\n name=\"mode\",\n display_name=\"Mode\",\n options=[\"URL\", \"cURL\"],\n value=\"URL\",\n info=\"Enable cURL mode to populate fields from a cURL command.\",\n real_time_refresh=True,\n ),\n DataInput(\n name=\"query_params\",\n display_name=\"Query Parameters\",\n info=\"The query parameters to append to the URL.\",\n advanced=True,\n ),\n TableInput(\n name=\"body\",\n display_name=\"Body\",\n info=\"The body to send with the request as a dictionary (for POST, PATCH, PUT).\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Key\",\n \"type\": \"str\",\n \"description\": \"Parameter name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"description\": \"Parameter value\",\n },\n ],\n value=[],\n input_types=[\"Data\", \"JSON\"],\n advanced=True,\n real_time_refresh=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": \"Langflow/1.0\"}],\n advanced=True,\n input_types=[\"Data\", \"JSON\"],\n real_time_refresh=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n value=30,\n info=\"The timeout to use for the request.\",\n advanced=True,\n ),\n BoolInput(\n name=\"follow_redirects\",\n display_name=\"Follow Redirects\",\n value=False,\n info=(\n \"Whether to follow HTTP redirects. \"\n \"WARNING: Enabling redirects may allow SSRF bypass attacks where a public URL \"\n \"redirects to internal resources. Only enable if you trust the target server. \"\n \"See OWASP SSRF Prevention Cheat Sheet for details.\"\n ),\n advanced=True,\n ),\n BoolInput(\n name=\"save_to_file\",\n display_name=\"Save to File\",\n value=False,\n info=\"Save the API response to a temporary file\",\n advanced=True,\n ),\n BoolInput(\n name=\"include_httpx_metadata\",\n display_name=\"Include HTTPx Metadata\",\n value=False,\n info=(\n \"Include properties such as headers, status_code, response_headers, \"\n \"and redirection_history in the output.\"\n ),\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"API Response\", name=\"data\", method=\"make_api_request\"),\n ]\n\n def _parse_json_value(self, value: Any) -> Any:\n \"\"\"Parse a value that might be a JSON string.\"\"\"\n if not isinstance(value, str):\n return value\n\n try:\n parsed = json.loads(value)\n except json.JSONDecodeError:\n return value\n else:\n return parsed\n\n def _process_body(self, body: Any) -> dict:\n \"\"\"Process the body input into a valid dictionary.\"\"\"\n if body is None:\n return {}\n if hasattr(body, \"data\"):\n body = body.data\n if isinstance(body, dict):\n return self._process_dict_body(body)\n if isinstance(body, str):\n return self._process_string_body(body)\n if isinstance(body, list):\n return self._process_list_body(body)\n return {}\n\n def _process_dict_body(self, body: dict) -> dict:\n \"\"\"Process dictionary body by parsing JSON values.\"\"\"\n return {k: self._parse_json_value(v) for k, v in body.items()}\n\n def _process_string_body(self, body: str) -> dict:\n \"\"\"Process string body by attempting JSON parse.\"\"\"\n try:\n return self._process_body(json.loads(body))\n except json.JSONDecodeError:\n return {\"data\": body}\n\n def _process_list_body(self, body: list) -> dict:\n \"\"\"Process list body by converting to key-value dictionary.\"\"\"\n processed_dict = {}\n try:\n for item in body:\n # Unwrap Data objects\n current_item = item\n if hasattr(item, \"data\"):\n unwrapped_data = item.data\n # If the unwrapped data is a dict but not key-value format, use it directly\n if isinstance(unwrapped_data, dict) and not self._is_valid_key_value_item(unwrapped_data):\n return unwrapped_data\n current_item = unwrapped_data\n if not self._is_valid_key_value_item(current_item):\n continue\n key = current_item[\"key\"]\n value = self._parse_json_value(current_item[\"value\"])\n processed_dict[key] = value\n except (KeyError, TypeError, ValueError) as e:\n self.log(f\"Failed to process body list: {e}\")\n return {}\n return processed_dict\n\n def _is_valid_key_value_item(self, item: Any) -> bool:\n \"\"\"Check if an item is a valid key-value dictionary.\"\"\"\n return isinstance(item, dict) and \"key\" in item and \"value\" in item\n\n def parse_curl(self, curl: str, build_config: dotdict) -> dotdict:\n \"\"\"Parse a cURL command and update build configuration.\"\"\"\n try:\n parsed = parse_context(curl)\n\n # Update basic configuration\n url = parsed.url\n # Normalize URL before setting it\n url = self._normalize_url(url)\n\n build_config[\"url_input\"][\"value\"] = url\n build_config[\"method\"][\"value\"] = parsed.method.upper()\n\n # Process headers\n headers_list = [{\"key\": k, \"value\": v} for k, v in parsed.headers.items()]\n build_config[\"headers\"][\"value\"] = headers_list\n\n # Process body data\n if not parsed.data:\n build_config[\"body\"][\"value\"] = []\n elif parsed.data:\n try:\n json_data = json.loads(parsed.data)\n if isinstance(json_data, dict):\n body_list = [\n {\"key\": k, \"value\": json.dumps(v) if isinstance(v, dict | list) else str(v)}\n for k, v in json_data.items()\n ]\n build_config[\"body\"][\"value\"] = body_list\n else:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": json.dumps(json_data)}]\n except json.JSONDecodeError:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": parsed.data}]\n\n except Exception as exc:\n msg = f\"Error parsing curl: {exc}\"\n self.log(msg)\n raise ValueError(msg) from exc\n\n return build_config\n\n def _normalize_url(self, url: str) -> str:\n \"\"\"Normalize URL by adding https:// if no protocol is specified.\"\"\"\n if not url or not isinstance(url, str):\n msg = \"URL cannot be empty\"\n raise ValueError(msg)\n\n url = url.strip()\n if url.startswith((\"http://\", \"https://\")):\n return url\n return f\"https://{url}\"\n\n async def make_request(\n self,\n client: httpx.AsyncClient,\n method: str,\n url: str,\n headers: dict | None = None,\n body: Any = None,\n timeout: int = 5,\n *,\n follow_redirects: bool = False,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n method = method.upper()\n if method not in {\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"}:\n msg = f\"Unsupported method: {method}\"\n raise ValueError(msg)\n\n processed_body = self._process_body(body)\n redirection_history = []\n\n try:\n # Prepare request parameters\n request_params = {\n \"method\": method,\n \"url\": url,\n \"headers\": headers,\n \"timeout\": timeout,\n \"follow_redirects\": follow_redirects,\n }\n # Only include body for methods that support it (GET must not have a body per HTTP spec)\n if method in {\"POST\", \"PATCH\", \"PUT\", \"DELETE\"} and processed_body is not None:\n request_params[\"json\"] = processed_body\n response = await client.request(**request_params)\n\n redirection_history = [\n {\n \"url\": redirect.headers.get(\"Location\", str(redirect.url)),\n \"status_code\": redirect.status_code,\n }\n for redirect in response.history\n ]\n\n return await self._build_response_data(\n response,\n url,\n headers,\n redirection_history,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n except (httpx.HTTPError, httpx.RequestError, httpx.TimeoutException) as exc:\n self.log(f\"Error making request to {url}\")\n return Data(\n data={\n \"source\": url,\n \"headers\": headers,\n \"status_code\": 500,\n \"error\": str(exc),\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n },\n )\n\n async def _build_response_data(\n self,\n response: httpx.Response,\n source_url: str,\n headers: dict | None,\n redirection_history: list,\n *,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n \"\"\"Turn an httpx response into the component's ``Data`` output.\n\n Shared by the standard request path (``make_request``) and the redirect\n re-validation path (``_follow_redirects_with_validation``) so both produce\n identical metadata, optional file saving, and body decoding.\n \"\"\"\n is_binary, file_path = await self._response_info(response, with_file_path=save_to_file)\n response_headers = self._headers_to_dict(response.headers)\n\n # Base metadata\n metadata = {\n \"source\": source_url,\n \"status_code\": response.status_code,\n \"response_headers\": response_headers,\n }\n\n if redirection_history:\n metadata[\"redirection_history\"] = redirection_history\n\n if save_to_file:\n mode = \"wb\" if is_binary else \"w\"\n encoding = response.encoding if mode == \"w\" else None\n if file_path:\n await aiofiles_os.makedirs(file_path.parent, exist_ok=True)\n if is_binary:\n async with aiofiles.open(file_path, \"wb\") as f:\n await f.write(response.content)\n await f.flush()\n else:\n async with aiofiles.open(file_path, \"w\", encoding=encoding) as f:\n await f.write(response.text)\n await f.flush()\n metadata[\"file_path\"] = str(file_path)\n\n if include_httpx_metadata:\n metadata.update({\"headers\": headers})\n return Data(data=metadata)\n\n # Handle response content\n if is_binary:\n result = response.content\n else:\n try:\n result = response.json()\n except json.JSONDecodeError:\n self.log(\"Failed to decode JSON response\")\n result = response.text.encode(\"utf-8\")\n\n metadata[\"result\"] = result\n\n if include_httpx_metadata:\n metadata.update({\"headers\": headers})\n\n return Data(data=metadata)\n\n def add_query_params(self, url: str, params: dict) -> str:\n \"\"\"Add query parameters to URL efficiently.\"\"\"\n if not params:\n return url\n url_parts = list(urlparse(url))\n query = dict(parse_qsl(url_parts[4]))\n query.update(params)\n url_parts[4] = urlencode(query)\n return urlunparse(url_parts)\n\n def _headers_to_dict(self, headers: httpx.Headers) -> dict[str, str]:\n \"\"\"Convert HTTP headers to a dictionary with lowercased keys.\"\"\"\n return {k.lower(): v for k, v in headers.items()}\n\n def _process_headers(self, headers: Any) -> dict:\n \"\"\"Process the headers input into a valid dictionary.\"\"\"\n if headers is None:\n return {}\n if isinstance(headers, dict):\n return headers\n if isinstance(headers, list):\n return {item[\"key\"]: item[\"value\"] for item in headers if self._is_valid_key_value_item(item)}\n return {}\n\n async def make_api_request(self) -> Data:\n \"\"\"Make HTTP request with SSRF protection and DNS pinning.\n\n This method implements comprehensive SSRF (Server-Side Request Forgery) protection\n using DNS pinning to prevent DNS rebinding attacks. The protection works by:\n 1. Validating the URL and resolving DNS during security check\n 2. Pinning the validated IP address\n 3. Forcing the HTTP client to use the pinned IP for the actual request\n 4. Ignoring any subsequent DNS changes (prevents rebinding attacks)\n\n Returns:\n Data: Response data from the HTTP request\n\n Raises:\n ValueError: If URL is invalid or blocked by SSRF protection\n \"\"\"\n # Extract request parameters\n method = self.method\n url = self.url_input.strip() if isinstance(self.url_input, str) else \"\"\n headers = self.headers or {}\n body = self.body or {}\n timeout = self.timeout\n follow_redirects = self.follow_redirects\n save_to_file = self.save_to_file\n include_httpx_metadata = self.include_httpx_metadata\n\n # Security warning: HTTP redirects can bypass SSRF protection\n # A public URL could redirect to an internal resource\n if follow_redirects:\n self.log(\n \"Security Warning: HTTP redirects are enabled. This may allow SSRF bypass attacks \"\n \"where a public URL redirects to internal resources (e.g., cloud metadata endpoints). \"\n \"Only enable this if you trust the target server.\"\n )\n\n # Normalize URL (add https:// if no protocol specified)\n url = self._normalize_url(url)\n\n # Basic URL format validation\n if not validators.url(url):\n msg = f\"Invalid URL provided: {url}\"\n raise ValueError(msg)\n\n # ============================================================================\n # SSRF Protection with DNS Pinning\n # ============================================================================\n # This prevents DNS rebinding attacks by:\n # 1. Resolving DNS and validating IPs during security check\n # 2. Pinning the validated IP address\n # 3. Using a custom HTTP transport that forces use of the pinned IP\n # 4. Ignoring any new DNS resolutions (prevents rebinding)\n #\n # Without DNS pinning, an attacker could:\n # - First DNS lookup: returns public IP (passes validation)\n # - Second DNS lookup: returns internal IP (bypasses protection)\n # - Attack succeeds: accesses internal services\n #\n # With DNS pinning:\n # - First DNS lookup: returns public IP (passes validation)\n # - IP is pinned: \"example.com = 93.184.216.34\"\n # - HTTP request: uses pinned IP directly (no new DNS lookup)\n # - Attack fails: even if DNS changes, we use the validated IP\n # ============================================================================\n\n try:\n # Validate URL and get validated IPs for DNS pinning\n _validated_url, validated_ips = validate_and_resolve_url(url)\n\n # Log DNS pinning information for security auditing\n if validated_ips:\n self.log(f\"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s)\")\n\n except SSRFProtectionError as e:\n # SSRF protection blocked the request (private IP, internal network, etc.)\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n\n # Process query parameters (from string or Data object)\n if isinstance(self.query_params, str):\n query_params = dict(parse_qsl(self.query_params))\n else:\n query_params = self.query_params.data if self.query_params else {}\n\n # Process headers and body into proper format\n headers = self._process_headers(headers)\n body = self._process_body(body)\n url = self.add_query_params(url, query_params)\n\n # ============================================================================\n # Execute the request (re-validating any redirects when SSRF protection is on)\n # ============================================================================\n # When SSRF protection is enabled we must NOT let httpx auto-follow redirects:\n # a validated public URL can redirect to an internal address (loopback, RFC1918,\n # link-local / cloud metadata) that was never checked, bypassing both the initial\n # validation and DNS pinning. Instead we follow redirects manually so every hop\n # is re-validated with the same denylist + DNS pinning. When protection is\n # disabled, we preserve the previous behavior and let httpx handle redirects.\n if is_ssrf_protection_enabled() and follow_redirects:\n result = await self._follow_redirects_with_validation(\n method,\n url,\n headers,\n body,\n timeout,\n validated_ips,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n else:\n # No redirect re-validation needed:\n # - SSRF protection is disabled (user opted out), or\n # - redirects are disabled, so httpx makes a single request.\n # DNS pinning still applies to the single request when protection is enabled\n # and the host resolved to validated IPs.\n async with self._build_http_client(url, validated_ips) as client:\n result = await self.make_request(\n client,\n method,\n url,\n headers,\n body,\n timeout,\n follow_redirects=follow_redirects,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n\n self.status = result\n return result\n\n def _build_http_client(self, url: str, validated_ips: list[str]) -> httpx.AsyncClient:\n \"\"\"Create an HTTP client, pinning DNS to validated IPs when SSRF protection applies.\n\n Args:\n url: The request URL whose hostname will be pinned.\n validated_ips: IPs validated by ``validate_and_resolve_url`` for this hop.\n\n Returns:\n httpx.AsyncClient: A client that pins DNS to ``validated_ips`` (preventing\n rebinding) when SSRF protection is enabled and the hop has validated IPs;\n otherwise a standard client (protection disabled, allowlisted host, or\n hostname extraction failure).\n \"\"\"\n if is_ssrf_protection_enabled() and validated_ips:\n # Extract hostname from the URL so the custom transport can pin it while\n # preserving the Host header for virtual hosting / TLS SNI.\n hostname = urlparse(url).hostname\n if hostname:\n # The custom transport tries validated IPs in order (dual-stack / LB).\n return create_ssrf_protected_client(hostname=hostname, validated_ips=validated_ips)\n return httpx.AsyncClient()\n\n @staticmethod\n def _method_for_redirect(method: str, status_code: int) -> str:\n \"\"\"Return the HTTP method to use after a redirect, mirroring httpx semantics.\n\n A 303 (See Other) always becomes GET; 301/302 downgrade POST to GET for\n browser compatibility; 307/308 preserve the original method (and body).\n \"\"\"\n method = method.upper()\n if status_code == HTTP_SEE_OTHER and method != \"HEAD\":\n return \"GET\"\n if status_code in (HTTP_MOVED_PERMANENTLY, HTTP_FOUND) and method == \"POST\":\n return \"GET\"\n return method\n\n @staticmethod\n def _headers_for_redirect(headers: dict | None, current_url: str, next_url: str) -> dict | None:\n \"\"\"Drop sensitive headers when a redirect crosses to a different host.\n\n Mirrors httpx's auto-follow behavior so manually following redirects does not\n leak credentials (Authorization / Cookie) to a different host than the one the\n caller intended them for. Same-host redirects keep all headers.\n \"\"\"\n if not headers:\n return headers\n if urlparse(current_url).hostname == urlparse(next_url).hostname:\n return headers\n sensitive = {\"authorization\", \"proxy-authorization\", \"cookie\"}\n return {k: v for k, v in headers.items() if k.lower() not in sensitive}\n\n async def _follow_redirects_with_validation(\n self,\n method: str,\n url: str,\n headers: dict | None,\n body: Any,\n timeout: int,\n validated_ips: list[str],\n *,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n \"\"\"Make the request and follow redirects manually, re-validating every hop.\n\n This closes an SSRF bypass: with ``follow_redirects`` enabled, httpx would\n otherwise auto-follow a redirect from a validated public URL to an internal\n address that was never checked. Here each redirect ``Location`` is resolved\n (relative locations included) and re-validated with ``validate_and_resolve_url``\n — the same private/loopback/link-local denylist and DNS pinning applied to the\n initial request — before any connection to it is made. A blocked hop raises\n ``ValueError``; the number of redirects is capped at ``MAX_REDIRECTS``.\n \"\"\"\n method = method.upper()\n if method not in {\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"}:\n msg = f\"Unsupported method: {method}\"\n raise ValueError(msg)\n\n processed_body = self._process_body(body)\n current_url = url\n current_ips = validated_ips\n redirection_history: list[dict] = []\n\n for _ in range(MAX_REDIRECTS + 1):\n request_params: dict[str, Any] = {\n \"method\": method,\n \"url\": current_url,\n \"headers\": headers,\n \"timeout\": timeout,\n # Never let httpx follow redirects itself; each hop is validated below.\n \"follow_redirects\": False,\n }\n # Only include body for methods that support it (GET must not have a body).\n if method in {\"POST\", \"PATCH\", \"PUT\", \"DELETE\"} and processed_body is not None:\n request_params[\"json\"] = processed_body\n\n try:\n async with self._build_http_client(current_url, current_ips) as client:\n response = await client.request(**request_params)\n except (httpx.HTTPError, httpx.RequestError, httpx.TimeoutException) as exc:\n self.log(f\"Error making request to {current_url}\")\n return Data(\n data={\n \"source\": url,\n \"headers\": headers,\n \"status_code\": 500,\n \"error\": str(exc),\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n },\n )\n\n location = response.headers.get(\"Location\")\n if response.status_code in REDIRECT_STATUS_CODES and location:\n # Resolve relative redirects against the current URL.\n next_url = urljoin(current_url, location)\n redirection_history.append({\"url\": location, \"status_code\": response.status_code})\n\n # Re-validate the redirect target with the same SSRF denylist + DNS pinning.\n # Non-http(s) schemes, private/loopback/link-local hosts, and hostnames that\n # resolve to blocked IPs all raise SSRFProtectionError here.\n try:\n _validated_url, current_ips = validate_and_resolve_url(next_url)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: blocked redirect to {next_url}: {e}\"\n raise ValueError(msg) from e\n\n method = self._method_for_redirect(method, response.status_code)\n headers = self._headers_for_redirect(headers, current_url, next_url)\n current_url = next_url\n continue\n\n # Not a redirect (or no Location header) - this is the final response.\n return await self._build_response_data(\n response,\n url,\n headers,\n redirection_history,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n\n msg = f\"SSRF Protection: exceeded the maximum of {MAX_REDIRECTS} redirects while requesting {url}\"\n raise ValueError(msg)\n\n def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict:\n \"\"\"Update the build config based on the selected mode.\"\"\"\n if field_name != \"mode\":\n if field_name == \"curl_input\" and self.mode == \"cURL\" and self.curl_input:\n return self.parse_curl(self.curl_input, build_config)\n return build_config\n\n if field_value == \"cURL\":\n set_field_display(build_config, \"curl_input\", value=True)\n if build_config[\"curl_input\"][\"value\"]:\n try:\n build_config = self.parse_curl(build_config[\"curl_input\"][\"value\"], build_config)\n except ValueError as e:\n self.log(f\"Failed to parse cURL input: {e}\")\n else:\n set_field_display(build_config, \"curl_input\", value=False)\n\n return set_current_fields(\n build_config=build_config,\n action_fields=MODE_FIELDS,\n selected_action=field_value,\n default_fields=DEFAULT_FIELDS,\n func=set_field_advanced,\n default_value=True,\n )\n\n async def _response_info(\n self, response: httpx.Response, *, with_file_path: bool = False\n ) -> tuple[bool, Path | None]:\n \"\"\"Determine the file path and whether the response content is binary.\n\n Args:\n response (Response): The HTTP response object.\n with_file_path (bool): Whether to save the response content to a file.\n\n Returns:\n Tuple[bool, Path | None]:\n A tuple containing a boolean indicating if the content is binary and the full file path (if applicable).\n \"\"\"\n content_type = response.headers.get(\"Content-Type\", \"\")\n is_binary = \"application/octet-stream\" in content_type or \"application/binary\" in content_type\n\n if not with_file_path:\n return is_binary, None\n\n component_temp_dir = Path(tempfile.gettempdir()) / self.__class__.__name__\n\n # Create directory asynchronously\n await aiofiles_os.makedirs(component_temp_dir, exist_ok=True)\n\n filename = None\n if \"Content-Disposition\" in response.headers:\n content_disposition = response.headers[\"Content-Disposition\"]\n filename_match = re.search(r'filename=\"(.+?)\"', content_disposition)\n if filename_match:\n extracted_filename = filename_match.group(1)\n filename = extracted_filename\n\n # Step 3: Infer file extension or use part of the request URL if no filename\n if not filename:\n # Extract the last segment of the URL path\n url_path = urlparse(str(response.request.url) if response.request else \"\").path\n base_name = Path(url_path).name # Get the last segment of the path\n if not base_name: # If the path ends with a slash or is empty\n base_name = \"response\"\n\n # Infer file extension\n content_type_to_extension = {\n \"text/plain\": \".txt\",\n \"application/json\": \".json\",\n \"image/jpeg\": \".jpg\",\n \"image/png\": \".png\",\n \"application/octet-stream\": \".bin\",\n }\n extension = content_type_to_extension.get(content_type, \".bin\" if is_binary else \".txt\")\n filename = f\"{base_name}{extension}\"\n\n # Step 4: Define the full file path\n file_path = component_temp_dir / filename\n\n # Step 5: Check if file exists asynchronously and handle accordingly\n try:\n # Try to create the file exclusively (x mode) to check existence\n async with aiofiles.open(file_path, \"x\") as _:\n pass # File created successfully, we can use this path\n except FileExistsError:\n # If file exists, append a timestamp to the filename\n timestamp = datetime.now(timezone.utc).strftime(\"%Y%m%d%H%M%S%f\")\n file_path = component_temp_dir / f\"{timestamp}-{filename}\"\n\n return is_binary, file_path\n" + "value": "import json\nimport re\nimport tempfile\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Any\nfrom urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse\n\nimport aiofiles\nimport aiofiles.os as aiofiles_os\nimport httpx\nimport validators\n\nfrom lfx.base.curl.parse import parse_context\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import TabInput\nfrom lfx.io import (\n BoolInput,\n DataInput,\n DropdownInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n Output,\n TableInput,\n)\nfrom lfx.schema.data import Data\nfrom lfx.schema.dotdict import dotdict\nfrom lfx.utils.component_utils import set_current_fields, set_field_advanced, set_field_display\n\n# SSRF Protection imports - for preventing Server-Side Request Forgery attacks\nfrom lfx.utils.ssrf_protection import (\n SSRFProtectionError,\n is_ssrf_protection_enabled,\n validate_and_resolve_url,\n)\nfrom lfx.utils.ssrf_transport import create_ssrf_protected_client\n\n# Define fields for each mode\nMODE_FIELDS = {\n \"URL\": [\n \"url_input\",\n \"method\",\n ],\n \"cURL\": [\"curl_input\"],\n}\n\n# Fields that should always be visible\nDEFAULT_FIELDS = [\"mode\"]\n\n# HTTP redirect status codes (RFC 9110).\nHTTP_MOVED_PERMANENTLY = 301\nHTTP_FOUND = 302\nHTTP_SEE_OTHER = 303\nHTTP_TEMPORARY_REDIRECT = 307\nHTTP_PERMANENT_REDIRECT = 308\n\n# Maximum number of redirects to follow when re-validating each hop (matches httpx's default).\nMAX_REDIRECTS = 20\n\n# HTTP status codes that represent a redirect carrying a Location header.\nREDIRECT_STATUS_CODES = frozenset(\n {\n HTTP_MOVED_PERMANENTLY,\n HTTP_FOUND,\n HTTP_SEE_OTHER,\n HTTP_TEMPORARY_REDIRECT,\n HTTP_PERMANENT_REDIRECT,\n }\n)\n\n# Default ports per scheme, used to compare redirect origins.\nDEFAULT_SCHEME_PORTS = {\"http\": 80, \"https\": 443}\n\n\nclass APIRequestComponent(Component):\n display_name = \"API Request\"\n description = \"Make HTTP requests using URL or cURL commands.\"\n documentation: str = \"https://docs.langflow.org/api-request\"\n icon = \"Globe\"\n name = \"APIRequest\"\n\n inputs = [\n MessageTextInput(\n name=\"url_input\",\n display_name=\"URL\",\n info=\"Enter the URL for the request.\",\n advanced=False,\n tool_mode=True,\n ),\n MultilineInput(\n name=\"curl_input\",\n display_name=\"cURL\",\n info=(\n \"Paste a curl command to populate the fields. \"\n \"This will fill in the dictionary fields for headers and body.\"\n ),\n real_time_refresh=True,\n tool_mode=True,\n advanced=True,\n show=False,\n ),\n DropdownInput(\n name=\"method\",\n display_name=\"Method\",\n options=[\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"],\n value=\"GET\",\n info=\"The HTTP method to use.\",\n real_time_refresh=True,\n ),\n TabInput(\n name=\"mode\",\n display_name=\"Mode\",\n options=[\"URL\", \"cURL\"],\n value=\"URL\",\n info=\"Enable cURL mode to populate fields from a cURL command.\",\n real_time_refresh=True,\n ),\n DataInput(\n name=\"query_params\",\n display_name=\"Query Parameters\",\n info=\"The query parameters to append to the URL.\",\n advanced=True,\n ),\n TableInput(\n name=\"body\",\n display_name=\"Body\",\n info=\"The body to send with the request as a dictionary (for POST, PATCH, PUT).\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Key\",\n \"type\": \"str\",\n \"description\": \"Parameter name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"description\": \"Parameter value\",\n },\n ],\n value=[],\n input_types=[\"Data\", \"JSON\"],\n advanced=True,\n real_time_refresh=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": \"Langflow/1.0\"}],\n advanced=True,\n input_types=[\"Data\", \"JSON\"],\n real_time_refresh=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n value=30,\n info=\"The timeout to use for the request.\",\n advanced=True,\n ),\n BoolInput(\n name=\"follow_redirects\",\n display_name=\"Follow Redirects\",\n value=False,\n info=(\n \"Whether to follow HTTP redirects. \"\n \"WARNING: Enabling redirects may allow SSRF bypass attacks where a public URL \"\n \"redirects to internal resources. Only enable if you trust the target server. \"\n \"See OWASP SSRF Prevention Cheat Sheet for details.\"\n ),\n advanced=True,\n ),\n BoolInput(\n name=\"save_to_file\",\n display_name=\"Save to File\",\n value=False,\n info=\"Save the API response to a temporary file\",\n advanced=True,\n ),\n BoolInput(\n name=\"include_httpx_metadata\",\n display_name=\"Include HTTPx Metadata\",\n value=False,\n info=(\n \"Include properties such as headers, status_code, response_headers, \"\n \"and redirection_history in the output.\"\n ),\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"API Response\", name=\"data\", method=\"make_api_request\"),\n ]\n\n def _parse_json_value(self, value: Any) -> Any:\n \"\"\"Parse a value that might be a JSON string.\"\"\"\n if not isinstance(value, str):\n return value\n\n try:\n parsed = json.loads(value)\n except json.JSONDecodeError:\n return value\n else:\n return parsed\n\n def _process_body(self, body: Any) -> dict:\n \"\"\"Process the body input into a valid dictionary.\"\"\"\n if body is None:\n return {}\n if hasattr(body, \"data\"):\n body = body.data\n if isinstance(body, dict):\n return self._process_dict_body(body)\n if isinstance(body, str):\n return self._process_string_body(body)\n if isinstance(body, list):\n return self._process_list_body(body)\n return {}\n\n def _process_dict_body(self, body: dict) -> dict:\n \"\"\"Process dictionary body by parsing JSON values.\"\"\"\n return {k: self._parse_json_value(v) for k, v in body.items()}\n\n def _process_string_body(self, body: str) -> dict:\n \"\"\"Process string body by attempting JSON parse.\"\"\"\n try:\n return self._process_body(json.loads(body))\n except json.JSONDecodeError:\n return {\"data\": body}\n\n def _process_list_body(self, body: list) -> dict:\n \"\"\"Process list body by converting to key-value dictionary.\"\"\"\n processed_dict = {}\n try:\n for item in body:\n # Unwrap Data objects\n current_item = item\n if hasattr(item, \"data\"):\n unwrapped_data = item.data\n # If the unwrapped data is a dict but not key-value format, use it directly\n if isinstance(unwrapped_data, dict) and not self._is_valid_key_value_item(unwrapped_data):\n return unwrapped_data\n current_item = unwrapped_data\n if not self._is_valid_key_value_item(current_item):\n continue\n key = current_item[\"key\"]\n value = self._parse_json_value(current_item[\"value\"])\n processed_dict[key] = value\n except (KeyError, TypeError, ValueError) as e:\n self.log(f\"Failed to process body list: {e}\")\n return {}\n return processed_dict\n\n def _is_valid_key_value_item(self, item: Any) -> bool:\n \"\"\"Check if an item is a valid key-value dictionary.\"\"\"\n return isinstance(item, dict) and \"key\" in item and \"value\" in item\n\n def parse_curl(self, curl: str, build_config: dotdict) -> dotdict:\n \"\"\"Parse a cURL command and update build configuration.\"\"\"\n try:\n parsed = parse_context(curl)\n\n # Update basic configuration\n url = parsed.url\n # Normalize URL before setting it\n url = self._normalize_url(url)\n\n build_config[\"url_input\"][\"value\"] = url\n build_config[\"method\"][\"value\"] = parsed.method.upper()\n\n # Process headers\n headers_list = [{\"key\": k, \"value\": v} for k, v in parsed.headers.items()]\n build_config[\"headers\"][\"value\"] = headers_list\n\n # Process body data\n if not parsed.data:\n build_config[\"body\"][\"value\"] = []\n elif parsed.data:\n try:\n json_data = json.loads(parsed.data)\n if isinstance(json_data, dict):\n body_list = [\n {\"key\": k, \"value\": json.dumps(v) if isinstance(v, dict | list) else str(v)}\n for k, v in json_data.items()\n ]\n build_config[\"body\"][\"value\"] = body_list\n else:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": json.dumps(json_data)}]\n except json.JSONDecodeError:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": parsed.data}]\n\n except Exception as exc:\n msg = f\"Error parsing curl: {exc}\"\n self.log(msg)\n raise ValueError(msg) from exc\n\n return build_config\n\n def _normalize_url(self, url: str) -> str:\n \"\"\"Normalize URL by adding https:// if no protocol is specified.\"\"\"\n if not url or not isinstance(url, str):\n msg = \"URL cannot be empty\"\n raise ValueError(msg)\n\n url = url.strip()\n if url.startswith((\"http://\", \"https://\")):\n return url\n return f\"https://{url}\"\n\n async def make_request(\n self,\n client: httpx.AsyncClient,\n method: str,\n url: str,\n headers: dict | None = None,\n body: Any = None,\n timeout: int = 5,\n *,\n follow_redirects: bool = False,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n method = method.upper()\n if method not in {\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"}:\n msg = f\"Unsupported method: {method}\"\n raise ValueError(msg)\n\n processed_body = self._process_body(body)\n redirection_history = []\n\n try:\n # Prepare request parameters\n request_params = {\n \"method\": method,\n \"url\": url,\n \"headers\": headers,\n \"timeout\": timeout,\n \"follow_redirects\": follow_redirects,\n }\n # Only include body for methods that support it (GET must not have a body per HTTP spec)\n if method in {\"POST\", \"PATCH\", \"PUT\", \"DELETE\"} and processed_body is not None:\n request_params[\"json\"] = processed_body\n response = await client.request(**request_params)\n\n redirection_history = [\n {\n \"url\": redirect.headers.get(\"Location\", str(redirect.url)),\n \"status_code\": redirect.status_code,\n }\n for redirect in response.history\n ]\n\n return await self._build_response_data(\n response,\n url,\n headers,\n redirection_history,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n except (httpx.HTTPError, httpx.RequestError, httpx.TimeoutException) as exc:\n self.log(f\"Error making request to {url}\")\n return Data(\n data={\n \"source\": url,\n \"headers\": headers,\n \"status_code\": 500,\n \"error\": str(exc),\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n },\n )\n\n async def _build_response_data(\n self,\n response: httpx.Response,\n source_url: str,\n headers: dict | None,\n redirection_history: list,\n *,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n \"\"\"Turn an httpx response into the component's ``Data`` output.\n\n Shared by the standard request path (``make_request``) and the redirect\n re-validation path (``_follow_redirects_with_validation``) so both produce\n identical metadata, optional file saving, and body decoding.\n \"\"\"\n is_binary, file_path = await self._response_info(response, with_file_path=save_to_file)\n response_headers = self._headers_to_dict(response.headers)\n\n # Base metadata\n metadata = {\n \"source\": source_url,\n \"status_code\": response.status_code,\n \"response_headers\": response_headers,\n }\n\n if redirection_history:\n metadata[\"redirection_history\"] = redirection_history\n\n if save_to_file:\n mode = \"wb\" if is_binary else \"w\"\n encoding = response.encoding if mode == \"w\" else None\n if file_path:\n await aiofiles_os.makedirs(file_path.parent, exist_ok=True)\n if is_binary:\n async with aiofiles.open(file_path, \"wb\") as f:\n await f.write(response.content)\n await f.flush()\n else:\n async with aiofiles.open(file_path, \"w\", encoding=encoding) as f:\n await f.write(response.text)\n await f.flush()\n metadata[\"file_path\"] = str(file_path)\n\n if include_httpx_metadata:\n metadata.update({\"headers\": headers})\n return Data(data=metadata)\n\n # Handle response content\n if is_binary:\n result = response.content\n else:\n try:\n result = response.json()\n except json.JSONDecodeError:\n self.log(\"Failed to decode JSON response\")\n result = response.text.encode(\"utf-8\")\n\n metadata[\"result\"] = result\n\n if include_httpx_metadata:\n metadata.update({\"headers\": headers})\n\n return Data(data=metadata)\n\n def add_query_params(self, url: str, params: dict) -> str:\n \"\"\"Add query parameters to URL efficiently.\"\"\"\n if not params:\n return url\n url_parts = list(urlparse(url))\n query = dict(parse_qsl(url_parts[4]))\n query.update(params)\n url_parts[4] = urlencode(query)\n return urlunparse(url_parts)\n\n def _headers_to_dict(self, headers: httpx.Headers) -> dict[str, str]:\n \"\"\"Convert HTTP headers to a dictionary with lowercased keys.\"\"\"\n return {k.lower(): v for k, v in headers.items()}\n\n def _process_headers(self, headers: Any) -> dict:\n \"\"\"Process the headers input into a valid dictionary.\"\"\"\n if headers is None:\n return {}\n if isinstance(headers, dict):\n return headers\n if isinstance(headers, list):\n return {item[\"key\"]: item[\"value\"] for item in headers if self._is_valid_key_value_item(item)}\n return {}\n\n async def make_api_request(self) -> Data:\n \"\"\"Make HTTP request with SSRF protection and DNS pinning.\n\n This method implements comprehensive SSRF (Server-Side Request Forgery) protection\n using DNS pinning to prevent DNS rebinding attacks. The protection works by:\n 1. Validating the URL and resolving DNS during security check\n 2. Pinning the validated IP address\n 3. Forcing the HTTP client to use the pinned IP for the actual request\n 4. Ignoring any subsequent DNS changes (prevents rebinding attacks)\n\n Returns:\n Data: Response data from the HTTP request\n\n Raises:\n ValueError: If URL is invalid or blocked by SSRF protection\n \"\"\"\n # Extract request parameters\n method = self.method\n url = self.url_input.strip() if isinstance(self.url_input, str) else \"\"\n headers = self.headers or {}\n body = self.body or {}\n timeout = self.timeout\n follow_redirects = self.follow_redirects\n save_to_file = self.save_to_file\n include_httpx_metadata = self.include_httpx_metadata\n\n # Security warning: HTTP redirects can bypass SSRF protection\n # A public URL could redirect to an internal resource\n if follow_redirects:\n self.log(\n \"Security Warning: HTTP redirects are enabled. This may allow SSRF bypass attacks \"\n \"where a public URL redirects to internal resources (e.g., cloud metadata endpoints). \"\n \"Only enable this if you trust the target server.\"\n )\n\n # Normalize URL (add https:// if no protocol specified)\n url = self._normalize_url(url)\n\n # Basic URL format validation\n if not validators.url(url):\n msg = f\"Invalid URL provided: {url}\"\n raise ValueError(msg)\n\n # ============================================================================\n # SSRF Protection with DNS Pinning\n # ============================================================================\n # This prevents DNS rebinding attacks by:\n # 1. Resolving DNS and validating IPs during security check\n # 2. Pinning the validated IP address\n # 3. Using a custom HTTP transport that forces use of the pinned IP\n # 4. Ignoring any new DNS resolutions (prevents rebinding)\n #\n # Without DNS pinning, an attacker could:\n # - First DNS lookup: returns public IP (passes validation)\n # - Second DNS lookup: returns internal IP (bypasses protection)\n # - Attack succeeds: accesses internal services\n #\n # With DNS pinning:\n # - First DNS lookup: returns public IP (passes validation)\n # - IP is pinned: \"example.com = 93.184.216.34\"\n # - HTTP request: uses pinned IP directly (no new DNS lookup)\n # - Attack fails: even if DNS changes, we use the validated IP\n # ============================================================================\n\n try:\n # Validate URL and get validated IPs for DNS pinning\n _validated_url, validated_ips = validate_and_resolve_url(url)\n\n # Log DNS pinning information for security auditing\n if validated_ips:\n self.log(f\"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s)\")\n\n except SSRFProtectionError as e:\n # SSRF protection blocked the request (private IP, internal network, etc.)\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n\n # Process query parameters (from string or Data object)\n if isinstance(self.query_params, str):\n query_params = dict(parse_qsl(self.query_params))\n else:\n query_params = self.query_params.data if self.query_params else {}\n\n # Process headers and body into proper format\n headers = self._process_headers(headers)\n body = self._process_body(body)\n url = self.add_query_params(url, query_params)\n\n # ============================================================================\n # Execute the request (re-validating any redirects when SSRF protection is on)\n # ============================================================================\n # When SSRF protection is enabled we must NOT let httpx auto-follow redirects:\n # a validated public URL can redirect to an internal address (loopback, RFC1918,\n # link-local / cloud metadata) that was never checked, bypassing both the initial\n # validation and DNS pinning. Instead we follow redirects manually so every hop\n # is re-validated with the same denylist + DNS pinning. When protection is\n # disabled, we preserve the previous behavior and let httpx handle redirects.\n if is_ssrf_protection_enabled() and follow_redirects:\n result = await self._follow_redirects_with_validation(\n method,\n url,\n headers,\n body,\n timeout,\n validated_ips,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n else:\n # No redirect re-validation needed:\n # - SSRF protection is disabled (user opted out), or\n # - redirects are disabled, so httpx makes a single request.\n # DNS pinning still applies to the single request when protection is enabled\n # and the host resolved to validated IPs.\n async with self._build_http_client(url, validated_ips) as client:\n result = await self.make_request(\n client,\n method,\n url,\n headers,\n body,\n timeout,\n follow_redirects=follow_redirects,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n\n self.status = result\n return result\n\n def _build_http_client(self, url: str, validated_ips: list[str]) -> httpx.AsyncClient:\n \"\"\"Create an HTTP client, pinning DNS to validated IPs when SSRF protection applies.\n\n Args:\n url: The request URL whose hostname will be pinned.\n validated_ips: IPs validated by ``validate_and_resolve_url`` for this hop.\n\n Returns:\n httpx.AsyncClient: A client that pins DNS to ``validated_ips`` (preventing\n rebinding) when SSRF protection is enabled and the hop has validated IPs;\n otherwise a standard client (protection disabled, allowlisted host, or\n hostname extraction failure).\n \"\"\"\n if is_ssrf_protection_enabled() and validated_ips:\n # Extract hostname from the URL so the custom transport can pin it while\n # preserving the Host header for virtual hosting / TLS SNI.\n hostname = urlparse(url).hostname\n if hostname:\n # The custom transport tries validated IPs in order (dual-stack / LB).\n return create_ssrf_protected_client(hostname=hostname, validated_ips=validated_ips)\n return httpx.AsyncClient()\n\n @staticmethod\n def _method_for_redirect(method: str, status_code: int) -> str:\n \"\"\"Return the HTTP method to use after a redirect, mirroring httpx semantics.\n\n A 303 (See Other) always becomes GET; 301/302 downgrade POST to GET for\n browser compatibility; 307/308 preserve the original method (and body).\n \"\"\"\n method = method.upper()\n if status_code == HTTP_SEE_OTHER and method != \"HEAD\":\n return \"GET\"\n if status_code in (HTTP_MOVED_PERMANENTLY, HTTP_FOUND) and method == \"POST\":\n return \"GET\"\n return method\n\n @staticmethod\n def _headers_for_redirect(headers: dict | None, current_url: str, next_url: str) -> dict | None:\n \"\"\"Drop sensitive headers when a redirect crosses to a different origin.\n\n Mirrors httpx's auto-follow behavior so manually following redirects does not\n leak credentials (Authorization / Cookie) to an origin other than the one the\n caller intended them for. Headers are kept only when the redirect stays on the\n same origin (scheme, host, port) or is a direct https upgrade of the same host\n on default ports - the exact cases where httpx keeps the Authorization header.\n \"\"\"\n if not headers:\n return headers\n current, nxt = urlparse(current_url), urlparse(next_url)\n current_port = current.port or DEFAULT_SCHEME_PORTS.get(current.scheme)\n next_port = nxt.port or DEFAULT_SCHEME_PORTS.get(nxt.scheme)\n same_origin = (current.scheme, current.hostname, current_port) == (nxt.scheme, nxt.hostname, next_port)\n https_upgrade = (\n current.hostname == nxt.hostname\n and current.scheme == \"http\"\n and nxt.scheme == \"https\"\n and current_port == DEFAULT_SCHEME_PORTS[\"http\"]\n and next_port == DEFAULT_SCHEME_PORTS[\"https\"]\n )\n if same_origin or https_upgrade:\n return headers\n sensitive = {\"authorization\", \"proxy-authorization\", \"cookie\"}\n return {k: v for k, v in headers.items() if k.lower() not in sensitive}\n\n async def _follow_redirects_with_validation(\n self,\n method: str,\n url: str,\n headers: dict | None,\n body: Any,\n timeout: int,\n validated_ips: list[str],\n *,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n \"\"\"Make the request and follow redirects manually, re-validating every hop.\n\n This closes an SSRF bypass: with ``follow_redirects`` enabled, httpx would\n otherwise auto-follow a redirect from a validated public URL to an internal\n address that was never checked. Here each redirect ``Location`` is resolved\n (relative locations included) and re-validated with ``validate_and_resolve_url``\n — the same private/loopback/link-local denylist and DNS pinning applied to the\n initial request — before any connection to it is made. A blocked hop raises\n ``ValueError``; the number of redirects is capped at ``MAX_REDIRECTS``.\n \"\"\"\n method = method.upper()\n if method not in {\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"}:\n msg = f\"Unsupported method: {method}\"\n raise ValueError(msg)\n\n processed_body = self._process_body(body)\n current_url = url\n current_ips = validated_ips\n redirection_history: list[dict] = []\n\n for _ in range(MAX_REDIRECTS + 1):\n request_params: dict[str, Any] = {\n \"method\": method,\n \"url\": current_url,\n \"headers\": headers,\n \"timeout\": timeout,\n # Never let httpx follow redirects itself; each hop is validated below.\n \"follow_redirects\": False,\n }\n # Only include body for methods that support it (GET must not have a body).\n if method in {\"POST\", \"PATCH\", \"PUT\", \"DELETE\"} and processed_body is not None:\n request_params[\"json\"] = processed_body\n\n try:\n async with self._build_http_client(current_url, current_ips) as client:\n response = await client.request(**request_params)\n except (httpx.HTTPError, httpx.RequestError, httpx.TimeoutException) as exc:\n self.log(f\"Error making request to {current_url}\")\n return Data(\n data={\n \"source\": url,\n \"headers\": headers,\n \"status_code\": 500,\n \"error\": str(exc),\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n },\n )\n\n location = response.headers.get(\"Location\")\n if response.status_code in REDIRECT_STATUS_CODES and location:\n # Resolve relative redirects against the current URL.\n next_url = urljoin(current_url, location)\n redirection_history.append({\"url\": location, \"status_code\": response.status_code})\n\n # Re-validate the redirect target with the same SSRF denylist + DNS pinning.\n # Non-http(s) schemes, private/loopback/link-local hosts, and hostnames that\n # resolve to blocked IPs all raise SSRFProtectionError here.\n try:\n _validated_url, current_ips = validate_and_resolve_url(next_url)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: blocked redirect to {next_url}: {e}\"\n raise ValueError(msg) from e\n\n method = self._method_for_redirect(method, response.status_code)\n headers = self._headers_for_redirect(headers, current_url, next_url)\n current_url = next_url\n continue\n\n # Not a redirect (or no Location header) - this is the final response.\n return await self._build_response_data(\n response,\n url,\n headers,\n redirection_history,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n\n msg = f\"SSRF Protection: exceeded the maximum of {MAX_REDIRECTS} redirects while requesting {url}\"\n raise ValueError(msg)\n\n def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict:\n \"\"\"Update the build config based on the selected mode.\"\"\"\n if field_name != \"mode\":\n if field_name == \"curl_input\" and self.mode == \"cURL\" and self.curl_input:\n return self.parse_curl(self.curl_input, build_config)\n return build_config\n\n if field_value == \"cURL\":\n set_field_display(build_config, \"curl_input\", value=True)\n if build_config[\"curl_input\"][\"value\"]:\n try:\n build_config = self.parse_curl(build_config[\"curl_input\"][\"value\"], build_config)\n except ValueError as e:\n self.log(f\"Failed to parse cURL input: {e}\")\n else:\n set_field_display(build_config, \"curl_input\", value=False)\n\n return set_current_fields(\n build_config=build_config,\n action_fields=MODE_FIELDS,\n selected_action=field_value,\n default_fields=DEFAULT_FIELDS,\n func=set_field_advanced,\n default_value=True,\n )\n\n async def _response_info(\n self, response: httpx.Response, *, with_file_path: bool = False\n ) -> tuple[bool, Path | None]:\n \"\"\"Determine the file path and whether the response content is binary.\n\n Args:\n response (Response): The HTTP response object.\n with_file_path (bool): Whether to save the response content to a file.\n\n Returns:\n Tuple[bool, Path | None]:\n A tuple containing a boolean indicating if the content is binary and the full file path (if applicable).\n \"\"\"\n content_type = response.headers.get(\"Content-Type\", \"\")\n is_binary = \"application/octet-stream\" in content_type or \"application/binary\" in content_type\n\n if not with_file_path:\n return is_binary, None\n\n component_temp_dir = Path(tempfile.gettempdir()) / self.__class__.__name__\n\n # Create directory asynchronously\n await aiofiles_os.makedirs(component_temp_dir, exist_ok=True)\n\n filename = None\n if \"Content-Disposition\" in response.headers:\n content_disposition = response.headers[\"Content-Disposition\"]\n filename_match = re.search(r'filename=\"(.+?)\"', content_disposition)\n if filename_match:\n extracted_filename = filename_match.group(1)\n filename = extracted_filename\n\n # Step 3: Infer file extension or use part of the request URL if no filename\n if not filename:\n # Extract the last segment of the URL path\n url_path = urlparse(str(response.request.url) if response.request else \"\").path\n base_name = Path(url_path).name # Get the last segment of the path\n if not base_name: # If the path ends with a slash or is empty\n base_name = \"response\"\n\n # Infer file extension\n content_type_to_extension = {\n \"text/plain\": \".txt\",\n \"application/json\": \".json\",\n \"image/jpeg\": \".jpg\",\n \"image/png\": \".png\",\n \"application/octet-stream\": \".bin\",\n }\n extension = content_type_to_extension.get(content_type, \".bin\" if is_binary else \".txt\")\n filename = f\"{base_name}{extension}\"\n\n # Step 4: Define the full file path\n file_path = component_temp_dir / filename\n\n # Step 5: Check if file exists asynchronously and handle accordingly\n try:\n # Try to create the file exclusively (x mode) to check existence\n async with aiofiles.open(file_path, \"x\") as _:\n pass # File created successfully, we can use this path\n except FileExistsError:\n # If file exists, append a timestamp to the filename\n timestamp = datetime.now(timezone.utc).strftime(\"%Y%m%d%H%M%S%f\")\n file_path = component_temp_dir / f\"{timestamp}-{filename}\"\n\n return is_binary, file_path\n" }, "curl_input": { "_input_type": "MultilineInput", diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Simple Agent.json b/src/backend/base/langflow/initial_setup/starter_projects/Simple Agent.json index 40a6f20ca2..d660a1b44b 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Simple Agent.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Simple Agent.json @@ -1516,6 +1516,7 @@ "max_depth", "prevent_outside", "use_async", + "follow_redirects", "format", "timeout", "headers", @@ -1530,7 +1531,7 @@ "last_updated": "2026-02-12T20:48:13.882Z", "legacy": false, "metadata": { - "code_hash": "2e225a90043a", + "code_hash": "52b5bd602242", "dependencies": { "dependencies": [ { @@ -1632,7 +1633,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import importlib.util\nimport io\nimport re\nfrom urllib.parse import urljoin, urlparse\n\nimport httpx\nfrom bs4 import BeautifulSoup\nfrom markitdown import MarkItDown\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.data import safe_convert\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageTextInput, Output, SliderInput, TableInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.utils.request_utils import get_user_agent\nfrom lfx.utils.ssrf_protection import SSRFProtectionError, is_ssrf_protection_enabled, validate_and_resolve_url\nfrom lfx.utils.ssrf_transport import create_ssrf_protected_client\n\n# Constants\nDEFAULT_TIMEOUT = 30\nDEFAULT_MAX_DEPTH = 1\nDEFAULT_FORMAT = \"Text\"\n\n\nURL_REGEX = re.compile(\n r\"^(https?:\\/\\/)?\" r\"(www\\.)?\" r\"([a-zA-Z0-9.-]+)\" r\"(\\.[a-zA-Z]{2,})?\" r\"(:\\d+)?\" r\"(\\/[^\\s]*)?$\",\n re.IGNORECASE,\n)\n\nUSER_AGENT = None\n# Check if langflow is installed using importlib.util.find_spec(name))\nif importlib.util.find_spec(\"langflow\"):\n langflow_installed = True\n USER_AGENT = get_user_agent()\nelse:\n langflow_installed = False\n USER_AGENT = \"lfx\"\n\n\nclass URLComponent(Component):\n \"\"\"A component that loads and parses content from web pages recursively.\n\n This component allows fetching content from one or more URLs, with options to:\n - Control crawl depth\n - Prevent crawling outside the root domain\n - Use async loading for better performance\n - Extract either raw HTML or clean text\n - Configure request headers and timeouts\n \"\"\"\n\n display_name = \"URL\"\n description = \"Fetch content from one or more web pages, following links recursively.\"\n documentation: str = \"https://docs.langflow.org/url\"\n icon = \"layout-template\"\n name = \"URLComponent\"\n\n inputs = [\n MessageTextInput(\n name=\"urls\",\n display_name=\"URLs\",\n info=\"Enter one or more URLs to crawl recursively, by clicking the '+' button.\",\n is_list=True,\n tool_mode=True,\n placeholder=\"Enter a URL...\",\n list_add_label=\"Add URL\",\n input_types=[\"Message\"],\n ),\n SliderInput(\n name=\"max_depth\",\n display_name=\"Depth\",\n info=(\n \"Controls how many 'clicks' away from the initial page the crawler will go:\\n\"\n \"- depth 1: only the initial page\\n\"\n \"- depth 2: initial page + all pages linked directly from it\\n\"\n \"- depth 3: initial page + direct links + links found on those direct link pages\\n\"\n \"Note: This is about link traversal, not URL path depth.\"\n ),\n value=DEFAULT_MAX_DEPTH,\n range_spec=RangeSpec(min=1, max=5, step=1),\n required=False,\n min_label=\" \",\n max_label=\" \",\n min_label_icon=\"None\",\n max_label_icon=\"None\",\n # slider_input=True\n ),\n BoolInput(\n name=\"prevent_outside\",\n display_name=\"Prevent Outside\",\n info=(\n \"If enabled, only crawls URLs within the same domain as the root URL. \"\n \"This helps prevent the crawler from going to external websites.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"use_async\",\n display_name=\"Use Async\",\n info=(\n \"If enabled, uses asynchronous loading which can be significantly faster \"\n \"but might use more system resources.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=(\n \"Output Format. Use 'Text' to extract the text from the HTML, \"\n \"'Markdown' to parse the HTML into Markdown format, or 'HTML' \"\n \"for the raw HTML content.\"\n ),\n options=[\"Text\", \"HTML\", \"Markdown\"],\n value=DEFAULT_FORMAT,\n advanced=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"Timeout for the request in seconds.\",\n value=DEFAULT_TIMEOUT,\n required=False,\n advanced=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": USER_AGENT}],\n advanced=True,\n input_types=[\"DataFrame\", \"Table\"],\n ),\n BoolInput(\n name=\"filter_text_html\",\n display_name=\"Filter Text/HTML\",\n info=\"If enabled, filters out text/css content type from the results.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"continue_on_failure\",\n display_name=\"Continue on Failure\",\n info=\"If enabled, continues crawling even if some requests fail.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"check_response_status\",\n display_name=\"Check Response Status\",\n info=\"If enabled, checks the response status of the request.\",\n value=False,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"autoset_encoding\",\n display_name=\"Autoset Encoding\",\n info=\"If enabled, automatically sets the encoding of the request.\",\n value=True,\n required=False,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Extracted Pages\", name=\"page_results\", method=\"fetch_content\"),\n Output(display_name=\"Raw Content\", name=\"raw_results\", method=\"fetch_content_as_message\", tool_mode=False),\n ]\n\n @staticmethod\n def _html_extractor(x: str) -> str:\n \"\"\"Extract raw HTML content.\"\"\"\n return x\n\n @staticmethod\n def _text_extractor(x: str) -> str:\n \"\"\"Extract clean text from HTML.\"\"\"\n return BeautifulSoup(x, \"lxml\").get_text()\n\n @staticmethod\n def _markdown_extractor(x: str) -> str:\n \"\"\"Convert HTML to Markdown format.\"\"\"\n stream = io.BytesIO(x.encode(\"utf-8\"))\n result = MarkItDown(enable_plugins=False).convert_stream(stream)\n return result.markdown\n\n @staticmethod\n def validate_url(url: str) -> bool:\n \"\"\"Validates if the given string matches URL pattern.\n\n Args:\n url: The URL string to validate\n\n Returns:\n bool: True if the URL is valid, False otherwise\n \"\"\"\n return bool(URL_REGEX.match(url))\n\n def ensure_url(self, url: str) -> tuple[str, list[str]]:\n \"\"\"Ensures the given string is a valid URL and returns validated IPs for DNS pinning.\n\n Args:\n url: The URL string to validate and normalize\n\n Returns:\n tuple[str, list[str]]: The normalized URL and list of validated IPs for DNS pinning\n\n Raises:\n ValueError: If the URL is invalid or blocked by SSRF protection\n \"\"\"\n url = url.strip()\n if not url.startswith((\"http://\", \"https://\")):\n url = \"https://\" + url\n\n if not self.validate_url(url):\n msg = f\"Invalid URL: {url}\"\n raise ValueError(msg)\n\n # ============================================================================\n # SSRF Protection with DNS Pinning\n # ============================================================================\n # This prevents DNS rebinding attacks by:\n # 1. Resolving DNS and validating IPs during security check\n # 2. Returning the validated IP addresses for DNS pinning\n # 3. Using a custom HTTP transport that forces use of the pinned IPs\n # 4. Ignoring any new DNS resolutions (prevents rebinding)\n #\n # Without DNS pinning, an attacker could:\n # - First DNS lookup: returns public IP (passes validation)\n # - Second DNS lookup: returns internal IP (bypasses protection)\n # - Attack succeeds: accesses internal services\n #\n # With DNS pinning:\n # - First DNS lookup: returns public IP (passes validation)\n # - IPs are pinned and returned\n # - HTTP request: uses pinned IPs directly (no new DNS lookup)\n # - Attack fails: even if DNS changes, we use the validated IPs\n # ============================================================================\n try:\n _validated_url, validated_ips = validate_and_resolve_url(url)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n else:\n # Log DNS pinning information for security auditing\n if validated_ips:\n logger.debug(f\"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s) for {url}\")\n\n return url, validated_ips\n\n def _build_http_client(self, url: str, validated_ips: list[str]) -> httpx.AsyncClient:\n \"\"\"Create an HTTP client with DNS pinning for SSRF protection.\n\n Args:\n url: The request URL whose hostname will be pinned\n validated_ips: IPs validated by validate_and_resolve_url for this URL\n\n Returns:\n httpx.AsyncClient: A client with DNS pinning when SSRF protection is enabled\n \"\"\"\n if is_ssrf_protection_enabled() and validated_ips:\n hostname = urlparse(url).hostname\n if hostname:\n return create_ssrf_protected_client(hostname=hostname, validated_ips=validated_ips)\n return httpx.AsyncClient()\n\n async def _fetch_url_with_pinning(self, url: str, validated_ips: list[str], headers: dict) -> tuple[str, dict]:\n \"\"\"Fetch a single URL with DNS pinning protection.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n async with self._build_http_client(url, validated_ips) as client:\n try:\n response = await client.get(url, headers=headers, timeout=self.timeout, follow_redirects=False)\n\n if self.check_response_status:\n response.raise_for_status()\n\n content_type = response.headers.get(\"content-type\", \"\").lower()\n\n # Filter out CSS files if requested\n if self.filter_text_html and \"text/css\" in content_type:\n logger.debug(f\"Skipping CSS file: {url}\")\n return \"\", {}\n\n # Get the HTML content\n html_content = response.text\n\n # Extract metadata\n metadata = {\n \"source\": str(response.url),\n \"title\": \"\",\n \"description\": \"\",\n \"content_type\": content_type,\n \"language\": \"\",\n }\n\n # Try to extract title and description from HTML\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - metadata extraction is optional\n # and we don't want to fail the entire request if it fails\n logger.debug(f\"Failed to extract metadata from {url}\")\n else:\n if soup.title:\n metadata[\"title\"] = soup.title.string or \"\"\n\n # Try to get description from meta tags\n meta_desc = soup.find(\"meta\", attrs={\"name\": \"description\"})\n if meta_desc and meta_desc.get(\"content\"):\n metadata[\"description\"] = meta_desc[\"content\"]\n\n # Try to get language\n html_tag = soup.find(\"html\")\n if html_tag and html_tag.get(\"lang\"):\n metadata[\"language\"] = html_tag[\"lang\"]\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Failed to fetch {url}: {e}\")\n return \"\", {}\n raise\n else:\n return html_content, metadata\n\n async def _crawl_recursive(\n self, start_url: str, validated_ips: list[str], headers: dict, visited: set, depth: int = 0\n ) -> list[dict]:\n \"\"\"Recursively crawl URLs with DNS pinning protection.\n\n Args:\n start_url: The URL to start crawling from\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n visited: Set of already visited URLs\n depth: Current crawl depth\n\n Returns:\n list[dict]: List of documents with content and metadata\n \"\"\"\n if depth >= self.max_depth or start_url in visited:\n return []\n\n visited.add(start_url)\n documents = []\n\n # Fetch the current URL\n html_content, metadata = await self._fetch_url_with_pinning(start_url, validated_ips, headers)\n\n if not html_content:\n return documents\n\n # Extract content based on format\n extractors = {\n \"HTML\": self._html_extractor,\n \"Markdown\": self._markdown_extractor,\n \"Text\": self._text_extractor,\n }\n extractor = extractors.get(self.format, self._text_extractor)\n extracted_content = extractor(html_content)\n\n # Add the document\n documents.append(\n {\n \"page_content\": extracted_content,\n \"metadata\": metadata,\n }\n )\n\n # If we haven't reached max depth, extract and follow links\n if depth < self.max_depth - 1:\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n links = soup.find_all(\"a\", href=True)\n\n for link in links:\n href = link[\"href\"]\n # Resolve relative URLs\n absolute_url = urljoin(start_url, href)\n\n # Skip if already visited\n if absolute_url in visited:\n continue\n\n # Check if we should prevent going outside the domain\n if self.prevent_outside:\n start_domain = urlparse(start_url).netloc\n link_domain = urlparse(absolute_url).netloc\n if start_domain != link_domain:\n continue\n\n # Validate and crawl the linked URL\n try:\n _, link_validated_ips = self.ensure_url(absolute_url)\n sub_docs = await self._crawl_recursive(\n absolute_url, link_validated_ips, headers, visited, depth + 1\n )\n documents.extend(sub_docs)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping {absolute_url}: {e}\")\n continue\n raise\n\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - link extraction is optional\n # and we don't want to fail the entire crawl if one page has issues\n logger.debug(f\"Failed to extract links from {start_url}\")\n\n return documents\n\n async def fetch_url_contents(self) -> list[dict]:\n \"\"\"Load documents from the configured URLs with SSRF protection and DNS pinning.\n\n This method implements comprehensive SSRF (Server-Side Request Forgery) protection\n using DNS pinning to prevent DNS rebinding attacks. Each URL is validated and its\n DNS resolution is pinned before any HTTP requests are made.\n\n Returns:\n list[dict]: List of documents with content and metadata\n\n Raises:\n ValueError: If no valid URLs are provided or if there's an error loading documents\n \"\"\"\n try:\n # Validate all URLs and get their validated IPs for DNS pinning\n validated_urls = []\n first_validation_error: Exception | None = None\n for url in self.urls:\n if not url.strip():\n continue\n try:\n normalized_url, validated_ips = self.ensure_url(url)\n validated_urls.append((normalized_url, validated_ips))\n except (ValueError, SSRFProtectionError) as e:\n # Remember the first rejection so its real cause (for example an\n # SSRF block) can be surfaced if no URL survives validation,\n # instead of being hidden behind \"No valid URLs provided\".\n if first_validation_error is None:\n first_validation_error = e\n if self.continue_on_failure:\n logger.warning(f\"Skipping invalid URL {url}: {e}\")\n continue\n raise\n\n # Remove duplicates while preserving order\n seen = set()\n unique_validated_urls = []\n for url, ips in validated_urls:\n if url not in seen:\n seen.add(url)\n unique_validated_urls.append((url, ips))\n\n logger.debug(f\"Validated {len(unique_validated_urls)} unique URL(s)\")\n\n if not unique_validated_urls:\n # Surface the actual reason every candidate URL was rejected (e.g. an\n # SSRF block) rather than a generic message, so a security failure is\n # not silently swallowed when continue_on_failure is enabled.\n if first_validation_error is not None:\n raise first_validation_error\n msg = \"No valid URLs provided.\"\n raise ValueError(msg)\n\n # Prepare headers\n headers_dict = {header[\"key\"]: header[\"value\"] for header in self.headers if header[\"value\"] is not None}\n\n # Crawl all URLs\n all_docs = []\n for url, validated_ips in unique_validated_urls:\n logger.debug(f\"Crawling {url} with max_depth={self.max_depth}\")\n\n try:\n visited = set()\n docs = await self._crawl_recursive(url, validated_ips, headers_dict, visited, depth=0)\n\n if not docs:\n logger.warning(f\"No documents found for {url}\")\n continue\n\n logger.debug(f\"Found {len(docs)} document(s) from {url}\")\n all_docs.extend(docs)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Error loading documents from {url}: {e}\")\n continue\n msg = f\"Error loading documents from {url}: {e}\"\n raise ValueError(msg) from e\n\n if not all_docs:\n msg = \"No documents were successfully loaded from any URL\"\n raise ValueError(msg)\n\n # Convert to output format\n return [\n {\n \"text\": safe_convert(doc[\"page_content\"], clean_data=True),\n \"url\": doc[\"metadata\"].get(\"source\", \"\"),\n \"title\": doc[\"metadata\"].get(\"title\", \"\"),\n \"description\": doc[\"metadata\"].get(\"description\", \"\"),\n \"content_type\": doc[\"metadata\"].get(\"content_type\", \"\"),\n \"language\": doc[\"metadata\"].get(\"language\", \"\"),\n }\n for doc in all_docs\n ]\n\n except Exception as e:\n error_msg = e.message if hasattr(e, \"message\") else str(e)\n msg = f\"Error loading documents: {error_msg}\"\n logger.exception(msg)\n raise ValueError(msg) from e\n\n async def fetch_content(self) -> DataFrame:\n \"\"\"Convert the documents to a DataFrame.\"\"\"\n url_contents = await self.fetch_url_contents()\n return DataFrame(data=url_contents)\n\n async def fetch_content_as_message(self) -> Message:\n \"\"\"Convert the documents to a Message.\"\"\"\n url_contents = await self.fetch_url_contents()\n return Message(text=\"\\n\\n\".join([x[\"text\"] for x in url_contents]), data={\"data\": url_contents})\n" + "value": "import importlib.util\nimport io\nimport re\nfrom urllib.parse import urljoin, urlparse\n\nimport httpx\nfrom bs4 import BeautifulSoup\nfrom markitdown import MarkItDown\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.data import safe_convert\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageTextInput, Output, SliderInput, TableInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.utils.request_utils import get_user_agent\nfrom lfx.utils.ssrf_protection import SSRFProtectionError, is_ssrf_protection_enabled, validate_and_resolve_url\nfrom lfx.utils.ssrf_transport import create_ssrf_protected_client\n\n# Constants\nDEFAULT_TIMEOUT = 30\nDEFAULT_MAX_DEPTH = 1\nDEFAULT_FORMAT = \"Text\"\n\n# HTTP status codes that carry a redirect Location header (RFC 9110).\nREDIRECT_STATUS_CODES = frozenset({301, 302, 303, 307, 308})\n\n# Maximum number of redirects to follow. Each hop is re-validated for SSRF safety;\n# matches httpx's default and the API Request component.\nMAX_REDIRECTS = 20\n\n# Default ports per scheme, used to compare redirect origins.\nDEFAULT_SCHEME_PORTS = {\"http\": 80, \"https\": 443}\n\n\nURL_REGEX = re.compile(\n r\"^(https?:\\/\\/)?\" r\"(www\\.)?\" r\"([a-zA-Z0-9.-]+)\" r\"(\\.[a-zA-Z]{2,})?\" r\"(:\\d+)?\" r\"(\\/[^\\s]*)?$\",\n re.IGNORECASE,\n)\n\nUSER_AGENT = None\n# Check if langflow is installed using importlib.util.find_spec(name))\nif importlib.util.find_spec(\"langflow\"):\n langflow_installed = True\n USER_AGENT = get_user_agent()\nelse:\n langflow_installed = False\n USER_AGENT = \"lfx\"\n\n\nclass URLComponent(Component):\n \"\"\"A component that loads and parses content from web pages recursively.\n\n This component allows fetching content from one or more URLs, with options to:\n - Control crawl depth\n - Prevent crawling outside the root domain\n - Use async loading for better performance\n - Extract either raw HTML or clean text\n - Configure request headers and timeouts\n \"\"\"\n\n display_name = \"URL\"\n description = \"Fetch content from one or more web pages, following links recursively.\"\n documentation: str = \"https://docs.langflow.org/url\"\n icon = \"layout-template\"\n name = \"URLComponent\"\n\n inputs = [\n MessageTextInput(\n name=\"urls\",\n display_name=\"URLs\",\n info=\"Enter one or more URLs to crawl recursively, by clicking the '+' button.\",\n is_list=True,\n tool_mode=True,\n placeholder=\"Enter a URL...\",\n list_add_label=\"Add URL\",\n input_types=[\"Message\"],\n ),\n SliderInput(\n name=\"max_depth\",\n display_name=\"Depth\",\n info=(\n \"Controls how many 'clicks' away from the initial page the crawler will go:\\n\"\n \"- depth 1: only the initial page\\n\"\n \"- depth 2: initial page + all pages linked directly from it\\n\"\n \"- depth 3: initial page + direct links + links found on those direct link pages\\n\"\n \"Note: This is about link traversal, not URL path depth.\"\n ),\n value=DEFAULT_MAX_DEPTH,\n range_spec=RangeSpec(min=1, max=5, step=1),\n required=False,\n min_label=\" \",\n max_label=\" \",\n min_label_icon=\"None\",\n max_label_icon=\"None\",\n # slider_input=True\n ),\n BoolInput(\n name=\"prevent_outside\",\n display_name=\"Prevent Outside\",\n info=(\n \"If enabled, only crawls URLs within the same domain as the root URL. \"\n \"This helps prevent the crawler from going to external websites.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"use_async\",\n display_name=\"Use Async\",\n info=(\n \"If enabled, uses asynchronous loading which can be significantly faster \"\n \"but might use more system resources.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"follow_redirects\",\n display_name=\"Follow Redirects\",\n info=(\n \"If enabled, follows HTTP redirects such as http→https or www/non-www \"\n \"normalization, which most sites rely on to serve their content. When SSRF \"\n \"protection is enabled (the default), every redirect hop is re-validated against \"\n \"the same blocked-IP denylist and DNS-pinned before it is fetched, so following \"\n \"redirects cannot be used to reach internal resources. Disable to capture the \"\n \"first response as-is without following redirects.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=(\n \"Output Format. Use 'Text' to extract the text from the HTML, \"\n \"'Markdown' to parse the HTML into Markdown format, or 'HTML' \"\n \"for the raw HTML content.\"\n ),\n options=[\"Text\", \"HTML\", \"Markdown\"],\n value=DEFAULT_FORMAT,\n advanced=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"Timeout for the request in seconds.\",\n value=DEFAULT_TIMEOUT,\n required=False,\n advanced=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": USER_AGENT}],\n advanced=True,\n input_types=[\"DataFrame\", \"Table\"],\n ),\n BoolInput(\n name=\"filter_text_html\",\n display_name=\"Filter Text/HTML\",\n info=\"If enabled, filters out text/css content type from the results.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"continue_on_failure\",\n display_name=\"Continue on Failure\",\n info=\"If enabled, continues crawling even if some requests fail.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"check_response_status\",\n display_name=\"Check Response Status\",\n info=\"If enabled, checks the response status of the request.\",\n value=False,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"autoset_encoding\",\n display_name=\"Autoset Encoding\",\n info=\"If enabled, automatically sets the encoding of the request.\",\n value=True,\n required=False,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Extracted Pages\", name=\"page_results\", method=\"fetch_content\"),\n Output(display_name=\"Raw Content\", name=\"raw_results\", method=\"fetch_content_as_message\", tool_mode=False),\n ]\n\n @staticmethod\n def _html_extractor(x: str) -> str:\n \"\"\"Extract raw HTML content.\"\"\"\n return x\n\n @staticmethod\n def _text_extractor(x: str) -> str:\n \"\"\"Extract clean text from HTML.\"\"\"\n return BeautifulSoup(x, \"lxml\").get_text()\n\n @staticmethod\n def _markdown_extractor(x: str) -> str:\n \"\"\"Convert HTML to Markdown format.\"\"\"\n stream = io.BytesIO(x.encode(\"utf-8\"))\n result = MarkItDown(enable_plugins=False).convert_stream(stream)\n return result.markdown\n\n @staticmethod\n def validate_url(url: str) -> bool:\n \"\"\"Validates if the given string matches URL pattern.\n\n Args:\n url: The URL string to validate\n\n Returns:\n bool: True if the URL is valid, False otherwise\n \"\"\"\n return bool(URL_REGEX.match(url))\n\n def ensure_url(self, url: str) -> tuple[str, list[str]]:\n \"\"\"Ensures the given string is a valid URL and returns validated IPs for DNS pinning.\n\n Args:\n url: The URL string to validate and normalize\n\n Returns:\n tuple[str, list[str]]: The normalized URL and list of validated IPs for DNS pinning\n\n Raises:\n ValueError: If the URL is invalid or blocked by SSRF protection\n \"\"\"\n url = url.strip()\n if not url.startswith((\"http://\", \"https://\")):\n url = \"https://\" + url\n\n if not self.validate_url(url):\n msg = f\"Invalid URL: {url}\"\n raise ValueError(msg)\n\n # ============================================================================\n # SSRF Protection with DNS Pinning\n # ============================================================================\n # This prevents DNS rebinding attacks by:\n # 1. Resolving DNS and validating IPs during security check\n # 2. Returning the validated IP addresses for DNS pinning\n # 3. Using a custom HTTP transport that forces use of the pinned IPs\n # 4. Ignoring any new DNS resolutions (prevents rebinding)\n #\n # Without DNS pinning, an attacker could:\n # - First DNS lookup: returns public IP (passes validation)\n # - Second DNS lookup: returns internal IP (bypasses protection)\n # - Attack succeeds: accesses internal services\n #\n # With DNS pinning:\n # - First DNS lookup: returns public IP (passes validation)\n # - IPs are pinned and returned\n # - HTTP request: uses pinned IPs directly (no new DNS lookup)\n # - Attack fails: even if DNS changes, we use the validated IPs\n # ============================================================================\n try:\n _validated_url, validated_ips = validate_and_resolve_url(url)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n else:\n # Log DNS pinning information for security auditing\n if validated_ips:\n logger.debug(f\"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s) for {url}\")\n\n return url, validated_ips\n\n def _build_http_client(self, url: str, validated_ips: list[str]) -> httpx.AsyncClient:\n \"\"\"Create an HTTP client with DNS pinning for SSRF protection.\n\n Args:\n url: The request URL whose hostname will be pinned\n validated_ips: IPs validated by validate_and_resolve_url for this URL\n\n Returns:\n httpx.AsyncClient: A client with DNS pinning when SSRF protection is enabled\n \"\"\"\n if is_ssrf_protection_enabled() and validated_ips:\n hostname = urlparse(url).hostname\n if hostname:\n return create_ssrf_protected_client(hostname=hostname, validated_ips=validated_ips)\n return httpx.AsyncClient()\n\n @staticmethod\n def _headers_for_redirect(headers: dict | None, current_url: str, next_url: str) -> dict | None:\n \"\"\"Drop sensitive headers when a redirect crosses to a different origin.\n\n Mirrors httpx's auto-follow behavior so manually following redirects does not\n leak credentials (Authorization / Cookie) to an origin other than the one the\n caller intended them for. Headers are kept only when the redirect stays on the\n same origin (scheme, host, port) or is a direct https upgrade of the same host\n on default ports - the exact cases where httpx keeps the Authorization header.\n \"\"\"\n if not headers:\n return headers\n current, nxt = urlparse(current_url), urlparse(next_url)\n current_port = current.port or DEFAULT_SCHEME_PORTS.get(current.scheme)\n next_port = nxt.port or DEFAULT_SCHEME_PORTS.get(nxt.scheme)\n same_origin = (current.scheme, current.hostname, current_port) == (nxt.scheme, nxt.hostname, next_port)\n https_upgrade = (\n current.hostname == nxt.hostname\n and current.scheme == \"http\"\n and nxt.scheme == \"https\"\n and current_port == DEFAULT_SCHEME_PORTS[\"http\"]\n and next_port == DEFAULT_SCHEME_PORTS[\"https\"]\n )\n if same_origin or https_upgrade:\n return headers\n sensitive = {\"authorization\", \"proxy-authorization\", \"cookie\"}\n return {k: v for k, v in headers.items() if k.lower() not in sensitive}\n\n def _process_response(self, response: httpx.Response) -> tuple[str, dict]:\n \"\"\"Turn a final (non-redirect) response into its HTML content and metadata.\n\n Args:\n response: The HTTP response to process\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n if self.check_response_status:\n response.raise_for_status()\n\n content_type = response.headers.get(\"content-type\", \"\").lower()\n\n # Filter out CSS files if requested\n if self.filter_text_html and \"text/css\" in content_type:\n logger.debug(f\"Skipping CSS file: {response.url}\")\n return \"\", {}\n\n # Get the HTML content\n html_content = response.text\n\n # Extract metadata\n metadata = {\n \"source\": str(response.url),\n \"title\": \"\",\n \"description\": \"\",\n \"content_type\": content_type,\n \"language\": \"\",\n }\n\n # Try to extract title and description from HTML\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - metadata extraction is optional\n # and we don't want to fail the entire request if it fails\n logger.debug(f\"Failed to extract metadata from {response.url}\")\n else:\n if soup.title:\n metadata[\"title\"] = soup.title.string or \"\"\n\n # Try to get description from meta tags\n meta_desc = soup.find(\"meta\", attrs={\"name\": \"description\"})\n if meta_desc and meta_desc.get(\"content\"):\n metadata[\"description\"] = meta_desc[\"content\"]\n\n # Try to get language\n html_tag = soup.find(\"html\")\n if html_tag and html_tag.get(\"lang\"):\n metadata[\"language\"] = html_tag[\"lang\"]\n\n return html_content, metadata\n\n async def _fetch_with_revalidated_redirects(\n self, url: str, validated_ips: list[str], headers: dict\n ) -> tuple[str, dict]:\n \"\"\"Fetch ``url``, following redirects manually and re-validating every hop.\n\n This closes an SSRF bypass: with redirects enabled, httpx would otherwise\n auto-follow a redirect from a validated public URL to an internal address that\n the pinned transport never checked (the redirect target is a different host, so\n it is not in the pin map). Each redirect ``Location`` is resolved (relative\n locations included) and re-validated with ``ensure_url`` - the same private/\n loopback/link-local denylist and DNS pinning applied to the initial request -\n before any connection to it is made. A blocked hop raises ``ValueError`` (unless\n ``continue_on_failure``); the chain is capped at ``MAX_REDIRECTS`` hops.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning of the initial URL\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n current_url = url\n current_ips = validated_ips\n\n for _ in range(MAX_REDIRECTS + 1):\n async with self._build_http_client(current_url, current_ips) as client:\n response = await client.get(current_url, headers=headers, timeout=self.timeout, follow_redirects=False)\n\n location = response.headers.get(\"location\")\n if response.status_code in REDIRECT_STATUS_CODES and location:\n # Resolve relative redirects against the current URL.\n next_url = urljoin(current_url, location)\n\n # Re-validate the redirect target with the same SSRF denylist + DNS pinning.\n try:\n validated_next_url, current_ips = self.ensure_url(next_url)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping blocked or invalid redirect to {next_url}: {e}\")\n return \"\", {}\n msg = f\"SSRF Protection: blocked redirect to {next_url}: {e}\"\n raise ValueError(msg) from e\n\n headers = self._headers_for_redirect(headers, current_url, next_url)\n current_url = validated_next_url\n continue\n\n # Not a redirect (or no Location header) - this is the final response.\n return self._process_response(response)\n\n # Exhausted the redirect budget.\n if self.continue_on_failure:\n logger.warning(f\"Exceeded the maximum of {MAX_REDIRECTS} redirects while requesting {url}\")\n return \"\", {}\n msg = f\"SSRF Protection: exceeded the maximum of {MAX_REDIRECTS} redirects while requesting {url}\"\n raise ValueError(msg)\n\n async def _fetch_url_with_pinning(self, url: str, validated_ips: list[str], headers: dict) -> tuple[str, dict]:\n \"\"\"Fetch a single URL with DNS pinning protection, following redirects safely.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n try:\n # When SSRF protection is enabled we must follow redirects manually so each hop\n # is re-validated and DNS-pinned; letting httpx auto-follow would connect to the\n # redirect target without pinning (different host, not in the pin map) and re-open\n # the SSRF hole that DNS pinning closes. With protection disabled there is no pin\n # to bypass, so httpx can follow redirects natively.\n if self.follow_redirects and is_ssrf_protection_enabled():\n return await self._fetch_with_revalidated_redirects(url, validated_ips, headers)\n\n async with self._build_http_client(url, validated_ips) as client:\n response = await client.get(\n url, headers=headers, timeout=self.timeout, follow_redirects=self.follow_redirects\n )\n return self._process_response(response)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Failed to fetch {url}: {e}\")\n return \"\", {}\n raise\n\n async def _crawl_recursive(\n self, start_url: str, validated_ips: list[str], headers: dict, visited: set, depth: int = 0\n ) -> list[dict]:\n \"\"\"Recursively crawl URLs with DNS pinning protection.\n\n Args:\n start_url: The URL to start crawling from\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n visited: Set of already visited URLs\n depth: Current crawl depth\n\n Returns:\n list[dict]: List of documents with content and metadata\n \"\"\"\n if depth >= self.max_depth or start_url in visited:\n return []\n\n visited.add(start_url)\n documents = []\n\n # Fetch the current URL\n html_content, metadata = await self._fetch_url_with_pinning(start_url, validated_ips, headers)\n\n # Redirects may have landed on a different canonical URL (http->https, www\n # normalization). Resolve relative links and the prevent_outside check against\n # the URL the content actually came from, and mark it visited so links back to\n # the canonical form are not re-crawled.\n base_url = metadata.get(\"source\") or start_url\n visited.add(base_url)\n\n if not html_content:\n return documents\n\n # Extract content based on format\n extractors = {\n \"HTML\": self._html_extractor,\n \"Markdown\": self._markdown_extractor,\n \"Text\": self._text_extractor,\n }\n extractor = extractors.get(self.format, self._text_extractor)\n extracted_content = extractor(html_content)\n\n # Add the document\n documents.append(\n {\n \"page_content\": extracted_content,\n \"metadata\": metadata,\n }\n )\n\n # If we haven't reached max depth, extract and follow links\n if depth < self.max_depth - 1:\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n links = soup.find_all(\"a\", href=True)\n\n for link in links:\n href = link[\"href\"]\n # Resolve relative URLs\n absolute_url = urljoin(base_url, href)\n\n # Skip if already visited\n if absolute_url in visited:\n continue\n\n # Check if we should prevent going outside the domain\n if self.prevent_outside:\n start_domain = urlparse(base_url).netloc\n link_domain = urlparse(absolute_url).netloc\n if start_domain != link_domain:\n continue\n\n # Validate and crawl the linked URL\n try:\n _, link_validated_ips = self.ensure_url(absolute_url)\n sub_docs = await self._crawl_recursive(\n absolute_url, link_validated_ips, headers, visited, depth + 1\n )\n documents.extend(sub_docs)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping {absolute_url}: {e}\")\n continue\n raise\n\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - link extraction is optional\n # and we don't want to fail the entire crawl if one page has issues\n logger.debug(f\"Failed to extract links from {start_url}\")\n\n return documents\n\n async def fetch_url_contents(self) -> list[dict]:\n \"\"\"Load documents from the configured URLs with SSRF protection and DNS pinning.\n\n This method implements comprehensive SSRF (Server-Side Request Forgery) protection\n using DNS pinning to prevent DNS rebinding attacks. Each URL is validated and its\n DNS resolution is pinned before any HTTP requests are made.\n\n Returns:\n list[dict]: List of documents with content and metadata\n\n Raises:\n ValueError: If no valid URLs are provided or if there's an error loading documents\n \"\"\"\n try:\n # Validate all URLs and get their validated IPs for DNS pinning\n validated_urls = []\n first_validation_error: Exception | None = None\n for url in self.urls:\n if not url.strip():\n continue\n try:\n normalized_url, validated_ips = self.ensure_url(url)\n validated_urls.append((normalized_url, validated_ips))\n except (ValueError, SSRFProtectionError) as e:\n # Remember the first rejection so its real cause (for example an\n # SSRF block) can be surfaced if no URL survives validation,\n # instead of being hidden behind \"No valid URLs provided\".\n if first_validation_error is None:\n first_validation_error = e\n if self.continue_on_failure:\n logger.warning(f\"Skipping invalid URL {url}: {e}\")\n continue\n raise\n\n # Remove duplicates while preserving order\n seen = set()\n unique_validated_urls = []\n for url, ips in validated_urls:\n if url not in seen:\n seen.add(url)\n unique_validated_urls.append((url, ips))\n\n logger.debug(f\"Validated {len(unique_validated_urls)} unique URL(s)\")\n\n if not unique_validated_urls:\n # Surface the actual reason every candidate URL was rejected (e.g. an\n # SSRF block) rather than a generic message, so a security failure is\n # not silently swallowed when continue_on_failure is enabled.\n if first_validation_error is not None:\n raise first_validation_error\n msg = \"No valid URLs provided.\"\n raise ValueError(msg)\n\n # Prepare headers\n headers_dict = {header[\"key\"]: header[\"value\"] for header in self.headers if header[\"value\"] is not None}\n\n # Crawl all URLs\n all_docs = []\n for url, validated_ips in unique_validated_urls:\n logger.debug(f\"Crawling {url} with max_depth={self.max_depth}\")\n\n try:\n visited = set()\n docs = await self._crawl_recursive(url, validated_ips, headers_dict, visited, depth=0)\n\n if not docs:\n logger.warning(f\"No documents found for {url}\")\n continue\n\n logger.debug(f\"Found {len(docs)} document(s) from {url}\")\n all_docs.extend(docs)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Error loading documents from {url}: {e}\")\n continue\n msg = f\"Error loading documents from {url}: {e}\"\n raise ValueError(msg) from e\n\n if not all_docs:\n msg = \"No documents were successfully loaded from any URL\"\n raise ValueError(msg)\n\n # Convert to output format\n return [\n {\n \"text\": safe_convert(doc[\"page_content\"], clean_data=True),\n \"url\": doc[\"metadata\"].get(\"source\", \"\"),\n \"title\": doc[\"metadata\"].get(\"title\", \"\"),\n \"description\": doc[\"metadata\"].get(\"description\", \"\"),\n \"content_type\": doc[\"metadata\"].get(\"content_type\", \"\"),\n \"language\": doc[\"metadata\"].get(\"language\", \"\"),\n }\n for doc in all_docs\n ]\n\n except Exception as e:\n error_msg = e.message if hasattr(e, \"message\") else str(e)\n msg = f\"Error loading documents: {error_msg}\"\n logger.exception(msg)\n raise ValueError(msg) from e\n\n async def fetch_content(self) -> DataFrame:\n \"\"\"Convert the documents to a DataFrame.\"\"\"\n url_contents = await self.fetch_url_contents()\n return DataFrame(data=url_contents)\n\n async def fetch_content_as_message(self) -> Message:\n \"\"\"Convert the documents to a Message.\"\"\"\n url_contents = await self.fetch_url_contents()\n return Message(text=\"\\n\\n\".join([x[\"text\"] for x in url_contents]), data={\"data\": url_contents})\n" }, "continue_on_failure": { "_input_type": "BoolInput", @@ -1670,6 +1671,26 @@ "type": "bool", "value": true }, + "follow_redirects": { + "_input_type": "BoolInput", + "advanced": true, + "display_name": "Follow Redirects", + "dynamic": false, + "info": "If enabled, follows HTTP redirects such as http→https or www/non-www normalization, which most sites rely on to serve their content. When SSRF protection is enabled (the default), every redirect hop is re-validated against the same blocked-IP denylist and DNS-pinned before it is fetched, so following redirects cannot be used to reach internal resources. Disable to capture the first response as-is without following redirects.", + "list": false, + "list_add_label": "Add More", + "name": "follow_redirects", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + }, "format": { "_input_type": "DropdownInput", "advanced": true, diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Travel Planning Agents.json b/src/backend/base/langflow/initial_setup/starter_projects/Travel Planning Agents.json index e7969703a9..069d5949a0 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Travel Planning Agents.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Travel Planning Agents.json @@ -807,6 +807,7 @@ "max_depth", "prevent_outside", "use_async", + "follow_redirects", "format", "timeout", "headers", @@ -820,7 +821,7 @@ "legacy": false, "lf_version": "1.2.0", "metadata": { - "code_hash": "2e225a90043a", + "code_hash": "52b5bd602242", "dependencies": { "dependencies": [ { @@ -920,7 +921,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import importlib.util\nimport io\nimport re\nfrom urllib.parse import urljoin, urlparse\n\nimport httpx\nfrom bs4 import BeautifulSoup\nfrom markitdown import MarkItDown\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.data import safe_convert\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageTextInput, Output, SliderInput, TableInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.utils.request_utils import get_user_agent\nfrom lfx.utils.ssrf_protection import SSRFProtectionError, is_ssrf_protection_enabled, validate_and_resolve_url\nfrom lfx.utils.ssrf_transport import create_ssrf_protected_client\n\n# Constants\nDEFAULT_TIMEOUT = 30\nDEFAULT_MAX_DEPTH = 1\nDEFAULT_FORMAT = \"Text\"\n\n\nURL_REGEX = re.compile(\n r\"^(https?:\\/\\/)?\" r\"(www\\.)?\" r\"([a-zA-Z0-9.-]+)\" r\"(\\.[a-zA-Z]{2,})?\" r\"(:\\d+)?\" r\"(\\/[^\\s]*)?$\",\n re.IGNORECASE,\n)\n\nUSER_AGENT = None\n# Check if langflow is installed using importlib.util.find_spec(name))\nif importlib.util.find_spec(\"langflow\"):\n langflow_installed = True\n USER_AGENT = get_user_agent()\nelse:\n langflow_installed = False\n USER_AGENT = \"lfx\"\n\n\nclass URLComponent(Component):\n \"\"\"A component that loads and parses content from web pages recursively.\n\n This component allows fetching content from one or more URLs, with options to:\n - Control crawl depth\n - Prevent crawling outside the root domain\n - Use async loading for better performance\n - Extract either raw HTML or clean text\n - Configure request headers and timeouts\n \"\"\"\n\n display_name = \"URL\"\n description = \"Fetch content from one or more web pages, following links recursively.\"\n documentation: str = \"https://docs.langflow.org/url\"\n icon = \"layout-template\"\n name = \"URLComponent\"\n\n inputs = [\n MessageTextInput(\n name=\"urls\",\n display_name=\"URLs\",\n info=\"Enter one or more URLs to crawl recursively, by clicking the '+' button.\",\n is_list=True,\n tool_mode=True,\n placeholder=\"Enter a URL...\",\n list_add_label=\"Add URL\",\n input_types=[\"Message\"],\n ),\n SliderInput(\n name=\"max_depth\",\n display_name=\"Depth\",\n info=(\n \"Controls how many 'clicks' away from the initial page the crawler will go:\\n\"\n \"- depth 1: only the initial page\\n\"\n \"- depth 2: initial page + all pages linked directly from it\\n\"\n \"- depth 3: initial page + direct links + links found on those direct link pages\\n\"\n \"Note: This is about link traversal, not URL path depth.\"\n ),\n value=DEFAULT_MAX_DEPTH,\n range_spec=RangeSpec(min=1, max=5, step=1),\n required=False,\n min_label=\" \",\n max_label=\" \",\n min_label_icon=\"None\",\n max_label_icon=\"None\",\n # slider_input=True\n ),\n BoolInput(\n name=\"prevent_outside\",\n display_name=\"Prevent Outside\",\n info=(\n \"If enabled, only crawls URLs within the same domain as the root URL. \"\n \"This helps prevent the crawler from going to external websites.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"use_async\",\n display_name=\"Use Async\",\n info=(\n \"If enabled, uses asynchronous loading which can be significantly faster \"\n \"but might use more system resources.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=(\n \"Output Format. Use 'Text' to extract the text from the HTML, \"\n \"'Markdown' to parse the HTML into Markdown format, or 'HTML' \"\n \"for the raw HTML content.\"\n ),\n options=[\"Text\", \"HTML\", \"Markdown\"],\n value=DEFAULT_FORMAT,\n advanced=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"Timeout for the request in seconds.\",\n value=DEFAULT_TIMEOUT,\n required=False,\n advanced=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": USER_AGENT}],\n advanced=True,\n input_types=[\"DataFrame\", \"Table\"],\n ),\n BoolInput(\n name=\"filter_text_html\",\n display_name=\"Filter Text/HTML\",\n info=\"If enabled, filters out text/css content type from the results.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"continue_on_failure\",\n display_name=\"Continue on Failure\",\n info=\"If enabled, continues crawling even if some requests fail.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"check_response_status\",\n display_name=\"Check Response Status\",\n info=\"If enabled, checks the response status of the request.\",\n value=False,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"autoset_encoding\",\n display_name=\"Autoset Encoding\",\n info=\"If enabled, automatically sets the encoding of the request.\",\n value=True,\n required=False,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Extracted Pages\", name=\"page_results\", method=\"fetch_content\"),\n Output(display_name=\"Raw Content\", name=\"raw_results\", method=\"fetch_content_as_message\", tool_mode=False),\n ]\n\n @staticmethod\n def _html_extractor(x: str) -> str:\n \"\"\"Extract raw HTML content.\"\"\"\n return x\n\n @staticmethod\n def _text_extractor(x: str) -> str:\n \"\"\"Extract clean text from HTML.\"\"\"\n return BeautifulSoup(x, \"lxml\").get_text()\n\n @staticmethod\n def _markdown_extractor(x: str) -> str:\n \"\"\"Convert HTML to Markdown format.\"\"\"\n stream = io.BytesIO(x.encode(\"utf-8\"))\n result = MarkItDown(enable_plugins=False).convert_stream(stream)\n return result.markdown\n\n @staticmethod\n def validate_url(url: str) -> bool:\n \"\"\"Validates if the given string matches URL pattern.\n\n Args:\n url: The URL string to validate\n\n Returns:\n bool: True if the URL is valid, False otherwise\n \"\"\"\n return bool(URL_REGEX.match(url))\n\n def ensure_url(self, url: str) -> tuple[str, list[str]]:\n \"\"\"Ensures the given string is a valid URL and returns validated IPs for DNS pinning.\n\n Args:\n url: The URL string to validate and normalize\n\n Returns:\n tuple[str, list[str]]: The normalized URL and list of validated IPs for DNS pinning\n\n Raises:\n ValueError: If the URL is invalid or blocked by SSRF protection\n \"\"\"\n url = url.strip()\n if not url.startswith((\"http://\", \"https://\")):\n url = \"https://\" + url\n\n if not self.validate_url(url):\n msg = f\"Invalid URL: {url}\"\n raise ValueError(msg)\n\n # ============================================================================\n # SSRF Protection with DNS Pinning\n # ============================================================================\n # This prevents DNS rebinding attacks by:\n # 1. Resolving DNS and validating IPs during security check\n # 2. Returning the validated IP addresses for DNS pinning\n # 3. Using a custom HTTP transport that forces use of the pinned IPs\n # 4. Ignoring any new DNS resolutions (prevents rebinding)\n #\n # Without DNS pinning, an attacker could:\n # - First DNS lookup: returns public IP (passes validation)\n # - Second DNS lookup: returns internal IP (bypasses protection)\n # - Attack succeeds: accesses internal services\n #\n # With DNS pinning:\n # - First DNS lookup: returns public IP (passes validation)\n # - IPs are pinned and returned\n # - HTTP request: uses pinned IPs directly (no new DNS lookup)\n # - Attack fails: even if DNS changes, we use the validated IPs\n # ============================================================================\n try:\n _validated_url, validated_ips = validate_and_resolve_url(url)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n else:\n # Log DNS pinning information for security auditing\n if validated_ips:\n logger.debug(f\"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s) for {url}\")\n\n return url, validated_ips\n\n def _build_http_client(self, url: str, validated_ips: list[str]) -> httpx.AsyncClient:\n \"\"\"Create an HTTP client with DNS pinning for SSRF protection.\n\n Args:\n url: The request URL whose hostname will be pinned\n validated_ips: IPs validated by validate_and_resolve_url for this URL\n\n Returns:\n httpx.AsyncClient: A client with DNS pinning when SSRF protection is enabled\n \"\"\"\n if is_ssrf_protection_enabled() and validated_ips:\n hostname = urlparse(url).hostname\n if hostname:\n return create_ssrf_protected_client(hostname=hostname, validated_ips=validated_ips)\n return httpx.AsyncClient()\n\n async def _fetch_url_with_pinning(self, url: str, validated_ips: list[str], headers: dict) -> tuple[str, dict]:\n \"\"\"Fetch a single URL with DNS pinning protection.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n async with self._build_http_client(url, validated_ips) as client:\n try:\n response = await client.get(url, headers=headers, timeout=self.timeout, follow_redirects=False)\n\n if self.check_response_status:\n response.raise_for_status()\n\n content_type = response.headers.get(\"content-type\", \"\").lower()\n\n # Filter out CSS files if requested\n if self.filter_text_html and \"text/css\" in content_type:\n logger.debug(f\"Skipping CSS file: {url}\")\n return \"\", {}\n\n # Get the HTML content\n html_content = response.text\n\n # Extract metadata\n metadata = {\n \"source\": str(response.url),\n \"title\": \"\",\n \"description\": \"\",\n \"content_type\": content_type,\n \"language\": \"\",\n }\n\n # Try to extract title and description from HTML\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - metadata extraction is optional\n # and we don't want to fail the entire request if it fails\n logger.debug(f\"Failed to extract metadata from {url}\")\n else:\n if soup.title:\n metadata[\"title\"] = soup.title.string or \"\"\n\n # Try to get description from meta tags\n meta_desc = soup.find(\"meta\", attrs={\"name\": \"description\"})\n if meta_desc and meta_desc.get(\"content\"):\n metadata[\"description\"] = meta_desc[\"content\"]\n\n # Try to get language\n html_tag = soup.find(\"html\")\n if html_tag and html_tag.get(\"lang\"):\n metadata[\"language\"] = html_tag[\"lang\"]\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Failed to fetch {url}: {e}\")\n return \"\", {}\n raise\n else:\n return html_content, metadata\n\n async def _crawl_recursive(\n self, start_url: str, validated_ips: list[str], headers: dict, visited: set, depth: int = 0\n ) -> list[dict]:\n \"\"\"Recursively crawl URLs with DNS pinning protection.\n\n Args:\n start_url: The URL to start crawling from\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n visited: Set of already visited URLs\n depth: Current crawl depth\n\n Returns:\n list[dict]: List of documents with content and metadata\n \"\"\"\n if depth >= self.max_depth or start_url in visited:\n return []\n\n visited.add(start_url)\n documents = []\n\n # Fetch the current URL\n html_content, metadata = await self._fetch_url_with_pinning(start_url, validated_ips, headers)\n\n if not html_content:\n return documents\n\n # Extract content based on format\n extractors = {\n \"HTML\": self._html_extractor,\n \"Markdown\": self._markdown_extractor,\n \"Text\": self._text_extractor,\n }\n extractor = extractors.get(self.format, self._text_extractor)\n extracted_content = extractor(html_content)\n\n # Add the document\n documents.append(\n {\n \"page_content\": extracted_content,\n \"metadata\": metadata,\n }\n )\n\n # If we haven't reached max depth, extract and follow links\n if depth < self.max_depth - 1:\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n links = soup.find_all(\"a\", href=True)\n\n for link in links:\n href = link[\"href\"]\n # Resolve relative URLs\n absolute_url = urljoin(start_url, href)\n\n # Skip if already visited\n if absolute_url in visited:\n continue\n\n # Check if we should prevent going outside the domain\n if self.prevent_outside:\n start_domain = urlparse(start_url).netloc\n link_domain = urlparse(absolute_url).netloc\n if start_domain != link_domain:\n continue\n\n # Validate and crawl the linked URL\n try:\n _, link_validated_ips = self.ensure_url(absolute_url)\n sub_docs = await self._crawl_recursive(\n absolute_url, link_validated_ips, headers, visited, depth + 1\n )\n documents.extend(sub_docs)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping {absolute_url}: {e}\")\n continue\n raise\n\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - link extraction is optional\n # and we don't want to fail the entire crawl if one page has issues\n logger.debug(f\"Failed to extract links from {start_url}\")\n\n return documents\n\n async def fetch_url_contents(self) -> list[dict]:\n \"\"\"Load documents from the configured URLs with SSRF protection and DNS pinning.\n\n This method implements comprehensive SSRF (Server-Side Request Forgery) protection\n using DNS pinning to prevent DNS rebinding attacks. Each URL is validated and its\n DNS resolution is pinned before any HTTP requests are made.\n\n Returns:\n list[dict]: List of documents with content and metadata\n\n Raises:\n ValueError: If no valid URLs are provided or if there's an error loading documents\n \"\"\"\n try:\n # Validate all URLs and get their validated IPs for DNS pinning\n validated_urls = []\n first_validation_error: Exception | None = None\n for url in self.urls:\n if not url.strip():\n continue\n try:\n normalized_url, validated_ips = self.ensure_url(url)\n validated_urls.append((normalized_url, validated_ips))\n except (ValueError, SSRFProtectionError) as e:\n # Remember the first rejection so its real cause (for example an\n # SSRF block) can be surfaced if no URL survives validation,\n # instead of being hidden behind \"No valid URLs provided\".\n if first_validation_error is None:\n first_validation_error = e\n if self.continue_on_failure:\n logger.warning(f\"Skipping invalid URL {url}: {e}\")\n continue\n raise\n\n # Remove duplicates while preserving order\n seen = set()\n unique_validated_urls = []\n for url, ips in validated_urls:\n if url not in seen:\n seen.add(url)\n unique_validated_urls.append((url, ips))\n\n logger.debug(f\"Validated {len(unique_validated_urls)} unique URL(s)\")\n\n if not unique_validated_urls:\n # Surface the actual reason every candidate URL was rejected (e.g. an\n # SSRF block) rather than a generic message, so a security failure is\n # not silently swallowed when continue_on_failure is enabled.\n if first_validation_error is not None:\n raise first_validation_error\n msg = \"No valid URLs provided.\"\n raise ValueError(msg)\n\n # Prepare headers\n headers_dict = {header[\"key\"]: header[\"value\"] for header in self.headers if header[\"value\"] is not None}\n\n # Crawl all URLs\n all_docs = []\n for url, validated_ips in unique_validated_urls:\n logger.debug(f\"Crawling {url} with max_depth={self.max_depth}\")\n\n try:\n visited = set()\n docs = await self._crawl_recursive(url, validated_ips, headers_dict, visited, depth=0)\n\n if not docs:\n logger.warning(f\"No documents found for {url}\")\n continue\n\n logger.debug(f\"Found {len(docs)} document(s) from {url}\")\n all_docs.extend(docs)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Error loading documents from {url}: {e}\")\n continue\n msg = f\"Error loading documents from {url}: {e}\"\n raise ValueError(msg) from e\n\n if not all_docs:\n msg = \"No documents were successfully loaded from any URL\"\n raise ValueError(msg)\n\n # Convert to output format\n return [\n {\n \"text\": safe_convert(doc[\"page_content\"], clean_data=True),\n \"url\": doc[\"metadata\"].get(\"source\", \"\"),\n \"title\": doc[\"metadata\"].get(\"title\", \"\"),\n \"description\": doc[\"metadata\"].get(\"description\", \"\"),\n \"content_type\": doc[\"metadata\"].get(\"content_type\", \"\"),\n \"language\": doc[\"metadata\"].get(\"language\", \"\"),\n }\n for doc in all_docs\n ]\n\n except Exception as e:\n error_msg = e.message if hasattr(e, \"message\") else str(e)\n msg = f\"Error loading documents: {error_msg}\"\n logger.exception(msg)\n raise ValueError(msg) from e\n\n async def fetch_content(self) -> DataFrame:\n \"\"\"Convert the documents to a DataFrame.\"\"\"\n url_contents = await self.fetch_url_contents()\n return DataFrame(data=url_contents)\n\n async def fetch_content_as_message(self) -> Message:\n \"\"\"Convert the documents to a Message.\"\"\"\n url_contents = await self.fetch_url_contents()\n return Message(text=\"\\n\\n\".join([x[\"text\"] for x in url_contents]), data={\"data\": url_contents})\n" + "value": "import importlib.util\nimport io\nimport re\nfrom urllib.parse import urljoin, urlparse\n\nimport httpx\nfrom bs4 import BeautifulSoup\nfrom markitdown import MarkItDown\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.data import safe_convert\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageTextInput, Output, SliderInput, TableInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.utils.request_utils import get_user_agent\nfrom lfx.utils.ssrf_protection import SSRFProtectionError, is_ssrf_protection_enabled, validate_and_resolve_url\nfrom lfx.utils.ssrf_transport import create_ssrf_protected_client\n\n# Constants\nDEFAULT_TIMEOUT = 30\nDEFAULT_MAX_DEPTH = 1\nDEFAULT_FORMAT = \"Text\"\n\n# HTTP status codes that carry a redirect Location header (RFC 9110).\nREDIRECT_STATUS_CODES = frozenset({301, 302, 303, 307, 308})\n\n# Maximum number of redirects to follow. Each hop is re-validated for SSRF safety;\n# matches httpx's default and the API Request component.\nMAX_REDIRECTS = 20\n\n# Default ports per scheme, used to compare redirect origins.\nDEFAULT_SCHEME_PORTS = {\"http\": 80, \"https\": 443}\n\n\nURL_REGEX = re.compile(\n r\"^(https?:\\/\\/)?\" r\"(www\\.)?\" r\"([a-zA-Z0-9.-]+)\" r\"(\\.[a-zA-Z]{2,})?\" r\"(:\\d+)?\" r\"(\\/[^\\s]*)?$\",\n re.IGNORECASE,\n)\n\nUSER_AGENT = None\n# Check if langflow is installed using importlib.util.find_spec(name))\nif importlib.util.find_spec(\"langflow\"):\n langflow_installed = True\n USER_AGENT = get_user_agent()\nelse:\n langflow_installed = False\n USER_AGENT = \"lfx\"\n\n\nclass URLComponent(Component):\n \"\"\"A component that loads and parses content from web pages recursively.\n\n This component allows fetching content from one or more URLs, with options to:\n - Control crawl depth\n - Prevent crawling outside the root domain\n - Use async loading for better performance\n - Extract either raw HTML or clean text\n - Configure request headers and timeouts\n \"\"\"\n\n display_name = \"URL\"\n description = \"Fetch content from one or more web pages, following links recursively.\"\n documentation: str = \"https://docs.langflow.org/url\"\n icon = \"layout-template\"\n name = \"URLComponent\"\n\n inputs = [\n MessageTextInput(\n name=\"urls\",\n display_name=\"URLs\",\n info=\"Enter one or more URLs to crawl recursively, by clicking the '+' button.\",\n is_list=True,\n tool_mode=True,\n placeholder=\"Enter a URL...\",\n list_add_label=\"Add URL\",\n input_types=[\"Message\"],\n ),\n SliderInput(\n name=\"max_depth\",\n display_name=\"Depth\",\n info=(\n \"Controls how many 'clicks' away from the initial page the crawler will go:\\n\"\n \"- depth 1: only the initial page\\n\"\n \"- depth 2: initial page + all pages linked directly from it\\n\"\n \"- depth 3: initial page + direct links + links found on those direct link pages\\n\"\n \"Note: This is about link traversal, not URL path depth.\"\n ),\n value=DEFAULT_MAX_DEPTH,\n range_spec=RangeSpec(min=1, max=5, step=1),\n required=False,\n min_label=\" \",\n max_label=\" \",\n min_label_icon=\"None\",\n max_label_icon=\"None\",\n # slider_input=True\n ),\n BoolInput(\n name=\"prevent_outside\",\n display_name=\"Prevent Outside\",\n info=(\n \"If enabled, only crawls URLs within the same domain as the root URL. \"\n \"This helps prevent the crawler from going to external websites.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"use_async\",\n display_name=\"Use Async\",\n info=(\n \"If enabled, uses asynchronous loading which can be significantly faster \"\n \"but might use more system resources.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"follow_redirects\",\n display_name=\"Follow Redirects\",\n info=(\n \"If enabled, follows HTTP redirects such as http→https or www/non-www \"\n \"normalization, which most sites rely on to serve their content. When SSRF \"\n \"protection is enabled (the default), every redirect hop is re-validated against \"\n \"the same blocked-IP denylist and DNS-pinned before it is fetched, so following \"\n \"redirects cannot be used to reach internal resources. Disable to capture the \"\n \"first response as-is without following redirects.\"\n ),\n value=True,\n required=False,\n advanced=True,\n ),\n DropdownInput(\n name=\"format\",\n display_name=\"Output Format\",\n info=(\n \"Output Format. Use 'Text' to extract the text from the HTML, \"\n \"'Markdown' to parse the HTML into Markdown format, or 'HTML' \"\n \"for the raw HTML content.\"\n ),\n options=[\"Text\", \"HTML\", \"Markdown\"],\n value=DEFAULT_FORMAT,\n advanced=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n info=\"Timeout for the request in seconds.\",\n value=DEFAULT_TIMEOUT,\n required=False,\n advanced=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": USER_AGENT}],\n advanced=True,\n input_types=[\"DataFrame\", \"Table\"],\n ),\n BoolInput(\n name=\"filter_text_html\",\n display_name=\"Filter Text/HTML\",\n info=\"If enabled, filters out text/css content type from the results.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"continue_on_failure\",\n display_name=\"Continue on Failure\",\n info=\"If enabled, continues crawling even if some requests fail.\",\n value=True,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"check_response_status\",\n display_name=\"Check Response Status\",\n info=\"If enabled, checks the response status of the request.\",\n value=False,\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"autoset_encoding\",\n display_name=\"Autoset Encoding\",\n info=\"If enabled, automatically sets the encoding of the request.\",\n value=True,\n required=False,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Extracted Pages\", name=\"page_results\", method=\"fetch_content\"),\n Output(display_name=\"Raw Content\", name=\"raw_results\", method=\"fetch_content_as_message\", tool_mode=False),\n ]\n\n @staticmethod\n def _html_extractor(x: str) -> str:\n \"\"\"Extract raw HTML content.\"\"\"\n return x\n\n @staticmethod\n def _text_extractor(x: str) -> str:\n \"\"\"Extract clean text from HTML.\"\"\"\n return BeautifulSoup(x, \"lxml\").get_text()\n\n @staticmethod\n def _markdown_extractor(x: str) -> str:\n \"\"\"Convert HTML to Markdown format.\"\"\"\n stream = io.BytesIO(x.encode(\"utf-8\"))\n result = MarkItDown(enable_plugins=False).convert_stream(stream)\n return result.markdown\n\n @staticmethod\n def validate_url(url: str) -> bool:\n \"\"\"Validates if the given string matches URL pattern.\n\n Args:\n url: The URL string to validate\n\n Returns:\n bool: True if the URL is valid, False otherwise\n \"\"\"\n return bool(URL_REGEX.match(url))\n\n def ensure_url(self, url: str) -> tuple[str, list[str]]:\n \"\"\"Ensures the given string is a valid URL and returns validated IPs for DNS pinning.\n\n Args:\n url: The URL string to validate and normalize\n\n Returns:\n tuple[str, list[str]]: The normalized URL and list of validated IPs for DNS pinning\n\n Raises:\n ValueError: If the URL is invalid or blocked by SSRF protection\n \"\"\"\n url = url.strip()\n if not url.startswith((\"http://\", \"https://\")):\n url = \"https://\" + url\n\n if not self.validate_url(url):\n msg = f\"Invalid URL: {url}\"\n raise ValueError(msg)\n\n # ============================================================================\n # SSRF Protection with DNS Pinning\n # ============================================================================\n # This prevents DNS rebinding attacks by:\n # 1. Resolving DNS and validating IPs during security check\n # 2. Returning the validated IP addresses for DNS pinning\n # 3. Using a custom HTTP transport that forces use of the pinned IPs\n # 4. Ignoring any new DNS resolutions (prevents rebinding)\n #\n # Without DNS pinning, an attacker could:\n # - First DNS lookup: returns public IP (passes validation)\n # - Second DNS lookup: returns internal IP (bypasses protection)\n # - Attack succeeds: accesses internal services\n #\n # With DNS pinning:\n # - First DNS lookup: returns public IP (passes validation)\n # - IPs are pinned and returned\n # - HTTP request: uses pinned IPs directly (no new DNS lookup)\n # - Attack fails: even if DNS changes, we use the validated IPs\n # ============================================================================\n try:\n _validated_url, validated_ips = validate_and_resolve_url(url)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n else:\n # Log DNS pinning information for security auditing\n if validated_ips:\n logger.debug(f\"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s) for {url}\")\n\n return url, validated_ips\n\n def _build_http_client(self, url: str, validated_ips: list[str]) -> httpx.AsyncClient:\n \"\"\"Create an HTTP client with DNS pinning for SSRF protection.\n\n Args:\n url: The request URL whose hostname will be pinned\n validated_ips: IPs validated by validate_and_resolve_url for this URL\n\n Returns:\n httpx.AsyncClient: A client with DNS pinning when SSRF protection is enabled\n \"\"\"\n if is_ssrf_protection_enabled() and validated_ips:\n hostname = urlparse(url).hostname\n if hostname:\n return create_ssrf_protected_client(hostname=hostname, validated_ips=validated_ips)\n return httpx.AsyncClient()\n\n @staticmethod\n def _headers_for_redirect(headers: dict | None, current_url: str, next_url: str) -> dict | None:\n \"\"\"Drop sensitive headers when a redirect crosses to a different origin.\n\n Mirrors httpx's auto-follow behavior so manually following redirects does not\n leak credentials (Authorization / Cookie) to an origin other than the one the\n caller intended them for. Headers are kept only when the redirect stays on the\n same origin (scheme, host, port) or is a direct https upgrade of the same host\n on default ports - the exact cases where httpx keeps the Authorization header.\n \"\"\"\n if not headers:\n return headers\n current, nxt = urlparse(current_url), urlparse(next_url)\n current_port = current.port or DEFAULT_SCHEME_PORTS.get(current.scheme)\n next_port = nxt.port or DEFAULT_SCHEME_PORTS.get(nxt.scheme)\n same_origin = (current.scheme, current.hostname, current_port) == (nxt.scheme, nxt.hostname, next_port)\n https_upgrade = (\n current.hostname == nxt.hostname\n and current.scheme == \"http\"\n and nxt.scheme == \"https\"\n and current_port == DEFAULT_SCHEME_PORTS[\"http\"]\n and next_port == DEFAULT_SCHEME_PORTS[\"https\"]\n )\n if same_origin or https_upgrade:\n return headers\n sensitive = {\"authorization\", \"proxy-authorization\", \"cookie\"}\n return {k: v for k, v in headers.items() if k.lower() not in sensitive}\n\n def _process_response(self, response: httpx.Response) -> tuple[str, dict]:\n \"\"\"Turn a final (non-redirect) response into its HTML content and metadata.\n\n Args:\n response: The HTTP response to process\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n if self.check_response_status:\n response.raise_for_status()\n\n content_type = response.headers.get(\"content-type\", \"\").lower()\n\n # Filter out CSS files if requested\n if self.filter_text_html and \"text/css\" in content_type:\n logger.debug(f\"Skipping CSS file: {response.url}\")\n return \"\", {}\n\n # Get the HTML content\n html_content = response.text\n\n # Extract metadata\n metadata = {\n \"source\": str(response.url),\n \"title\": \"\",\n \"description\": \"\",\n \"content_type\": content_type,\n \"language\": \"\",\n }\n\n # Try to extract title and description from HTML\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - metadata extraction is optional\n # and we don't want to fail the entire request if it fails\n logger.debug(f\"Failed to extract metadata from {response.url}\")\n else:\n if soup.title:\n metadata[\"title\"] = soup.title.string or \"\"\n\n # Try to get description from meta tags\n meta_desc = soup.find(\"meta\", attrs={\"name\": \"description\"})\n if meta_desc and meta_desc.get(\"content\"):\n metadata[\"description\"] = meta_desc[\"content\"]\n\n # Try to get language\n html_tag = soup.find(\"html\")\n if html_tag and html_tag.get(\"lang\"):\n metadata[\"language\"] = html_tag[\"lang\"]\n\n return html_content, metadata\n\n async def _fetch_with_revalidated_redirects(\n self, url: str, validated_ips: list[str], headers: dict\n ) -> tuple[str, dict]:\n \"\"\"Fetch ``url``, following redirects manually and re-validating every hop.\n\n This closes an SSRF bypass: with redirects enabled, httpx would otherwise\n auto-follow a redirect from a validated public URL to an internal address that\n the pinned transport never checked (the redirect target is a different host, so\n it is not in the pin map). Each redirect ``Location`` is resolved (relative\n locations included) and re-validated with ``ensure_url`` - the same private/\n loopback/link-local denylist and DNS pinning applied to the initial request -\n before any connection to it is made. A blocked hop raises ``ValueError`` (unless\n ``continue_on_failure``); the chain is capped at ``MAX_REDIRECTS`` hops.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning of the initial URL\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n current_url = url\n current_ips = validated_ips\n\n for _ in range(MAX_REDIRECTS + 1):\n async with self._build_http_client(current_url, current_ips) as client:\n response = await client.get(current_url, headers=headers, timeout=self.timeout, follow_redirects=False)\n\n location = response.headers.get(\"location\")\n if response.status_code in REDIRECT_STATUS_CODES and location:\n # Resolve relative redirects against the current URL.\n next_url = urljoin(current_url, location)\n\n # Re-validate the redirect target with the same SSRF denylist + DNS pinning.\n try:\n validated_next_url, current_ips = self.ensure_url(next_url)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping blocked or invalid redirect to {next_url}: {e}\")\n return \"\", {}\n msg = f\"SSRF Protection: blocked redirect to {next_url}: {e}\"\n raise ValueError(msg) from e\n\n headers = self._headers_for_redirect(headers, current_url, next_url)\n current_url = validated_next_url\n continue\n\n # Not a redirect (or no Location header) - this is the final response.\n return self._process_response(response)\n\n # Exhausted the redirect budget.\n if self.continue_on_failure:\n logger.warning(f\"Exceeded the maximum of {MAX_REDIRECTS} redirects while requesting {url}\")\n return \"\", {}\n msg = f\"SSRF Protection: exceeded the maximum of {MAX_REDIRECTS} redirects while requesting {url}\"\n raise ValueError(msg)\n\n async def _fetch_url_with_pinning(self, url: str, validated_ips: list[str], headers: dict) -> tuple[str, dict]:\n \"\"\"Fetch a single URL with DNS pinning protection, following redirects safely.\n\n Args:\n url: The URL to fetch\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n\n Returns:\n tuple[str, dict]: The HTML content and metadata\n \"\"\"\n try:\n # When SSRF protection is enabled we must follow redirects manually so each hop\n # is re-validated and DNS-pinned; letting httpx auto-follow would connect to the\n # redirect target without pinning (different host, not in the pin map) and re-open\n # the SSRF hole that DNS pinning closes. With protection disabled there is no pin\n # to bypass, so httpx can follow redirects natively.\n if self.follow_redirects and is_ssrf_protection_enabled():\n return await self._fetch_with_revalidated_redirects(url, validated_ips, headers)\n\n async with self._build_http_client(url, validated_ips) as client:\n response = await client.get(\n url, headers=headers, timeout=self.timeout, follow_redirects=self.follow_redirects\n )\n return self._process_response(response)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Failed to fetch {url}: {e}\")\n return \"\", {}\n raise\n\n async def _crawl_recursive(\n self, start_url: str, validated_ips: list[str], headers: dict, visited: set, depth: int = 0\n ) -> list[dict]:\n \"\"\"Recursively crawl URLs with DNS pinning protection.\n\n Args:\n start_url: The URL to start crawling from\n validated_ips: Validated IPs for DNS pinning\n headers: HTTP headers to send\n visited: Set of already visited URLs\n depth: Current crawl depth\n\n Returns:\n list[dict]: List of documents with content and metadata\n \"\"\"\n if depth >= self.max_depth or start_url in visited:\n return []\n\n visited.add(start_url)\n documents = []\n\n # Fetch the current URL\n html_content, metadata = await self._fetch_url_with_pinning(start_url, validated_ips, headers)\n\n # Redirects may have landed on a different canonical URL (http->https, www\n # normalization). Resolve relative links and the prevent_outside check against\n # the URL the content actually came from, and mark it visited so links back to\n # the canonical form are not re-crawled.\n base_url = metadata.get(\"source\") or start_url\n visited.add(base_url)\n\n if not html_content:\n return documents\n\n # Extract content based on format\n extractors = {\n \"HTML\": self._html_extractor,\n \"Markdown\": self._markdown_extractor,\n \"Text\": self._text_extractor,\n }\n extractor = extractors.get(self.format, self._text_extractor)\n extracted_content = extractor(html_content)\n\n # Add the document\n documents.append(\n {\n \"page_content\": extracted_content,\n \"metadata\": metadata,\n }\n )\n\n # If we haven't reached max depth, extract and follow links\n if depth < self.max_depth - 1:\n try:\n soup = BeautifulSoup(html_content, \"lxml\")\n links = soup.find_all(\"a\", href=True)\n\n for link in links:\n href = link[\"href\"]\n # Resolve relative URLs\n absolute_url = urljoin(base_url, href)\n\n # Skip if already visited\n if absolute_url in visited:\n continue\n\n # Check if we should prevent going outside the domain\n if self.prevent_outside:\n start_domain = urlparse(base_url).netloc\n link_domain = urlparse(absolute_url).netloc\n if start_domain != link_domain:\n continue\n\n # Validate and crawl the linked URL\n try:\n _, link_validated_ips = self.ensure_url(absolute_url)\n sub_docs = await self._crawl_recursive(\n absolute_url, link_validated_ips, headers, visited, depth + 1\n )\n documents.extend(sub_docs)\n except (ValueError, SSRFProtectionError) as e:\n if self.continue_on_failure:\n logger.warning(f\"Skipping {absolute_url}: {e}\")\n continue\n raise\n\n except Exception: # noqa: BLE001\n # Broad exception is acceptable here - link extraction is optional\n # and we don't want to fail the entire crawl if one page has issues\n logger.debug(f\"Failed to extract links from {start_url}\")\n\n return documents\n\n async def fetch_url_contents(self) -> list[dict]:\n \"\"\"Load documents from the configured URLs with SSRF protection and DNS pinning.\n\n This method implements comprehensive SSRF (Server-Side Request Forgery) protection\n using DNS pinning to prevent DNS rebinding attacks. Each URL is validated and its\n DNS resolution is pinned before any HTTP requests are made.\n\n Returns:\n list[dict]: List of documents with content and metadata\n\n Raises:\n ValueError: If no valid URLs are provided or if there's an error loading documents\n \"\"\"\n try:\n # Validate all URLs and get their validated IPs for DNS pinning\n validated_urls = []\n first_validation_error: Exception | None = None\n for url in self.urls:\n if not url.strip():\n continue\n try:\n normalized_url, validated_ips = self.ensure_url(url)\n validated_urls.append((normalized_url, validated_ips))\n except (ValueError, SSRFProtectionError) as e:\n # Remember the first rejection so its real cause (for example an\n # SSRF block) can be surfaced if no URL survives validation,\n # instead of being hidden behind \"No valid URLs provided\".\n if first_validation_error is None:\n first_validation_error = e\n if self.continue_on_failure:\n logger.warning(f\"Skipping invalid URL {url}: {e}\")\n continue\n raise\n\n # Remove duplicates while preserving order\n seen = set()\n unique_validated_urls = []\n for url, ips in validated_urls:\n if url not in seen:\n seen.add(url)\n unique_validated_urls.append((url, ips))\n\n logger.debug(f\"Validated {len(unique_validated_urls)} unique URL(s)\")\n\n if not unique_validated_urls:\n # Surface the actual reason every candidate URL was rejected (e.g. an\n # SSRF block) rather than a generic message, so a security failure is\n # not silently swallowed when continue_on_failure is enabled.\n if first_validation_error is not None:\n raise first_validation_error\n msg = \"No valid URLs provided.\"\n raise ValueError(msg)\n\n # Prepare headers\n headers_dict = {header[\"key\"]: header[\"value\"] for header in self.headers if header[\"value\"] is not None}\n\n # Crawl all URLs\n all_docs = []\n for url, validated_ips in unique_validated_urls:\n logger.debug(f\"Crawling {url} with max_depth={self.max_depth}\")\n\n try:\n visited = set()\n docs = await self._crawl_recursive(url, validated_ips, headers_dict, visited, depth=0)\n\n if not docs:\n logger.warning(f\"No documents found for {url}\")\n continue\n\n logger.debug(f\"Found {len(docs)} document(s) from {url}\")\n all_docs.extend(docs)\n\n except httpx.HTTPError as e:\n if self.continue_on_failure:\n logger.warning(f\"Error loading documents from {url}: {e}\")\n continue\n msg = f\"Error loading documents from {url}: {e}\"\n raise ValueError(msg) from e\n\n if not all_docs:\n msg = \"No documents were successfully loaded from any URL\"\n raise ValueError(msg)\n\n # Convert to output format\n return [\n {\n \"text\": safe_convert(doc[\"page_content\"], clean_data=True),\n \"url\": doc[\"metadata\"].get(\"source\", \"\"),\n \"title\": doc[\"metadata\"].get(\"title\", \"\"),\n \"description\": doc[\"metadata\"].get(\"description\", \"\"),\n \"content_type\": doc[\"metadata\"].get(\"content_type\", \"\"),\n \"language\": doc[\"metadata\"].get(\"language\", \"\"),\n }\n for doc in all_docs\n ]\n\n except Exception as e:\n error_msg = e.message if hasattr(e, \"message\") else str(e)\n msg = f\"Error loading documents: {error_msg}\"\n logger.exception(msg)\n raise ValueError(msg) from e\n\n async def fetch_content(self) -> DataFrame:\n \"\"\"Convert the documents to a DataFrame.\"\"\"\n url_contents = await self.fetch_url_contents()\n return DataFrame(data=url_contents)\n\n async def fetch_content_as_message(self) -> Message:\n \"\"\"Convert the documents to a Message.\"\"\"\n url_contents = await self.fetch_url_contents()\n return Message(text=\"\\n\\n\".join([x[\"text\"] for x in url_contents]), data={\"data\": url_contents})\n" }, "continue_on_failure": { "_input_type": "BoolInput", @@ -958,6 +959,26 @@ "type": "bool", "value": true }, + "follow_redirects": { + "_input_type": "BoolInput", + "advanced": true, + "display_name": "Follow Redirects", + "dynamic": false, + "info": "If enabled, follows HTTP redirects such as http→https or www/non-www normalization, which most sites rely on to serve their content. When SSRF protection is enabled (the default), every redirect hop is re-validated against the same blocked-IP denylist and DNS-pinned before it is fetched, so following redirects cannot be used to reach internal resources. Disable to capture the first response as-is without following redirects.", + "list": false, + "list_add_label": "Add More", + "name": "follow_redirects", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + }, "format": { "_input_type": "DropdownInput", "advanced": true, diff --git a/src/backend/base/langflow/locales/en.json b/src/backend/base/langflow/locales/en.json index ba5df4bef0..69f01063b4 100644 --- a/src/backend/base/langflow/locales/en.json +++ b/src/backend/base/langflow/locales/en.json @@ -6147,6 +6147,8 @@ "components.urlcomponent.inputs.continue_on_failure.info.635413d8": "If enabled, continues crawling even if some requests fail.", "components.urlcomponent.inputs.filter_text_html.display_name.ee58d47a": "Filter Text/HTML", "components.urlcomponent.inputs.filter_text_html.info.ea4adc6c": "If enabled, filters out text/css content type from the results.", + "components.urlcomponent.inputs.follow_redirects.display_name.0bbacd03": "Follow Redirects", + "components.urlcomponent.inputs.follow_redirects.info.580bb301": "If enabled, follows HTTP redirects such as http→https or www/non-www normalization, which most sites rely on to serve their content. When SSRF protection is enabled (the default), every redirect hop is re-validated against the same blocked-IP denylist and DNS-pinned before it is fetched, so following redirects cannot be used to reach internal resources. Disable to capture the first response as-is without following redirects.", "components.urlcomponent.inputs.format.display_name.ef22a51c": "Output Format", "components.urlcomponent.inputs.format.info.e5559996": "Output Format. Use 'Text' to extract the text from the HTML, 'Markdown' to parse the HTML into Markdown format, or 'HTML' for the raw HTML content.", "components.urlcomponent.inputs.headers.display_name.194e9fe6": "Headers", diff --git a/src/backend/base/langflow/services/auth/context.py b/src/backend/base/langflow/services/auth/context.py new file mode 100644 index 0000000000..c052f09dbe --- /dev/null +++ b/src/backend/base/langflow/services/auth/context.py @@ -0,0 +1,108 @@ +"""Request-local authentication credential context. + +Authentication resolves every credential to a Langflow user, but authorization +plugins sometimes need to know how that user authenticated. Keep that metadata +request-local so API-key caveats can be enforced without changing route +signatures or teaching OSS how to interpret policy. +""" + +from __future__ import annotations + +from contextvars import ContextVar +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from uuid import UUID + + from langflow.services.database.models.api_key.crud import ApiKeyAuthResult + + +AUTH_METHOD_API_KEY = "api_key" # pragma: allowlist secret +AUTH_METHOD_AUTO_LOGIN = "auto_login" +AUTH_METHOD_EXTERNAL = "external" +AUTH_METHOD_JWT = "jwt" + + +@dataclass(frozen=True) +class AuthCredentialContext: + """Metadata about the credential that authenticated the current request.""" + + method: str + api_key_id: UUID | None = None + api_key_source: str | None = None + external_provider: str | None = None + + @classmethod + def from_api_key_result(cls, result: ApiKeyAuthResult) -> AuthCredentialContext: + """Build API-key credential context from an authenticated API-key result. + + Centralizes the API-key projection so every call site stays in sync if the + set of API-key caveat fields ever changes. + """ + return cls( + method=AUTH_METHOD_API_KEY, + api_key_id=result.api_key_id, + api_key_source=result.api_key_source, + ) + + def to_authz_context(self) -> dict[str, Any]: + """Return values safe to pass into authorization plugin context.""" + context: dict[str, Any] = {"auth_method": self.method} + if self.api_key_id is not None: + context["api_key_id"] = self.api_key_id + if self.api_key_source is not None: + context["api_key_source"] = self.api_key_source + if self.external_provider is not None: + context["external_provider"] = self.external_provider + return context + + def to_audit_details(self) -> dict[str, str]: + """Return JSON-friendly values safe for authz audit details.""" + details = {"auth_method": self.method} + if self.api_key_id is not None: + details["api_key_id"] = str(self.api_key_id) + if self.api_key_source is not None: + details["api_key_source"] = self.api_key_source + if self.external_provider is not None: + details["external_provider"] = self.external_provider + return details + + +_current_auth_context = ContextVar["AuthCredentialContext | None"]( + "langflow_auth_credential_context", + default=None, +) + + +def set_current_auth_context(context: AuthCredentialContext | None) -> None: + """Store credential metadata for the current request/task.""" + _current_auth_context.set(context) + + +def clear_current_auth_context() -> None: + """Clear credential metadata for the current request/task.""" + _current_auth_context.set(None) + + +def get_current_auth_context() -> AuthCredentialContext | None: + """Return credential metadata for the current request/task, if any.""" + return _current_auth_context.get() + + +def current_auth_context_for_authz() -> dict[str, Any]: + """Return current credential metadata as an authz context fragment.""" + context = get_current_auth_context() + return context.to_authz_context() if context is not None else {} + + +def current_auth_context_for_audit() -> dict[str, str]: + """Return current credential metadata as an audit details fragment.""" + context = get_current_auth_context() + return context.to_audit_details() if context is not None else {} + + +def current_auth_is_api_key() -> bool: + """Return True when the active request authenticated with a Langflow API key.""" + context = get_current_auth_context() + return context is not None and context.method == AUTH_METHOD_API_KEY diff --git a/src/backend/base/langflow/services/auth/external.py b/src/backend/base/langflow/services/auth/external.py new file mode 100644 index 0000000000..eac4c7ae58 --- /dev/null +++ b/src/backend/base/langflow/services/auth/external.py @@ -0,0 +1,537 @@ +"""External trusted-identity helpers. + +When an upstream identity layer (proxy, gateway, IdP) issues or validates a +credential, Langflow accepts it via this module: extract the token from the +configured header/cookie, validate it (built-in JWT/JWKS or a pluggable +resolver), and return a normalized :class:`ExternalIdentity`. JIT user +provisioning is handled separately by +``BaseAuthService.get_or_create_user_from_claims`` so the auth service stays +the single source of truth for user lifecycle. +""" + +from __future__ import annotations + +import hashlib +import inspect +import json +import time +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, Protocol, TypeAlias, TypeVar, cast +from urllib.parse import urlparse + +import httpx +import jwt +from jwt import InvalidTokenError as PyJWTInvalidTokenError +from lfx.log.logger import logger + +from langflow.services.auth.exceptions import InvalidTokenError as AuthInvalidTokenError + +# The request-scoped access ceiling is an authorization primitive. It lives in +# the authorization package so guards can enforce it without importing the auth +# layer; the auth layer (here) only *derives* the ceiling from an identity and +# installs it. These re-exports keep ``langflow.services.auth.external`` a stable +# import site for callers that derive/inspect the ceiling. +from langflow.services.authorization.access_ceiling import ( + EXTERNAL_ACCESS_ADMIN, + EXTERNAL_ACCESS_EDITOR, + EXTERNAL_ACCESS_LEVELS, + EXTERNAL_ACCESS_VIEWER, + ExternalAccessContext, + clear_current_external_access_context, + external_access_allows, + filter_actions_by_external_access_ceiling, + get_current_external_access_context, + set_current_external_access_context, +) + +if TYPE_CHECKING: + from lfx.services.settings.auth import AuthSettings + +__all__ = [ + "EXTERNAL_ACCESS_ADMIN", + "EXTERNAL_ACCESS_EDITOR", + "EXTERNAL_ACCESS_LEVELS", + "EXTERNAL_ACCESS_VIEWER", + "ExternalAccessContext", + "ExternalIdentity", + "ExternalIdentityResolver", + "JwtExternalIdentityResolver", + "access_context_from_identity", + "clear_current_external_access_context", + "decode_external_jwt", + "external_access_allows", + "extract_bearer_or_raw_token", + "extract_external_token", + "filter_actions_by_external_access_ceiling", + "get_current_external_access_context", + "identity_from_claims", + "resolve_external_identity", + "set_current_external_access_context", +] + + +JWKS_CACHE_TTL_SECONDS = 300 +JWKS_MIN_REFRESH_INTERVAL_SECONDS = 30 +_jwks_cache: dict[str, tuple[float, dict[str, Any]]] = {} +# Loopback hosts allowed to use http:// for the JWKS URL in local development. +_JWKS_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) +T = TypeVar("T") + +# Maps raw external claim values to a normalized access level. The level +# vocabulary and the deny-only enforcement live in the authorization package +# (see ``access_ceiling``); this alias table is the auth-side interpretation of +# provider-specific claim strings. +_EXTERNAL_ACCESS_ALIASES = { + "view": EXTERNAL_ACCESS_VIEWER, + "viewer": EXTERNAL_ACCESS_VIEWER, + "read": EXTERNAL_ACCESS_VIEWER, + "readonly": EXTERNAL_ACCESS_VIEWER, + "read_only": EXTERNAL_ACCESS_VIEWER, + "read-only": EXTERNAL_ACCESS_VIEWER, + "edit": EXTERNAL_ACCESS_EDITOR, + "editor": EXTERNAL_ACCESS_EDITOR, + "write": EXTERNAL_ACCESS_EDITOR, + "developer": EXTERNAL_ACCESS_EDITOR, + "admin": EXTERNAL_ACCESS_ADMIN, + "administrator": EXTERNAL_ACCESS_ADMIN, +} + + +@dataclass(frozen=True) +class ExternalIdentity: + """Normalized identity returned by an :class:`ExternalIdentityResolver`.""" + + provider: str + subject: str + username: str + email: str | None = None + name: str | None = None + claims: Mapping[str, Any] = field(default_factory=dict) + + +class ExternalIdentityResolver(Protocol): + """Resolver that turns an external credential into an identity.""" + + async def resolve( + self, + token: str, + auth_settings: AuthSettings, + ) -> ExternalIdentity | Mapping[str, Any]: ... + + +ExternalResolverResult: TypeAlias = ExternalIdentity | Mapping[str, Any] +ExternalResolverCallable: TypeAlias = Callable[ + [str, "AuthSettings"], + ExternalResolverResult | Awaitable[ExternalResolverResult], +] + + +def _split_csv(value: str | None) -> list[str]: + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + +def _claim_as_str(claims: Mapping[str, Any], claim_name: str | None) -> str | None: + if not claim_name: + return None + value = claims.get(claim_name) + if isinstance(value, str): + stripped = value.strip() + return stripped or None + if value is None: + return None + return str(value) + + +def _normalize_username(value: str) -> str: + username = value.strip() + if not username: + return "external-user" + return username[:255] + + +def _external_username_fallback(provider: str, subject: str) -> str: + digest = hashlib.sha256(f"{provider}:{subject}".encode()).hexdigest()[:12] + normalized_provider = provider[:200] or "external" + return f"{normalized_provider}-{digest}" + + +def identity_from_claims(claims: Mapping[str, Any], auth_settings: AuthSettings) -> ExternalIdentity: + """Build an :class:`ExternalIdentity` from raw JWT claims using the configured mapping.""" + provider = (auth_settings.EXTERNAL_AUTH_PROVIDER or "external").strip() or "external" + subject = _claim_as_str(claims, auth_settings.EXTERNAL_AUTH_SUBJECT_CLAIM) + if not subject: + msg = f"External credential is missing required claim: {auth_settings.EXTERNAL_AUTH_SUBJECT_CLAIM}" + raise AuthInvalidTokenError(msg) + + email = _claim_as_str(claims, auth_settings.EXTERNAL_AUTH_EMAIL_CLAIM) + preferred_username = _claim_as_str(claims, auth_settings.EXTERNAL_AUTH_USERNAME_CLAIM) + name = _claim_as_str(claims, auth_settings.EXTERNAL_AUTH_NAME_CLAIM) + username_claim = preferred_username or email or name or _external_username_fallback(provider, subject) + + return ExternalIdentity( + provider=provider, + subject=subject, + username=_normalize_username(username_claim), + email=email, + name=name, + claims=dict(claims), + ) + + +def _normalize_access_level(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip().lower() + if not normalized: + return None + return _EXTERNAL_ACCESS_ALIASES.get(normalized, normalized if normalized in EXTERNAL_ACCESS_LEVELS else None) + + +def _access_claim_mapping(auth_settings: AuthSettings) -> dict[str, str]: + raw_mapping = auth_settings.EXTERNAL_AUTH_ACCESS_CLAIM_MAPPING + if not raw_mapping: + return {} + + mapping: dict[str, str] = {} + try: + loaded = json.loads(raw_mapping) + except json.JSONDecodeError: + loaded = None + + if isinstance(loaded, Mapping): + pairs = loaded.items() + else: + pairs = [] + for item in raw_mapping.split(","): + key, separator, value = item.partition(":") + if not separator: + continue + pairs.append((key, value)) + + for key, value in pairs: + if not isinstance(key, str): + continue + normalized_level = _normalize_access_level(str(value)) + if normalized_level is not None: + mapping[key.strip().lower()] = normalized_level + return mapping + + +def access_context_from_identity( + identity: ExternalIdentity, + auth_settings: AuthSettings, +) -> ExternalAccessContext | None: + """Return the request-local access ceiling for an external identity.""" + if not auth_settings.EXTERNAL_AUTH_ACCESS_CEILING_ENABLED: + return None + + claim_name = auth_settings.EXTERNAL_AUTH_ACCESS_CLAIM + claim_value = _claim_as_str(identity.claims, claim_name) + mapping = _access_claim_mapping(auth_settings) + # Gate the alias fallthrough on whether the operator CONFIGURED a mapping + # (the raw setting), not on whether it parsed to a non-empty dict. A + # configured-but-all-invalid mapping still parses empty; treating that as + # "no mapping" would let a raw "admin"/"editor" claim self-elevate via the + # alias table, re-opening the hole the authoritative-mapping rule closes. + raw_mapping_configured = bool((auth_settings.EXTERNAL_AUTH_ACCESS_CLAIM_MAPPING or "").strip()) + mapped_level = None + if claim_value is not None: + mapped_level = mapping.get(claim_value.strip().lower()) + # When an explicit mapping is configured it is authoritative: a claim + # value absent from it must NOT be reinterpreted through the built-in + # alias table (otherwise a raw "admin"/"editor" claim would silently + # elevate without an explicit grant). Fall through to the default level + # instead. The alias interpretation is only used when NO mapping is + # configured at all. + if mapped_level is None and not raw_mapping_configured: + mapped_level = _normalize_access_level(claim_value) + level = mapped_level or _normalize_access_level(auth_settings.EXTERNAL_AUTH_DEFAULT_ACCESS_LEVEL) + if level is None: + level = EXTERNAL_ACCESS_VIEWER + + return ExternalAccessContext( + provider=identity.provider, + subject=identity.subject, + level=level, + claim_name=claim_name, + claim_value=claim_value, + ) + + +def _validate_trusted_time_claims(claims: Mapping[str, Any]) -> None: + now = datetime.now(timezone.utc).timestamp() + exp = claims.get("exp") + # A token that omits exp never expires. Require it on the trusted-decode + # path too so a credential without an expiry is rejected rather than + # accepted forever (mirrors the JWKS path's require=["exp"]). + if exp is None: + msg = "External credential is missing exp" + raise AuthInvalidTokenError(msg) + if now > float(exp): + msg = "External credential has expired" + raise AuthInvalidTokenError(msg) + + nbf = claims.get("nbf") + if nbf is not None and now < float(nbf): + msg = "External credential is not valid yet" + raise AuthInvalidTokenError(msg) + + +def _require_https_jwks_url(jwks_url: str) -> None: + """Reject a non-https JWKS URL (http allowed only for loopback hosts). + + Belt-and-suspenders alongside the settings validator: an http:// JWKS lets a + network MITM swap the signing keys and forge tokens, so the fetch itself + refuses anything that is not https (or http to a loopback host for dev). + """ + parsed = urlparse(jwks_url) + scheme = parsed.scheme.lower() + if scheme == "https": + return + if scheme == "http" and parsed.hostname in _JWKS_LOOPBACK_HOSTS: + return + msg = "External JWKS URL must use https (http is allowed only for localhost)" + raise AuthInvalidTokenError(msg) + + +async def _fetch_jwks(jwks_url: str, *, force_refresh: bool = False) -> dict[str, Any]: + _require_https_jwks_url(jwks_url) + cached = _jwks_cache.get(jwks_url) + now = time.monotonic() + if cached and cached[0] > now: + # force_refresh is rate-limited so attacker-supplied kids cannot turn + # every rejected token into a fetch against the IdP's JWKS endpoint. + fetched_at = cached[0] - JWKS_CACHE_TTL_SECONDS + if not force_refresh or now - fetched_at < JWKS_MIN_REFRESH_INTERVAL_SECONDS: + return cached[1] + + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(jwks_url) + response.raise_for_status() + jwks = response.json() + + _jwks_cache[jwks_url] = (now + JWKS_CACHE_TTL_SECONDS, jwks) + return jwks + + +def _select_jwk(jwks: dict[str, Any], token: str) -> dict[str, Any]: + keys = jwks.get("keys") + if not isinstance(keys, list) or not keys: + msg = "External JWKS does not contain signing keys" + raise AuthInvalidTokenError(msg) + + header = jwt.get_unverified_header(token) + kid = header.get("kid") + if kid: + for key in keys: + if key.get("kid") == kid: + return key + msg = "External JWT signing key was not found in JWKS" + raise AuthInvalidTokenError(msg) + + if len(keys) == 1: + return keys[0] + + msg = "External JWT is missing kid and JWKS contains multiple keys" + raise AuthInvalidTokenError(msg) + + +async def decode_external_jwt(token: str, auth_settings: AuthSettings) -> dict[str, Any]: + """Validate an external JWT and return its claims. + + If ``EXTERNAL_AUTH_TRUSTED_JWT_DECODE`` is enabled, signature verification + is skipped (the caller has stated an upstream proxy already validated it). + Otherwise ``EXTERNAL_AUTH_JWKS_URL`` and ``EXTERNAL_AUTH_AUDIENCE`` are both + required: the signature is verified against the fetched JWKS using + ``EXTERNAL_AUTH_ALGORITHMS`` and the ``aud`` claim is bound to this + deployment so tokens the IdP minted for other services are rejected. + ``EXTERNAL_AUTH_ISSUER`` is verified when set. ``exp`` is required and + verified on both paths (a token that omits it is rejected); ``nbf`` is + verified when present. + """ + if not auth_settings.EXTERNAL_AUTH_ENABLED: + msg = "External authentication is not enabled" + raise AuthInvalidTokenError(msg) + + try: + if auth_settings.EXTERNAL_AUTH_TRUSTED_JWT_DECODE: + claims = jwt.decode( + token, + options={ + "verify_signature": False, + "verify_aud": False, + "verify_iss": False, + "verify_exp": False, + "verify_nbf": False, + }, + ) + _validate_trusted_time_claims(claims) + return claims + + if not auth_settings.EXTERNAL_AUTH_JWKS_URL: + msg = "External authentication requires EXTERNAL_AUTH_JWKS_URL unless trusted decode is enabled" + raise AuthInvalidTokenError(msg) + + audience = _split_csv(auth_settings.EXTERNAL_AUTH_AUDIENCE) + if not audience: + # Without audience binding, any token the IdP minted for a *different* + # relying party would verify here (same signing keys, valid exp). + # Audience is the control that stops cross-service token reuse, so it + # is required whenever signatures are checked against a JWKS. + msg = ( + "External JWKS verification requires EXTERNAL_AUTH_AUDIENCE so tokens the IdP issued " + "for other services are rejected. Set EXTERNAL_AUTH_AUDIENCE to this deployment's " + "expected audience, or only enable EXTERNAL_AUTH_TRUSTED_JWT_DECODE behind a proxy " + "that already validates audience." + ) + raise AuthInvalidTokenError(msg) + + jwks = await _fetch_jwks(auth_settings.EXTERNAL_AUTH_JWKS_URL) + try: + jwk = _select_jwk(jwks, token) + except AuthInvalidTokenError: + # The token's kid may belong to a key published after the cached + # JWKS was fetched (IdP key rotation). Refetch once; when the + # rate limit suppresses the refetch we get the same cached object + # back and re-raise the original error. + refreshed = await _fetch_jwks(auth_settings.EXTERNAL_AUTH_JWKS_URL, force_refresh=True) + if refreshed is jwks: + raise + jwk = _select_jwk(refreshed, token) + signing_key = jwt.PyJWK.from_dict(jwk).key + issuer = auth_settings.EXTERNAL_AUTH_ISSUER or None + algorithms = _split_csv(auth_settings.EXTERNAL_AUTH_ALGORITHMS) or ["RS256"] + + return jwt.decode( + token, + signing_key, + algorithms=algorithms, + audience=audience, + issuer=issuer, + options={ + "verify_aud": True, + "verify_iss": bool(issuer), + # PyJWT only rejects an *expired* exp; a token that omits exp + # otherwise passes and never expires. require=["exp"] forces the + # claim to be present so unbounded-lifetime tokens are rejected. + "verify_exp": True, + "require": ["exp"], + }, + ) + except AuthInvalidTokenError: + raise + except PyJWTInvalidTokenError as exc: + msg = "External credential validation failed" + raise AuthInvalidTokenError(msg) from exc + except Exception as exc: + logger.debug(f"External credential validation failed: {exc}") + msg = "External credential validation failed" + raise AuthInvalidTokenError(msg) from exc + + +class JwtExternalIdentityResolver: + """Default resolver: validate the credential as a JWT and map claims.""" + + async def resolve(self, token: str, auth_settings: AuthSettings) -> ExternalIdentity: + claims = await decode_external_jwt(token, auth_settings) + return identity_from_claims(claims, auth_settings) + + +async def resolve_external_identity(token: str, auth_settings: AuthSettings) -> ExternalIdentity: + """Resolve a credential to an :class:`ExternalIdentity` using the configured resolver.""" + resolver = _load_external_identity_resolver(auth_settings) + if hasattr(resolver, "resolve"): + result = await _maybe_await(resolver.resolve(token, auth_settings)) + elif callable(resolver): + result = await _maybe_await(resolver(token, auth_settings)) + else: + msg = "External authentication resolver must be callable or expose resolve()" + raise AuthInvalidTokenError(msg) + + if isinstance(result, ExternalIdentity): + return result + if isinstance(result, Mapping): + return identity_from_claims(result, auth_settings) + + msg = "External authentication resolver returned an invalid identity" + raise AuthInvalidTokenError(msg) + + +def _load_external_identity_resolver( + auth_settings: AuthSettings, +) -> ExternalIdentityResolver | ExternalResolverCallable: + resolver_path = auth_settings.EXTERNAL_AUTH_IDENTITY_RESOLVER + if not resolver_path: + return JwtExternalIdentityResolver() + + from lfx.services.config_discovery import load_object_from_import_path + + resolver = load_object_from_import_path( + resolver_path, + object_kind="external auth resolver", + object_key="EXTERNAL_AUTH_IDENTITY_RESOLVER", + ) + if resolver is None: + msg = "External authentication resolver could not be loaded" + raise AuthInvalidTokenError(msg) + + if inspect.isclass(resolver): + signature = inspect.signature(resolver) + required_params = [ + parameter + for parameter in signature.parameters.values() + if parameter.default is inspect.Parameter.empty + and parameter.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + ] + if required_params: + return resolver(auth_settings) + return resolver() + + return resolver + + +async def _maybe_await(value: T | Awaitable[T]) -> T: + if inspect.isawaitable(value): + return await cast("Awaitable[T]", value) + return cast("T", value) + + +def extract_bearer_or_raw_token(value: str | None) -> str | None: + """Strip a 'Bearer ' prefix if present and return the credential.""" + if not value: + return None + stripped = value.strip() + if not stripped: + return None + scheme, _, token = stripped.partition(" ") + if scheme.lower() == "bearer": + return token.strip() or None + return stripped + + +def extract_external_token( + headers: Mapping[str, str], + cookies: Mapping[str, str], + auth_settings: AuthSettings, +) -> str | None: + """Return the external credential from configured header/cookie, header first.""" + if not auth_settings.EXTERNAL_AUTH_ENABLED: + return None + + header_name = auth_settings.EXTERNAL_AUTH_TOKEN_HEADER + if header_name: + header_value = headers.get(header_name) + if token := extract_bearer_or_raw_token(header_value): + return token + + cookie_name = auth_settings.EXTERNAL_AUTH_TOKEN_COOKIE + if cookie_name: + cookie_value = cookies.get(cookie_name) + if token := extract_bearer_or_raw_token(cookie_value): + return token + + return None diff --git a/src/backend/base/langflow/services/auth/service.py b/src/backend/base/langflow/services/auth/service.py index df32a81a40..9cf891a53b 100644 --- a/src/backend/base/langflow/services/auth/service.py +++ b/src/backend/base/langflow/services/auth/service.py @@ -17,6 +17,14 @@ from sqlalchemy.exc import IntegrityError from langflow.helpers.user import get_user_by_flow_id_or_endpoint_name from langflow.services.auth.constants import AUTO_LOGIN_ERROR, AUTO_LOGIN_WARNING +from langflow.services.auth.context import ( + AUTH_METHOD_AUTO_LOGIN, + AUTH_METHOD_EXTERNAL, + AUTH_METHOD_JWT, + AuthCredentialContext, + clear_current_auth_context, + set_current_auth_context, +) from langflow.services.auth.exceptions import ( InactiveUserError, InvalidCredentialsError, @@ -26,7 +34,16 @@ from langflow.services.auth.exceptions import ( from langflow.services.auth.exceptions import ( InvalidTokenError as AuthInvalidTokenError, ) -from langflow.services.database.models.api_key.crud import check_key +from langflow.services.auth.external import ( + ExternalIdentity, + _external_username_fallback, + access_context_from_identity, + clear_current_external_access_context, + identity_from_claims, + resolve_external_identity, + set_current_external_access_context, +) +from langflow.services.database.models.api_key.crud import authenticate_api_key from langflow.services.database.models.user.crud import ( get_user_by_id, get_user_by_username, @@ -40,8 +57,6 @@ if TYPE_CHECKING: from lfx.services.settings.service import SettingsService from sqlmodel.ext.asyncio.session import AsyncSession - from langflow.services.database.models.api_key.model import ApiKey - class AuthService(BaseAuthService): """Default Langflow authentication service (implements LFX BaseAuthService).""" @@ -61,6 +76,7 @@ class AuthService(BaseAuthService): token: str | None, api_key: str | None, db: AsyncSession, + external_token: str | None = None, ) -> User | UserRead: """Framework-agnostic authentication method. @@ -71,6 +87,11 @@ class AuthService(BaseAuthService): token: Access token (JWT, OIDC token, etc.) api_key: API key for authentication db: Database session + external_token: Separately-extracted external credential to try as a + fallback when native token authentication fails for any reason + (expired, invalid, inactive user). When ``None`` behavior is + unchanged. This lets a valid external credential authenticate even + when a present-but-invalid native token would otherwise shadow it. Returns: @@ -84,15 +105,31 @@ class AuthService(BaseAuthService): TokenExpiredError: If token has expired InactiveUserError: If user account is inactive """ + clear_current_auth_context() + clear_current_external_access_context() + # Try token authentication first (if token provided) if token: try: return await self._authenticate_with_token(token, db) - except (AuthInvalidTokenError, TokenExpiredError, InactiveUserError): - # Re-raise our generic exceptions - raise + except (AuthInvalidTokenError, TokenExpiredError, InactiveUserError) as e: + # Native auth failed. If a *distinct* external credential was + # extracted, try it before surfacing the native error so a present + # but invalid/expired native token can't shadow a valid external + # one. When external_token is None or identical to the token we + # already tried, behavior is unchanged. + if external_token and external_token != token: + external_user = await self._authenticate_with_external_token(external_token, db) + if external_user is not None: + return external_user + raise e # noqa: TRY201 except Exception as e: - # Token auth failed; fall back to API key if provided + # Token auth failed for an unexpected reason; try the distinct + # external credential first, then fall back to API key if provided. + if external_token and external_token != token: + external_user = await self._authenticate_with_external_token(external_token, db) + if external_user is not None: + return external_user if api_key: try: user = await self._authenticate_with_api_key(api_key, db) @@ -110,6 +147,13 @@ class AuthService(BaseAuthService): msg = "Token authentication failed" raise AuthInvalidTokenError(msg) from e + # No native token, but a separately-extracted external credential may be + # present (extractors no longer collapse native/external into one string). + if external_token: + external_user = await self._authenticate_with_external_token(external_token, db) + if external_user is not None: + return external_user + # Try API key authentication if api_key: try: @@ -150,6 +194,7 @@ class AuthService(BaseAuthService): msg = "User account is inactive" raise InactiveUserError(msg) logger.warning(AUTO_LOGIN_WARNING) + set_current_auth_context(AuthCredentialContext(method=AUTH_METHOD_AUTO_LOGIN)) return superuser # No credentials provided @@ -198,10 +243,16 @@ class AuthService(BaseAuthService): msg = "Token has expired" raise TokenExpiredError(msg) from e except InvalidTokenError as e: + external_user = await self._authenticate_with_external_token(token, db) + if external_user is not None: + return external_user logger.debug("JWT validation failed: Invalid token format or signature") msg = "Invalid token" raise AuthInvalidTokenError(msg) from e except Exception as e: + external_user = await self._authenticate_with_external_token(token, db) + if external_user is not None: + return external_user logger.error(f"Unexpected error decoding token: {e}") msg = "Token validation failed" raise AuthInvalidTokenError(msg) from e @@ -218,23 +269,179 @@ class AuthService(BaseAuthService): msg = "User account is inactive" raise InactiveUserError(msg) + set_current_auth_context(AuthCredentialContext(method=AUTH_METHOD_JWT)) return user + async def _authenticate_with_external_token(self, token: str, db: AsyncSession) -> User | None: + """Fallback path: try the configured external identity resolver. + + Returns the JIT-provisioned local user when the token resolves to a + valid external identity, ``None`` otherwise. Callers raise the native + JWT error if this returns ``None``. + """ + if not self.settings.auth_settings.EXTERNAL_AUTH_ENABLED: + return None + try: + identity = await resolve_external_identity(token, self.settings.auth_settings) + except AuthInvalidTokenError as exc: + logger.debug(f"External credential rejected: {exc}") + return None + set_current_auth_context( + AuthCredentialContext(method=AUTH_METHOD_EXTERNAL, external_provider=identity.provider) + ) + set_current_external_access_context(access_context_from_identity(identity, self.settings.auth_settings)) + return await self._materialize_external_user(identity, db) + async def _authenticate_with_api_key(self, api_key: str, db: AsyncSession) -> UserRead | None: - """Internal method to authenticate with API key (raises generic exceptions).""" - result = await check_key(db, api_key) + """Internal method to authenticate with API key (raises generic exceptions). + + The EXTERNAL_AUTH access ceiling block for externally-managed users is + enforced inside ``authenticate_api_key`` (the shared chokepoint), which + returns ``None`` for a blocked user so every caller treats it as an auth + failure. No additional ceiling check is needed here. + """ + result = await authenticate_api_key(db, api_key) if not result: return None - if isinstance(result, User): - user_read = UserRead.model_validate(result, from_attributes=True) + if isinstance(result.user, User): + user_read = UserRead.model_validate(result.user, from_attributes=True) if not user_read.is_active: msg = "User account is inactive" raise InactiveUserError(msg) + set_current_auth_context(AuthCredentialContext.from_api_key_result(result)) return user_read return None + # ------------------------------------------------------------------ + # JIT user provisioning via BaseAuthService hook + # ------------------------------------------------------------------ + + def extract_user_info_from_claims(self, claims: dict) -> dict: + """Normalize provider claims using the configured EXTERNAL_AUTH_* mapping. + + Returns a dict with ``provider``, ``subject``, ``username``, ``email``, + and ``name`` keys; raises :class:`AuthInvalidTokenError` when the + subject claim is missing. + """ + identity = identity_from_claims(claims, self.settings.auth_settings) + return { + "provider": identity.provider, + "subject": identity.subject, + "username": identity.username, + "email": identity.email, + "name": identity.name, + } + + async def get_or_create_user_from_claims(self, claims: dict, db: AsyncSession) -> User: + """Return the local Langflow user mapped to these external claims. + + Looks up SSOUserProfile by (provider, sso_user_id). On hit, refreshes + the email + last-login timestamps and returns the existing user. On + miss, JIT-provisions a fresh user, writes a profile row, and seeds + the default folder + variables. + """ + identity = identity_from_claims(claims, self.settings.auth_settings) + return await self._materialize_external_user(identity, db) + + async def _materialize_external_user(self, identity: ExternalIdentity, db: AsyncSession) -> User: + """Find-or-create the local user backing an external identity.""" + import secrets + from datetime import datetime, timezone + + from sqlalchemy.exc import IntegrityError + from sqlmodel import select + + from langflow.services.database.models.auth import SSOUserProfile + + profile_stmt = select(SSOUserProfile).where( + SSOUserProfile.sso_provider == identity.provider, + SSOUserProfile.sso_user_id == identity.subject, + ) + profile = (await db.exec(profile_stmt)).first() + + if profile is not None: + user = await get_user_by_id(db, profile.user_id) + if user is None: + msg = "Mapped external user was not found" + raise AuthInvalidTokenError(msg) + if not user.is_active: + msg = "User account is inactive" + raise InactiveUserError(msg) + now = datetime.now(timezone.utc) + # Only overwrite the stored email when the token carries one; a later + # token that omits the email claim must not erase a previously stored + # address. + if identity.email is not None: + profile.email = identity.email + profile.sso_last_login_at = now + profile.updated_at = now + await update_user_last_login_at(user.id, db) + return user + + username = await self._unique_external_username(db, identity) + random_password = secrets.token_urlsafe(48) + now = datetime.now(timezone.utc) + user = User( + username=username, + password=self.get_password_hash(random_password), + is_active=True, + is_superuser=False, + last_login_at=now, + ) + new_profile = SSOUserProfile( + user_id=user.id, + sso_provider=identity.provider, + sso_user_id=identity.subject, + email=identity.email, + sso_last_login_at=now, + ) + db.add(user) + db.add(new_profile) + try: + await db.flush() + await db.refresh(user) + await self._initialize_jit_user_defaults(user, db) + except IntegrityError: + await db.rollback() + profile = (await db.exec(profile_stmt)).first() + if profile is None: + raise + user = await get_user_by_id(db, profile.user_id) + if user is None: + msg = "Mapped external user was not found" + raise AuthInvalidTokenError(msg) from None + if not user.is_active: + msg = "User account is inactive" + raise InactiveUserError(msg) from None + + return user + + @staticmethod + async def _unique_external_username(db: AsyncSession, identity: ExternalIdentity) -> str: + desired = identity.username + if await get_user_by_username(db, desired) is None: + return desired + fallback = _external_username_fallback(identity.provider, identity.subject) + if await get_user_by_username(db, fallback) is None: + return fallback + # Final tier: fold the desired name into the digest so two providers' + # subjects that collide on the helper's digest still resolve uniquely. + import hashlib + + long_digest = hashlib.sha256(f"{identity.provider}:{identity.subject}:{desired}".encode()).hexdigest()[:16] + normalized_provider = identity.provider[:200] or "external" + return f"{normalized_provider}-{long_digest}" + + @staticmethod + async def _initialize_jit_user_defaults(user: User, db: AsyncSession) -> None: + from langflow.initial_setup.setup import get_or_create_default_folder + from langflow.services.deps import get_variable_service + + await get_or_create_default_folder(db, user.id) + await get_variable_service().initialize_user_variables(user.id, db) + async def api_key_security( self, query_param: str | None, header_param: str | None, db: AsyncSession | None = None ) -> UserRead | None: @@ -254,7 +461,8 @@ class AuthService(BaseAuthService): db: AsyncSession, settings_service, ) -> UserRead | None: - result: ApiKey | User | None + clear_current_auth_context() + clear_current_external_access_context() if settings_service.auth_settings.AUTO_LOGIN: if not settings_service.auth_settings.SUPERUSER: @@ -276,6 +484,7 @@ class AuthService(BaseAuthService): detail="User account is inactive", ) logger.warning(AUTO_LOGIN_WARNING) + set_current_auth_context(AuthCredentialContext(method=AUTH_METHOD_AUTO_LOGIN)) return UserRead.model_validate(result, from_attributes=True) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -285,7 +494,7 @@ class AuthService(BaseAuthService): api_key = query_param or header_param if api_key is None: # pragma: no cover - guaranteed by the if-condition above raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing API key") - result = await check_key(db, api_key) + api_key_result = await authenticate_api_key(db, api_key) elif not query_param and not header_param: raise HTTPException( @@ -298,23 +507,27 @@ class AuthService(BaseAuthService): api_key = query_param or header_param if api_key is None: # pragma: no cover - guaranteed by the elif-condition above raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing API key") - result = await check_key(db, api_key) + api_key_result = await authenticate_api_key(db, api_key) - if not result: + if not api_key_result: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing API key", ) - if isinstance(result, User): - return UserRead.model_validate(result, from_attributes=True) + if isinstance(api_key_result.user, User): + set_current_auth_context(AuthCredentialContext.from_api_key_result(api_key_result)) + return UserRead.model_validate(api_key_result.user, from_attributes=True) msg = "Invalid result type" raise ValueError(msg) async def ws_api_key_security(self, api_key: str | None) -> UserRead: settings = self.settings + clear_current_auth_context() + clear_current_external_access_context() async with session_scope() as db: + api_key_result = None if settings.auth_settings.AUTO_LOGIN: if not settings.auth_settings.SUPERUSER: raise WebSocketException( @@ -335,13 +548,15 @@ class AuthService(BaseAuthService): reason="User account is inactive", ) logger.warning(AUTO_LOGIN_WARNING) + set_current_auth_context(AuthCredentialContext(method=AUTH_METHOD_AUTO_LOGIN)) else: raise WebSocketException( code=status.WS_1008_POLICY_VIOLATION, reason=AUTO_LOGIN_ERROR, ) else: - result = await check_key(db, api_key) + api_key_result = await authenticate_api_key(db, api_key) + result = api_key_result.user if api_key_result is not None else None else: if not api_key: @@ -349,7 +564,8 @@ class AuthService(BaseAuthService): code=status.WS_1008_POLICY_VIOLATION, reason="An API key must be passed as query or header", ) - result = await check_key(db, api_key) + api_key_result = await authenticate_api_key(db, api_key) + result = api_key_result.user if api_key_result is not None else None if not result: raise WebSocketException( @@ -358,6 +574,8 @@ class AuthService(BaseAuthService): ) if isinstance(result, User): + if api_key_result is not None: + set_current_auth_context(AuthCredentialContext.from_api_key_result(api_key_result)) return UserRead.model_validate(result, from_attributes=True) raise WebSocketException( @@ -371,6 +589,7 @@ class AuthService(BaseAuthService): query_param: str | None, header_param: str | None, db: AsyncSession, + external_token: str | None = None, ) -> User | UserRead: # Handle coroutine token (FastAPI dependency injection) resolved_token: str | None = None @@ -383,24 +602,31 @@ class AuthService(BaseAuthService): api_key = query_param or header_param # Delegate to framework-agnostic method - return await self.authenticate_with_credentials(resolved_token, api_key, db) + return await self.authenticate_with_credentials(resolved_token, api_key, db, external_token=external_token) async def get_current_user_from_access_token( self, token: str | Coroutine | None, db: AsyncSession, + external_token: str | None = None, ) -> User: """Get user from access token (raises generic exceptions). This method now uses the framework-agnostic _authenticate_with_token() internally. + + ``external_token`` is an optional, separately-extracted external credential + tried as a fallback when native token authentication fails so a + present-but-invalid native token cannot shadow a valid external one. When + ``None`` (or identical to ``token``) behavior is unchanged. """ - if token is None: - msg = "Missing authentication token" - raise MissingCredentialsError(msg) + clear_current_auth_context() + clear_current_external_access_context() # Handle coroutine token (FastAPI dependency injection) - resolved_token: str - if isinstance(token, Coroutine): + resolved_token: str | None + if token is None: + resolved_token = None + elif isinstance(token, Coroutine): resolved_token = await token elif isinstance(token, str): resolved_token = token @@ -408,26 +634,51 @@ class AuthService(BaseAuthService): msg = "Invalid token format" raise AuthInvalidTokenError(msg) - # Use internal authentication method - return await self._authenticate_with_token(resolved_token, db) + # No native token: try a separately-extracted external credential before + # rejecting so a valid external credential authenticates on its own. When + # external_token is None (the default), behavior is unchanged: a missing + # native token raises MissingCredentialsError. + if not resolved_token: + if external_token: + external_user = await self._authenticate_with_external_token(external_token, db) + if external_user is not None: + return external_user + msg = "Missing authentication token" + raise MissingCredentialsError(msg) + + # Use internal authentication method. Try the native token first; on + # failure fall back to a *distinct* external credential before surfacing + # the native error so a stale/invalid native token can't shadow a valid + # external one. When external_token is None or identical, behavior is + # unchanged. + try: + return await self._authenticate_with_token(resolved_token, db) + except (AuthInvalidTokenError, TokenExpiredError, InactiveUserError, InvalidCredentialsError) as e: + if external_token and external_token != resolved_token: + external_user = await self._authenticate_with_external_token(external_token, db) + if external_user is not None: + return external_user + raise e # noqa: TRY201 async def get_current_user_for_websocket( self, token: str | None, api_key: str | None, db: AsyncSession, + external_token: str | None = None, ) -> User | UserRead: """Delegates to authenticate_with_credentials().""" - return await self.authenticate_with_credentials(token, api_key, db) + return await self.authenticate_with_credentials(token, api_key, db, external_token=external_token) async def get_current_user_for_sse( self, token: str | None, api_key: str | None, db: AsyncSession, + external_token: str | None = None, ) -> User | UserRead: """Delegates to authenticate_with_credentials().""" - return await self.authenticate_with_credentials(token, api_key, db) + return await self.authenticate_with_credentials(token, api_key, db, external_token=external_token) async def get_current_active_user(self, current_user: User | UserRead) -> User | UserRead | None: if not current_user.is_active: @@ -441,6 +692,8 @@ class AuthService(BaseAuthService): async def get_webhook_user(self, flow_id: str, request: Request) -> UserRead: settings_service = self.settings + clear_current_auth_context() + clear_current_external_access_context() if not settings_service.auth_settings.WEBHOOK_AUTH_ENABLE: try: @@ -463,12 +716,13 @@ class AuthService(BaseAuthService): try: async with session_scope() as db: - result = await check_key(db, api_key) + result = await authenticate_api_key(db, api_key) if not result: logger.warning("Invalid API key provided for webhook") raise HTTPException(status_code=403, detail="Invalid API key") - authenticated_user = UserRead.model_validate(result, from_attributes=True) + set_current_auth_context(AuthCredentialContext.from_api_key_result(result)) + authenticated_user = UserRead.model_validate(result.user, from_attributes=True) logger.info("Webhook API key validated successfully") except HTTPException: raise @@ -776,11 +1030,14 @@ class AuthService(BaseAuthService): header_param: str | None, db: AsyncSession, ) -> User | UserRead: + clear_current_auth_context() + clear_current_external_access_context() if token: return await self.get_current_user_from_access_token(token, db) settings_service = self.settings - result: ApiKey | User | None + result: User | None + api_key_result = None if settings_service.auth_settings.AUTO_LOGIN: if not settings_service.auth_settings.SUPERUSER: @@ -792,13 +1049,15 @@ class AuthService(BaseAuthService): result = await get_user_by_username(db, settings_service.auth_settings.SUPERUSER) if result: logger.warning(AUTO_LOGIN_WARNING) + set_current_auth_context(AuthCredentialContext(method=AUTH_METHOD_AUTO_LOGIN)) return result else: # At least one of query_param or header_param is truthy api_key = query_param or header_param if api_key is None: # pragma: no cover - guaranteed by the if-condition above raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing API key") - result = await check_key(db, api_key) + api_key_result = await authenticate_api_key(db, api_key) + result = api_key_result.user if api_key_result is not None else None elif not query_param and not header_param: raise HTTPException( @@ -807,13 +1066,15 @@ class AuthService(BaseAuthService): ) elif query_param: - result = await check_key(db, query_param) + api_key_result = await authenticate_api_key(db, query_param) + result = api_key_result.user if api_key_result is not None else None else: # header_param must be truthy here (query_param is falsy, and we passed the not-both-None check) if header_param is None: # pragma: no cover - guaranteed by the elif chain above raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing API key") - result = await check_key(db, header_param) + api_key_result = await authenticate_api_key(db, header_param) + result = api_key_result.user if api_key_result is not None else None if not result: raise HTTPException( @@ -822,6 +1083,8 @@ class AuthService(BaseAuthService): ) if isinstance(result, User): + if api_key_result is not None: + set_current_auth_context(AuthCredentialContext.from_api_key_result(api_key_result)) return result raise HTTPException( diff --git a/src/backend/base/langflow/services/auth/utils.py b/src/backend/base/langflow/services/auth/utils.py index 44ec17f275..b033817dfb 100644 --- a/src/backend/base/langflow/services/auth/utils.py +++ b/src/backend/base/langflow/services/auth/utils.py @@ -17,7 +17,8 @@ from langflow.services.auth.exceptions import ( InvalidCredentialsError, MissingCredentialsError, ) -from langflow.services.deps import get_auth_service +from langflow.services.auth.external import extract_external_token +from langflow.services.deps import get_auth_service, get_settings_service if TYPE_CHECKING: from collections.abc import Coroutine @@ -35,6 +36,8 @@ class OAuth2PasswordBearerCookie(OAuth2PasswordBearer): This allows the application to work with HttpOnly cookies while supporting explicit Authorization headers for backward compatibility and testing scenarios. If an explicit Authorization header is provided, it takes precedence over cookies. + When external trusted auth is enabled, the configured external header/cookie + is consulted last so the native JWT path is always tried first. """ async def __call__(self, request: Request) -> str | None: @@ -49,11 +52,24 @@ class OAuth2PasswordBearerCookie(OAuth2PasswordBearer): if token: return token + # Final fallback: external trusted credential (validated downstream). + if external := _get_external_token(request.headers, request.cookies): + return external + # If auto_error is True, this would raise an exception # Since we set auto_error=False, return None return None +def _get_external_token(headers, cookies) -> str | None: + """Return the configured external credential, swallowing transient failures.""" + try: + auth_settings = get_settings_service().auth_settings + except Exception: # noqa: BLE001 + return None + return extract_external_token(headers, cookies, auth_settings) + + oauth2_login = OAuth2PasswordBearerCookie(tokenUrl="api/v1/login", auto_error=False) API_KEY_NAME = "x-api-key" @@ -155,13 +171,22 @@ def _auth_error_to_http(e: AuthenticationError) -> HTTPException: async def get_current_user( + request: Request, token: Annotated[str | None, Security(oauth2_login)], query_param: Annotated[str | None, Security(api_key_query)], header_param: Annotated[str | None, Security(api_key_header)], db: AsyncSession = Depends(injectable_session_scope), ) -> User: + # Keep the native token (resolved by oauth2_login, which may already have + # collapsed to the external credential) separate from a freshly-extracted + # external credential so a present-but-invalid native cookie cannot shadow a + # valid external one. The auth service tries the native token first and only + # falls back to the external credential when it differs from the token. + external_token = _get_external_token(request.headers, request.cookies) try: - return await _auth_service().get_current_user(token, query_param, header_param, db) + return await _auth_service().get_current_user( + token, query_param, header_param, db, external_token=external_token + ) except AuthenticationError as e: raise _auth_error_to_http(e) from e @@ -169,18 +194,21 @@ async def get_current_user( async def get_current_user_from_access_token( token: str | Coroutine | None, db: AsyncSession, + external_token: str | None = None, ) -> User: """Compatibility helper to resolve a user from an access token. This simply delegates to the active auth service's - `get_current_user_from_access_token` implementation. + `get_current_user_from_access_token` implementation. ``external_token`` is an + optional, separately-extracted external credential tried as a fallback when + native token authentication fails; when ``None`` behavior is unchanged. **For new code, prefer calling `get_auth_service().get_current_user_from_access_token(...)` directly** instead of importing this function. """ try: - return await _auth_service().get_current_user_from_access_token(token, db) + return await _auth_service().get_current_user_from_access_token(token, db, external_token=external_token) except AuthenticationError as e: raise _auth_error_to_http(e) from e @@ -193,7 +221,11 @@ async def get_current_user_for_websocket( db: AsyncSession, ) -> User | UserRead: """Extracts credentials from WebSocket and delegates to auth service.""" + # Keep the native token and the external credential separate so a present but + # invalid/expired native token cannot shadow a valid external credential; the + # auth service tries the external token as a fallback when native auth fails. token = websocket.cookies.get("access_token_lf") or websocket.query_params.get("token") + external_token = _get_external_token(websocket.headers, websocket.cookies) api_key = ( websocket.query_params.get("x-api-key") or websocket.query_params.get("api_key") @@ -202,7 +234,7 @@ async def get_current_user_for_websocket( ) try: - return await _auth_service().get_current_user_for_websocket(token, api_key, db) + return await _auth_service().get_current_user_for_websocket(token, api_key, db, external_token=external_token) except AuthenticationError as e: raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION, reason=WS_AUTH_REASON) from e @@ -215,11 +247,15 @@ async def get_current_user_for_sse( Accepts cookie (access_token_lf) or API key (x-api-key query param). """ + # Keep the native token and the external credential separate (see + # get_current_user_for_websocket) so the external credential remains a usable + # fallback even when a stale native cookie is present. token = request.cookies.get("access_token_lf") + external_token = _get_external_token(request.headers, request.cookies) api_key = request.query_params.get("x-api-key") or request.headers.get("x-api-key") try: - return await _auth_service().get_current_user_for_sse(token, api_key, db) + return await _auth_service().get_current_user_for_sse(token, api_key, db, external_token=external_token) except AuthenticationError as e: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -285,11 +321,15 @@ async def get_current_user_optional( if auth_header and auth_header.startswith("Bearer "): token = token or auth_header[len("Bearer ") :] - if not token and not api_key: + # Keep the external credential separate so it remains a usable fallback when a + # stale/invalid native token is present (see get_current_user_for_websocket). + external_token = _get_external_token(request.headers, request.cookies) + + if not token and not external_token and not api_key: return None try: - return await _auth_service().get_current_user_for_sse(token, api_key, db) + return await _auth_service().get_current_user_for_sse(token, api_key, db, external_token=external_token) except (AuthenticationError, HTTPException): return None diff --git a/src/backend/base/langflow/services/authorization/access_ceiling.py b/src/backend/base/langflow/services/authorization/access_ceiling.py new file mode 100644 index 0000000000..5622028c8a --- /dev/null +++ b/src/backend/base/langflow/services/authorization/access_ceiling.py @@ -0,0 +1,95 @@ +"""Request-scoped action ceiling (deny-only) enforced by authorization guards. + +This is an authorization primitive: a coarse, deny-only cap on which actions a +request may perform, stored in a context variable for the lifetime of the +request/task. The authentication layer derives the ceiling from a trusted +external identity (see +``langflow.services.auth.external.access_context_from_identity``) and installs +it via :func:`set_current_external_access_context`; the guards in this package +consult it. Keeping the primitive here lets authorization enforce the ceiling +without importing the authentication layer, preserving the auth/authz split +documented in AGENTS.md. +""" + +from __future__ import annotations + +from contextvars import ContextVar +from dataclasses import dataclass + +EXTERNAL_ACCESS_VIEWER = "viewer" +EXTERNAL_ACCESS_EDITOR = "editor" +EXTERNAL_ACCESS_ADMIN = "admin" +EXTERNAL_ACCESS_LEVELS = frozenset( + { + EXTERNAL_ACCESS_VIEWER, + EXTERNAL_ACCESS_EDITOR, + EXTERNAL_ACCESS_ADMIN, + } +) +# Action ceilings per external access level (deny-only caps): +# viewer -> {read} +# editor -> viewer + {write, create, delete, execute, ingest} +# admin -> all actions (no ceiling) +# ``deploy`` is intentionally admin-only and is therefore excluded from the +# editor set: an editor cannot promote a flow to a deployment. +_VIEWER_ALLOWED_ACTIONS = frozenset({"read"}) +_EDITOR_ALLOWED_ACTIONS = frozenset({"read", "write", "create", "delete", "execute", "ingest"}) + + +@dataclass(frozen=True) +class ExternalAccessContext: + """Request-local access ceiling derived from an external identity claim.""" + + provider: str + subject: str + level: str + claim_name: str | None = None + claim_value: str | None = None + + +_current_external_access: ContextVar[ExternalAccessContext | None] = ContextVar( + "langflow_external_access", + default=None, +) + + +def set_current_external_access_context(context: ExternalAccessContext | None) -> None: + """Store the external access ceiling for the current request/task.""" + _current_external_access.set(context) + + +def clear_current_external_access_context() -> None: + """Clear any external access ceiling from the current request/task.""" + _current_external_access.set(None) + + +def get_current_external_access_context() -> ExternalAccessContext | None: + """Return the external access ceiling for the current request/task, if any.""" + return _current_external_access.get() + + +def external_access_allows(action: str, context: ExternalAccessContext | None = None) -> bool: + """Return whether the external access ceiling allows this action. + + This is deliberately action-level and deny-only. It does not grant access to + resources; normal ownership, route guards, and enterprise authz plugins still + decide whether an otherwise allowed action may proceed. + """ + context = context if context is not None else get_current_external_access_context() + if context is None: + return True + + normalized_action = action.strip().lower() + if context.level == EXTERNAL_ACCESS_ADMIN: + return True + if context.level == EXTERNAL_ACCESS_EDITOR: + return normalized_action in _EDITOR_ALLOWED_ACTIONS + return normalized_action in _VIEWER_ALLOWED_ACTIONS + + +def filter_actions_by_external_access_ceiling(actions: list[str] | tuple[str, ...]) -> list[str]: + """Filter an action list through the current external access ceiling.""" + context = get_current_external_access_context() + if context is None: + return list(actions) + return [action for action in actions if external_access_allows(action, context)] diff --git a/src/backend/base/langflow/services/authorization/guards.py b/src/backend/base/langflow/services/authorization/guards.py index eec1e07630..52afcabe19 100644 --- a/src/backend/base/langflow/services/authorization/guards.py +++ b/src/backend/base/langflow/services/authorization/guards.py @@ -8,7 +8,16 @@ from typing import TYPE_CHECKING, Any from fastapi import HTTPException, status from lfx.log.logger import logger +from langflow.services.auth.context import ( + current_auth_context_for_audit, + current_auth_context_for_authz, + current_auth_is_api_key, +) from langflow.services.authorization import audit as _audit +from langflow.services.authorization.access_ceiling import ( + external_access_allows, + get_current_external_access_context, +) from langflow.services.authorization.actions import ( DeploymentAction, FileAction, @@ -56,7 +65,35 @@ _DEFAULT_DENY_DETAIL = "Permission denied" def _auth_context(user: User | UserRead) -> dict[str, Any]: """Build the base context dict passed to authorization enforce calls.""" - return {"is_superuser": getattr(user, "is_superuser", False)} + return { + **current_auth_context_for_authz(), + "is_superuser": getattr(user, "is_superuser", False), + } + + +def _auth_audit_details() -> dict[str, str]: + """Build JSON-friendly auth context for audit details.""" + return current_auth_context_for_audit() + + +async def _api_key_scopes_require_plugin_enforcement() -> bool: + """Return True when owner override must not hide API-key caveats.""" + if not current_auth_is_api_key(): + return False + + settings = get_settings_service() + if not settings.auth_settings.AUTHZ_ENABLED: + return False + + authz = get_authorization_service() + supports_api_key_scopes = getattr(authz, "supports_api_key_scopes", None) + if supports_api_key_scopes is None: + return False + try: + return bool(await supports_api_key_scopes()) + except Exception: # noqa: BLE001 + logger.exception("Authorization plugin failed API-key scope capability check; preserving owner override") + return False def _coerce_action( @@ -114,7 +151,7 @@ async def ensure_permission( merged_context = {**(context or {}), **_auth_context(user)} # Fail closed when enforce() raises (deny + audit, not HTTP 500). audit_action = f"{obj.split(':', 1)[0]}:{act}" if ":" in obj else act - audit_details: dict[str, Any] = {"domain": domain} + audit_details: dict[str, Any] = {"domain": domain, **_auth_audit_details()} for owner_key in _OWNER_CONTEXT_KEYS: if owner_key in merged_context and merged_context[owner_key] is not None: audit_details[owner_key] = str(merged_context[owner_key]) @@ -169,13 +206,35 @@ async def _ensure_resource_permission( """Build object key, apply owner override, else delegate to ensure_permission.""" obj = f"{resource_type}:{resource_id}" if resource_id else f"{resource_type}:*" - if owner_id is not None and getattr(user, "id", None) == owner_id: + external_context = get_current_external_access_context() + if external_context is not None and not external_access_allows(act_str, external_context): + await _audit.audit_decision( + user_id=user.id, + action=f"{resource_type}:{act_str}", + obj=obj, + result=_audit.AUDIT_DENY, + details={ + "domain": resolved_domain, + "external_auth_provider": external_context.provider, + "external_access_level": external_context.level, + }, + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="External credentials do not allow this action", + ) + + if ( + owner_id is not None + and getattr(user, "id", None) == owner_id + and not await _api_key_scopes_require_plugin_enforcement() + ): await _audit.audit_decision( user_id=user.id, action=f"{resource_type}:{act_str}", obj=obj, result=_audit.AUDIT_OWNER_OVERRIDE, - details={"domain": resolved_domain}, + details={"domain": resolved_domain, **_auth_audit_details()}, ) return diff --git a/src/backend/base/langflow/services/authorization/listing.py b/src/backend/base/langflow/services/authorization/listing.py index f9945a7c33..5ef737e607 100644 --- a/src/backend/base/langflow/services/authorization/listing.py +++ b/src/backend/base/langflow/services/authorization/listing.py @@ -5,7 +5,11 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, TypeVar from langflow.services.authorization.actions import FlowAction -from langflow.services.authorization.guards import _auth_context, _coerce_action +from langflow.services.authorization.guards import ( + _api_key_scopes_require_plugin_enforcement, + _auth_context, + _coerce_action, +) from langflow.services.deps import get_authorization_service, get_settings_service if TYPE_CHECKING: @@ -43,13 +47,19 @@ async def filter_visible_resources( authz = get_authorization_service() act_str = _coerce_action(act) user_id = getattr(user, "id", None) + owner_override_enabled = not await _api_key_scopes_require_plugin_enforcement() # Owned rows skip batch_enforce (matches direct-read owner override). owned_indices: set[int] = set() enforce_indices: list[int] = [] enforce_items: list[T] = [] for index, item in enumerate(candidates): - if owner_extractor is not None and user_id is not None and owner_extractor(item) == user_id: + if ( + owner_override_enabled + and owner_extractor is not None + and user_id is not None + and owner_extractor(item) == user_id + ): owned_indices.add(index) else: enforce_indices.append(index) diff --git a/src/backend/base/langflow/services/database/models/api_key/crud.py b/src/backend/base/langflow/services/database/models/api_key/crud.py index 078b36a9c2..f3d25abbab 100644 --- a/src/backend/base/langflow/services/database/models/api_key/crud.py +++ b/src/backend/base/langflow/services/database/models/api_key/crud.py @@ -3,6 +3,7 @@ import datetime import hashlib import os import secrets +from dataclasses import dataclass from typing import TYPE_CHECKING from uuid import UUID @@ -20,6 +21,15 @@ if TYPE_CHECKING: from sqlmodel.sql.expression import SelectOfScalar +@dataclass(frozen=True) +class ApiKeyAuthResult: + """Authenticated API-key metadata.""" + + user: User + api_key_source: str + api_key_id: UUID | None = None + + def hash_api_key(api_key: str) -> str: """One-way SHA-256 hash of a plaintext API key for indexed lookup.""" return hashlib.sha256(api_key.encode()).hexdigest() @@ -64,6 +74,18 @@ async def get_api_keys(session: AsyncSession, user_id: UUID) -> list[ApiKeyRead] async def create_api_key(session: AsyncSession, api_key_create: ApiKeyCreate, user_id: UUID) -> UnmaskedApiKeyRead: """Create a new API key, storing an encrypted value and a SHA-256 hash for fast lookup.""" + settings_service = get_settings_service() + auth_settings = settings_service.auth_settings + if ( + auth_settings.EXTERNAL_AUTH_ACCESS_CEILING_ENABLED + and auth_settings.EXTERNAL_AUTH_DISABLE_API_KEYS_FOR_EXTERNAL_USERS + ): + from langflow.services.authorization.access_ceiling import get_current_external_access_context + + if get_current_external_access_context() is not None: + msg = "API key creation is disabled for externally authenticated users" + raise PermissionError(msg) + # Generate a random API key with 32 bytes of randomness generated_api_key = f"sk-{secrets.token_urlsafe(32)}" @@ -117,7 +139,56 @@ async def check_key(session: AsyncSession, api_key: str) -> User | None: return await _check_key_from_db(session, api_key, settings_service) +async def authenticate_api_key(session: AsyncSession, api_key: str) -> ApiKeyAuthResult | None: + """Validate an API key and return the user plus non-secret key metadata.""" + settings_service = get_settings_service() + api_key_source = settings_service.auth_settings.API_KEY_SOURCE + + if api_key_source == "env": + user = await _check_key_from_env(session, api_key, settings_service) + if user is not None: + return ApiKeyAuthResult(user=user, api_key_source="env") + # Fallback to database if env validation fails + return await _check_key_from_db_with_context(session, api_key, settings_service) + + +async def _external_access_ceiling_blocks_user(session: AsyncSession, user: User, settings_service) -> bool: + """Return True when API-key auth must be denied for an externally-managed user. + + Single chokepoint for the EXTERNAL_AUTH access ceiling: every DB-path API-key + authentication flows through ``_check_key_from_db_with_context``, so enforcing + here covers ``_api_key_security_impl`` (/run, v2 workflow, openai_responses), + ``ws_api_key_security``, ``get_webhook_user``, ``get_current_user_mcp`` and the + auth service. When the feature is OFF (ceiling disabled OR disable-keys + disabled) this is a no-op. + """ + auth_settings = settings_service.auth_settings + if ( + not auth_settings.EXTERNAL_AUTH_ACCESS_CEILING_ENABLED + or not auth_settings.EXTERNAL_AUTH_DISABLE_API_KEYS_FOR_EXTERNAL_USERS + ): + return False + + from langflow.services.database.models.auth import SSOUserProfile + + profile_stmt = select(SSOUserProfile).where( + SSOUserProfile.user_id == user.id, + SSOUserProfile.sso_provider == auth_settings.EXTERNAL_AUTH_PROVIDER, + ) + return (await session.exec(profile_stmt)).first() is not None + + async def _check_key_from_db(session: AsyncSession, api_key: str, settings_service) -> User | None: + """Validate API key against the database and return only the user.""" + result = await _check_key_from_db_with_context(session, api_key, settings_service) + return result.user if result is not None else None + + +async def _check_key_from_db_with_context( + session: AsyncSession, + api_key: str, + settings_service, +) -> ApiKeyAuthResult | None: """Validate API key against the database. Uses hash-based O(1) lookup first. Falls back to decrypt-and-compare @@ -136,12 +207,21 @@ async def _check_key_from_db(session: AsyncSession, api_key: str, settings_servi api_key_obj = matches[0] if _is_expired(api_key_obj.expires_at): return None + # Resolve + authorize the user BEFORE mutating usage counters so a denied + # authentication (missing user or blocked external user) does not bump + # total_uses / last_used_at. + user = await session.get(User, api_key_obj.user_id) + if user is None: + return None + if await _external_access_ceiling_blocks_user(session, user, settings_service): + logger.info("API key rejected for externally managed user while external access ceiling is enabled") + return None if settings_service.settings.disable_track_apikey_usage is not True: api_key_obj.total_uses += 1 api_key_obj.last_used_at = datetime.datetime.now(datetime.timezone.utc) session.add(api_key_obj) await session.flush() - return await session.get(User, api_key_obj.user_id) + return ApiKeyAuthResult(user=user, api_key_source="db", api_key_id=api_key_obj.id) # pragma: allowlist secret if len(matches) > 1: key_ids = [str(m.id) for m in matches] @@ -172,6 +252,15 @@ async def _check_key_from_db(session: AsyncSession, api_key: str, settings_servi if matched: if _is_expired(api_key_obj.expires_at): return None + # Resolve + authorize the user BEFORE mutating usage counters / hash + # backfill so a denied authentication (missing user or blocked + # external user) does not bump total_uses / last_used_at. + user = await session.get(User, api_key_obj.user_id) + if user is None: + return None + if await _external_access_ceiling_blocks_user(session, user, settings_service): + logger.info("API key rejected for externally managed user while external access ceiling is enabled") + return None # Backfill hash for future O(1) lookups api_key_obj.api_key_hash = incoming_hash if settings_service.settings.disable_track_apikey_usage is not True: @@ -179,7 +268,11 @@ async def _check_key_from_db(session: AsyncSession, api_key: str, settings_servi api_key_obj.last_used_at = datetime.datetime.now(datetime.timezone.utc) session.add(api_key_obj) await session.flush() - return await session.get(User, api_key_obj.user_id) + return ApiKeyAuthResult( + user=user, + api_key_source="db", # pragma: allowlist secret + api_key_id=api_key_obj.id, + ) return None diff --git a/src/backend/base/langflow/services/job_queue/service.py b/src/backend/base/langflow/services/job_queue/service.py index a474f953f9..90b0b4d9d4 100644 --- a/src/backend/base/langflow/services/job_queue/service.py +++ b/src/backend/base/langflow/services/job_queue/service.py @@ -29,6 +29,10 @@ _CANCEL_CHANNEL_PREFIX = "langflow:cancel:" # Activity heartbeat key written by polling and streaming responses. The # polling watchdog scans these to detect abandoned builds (client gave up). _ACTIVITY_PREFIX = "langflow:activity:" +# Presence key for jobs started through the public (unauthenticated) build +# endpoint. Allows the public events/cancel endpoints to reject job_ids that +# belong to private-flow builds. Uses the same TTL as the stream/owner keys. +_PUBLIC_JOB_PREFIX = "langflow:public_job:" class JobQueueNotFoundError(Exception): @@ -133,6 +137,7 @@ class JobQueueService(Service): """ self._queues: dict[str, tuple[asyncio.Queue, EventManager, asyncio.Task | None, float | None]] = {} self._job_owners: dict[str, UUID] = {} + self._public_jobs: set[str] = set() self._cleanup_task: asyncio.Task | None = None self._closed = False self.ready = False @@ -352,6 +357,32 @@ class JobQueueService(Service): """Return the user ID that owns a job, or None if not tracked.""" return self._job_owners.get(job_id) + async def register_public_job(self, job_id: str) -> None: + """Mark a job as started through the public (unauthenticated) build endpoint. + + Only jobs registered here may be accessed via the public events/cancel endpoints. + This prevents unauthenticated callers from reading or cancelling private-flow jobs. + + Async (even though the base implementation is a synchronous set add): + RedisJobQueueService overrides this to also persist the marker to Redis + *before returning*, so a request landing on a different worker immediately + after registration sees the marker via is_public_job_async. + """ + self._public_jobs.add(job_id) + + def is_public_job(self, job_id: str) -> bool: + """Return True if the job was started through the public build endpoint.""" + return job_id in self._public_jobs + + async def is_public_job_async(self, job_id: str) -> bool: + """Return True if the job was started through the public build endpoint. + + Base implementation is synchronous (in-memory set lookup). + RedisJobQueueService overrides this to also check Redis for cross-worker + correctness in multi-worker deployments. + """ + return self.is_public_job(job_id) + async def cleanup_job(self, job_id: str) -> None: """Clean up and release resources for a specific job. @@ -407,6 +438,7 @@ class JobQueueService(Service): # Remove the job entry from the registry self._queues.pop(job_id, None) self._job_owners.pop(job_id, None) + self._public_jobs.discard(job_id) await logger.adebug(f"Cleanup successful for job_id {job_id}: resources have been released.") async def cancel_job(self, job_id: str) -> None: @@ -776,6 +808,7 @@ class RedisJobQueueService(JobQueueService): OWNER_PREFIX = _OWNER_PREFIX CANCEL_CHANNEL_PREFIX = _CANCEL_CHANNEL_PREFIX ACTIVITY_PREFIX = _ACTIVITY_PREFIX + PUBLIC_JOB_PREFIX = _PUBLIC_JOB_PREFIX def __init__( self, @@ -853,6 +886,9 @@ class RedisJobQueueService(JobQueueService): def _owner_key(self, job_id: str) -> str: return f"{self.OWNER_PREFIX}{job_id}" + def _public_job_key(self, job_id: str) -> str: + return f"{self.PUBLIC_JOB_PREFIX}{job_id}" + def _cancel_channel(self, job_id: str) -> str: return f"{self.CANCEL_CHANNEL_PREFIX}{job_id}" @@ -1077,6 +1113,16 @@ class RedisJobQueueService(JobQueueService): published = True if needs_ttl_refresh: await self._client.expire(stream_key, self._ttl) + # Why: register_public_job sets the public_job marker with + # ex=self._ttl once at job start. Long-running builds that + # outlive that TTL would have the marker expire while the + # stream itself is kept alive, causing is_public_job_async + # to 404 a still-active public job on other workers. Refresh + # it on the same cadence as the stream TTL. is_public_job is + # the in-memory (sync) check — true on the worker that owns + # this bridge, which is the same worker that registered it. + if self.is_public_job(job_id): + await self._client.expire(self._public_job_key(job_id), self._ttl) last_ttl_refresh = time.monotonic() in_flight_item = None _retry_delay = 0.1 @@ -1662,6 +1708,7 @@ class RedisJobQueueService(JobQueueService): self._stream_key(job_id), self._owner_key(job_id), self._activity_key(job_id), + self._public_job_key(job_id), ) except asyncio.CancelledError: raise @@ -1719,3 +1766,51 @@ class RedisJobQueueService(JobQueueService): raise JobQueueBackendUnavailableError(self._backend_unavailable_message()) from exc raise return None + + async def register_public_job(self, job_id: str) -> None: + """Mark a job as public in both local memory and Redis for cross-worker access. + + Why synchronous (not fire-and-forget): a request for the public events/cancel + endpoint can land on a different worker than the one that registered the job. + If the Redis write were backgrounded, that worker could run is_public_job_async + before the marker exists and incorrectly 404 a legitimate public job. Awaiting + the write here guarantees the marker is visible to every worker by the time + build_public_tmp's response (containing job_id) reaches the client. + + Raises: + JobQueueBackendUnavailableError: if a Redis client is configured but the + marker write fails. Surfacing (instead of swallowing) prevents + build_public_tmp from returning a job_id that only this worker + recognizes — on a multi-worker deployment that would let the public + events/cancel endpoints 404 on every other worker. The caller cleans + up the started job and returns a 503. With no Redis client configured + (single-worker, in-memory) there is no shared marker to persist, so + the base no-op success path is unchanged. + """ + await super().register_public_job(job_id) + if self._client: + await self._set_public_job_key(job_id) + + async def _set_public_job_key(self, job_id: str) -> None: + try: + await self._client.set(self._public_job_key(job_id), b"1", ex=self._ttl) + except Exception as exc: + if _is_backend_connection_error(exc): + raise JobQueueBackendUnavailableError(self._backend_unavailable_message()) from exc + raise + + async def is_public_job_async(self, job_id: str) -> bool: + """Return True if the job was started through the public build endpoint. + + Checks local memory first (fast path), then falls back to Redis so that + a request hitting a different worker than the one that started the build + still works correctly. + """ + if super().is_public_job(job_id): + return True + if self._client: + try: + return bool(await self._client.exists(self._public_job_key(job_id))) + except Exception as exc: # noqa: BLE001 + await logger.awarning(f"Redis public_job check failed for {job_id}: {exc!r}") + return False diff --git a/src/backend/tests/unit/api/v1/test_mcp_utils.py b/src/backend/tests/unit/api/v1/test_mcp_utils.py index 602c46e45b..9dce5a801f 100644 --- a/src/backend/tests/unit/api/v1/test_mcp_utils.py +++ b/src/backend/tests/unit/api/v1/test_mcp_utils.py @@ -391,7 +391,10 @@ def _build_fake_server() -> SimpleNamespace: async def _invoke_handle_call_tool(monkeypatch, arguments: dict) -> AsyncMock: """Run handle_call_tool with all external deps stubbed; return the simple_run_flow mock.""" - flow = SimpleNamespace(id="flow-id-1", name="my_flow", folder_id=None) + # ``user_id`` matches the current user (see ``current_user_ctx`` below) so the + # owner-override path in ``ensure_flow_permission`` is exercised; ``workspace_id`` + # is read by the same guard. + flow = SimpleNamespace(id="flow-id-1", name="my_flow", folder_id=None, user_id="user-1", workspace_id=None) async def fake_get_flow_snake_case(*_args, **_kwargs): return flow diff --git a/src/backend/tests/unit/components/data_source/test_dns_rebinding.py b/src/backend/tests/unit/components/data_source/test_dns_rebinding.py index b20eee0dde..a9b47f92ac 100644 --- a/src/backend/tests/unit/components/data_source/test_dns_rebinding.py +++ b/src/backend/tests/unit/components/data_source/test_dns_rebinding.py @@ -750,6 +750,220 @@ class TestURLComponentDNSRebindingProtection: with pytest.raises(ValueError, match="SSRF Protection"): await component.fetch_url_contents() + @pytest.mark.asyncio + async def test_url_component_follows_redirects_with_per_hop_pinning(self): + """Test that the URL component follows redirects and re-validates/pins every hop. + + Regression test: the component previously sent every request with + follow_redirects=False, so a site that 301s to its canonical URL (http->https or + www normalization - extremely common) returned the redirect stub instead of the + page. Redirects are now followed, and each hop is independently DNS-pinned so a + rebinding attacker who flips the redirect target after validation can never cause + a connection to an unvalidated address. + """ + resolved = [] + + def mock_getaddrinfo(host, *_args, **_kwargs): + """Every host resolves to the same public IP at validation time.""" + resolved.append(host) + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("8.8.8.8", 0))] + + connect_hosts = [] + redirect_response = [ + b"HTTP/1.1 301 Moved Permanently\r\n", + b"Location: https://final.test/\r\n", + b"Content-Length: 0\r\n", + b"\r\n", + ] + final_response = [ + b"HTTP/1.1 200 OK\r\n", + b"Content-Type: text/html\r\n", + b"Content-Length: 39\r\n", + b"\r\n", + b"
Final content", + ] + + async def mock_connect_tcp(self, host, port, **kwargs): + """Capture every IP connected to; first hop redirects, second succeeds.""" + connect_hosts.append(host) + stream = redirect_response if len(connect_hosts) == 1 else final_response + return httpcore.AsyncMockStream(list(stream)) + + with ( + patch("socket.getaddrinfo", side_effect=mock_getaddrinfo), + patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}), + patch.object(httpcore.AnyIOBackend, "connect_tcp", mock_connect_tcp), + ): + component = URLComponent() + component.urls = ["http://redirect.test/"] + component.max_depth = 1 + component.format = "Text" + component.headers = [{"key": "User-Agent", "value": "Test"}] + component.timeout = 30 + component.prevent_outside = True + component.use_async = True + component.follow_redirects = True + component.filter_text_html = False + component.continue_on_failure = False + component.check_response_status = False + component.autoset_encoding = True + + result = await component.fetch_url_contents() + + # The redirect was followed through to the final page's content. + assert len(result) == 1 + assert "Final content" in result[0]["text"] + # Both the initial request and the redirect hop connected to the pinned public IP. + assert connect_hosts == ["8.8.8.8", "8.8.8.8"], connect_hosts + # The redirect target was resolved exactly once (validation only); httpx never + # re-resolved it, so a rebinding flip after validation has no effect. + assert resolved.count("final.test") == 1, resolved + + @pytest.mark.asyncio + async def test_url_component_blocks_redirect_to_internal_ip(self): + """Test that a redirect pointing at an internal address is blocked before connecting. + + A validated public host redirects to a host that resolves to localhost. The hop + is re-validated with the SSRF denylist, so the component raises rather than + fetching the internal resource, and never opens a connection to it. + """ + resolved = [] + + def mock_getaddrinfo(host, *_args, **_kwargs): + """The public redirector resolves to a public IP; the target to localhost.""" + resolved.append(host) + if host == "internal.test": + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 0))] + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("8.8.8.8", 0))] + + connect_hosts = [] + redirect_response = [ + b"HTTP/1.1 302 Found\r\n", + b"Location: http://internal.test/secret\r\n", + b"Content-Length: 0\r\n", + b"\r\n", + ] + + async def mock_connect_tcp(self, host, port, **kwargs): + """First (and only) connection returns a redirect to the internal host.""" + connect_hosts.append(host) + return httpcore.AsyncMockStream(list(redirect_response)) + + with ( + patch("socket.getaddrinfo", side_effect=mock_getaddrinfo), + patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}), + patch.object(httpcore.AnyIOBackend, "connect_tcp", mock_connect_tcp), + ): + component = URLComponent() + component.urls = ["http://safe-redirector.test/"] + component.max_depth = 1 + component.format = "Text" + component.headers = [{"key": "User-Agent", "value": "Test"}] + component.timeout = 30 + component.prevent_outside = True + component.use_async = True + component.follow_redirects = True + component.filter_text_html = False + component.continue_on_failure = False + component.check_response_status = False + component.autoset_encoding = True + + # The redirect to a localhost-resolving host must be blocked by SSRF protection. + with pytest.raises(ValueError, match="SSRF Protection"): + await component.fetch_url_contents() + + # Only the first (public) hop ever connected; the internal target was never reached. + assert connect_hosts == ["8.8.8.8"], connect_hosts + assert "internal.test" in resolved, resolved + + @pytest.mark.asyncio + async def test_url_component_crawls_links_from_redirected_base(self): + """Test that depth>1 crawls resolve links against the final post-redirect URL. + + Regression test: after following http://redirect.test/ -> https://final.test/, + relative links were still resolved against the original URL (sending /about to + http://redirect.test/about) and prevent_outside compared link domains against + the pre-redirect host, skipping same-site absolute links. Links must be resolved + against the URL the content actually came from, and the canonical URL itself is + marked visited so links back to it are not re-crawled. + """ + resolved = [] + + def mock_getaddrinfo(host, *_args, **_kwargs): + """Every host resolves to the same public IP at validation time.""" + resolved.append(host) + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("8.8.8.8", 0))] + + def http_ok(body: bytes) -> list[bytes]: + return [ + b"HTTP/1.1 200 OK\r\n", + b"Content-Type: text/html\r\n", + b"Content-Length: " + str(len(body)).encode() + b"\r\n", + b"\r\n", + body, + ] + + responses = [ + [ + b"HTTP/1.1 301 Moved Permanently\r\n", + b"Location: https://final.test/\r\n", + b"Content-Length: 0\r\n", + b"\r\n", + ], + http_ok( + b"Final page" + b'About' + b'Contact' + b'Home' + b"" + ), + http_ok(b"About page"), + http_ok(b"Contact page"), + ] + + connect_hosts = [] + + async def mock_connect_tcp(self, host, port, **kwargs): + """Serve the redirect, the final page, then one page per crawled link.""" + connect_hosts.append(host) + return httpcore.AsyncMockStream(list(responses[len(connect_hosts) - 1])) + + with ( + patch("socket.getaddrinfo", side_effect=mock_getaddrinfo), + patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}), + patch.object(httpcore.AnyIOBackend, "connect_tcp", mock_connect_tcp), + ): + component = URLComponent() + component.urls = ["http://redirect.test/"] + component.max_depth = 2 + component.format = "Text" + component.headers = [{"key": "User-Agent", "value": "Test"}] + component.timeout = 30 + component.prevent_outside = True + component.use_async = True + component.follow_redirects = True + component.filter_text_html = False + component.continue_on_failure = False + component.check_response_status = False + component.autoset_encoding = True + + result = await component.fetch_url_contents() + + # The final page and both same-site links (relative and absolute) were crawled. + assert [doc["url"] for doc in result] == [ + "https://final.test/", + "https://final.test/about", + "https://final.test/contact", + ] + assert "About page" in result[1]["text"] + # The back-link to the canonical URL was already visited - exactly 4 connections + # (initial + redirect hop + 2 links), all to the pinned public IP. + assert connect_hosts == ["8.8.8.8"] * 4, connect_hosts + # Links validated against the post-redirect host; the pre-redirect host was only + # resolved once, for the initial URL validation. + assert resolved.count("redirect.test") == 1, resolved + assert resolved.count("final.test") == 3, resolved + class TestAPIRequestDNSRebindingEdgeCases: """Additional edge case tests for API Request component DNS rebinding protection.""" diff --git a/src/backend/tests/unit/services/auth/test_auth_service.py b/src/backend/tests/unit/services/auth/test_auth_service.py index 939add8c04..ce98924602 100644 --- a/src/backend/tests/unit/services/auth/test_auth_service.py +++ b/src/backend/tests/unit/services/auth/test_auth_service.py @@ -10,6 +10,11 @@ import jwt import pytest from fastapi import HTTPException, WebSocketException, status from langflow.services.auth.constants import AUTO_LOGIN_WARNING +from langflow.services.auth.context import ( + AUTH_METHOD_API_KEY, + clear_current_auth_context, + get_current_auth_context, +) from langflow.services.auth.exceptions import ( InactiveUserError, InvalidTokenError, @@ -17,6 +22,7 @@ from langflow.services.auth.exceptions import ( TokenExpiredError, ) from langflow.services.auth.service import AuthService +from langflow.services.database.models.api_key.crud import ApiKeyAuthResult from langflow.services.database.models.user.model import User from lfx.services.settings.auth import AuthSettings from pydantic import SecretStr @@ -118,6 +124,38 @@ async def test_authenticate_with_credentials_missing_creds_raises( await auth_service.authenticate_with_credentials(token=None, api_key=None, db=AsyncMock()) +@pytest.mark.anyio +async def test_authenticate_with_api_key_sets_auth_context(auth_service: AuthService): + user = _dummy_user(uuid4()) + api_key_id = uuid4() + + with patch( + "langflow.services.auth.service.authenticate_api_key", + new=AsyncMock( + return_value=ApiKeyAuthResult( + user=user, + api_key_source="db", # pragma: allowlist secret + api_key_id=api_key_id, + ) + ), + ): + try: + result = await auth_service.authenticate_with_credentials( + token=None, + api_key="sk-test-key", # pragma: allowlist secret + db=AsyncMock(), + ) + context = get_current_auth_context() + finally: + clear_current_auth_context() + + assert result.id == user.id + assert context is not None + assert context.method == AUTH_METHOD_API_KEY + assert context.api_key_id == api_key_id + assert context.api_key_source == "db" # pragma: allowlist secret + + @pytest.mark.anyio async def test_authenticate_with_credentials_auto_login_alone_still_rejects( auth_service: AuthService, @@ -781,3 +819,324 @@ async def test_api_key_security_impl_auto_login_skip_rejects_inactive_superuser( ) assert exc.value.status_code == status.HTTP_403_FORBIDDEN + + +# ============================================================================= +# External-user materialization (F1): email is never erased by an email-less token +# ============================================================================= + + +def _external_jwt(auth_service: AuthService, claims: dict) -> str: + """Encode a trusted external JWT signed with the service secret. + + A future ``exp`` is always supplied because the trusted-decode path requires + it (see external._validate_trusted_time_claims). + """ + secret = auth_service.settings.auth_settings.SECRET_KEY.get_secret_value() + payload = {"exp": datetime.now(timezone.utc) + timedelta(minutes=5), **claims} + return jwt.encode(payload, secret, algorithm="HS256") + + +@pytest.mark.anyio +async def test_materialize_external_user_preserves_email_when_token_omits_it( + auth_service: AuthService, + auth_settings: AuthSettings, + async_session, +): + """A later token without an email claim must not erase the stored email (F1).""" + from langflow.services.auth.external import identity_from_claims + from langflow.services.database.models.auth import SSOUserProfile + from sqlmodel import select + + auth_settings.EXTERNAL_AUTH_ENABLED = True + auth_settings.EXTERNAL_AUTH_PROVIDER = "external" + + # First login carries an email and provisions the user + profile. + identity_with_email = identity_from_claims( + {"sub": "ext-subject-1", "email": "alice@example.com", "preferred_username": "alice"}, + auth_settings, + ) + user = await auth_service._materialize_external_user(identity_with_email, async_session) + await async_session.flush() + + profile = (await async_session.exec(select(SSOUserProfile).where(SSOUserProfile.user_id == user.id))).first() + assert profile is not None + assert profile.email == "alice@example.com" + + # Second login with the SAME subject but NO email claim must keep the stored email. + identity_without_email = identity_from_claims( + {"sub": "ext-subject-1", "preferred_username": "alice"}, + auth_settings, + ) + assert identity_without_email.email is None + + same_user = await auth_service._materialize_external_user(identity_without_email, async_session) + await async_session.flush() + await async_session.refresh(profile) + + assert same_user.id == user.id + assert profile.email == "alice@example.com" + + # A later token that DOES carry an email still updates it. + identity_new_email = identity_from_claims( + {"sub": "ext-subject-1", "email": "alice2@example.com"}, + auth_settings, + ) + await auth_service._materialize_external_user(identity_new_email, async_session) + await async_session.flush() + await async_session.refresh(profile) + assert profile.email == "alice2@example.com" + + +# ============================================================================= +# External fallback (F2/F14): a valid external credential is tried when native fails +# ============================================================================= + + +@pytest.mark.anyio +async def test_invalid_native_token_falls_back_to_external_credential( + auth_service: AuthService, + auth_settings: AuthSettings, + async_session, +): + """An invalid native token plus a valid external credential authenticates via external (F2/F14).""" + from langflow.services.auth.external import identity_from_claims + + auth_settings.EXTERNAL_AUTH_ENABLED = True + auth_settings.EXTERNAL_AUTH_TRUSTED_JWT_DECODE = True + auth_settings.EXTERNAL_AUTH_PROVIDER = "external" + + # Pre-provision the external user + profile so the resolver takes the + # existing-profile branch (no folder/variable service needed). + identity = identity_from_claims( + {"sub": "ext-subject-2", "email": "bob@example.com", "preferred_username": "bob"}, + auth_settings, + ) + user = await auth_service._materialize_external_user(identity, async_session) + await async_session.flush() + + # A present-but-invalid native token must NOT shadow the valid external one. + external_token = _external_jwt( + auth_service, {"sub": "ext-subject-2", "email": "bob@example.com", "preferred_username": "bob"} + ) + + try: + result = await auth_service.authenticate_with_credentials( + token="not-a-valid-jwt", # noqa: S106 # native decode fails + api_key=None, + db=async_session, + external_token=external_token, + ) + finally: + clear_current_auth_context() + + assert result.id == user.id + + +@pytest.mark.anyio +async def test_no_external_token_keeps_native_error( + auth_service: AuthService, + auth_settings: AuthSettings, +): + """With external_token=None, an invalid native token still raises (no behavior change).""" + auth_settings.EXTERNAL_AUTH_ENABLED = True + auth_settings.EXTERNAL_AUTH_TRUSTED_JWT_DECODE = True + + with pytest.raises(InvalidTokenError): + await auth_service.authenticate_with_credentials( + token="not-a-valid-jwt", # noqa: S106 + api_key=None, + db=AsyncMock(), + external_token=None, + ) + + +@pytest.mark.anyio +async def test_external_token_only_authenticates_without_native_token( + auth_service: AuthService, + auth_settings: AuthSettings, + async_session, +): + """When no native token is present, the separately-extracted external token still works.""" + from langflow.services.auth.external import identity_from_claims + + auth_settings.EXTERNAL_AUTH_ENABLED = True + auth_settings.EXTERNAL_AUTH_TRUSTED_JWT_DECODE = True + auth_settings.EXTERNAL_AUTH_PROVIDER = "external" + + identity = identity_from_claims( + {"sub": "ext-subject-3", "preferred_username": "carol"}, + auth_settings, + ) + user = await auth_service._materialize_external_user(identity, async_session) + await async_session.flush() + + external_token = _external_jwt(auth_service, {"sub": "ext-subject-3", "preferred_username": "carol"}) + + try: + result = await auth_service.authenticate_with_credentials( + token=None, + api_key=None, + db=async_session, + external_token=external_token, + ) + finally: + clear_current_auth_context() + + assert result.id == user.id + + +# ============================================================================= +# P1: regular HTTP + /session external-credential shadowing +# get_current_user_from_access_token must fall back to a distinct external token +# when the native token is stale/invalid, and accept an external-only credential. +# ============================================================================= + + +@pytest.mark.anyio +async def test_access_token_path_falls_back_to_external_on_invalid_native( + auth_service: AuthService, + auth_settings: AuthSettings, + async_session, +): + """A stale/invalid native token plus a valid external credential recovers (P1).""" + from langflow.services.auth.external import identity_from_claims + + auth_settings.EXTERNAL_AUTH_ENABLED = True + auth_settings.EXTERNAL_AUTH_TRUSTED_JWT_DECODE = True + auth_settings.EXTERNAL_AUTH_PROVIDER = "external" + + identity = identity_from_claims( + {"sub": "ext-p1-1", "preferred_username": "dave"}, + auth_settings, + ) + user = await auth_service._materialize_external_user(identity, async_session) + await async_session.flush() + + external_token = _external_jwt(auth_service, {"sub": "ext-p1-1", "preferred_username": "dave"}) + + try: + result = await auth_service.get_current_user_from_access_token( + "not-a-valid-jwt", # native decode fails + async_session, + external_token=external_token, + ) + finally: + clear_current_auth_context() + + assert result.id == user.id + + +@pytest.mark.anyio +async def test_access_token_path_external_only_authenticates( + auth_service: AuthService, + auth_settings: AuthSettings, + async_session, +): + """No native token but a valid external credential authenticates via /session path (P1).""" + from langflow.services.auth.external import identity_from_claims + + auth_settings.EXTERNAL_AUTH_ENABLED = True + auth_settings.EXTERNAL_AUTH_TRUSTED_JWT_DECODE = True + auth_settings.EXTERNAL_AUTH_PROVIDER = "external" + + identity = identity_from_claims( + {"sub": "ext-p1-2", "preferred_username": "erin"}, + auth_settings, + ) + user = await auth_service._materialize_external_user(identity, async_session) + await async_session.flush() + + external_token = _external_jwt(auth_service, {"sub": "ext-p1-2", "preferred_username": "erin"}) + + try: + result = await auth_service.get_current_user_from_access_token( + None, + async_session, + external_token=external_token, + ) + finally: + clear_current_auth_context() + + assert result.id == user.id + + +@pytest.mark.anyio +async def test_access_token_path_no_external_keeps_native_error( + auth_service: AuthService, + auth_settings: AuthSettings, +): + """With external_token=None, an invalid native token still raises (no behavior change).""" + auth_settings.EXTERNAL_AUTH_ENABLED = True + auth_settings.EXTERNAL_AUTH_TRUSTED_JWT_DECODE = True + + with pytest.raises(InvalidTokenError): + await auth_service.get_current_user_from_access_token( + "not-a-valid-jwt", + AsyncMock(), + external_token=None, + ) + + +@pytest.mark.anyio +async def test_access_token_path_missing_native_token_still_raises( + auth_service: AuthService, +): + """A None native token with no external credential still raises MissingCredentialsError.""" + with pytest.raises(MissingCredentialsError): + await auth_service.get_current_user_from_access_token(None, AsyncMock()) + + +# ============================================================================= +# P2: the external-access ceiling ContextVar is cleared at every auth entrypoint. +# A stale ceiling left over from a prior same-task external auth must not leak +# into a subsequent non-external API-key auth path. +# ============================================================================= + + +@pytest.mark.anyio +async def test_api_key_entrypoint_clears_stale_external_access_ceiling( + auth_service: AuthService, + auth_settings: AuthSettings, +): + """_api_key_security_impl must clear a stale external-access ceiling ContextVar.""" + from langflow.services.auth.external import ( + ExternalAccessContext, + get_current_external_access_context, + set_current_external_access_context, + ) + + auth_settings.AUTO_LOGIN = False + user = _dummy_user(uuid4()) + + # Simulate a stale ceiling left in this task by a prior external auth. + set_current_external_access_context( + ExternalAccessContext(provider="external", subject="stale-subject", level="viewer") + ) + assert get_current_external_access_context() is not None + + try: + with patch( + "langflow.services.auth.service.authenticate_api_key", + new=AsyncMock( + return_value=ApiKeyAuthResult( + user=user, + api_key_source="db", # pragma: allowlist secret + api_key_id=uuid4(), + ) + ), + ): + result = await auth_service._api_key_security_impl( + query_param="sk-test-key", # pragma: allowlist secret + header_param=None, + db=AsyncMock(), + settings_service=auth_service.settings, + ) + + # The stale ceiling must have been cleared by the entrypoint. + assert get_current_external_access_context() is None + finally: + clear_current_auth_context() + set_current_external_access_context(None) + + assert result.id == user.id diff --git a/src/backend/tests/unit/services/auth/test_external_auth.py b/src/backend/tests/unit/services/auth/test_external_auth.py new file mode 100644 index 0000000000..5afcf7a354 --- /dev/null +++ b/src/backend/tests/unit/services/auth/test_external_auth.py @@ -0,0 +1,652 @@ +"""Unit tests for langflow.services.auth.external.""" + +from __future__ import annotations + +import base64 +import time +from datetime import datetime, timedelta, timezone + +import jwt +import pytest +from langflow.services.auth import external +from langflow.services.auth.exceptions import InvalidTokenError +from langflow.services.auth.external import ( + access_context_from_identity, + decode_external_jwt, + external_access_allows, + extract_bearer_or_raw_token, + extract_external_token, + filter_actions_by_external_access_ceiling, + get_current_external_access_context, + identity_from_claims, + resolve_external_identity, + set_current_external_access_context, +) +from lfx.services.settings.auth import AuthSettings +from pydantic import ValidationError + +_TEST_JWT_SECRET = "external-test-secret-with-enough-length" # noqa: S105 # pragma: allowlist secret +_EXTERNAL_AUTH_HEADER = "X-External-Auth" +_EXTERNAL_AUTH_COOKIE = "external-session" +_OPAQUE_CREDENTIAL = "opaque-token" # pragma: allowlist secret +_HEADER_CREDENTIAL = "header-token" # pragma: allowlist secret + + +def _auth_settings(tmp_path, **overrides) -> AuthSettings: + settings = AuthSettings(CONFIG_DIR=str(tmp_path)) + settings.EXTERNAL_AUTH_ENABLED = True + settings.EXTERNAL_AUTH_PROVIDER = "test-provider" + settings.EXTERNAL_AUTH_TRUSTED_JWT_DECODE = True + settings.EXTERNAL_AUTH_TOKEN_HEADER = _EXTERNAL_AUTH_HEADER + settings.EXTERNAL_AUTH_TOKEN_COOKIE = _EXTERNAL_AUTH_COOKIE + for key, value in overrides.items(): + setattr(settings, key, value) + return settings + + +# --------------------------------------------------------------------------- +# extract_external_token / extract_bearer_or_raw_token +# --------------------------------------------------------------------------- + + +def test_extract_external_token_prefers_header_over_cookie(tmp_path): + settings = _auth_settings(tmp_path) + token = extract_external_token( + {_EXTERNAL_AUTH_HEADER: f"Bearer {_HEADER_CREDENTIAL}"}, + {_EXTERNAL_AUTH_COOKIE: "cookie-token"}, + settings, + ) + assert token == _HEADER_CREDENTIAL + + +def test_extract_external_token_returns_none_when_disabled(tmp_path): + settings = _auth_settings(tmp_path, EXTERNAL_AUTH_ENABLED=False) + assert extract_external_token({_EXTERNAL_AUTH_HEADER: "Bearer x"}, {}, settings) is None + + +def test_extract_external_token_falls_back_to_cookie(tmp_path): + settings = _auth_settings(tmp_path) + cookie_value = "cookie-token" # pragma: allowlist secret + token = extract_external_token({}, {_EXTERNAL_AUTH_COOKIE: cookie_value}, settings) + assert token == cookie_value + + +def test_extract_bearer_or_raw_token_handles_variants(): + assert extract_bearer_or_raw_token(None) is None + assert extract_bearer_or_raw_token("") is None + assert extract_bearer_or_raw_token(" ") is None + assert extract_bearer_or_raw_token("Bearer abc") == "abc" + assert extract_bearer_or_raw_token("bearer abc") == "abc" + assert extract_bearer_or_raw_token("opaque") == "opaque" + + +# --------------------------------------------------------------------------- +# decode_external_jwt (trusted-decode path) +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_trusted_jwt_decode_validates_expiry(tmp_path): + settings = _auth_settings(tmp_path) + token = jwt.encode( + {"sub": "expired-subject", "exp": datetime.now(timezone.utc) - timedelta(minutes=1)}, + _TEST_JWT_SECRET, + algorithm="HS256", + ) + with pytest.raises(InvalidTokenError, match="expired"): + await decode_external_jwt(token, settings) + + +@pytest.mark.anyio +async def test_trusted_jwt_decode_validates_not_before(tmp_path): + settings = _auth_settings(tmp_path) + token = jwt.encode( + { + "sub": "future-subject", + "nbf": datetime.now(timezone.utc) + timedelta(minutes=5), + "exp": datetime.now(timezone.utc) + timedelta(minutes=10), + }, + _TEST_JWT_SECRET, + algorithm="HS256", + ) + with pytest.raises(InvalidTokenError, match="not valid yet"): + await decode_external_jwt(token, settings) + + +@pytest.mark.anyio +async def test_trusted_jwt_decode_returns_claims_when_valid(tmp_path): + settings = _auth_settings(tmp_path) + expected_sub = "subject-1" + token = jwt.encode( + { + "sub": expected_sub, + "preferred_username": "alice", + "email": "alice@example.com", + "exp": datetime.now(timezone.utc) + timedelta(minutes=5), + }, + _TEST_JWT_SECRET, + algorithm="HS256", + ) + claims = await decode_external_jwt(token, settings) + assert claims["sub"] == expected_sub + assert claims["preferred_username"] == "alice" + + +@pytest.mark.anyio +async def test_decode_external_jwt_rejects_when_external_auth_disabled(tmp_path): + settings = _auth_settings(tmp_path, EXTERNAL_AUTH_ENABLED=False) + with pytest.raises(InvalidTokenError, match="not enabled"): + await decode_external_jwt("anything", settings) + + +@pytest.mark.anyio +async def test_decode_external_jwt_requires_jwks_when_not_trusted(tmp_path): + settings = _auth_settings( + tmp_path, + EXTERNAL_AUTH_TRUSTED_JWT_DECODE=False, + EXTERNAL_AUTH_JWKS_URL=None, + ) + with pytest.raises(InvalidTokenError, match="EXTERNAL_AUTH_JWKS_URL"): + await decode_external_jwt("anything", settings) + + +# --------------------------------------------------------------------------- +# decode_external_jwt (JWKS path) +# --------------------------------------------------------------------------- + +_JWKS_URL = "https://idp.example.com/.well-known/jwks.json" +_JWKS_AUDIENCE = "langflow" + + +def _jwks_settings(tmp_path) -> AuthSettings: + return _auth_settings( + tmp_path, + EXTERNAL_AUTH_TRUSTED_JWT_DECODE=False, + EXTERNAL_AUTH_JWKS_URL=_JWKS_URL, + EXTERNAL_AUTH_ALGORITHMS="HS256", + EXTERNAL_AUTH_AUDIENCE=_JWKS_AUDIENCE, + ) + + +def _symmetric_jwk(secret: str, kid: str) -> dict: + return { + "kty": "oct", + "alg": "HS256", + "kid": kid, + "k": base64.urlsafe_b64encode(secret.encode()).rstrip(b"=").decode(), + } + + +def _install_fake_jwks_endpoint(monkeypatch, payloads: list[dict]) -> list[str]: + """Serve payloads[i] for the i-th JWKS fetch (last payload repeats); returns the call log.""" + calls: list[str] = [] + + class _FakeResponse: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self._payload + + class _FakeAsyncClient: + def __init__(self, **_kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + async def get(self, url): + calls.append(url) + return _FakeResponse(payloads[min(len(calls) - 1, len(payloads) - 1)]) + + monkeypatch.setattr(external.httpx, "AsyncClient", _FakeAsyncClient) + return calls + + +@pytest.mark.anyio +async def test_jwks_decode_verifies_signature(tmp_path, monkeypatch): + settings = _jwks_settings(tmp_path) + monkeypatch.setattr(external, "_jwks_cache", {}) + _install_fake_jwks_endpoint(monkeypatch, [{"keys": [_symmetric_jwk(_TEST_JWT_SECRET, kid="key-1")]}]) + + valid_exp = datetime.now(timezone.utc) + timedelta(minutes=5) + token = jwt.encode( + {"sub": "subject-1", "aud": _JWKS_AUDIENCE, "exp": valid_exp}, + _TEST_JWT_SECRET, + algorithm="HS256", + headers={"kid": "key-1"}, + ) + claims = await decode_external_jwt(token, settings) + assert claims["sub"] == "subject-1" + + wrong_secret = "another-secret-that-did-not-sign-the-jwks-key" # noqa: S105 # pragma: allowlist secret + tampered = jwt.encode( + {"sub": "subject-1", "aud": _JWKS_AUDIENCE, "exp": valid_exp}, + wrong_secret, + algorithm="HS256", + headers={"kid": "key-1"}, + ) + with pytest.raises(InvalidTokenError, match="validation failed"): + await decode_external_jwt(tampered, settings) + + +@pytest.mark.anyio +async def test_jwks_decode_requires_audience(tmp_path, monkeypatch): + """JWKS verification must reject the config when no expected audience is bound.""" + settings = _jwks_settings(tmp_path) + settings.EXTERNAL_AUTH_AUDIENCE = None + monkeypatch.setattr(external, "_jwks_cache", {}) + calls = _install_fake_jwks_endpoint(monkeypatch, [{"keys": [_symmetric_jwk(_TEST_JWT_SECRET, kid="key-1")]}]) + + token = jwt.encode({"sub": "subject-1"}, _TEST_JWT_SECRET, algorithm="HS256", headers={"kid": "key-1"}) + with pytest.raises(InvalidTokenError, match="EXTERNAL_AUTH_AUDIENCE"): + await decode_external_jwt(token, settings) + # Misconfiguration is rejected before any network call to the JWKS endpoint. + assert calls == [] + + +@pytest.mark.anyio +async def test_jwks_decode_rejects_token_for_another_audience(tmp_path, monkeypatch): + """A token the same IdP minted for a different relying party is rejected.""" + settings = _jwks_settings(tmp_path) + monkeypatch.setattr(external, "_jwks_cache", {}) + _install_fake_jwks_endpoint(monkeypatch, [{"keys": [_symmetric_jwk(_TEST_JWT_SECRET, kid="key-1")]}]) + + foreign = jwt.encode( + {"sub": "subject-1", "aud": "some-other-service", "exp": datetime.now(timezone.utc) + timedelta(minutes=5)}, + _TEST_JWT_SECRET, + algorithm="HS256", + headers={"kid": "key-1"}, + ) + with pytest.raises(InvalidTokenError, match="validation failed"): + await decode_external_jwt(foreign, settings) + + +@pytest.mark.anyio +async def test_jwks_refetches_when_kid_is_newer_than_cache(tmp_path, monkeypatch): + """IdP key rotation: an unknown kid forces one cache-busting JWKS refetch.""" + settings = _jwks_settings(tmp_path) + old_jwks = {"keys": [_symmetric_jwk(_TEST_JWT_SECRET, kid="old-key")]} + new_jwks = {"keys": [_symmetric_jwk(_TEST_JWT_SECRET, kid="new-key")]} + # Cached entry is still valid but old enough to be past the refresh rate limit. + fetched_at = time.monotonic() - external.JWKS_MIN_REFRESH_INTERVAL_SECONDS - 1 + monkeypatch.setattr(external, "_jwks_cache", {_JWKS_URL: (fetched_at + external.JWKS_CACHE_TTL_SECONDS, old_jwks)}) + calls = _install_fake_jwks_endpoint(monkeypatch, [new_jwks]) + + token = jwt.encode( + {"sub": "rotated", "aud": _JWKS_AUDIENCE, "exp": datetime.now(timezone.utc) + timedelta(minutes=5)}, + _TEST_JWT_SECRET, + algorithm="HS256", + headers={"kid": "new-key"}, + ) + claims = await decode_external_jwt(token, settings) + + assert claims["sub"] == "rotated" + assert calls == [_JWKS_URL] + + +@pytest.mark.anyio +async def test_jwks_refresh_is_rate_limited_for_unknown_kid(tmp_path, monkeypatch): + """A freshly fetched JWKS is not refetched for an unknown kid (anti-hammering).""" + settings = _jwks_settings(tmp_path) + jwks = {"keys": [_symmetric_jwk(_TEST_JWT_SECRET, kid="key-1")]} + monkeypatch.setattr( + external, "_jwks_cache", {_JWKS_URL: (time.monotonic() + external.JWKS_CACHE_TTL_SECONDS, jwks)} + ) + calls = _install_fake_jwks_endpoint(monkeypatch, [jwks]) + + token = jwt.encode({"sub": "nope"}, _TEST_JWT_SECRET, algorithm="HS256", headers={"kid": "unknown-key"}) + with pytest.raises(InvalidTokenError, match="not found in JWKS"): + await decode_external_jwt(token, settings) + + assert calls == [] + + +# --------------------------------------------------------------------------- +# identity_from_claims / resolve_external_identity +# --------------------------------------------------------------------------- + + +def test_identity_from_claims_uses_configured_mapping(tmp_path): + settings = _auth_settings(tmp_path) + identity = identity_from_claims( + { + "sub": "subject-1", + "preferred_username": "alice", + "email": "alice@example.com", + "name": "Alice", + }, + settings, + ) + assert identity.provider == "test-provider" + assert identity.subject == "subject-1" + assert identity.username == "alice" + assert identity.email == "alice@example.com" + assert identity.name == "Alice" + + +def test_identity_from_claims_requires_subject(tmp_path): + settings = _auth_settings(tmp_path) + with pytest.raises(InvalidTokenError, match="missing required claim"): + identity_from_claims({"preferred_username": "no-sub"}, settings) + + +def test_identity_from_claims_falls_back_to_synthesized_username(tmp_path): + settings = _auth_settings(tmp_path) + identity = identity_from_claims({"sub": "subject-9"}, settings) + assert identity.username.startswith("test-provider-") + assert identity.email is None + assert identity.name is None + + +def test_external_access_context_uses_configured_claim_mapping(tmp_path): + settings = _auth_settings( + tmp_path, + EXTERNAL_AUTH_ACCESS_CEILING_ENABLED=True, + EXTERNAL_AUTH_ACCESS_CLAIM="openrag_mode", + EXTERNAL_AUTH_ACCESS_CLAIM_MAPPING='{"can_view":"viewer","can_edit":"editor"}', + ) + identity = identity_from_claims({"sub": "subject-1", "openrag_mode": "can_edit"}, settings) + + context = access_context_from_identity(identity, settings) + + assert context is not None + assert context.level == "editor" + assert external_access_allows("write", context) + assert external_access_allows("execute", context) + # An editor may delete their own resources; only ``deploy`` stays admin-only. + assert external_access_allows("delete", context) + assert not external_access_allows("deploy", context) + + +def test_external_access_context_defaults_to_viewer_for_missing_claim(tmp_path): + settings = _auth_settings( + tmp_path, + EXTERNAL_AUTH_ACCESS_CEILING_ENABLED=True, + EXTERNAL_AUTH_ACCESS_CLAIM="openrag_mode", + ) + identity = identity_from_claims({"sub": "subject-1"}, settings) + + context = access_context_from_identity(identity, settings) + + assert context is not None + assert context.level == "viewer" + assert external_access_allows("read", context) + assert not external_access_allows("write", context) + + +def test_filter_actions_by_external_access_ceiling_uses_request_context(): + set_current_external_access_context( + external.ExternalAccessContext(provider="test-provider", subject="subject-1", level="viewer") + ) + try: + assert filter_actions_by_external_access_ceiling(["read", "write", "delete"]) == ["read"] + assert get_current_external_access_context() is not None + finally: + set_current_external_access_context(None) + + +@pytest.mark.anyio +async def test_resolve_external_identity_uses_configured_resolver(tmp_path, monkeypatch): + settings = _auth_settings(tmp_path) + settings.EXTERNAL_AUTH_IDENTITY_RESOLVER = "tests.fake:resolver" + + async def resolver(token, auth_settings): + assert token == _OPAQUE_CREDENTIAL + assert auth_settings is settings + return { + "sub": "opaque-subject", + "preferred_username": "opaque-user", + "email": "opaque@example.com", + "name": "Opaque User", + } + + from lfx.services import config_discovery + + def load_resolver(import_path, *, object_kind, object_key): + assert import_path == settings.EXTERNAL_AUTH_IDENTITY_RESOLVER + assert object_kind == "external auth resolver" + assert object_key == "EXTERNAL_AUTH_IDENTITY_RESOLVER" + return resolver + + monkeypatch.setattr(config_discovery, "load_object_from_import_path", load_resolver) + + identity = await resolve_external_identity(_OPAQUE_CREDENTIAL, settings) + + assert identity.provider == "test-provider" + assert identity.subject == "opaque-subject" + assert identity.username == "opaque-user" + assert identity.email == "opaque@example.com" + assert identity.name == "Opaque User" + + +@pytest.mark.anyio +async def test_resolve_external_identity_default_uses_jwt(tmp_path): + settings = _auth_settings(tmp_path) + token = jwt.encode( + { + "sub": "subject-1", + "preferred_username": "bob", + "exp": datetime.now(timezone.utc) + timedelta(minutes=5), + }, + _TEST_JWT_SECRET, + algorithm="HS256", + ) + identity = await resolve_external_identity(token, settings) + assert identity.subject == "subject-1" + assert identity.username == "bob" + + +# --------------------------------------------------------------------------- +# F7: exp is required on both decode paths (a token without exp never expires) +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_trusted_jwt_decode_rejects_missing_exp(tmp_path): + """Trusted-decode path must reject a token that omits exp (never expires).""" + settings = _auth_settings(tmp_path) + token = jwt.encode({"sub": "no-exp-subject"}, _TEST_JWT_SECRET, algorithm="HS256") + with pytest.raises(InvalidTokenError, match="missing exp"): + await decode_external_jwt(token, settings) + + +@pytest.mark.anyio +async def test_jwks_decode_rejects_missing_exp(tmp_path, monkeypatch): + """JWKS path must reject a validly-signed token that omits exp.""" + settings = _jwks_settings(tmp_path) + monkeypatch.setattr(external, "_jwks_cache", {}) + _install_fake_jwks_endpoint(monkeypatch, [{"keys": [_symmetric_jwk(_TEST_JWT_SECRET, kid="key-1")]}]) + + # Validly signed, correct audience, but no exp claim. + token = jwt.encode( + {"sub": "subject-1", "aud": _JWKS_AUDIENCE}, + _TEST_JWT_SECRET, + algorithm="HS256", + headers={"kid": "key-1"}, + ) + with pytest.raises(InvalidTokenError, match="validation failed"): + await decode_external_jwt(token, settings) + + +# --------------------------------------------------------------------------- +# F8: EXTERNAL_AUTH_JWKS_URL must be https (http allowed only for loopback) +# --------------------------------------------------------------------------- + + +def test_jwks_url_rejects_http_scheme(tmp_path): + """An http:// JWKS URL is rejected (MITM could swap signing keys).""" + with pytest.raises(ValidationError, match="https"): + AuthSettings( + CONFIG_DIR=str(tmp_path), + EXTERNAL_AUTH_JWKS_URL="http://idp.example.com/.well-known/jwks.json", + ) + + +def test_jwks_url_accepts_https_scheme(tmp_path): + """An https:// JWKS URL is accepted.""" + settings = AuthSettings( + CONFIG_DIR=str(tmp_path), + EXTERNAL_AUTH_JWKS_URL="https://idp.example.com/.well-known/jwks.json", + ) + assert settings.EXTERNAL_AUTH_JWKS_URL == "https://idp.example.com/.well-known/jwks.json" + + +def test_jwks_url_allows_http_for_localhost(tmp_path): + """http:// is permitted for loopback hosts to keep local development usable.""" + for url in ( + "http://localhost:8080/jwks.json", + "http://127.0.0.1:8080/jwks.json", + "http://[::1]:8080/jwks.json", + ): + settings = AuthSettings(CONFIG_DIR=str(tmp_path), EXTERNAL_AUTH_JWKS_URL=url) + assert url == settings.EXTERNAL_AUTH_JWKS_URL + + +def test_jwks_url_none_is_allowed(tmp_path): + """JWKS URL is optional (None / empty) so the validator must not reject it.""" + settings = AuthSettings(CONFIG_DIR=str(tmp_path), EXTERNAL_AUTH_JWKS_URL=None) + assert settings.EXTERNAL_AUTH_JWKS_URL is None + + +@pytest.mark.anyio +async def test_fetch_jwks_guard_rejects_non_https(monkeypatch): + """Belt-and-suspenders: _fetch_jwks itself refuses a non-https URL.""" + monkeypatch.setattr(external, "_jwks_cache", {}) + calls = _install_fake_jwks_endpoint(monkeypatch, [{"keys": []}]) + with pytest.raises(InvalidTokenError, match="https"): + await external._fetch_jwks("http://idp.example.com/jwks.json") + # No network call is made for a rejected scheme. + assert calls == [] + + +# --------------------------------------------------------------------------- +# F11: EXTERNAL_AUTH_PROVIDER normalizes once at the config boundary +# --------------------------------------------------------------------------- + + +def test_external_auth_provider_empty_normalizes_to_external(tmp_path): + settings = AuthSettings(CONFIG_DIR=str(tmp_path), EXTERNAL_AUTH_PROVIDER="") + assert settings.EXTERNAL_AUTH_PROVIDER == "external" + + +def test_external_auth_provider_whitespace_normalizes_to_external(tmp_path): + settings = AuthSettings(CONFIG_DIR=str(tmp_path), EXTERNAL_AUTH_PROVIDER=" ") + assert settings.EXTERNAL_AUTH_PROVIDER == "external" + + +def test_external_auth_provider_strips_whitespace(tmp_path): + settings = AuthSettings(CONFIG_DIR=str(tmp_path), EXTERNAL_AUTH_PROVIDER=" okta ") + assert settings.EXTERNAL_AUTH_PROVIDER == "okta" + + +def test_external_auth_provider_normalizes_on_assignment(tmp_path): + """validate_assignment=True means setattr must normalize too (the test helper path).""" + settings = AuthSettings(CONFIG_DIR=str(tmp_path)) + settings.EXTERNAL_AUTH_PROVIDER = " " + assert settings.EXTERNAL_AUTH_PROVIDER == "external" + + +# --------------------------------------------------------------------------- +# F12: a configured access-claim mapping is authoritative (no alias fallthrough) +# --------------------------------------------------------------------------- + + +def test_access_mapping_unmapped_value_does_not_elevate_via_aliases(tmp_path): + """Unmapped value falls to the default, not the built-in alias table. + + With a non-empty mapping, a claim value absent from it falls to the default + (viewer) instead of being reinterpreted through the built-in alias table. + """ + settings = _auth_settings( + tmp_path, + EXTERNAL_AUTH_ACCESS_CEILING_ENABLED=True, + EXTERNAL_AUTH_ACCESS_CLAIM="role", + EXTERNAL_AUTH_ACCESS_CLAIM_MAPPING='{"can_view":"viewer","can_edit":"editor"}', + ) + # Raw "admin" is an alias for the admin level, but it is NOT a key in the + # configured mapping, so it must NOT elevate. + identity = identity_from_claims({"sub": "subject-1", "role": "admin"}, settings) + + context = access_context_from_identity(identity, settings) + + assert context is not None + assert context.level == "viewer" # default, not admin + assert external_access_allows("read", context) + assert not external_access_allows("write", context) + assert not external_access_allows("delete", context) + + +def test_access_mapping_unmapped_value_uses_configured_default(tmp_path): + """The configured default level is applied for an unmapped claim value. + + The configured default level (not the viewer floor) is applied for an + unmapped claim value when a mapping is present. + """ + settings = _auth_settings( + tmp_path, + EXTERNAL_AUTH_ACCESS_CEILING_ENABLED=True, + EXTERNAL_AUTH_ACCESS_CLAIM="role", + EXTERNAL_AUTH_ACCESS_CLAIM_MAPPING='{"can_edit":"editor"}', + EXTERNAL_AUTH_DEFAULT_ACCESS_LEVEL="editor", + ) + identity = identity_from_claims({"sub": "subject-1", "role": "admin"}, settings) + + context = access_context_from_identity(identity, settings) + + assert context is not None + assert context.level == "editor" + + +def test_no_mapping_still_interprets_raw_claim_via_aliases(tmp_path): + """With no mapping configured, built-in aliases still apply to the raw claim. + + When NO mapping is configured the built-in aliases still apply to the raw + claim value, so a raw 'admin' claim maps to the admin level. + """ + settings = _auth_settings( + tmp_path, + EXTERNAL_AUTH_ACCESS_CEILING_ENABLED=True, + EXTERNAL_AUTH_ACCESS_CLAIM="role", + ) + identity = identity_from_claims({"sub": "subject-1", "role": "admin"}, settings) + + context = access_context_from_identity(identity, settings) + + assert context is not None + assert context.level == "admin" + assert external_access_allows("delete", context) + + +def test_configured_but_all_invalid_mapping_does_not_elevate_via_aliases(tmp_path): + """A configured-but-all-invalid mapping must still suppress alias fallthrough. + + If an operator configures EXTERNAL_AUTH_ACCESS_CLAIM_MAPPING but every entry + is invalid/typo'd, the parsed dict is empty. Gating the alias fallthrough on + the parsed dict (``not mapping``) would treat this as "no mapping" and let a + raw "admin" claim self-elevate through the built-in alias table, re-opening + the hole F12 closes. The gate is on the RAW setting being configured, so an + unmapped claim must fall to the default (viewer floor) instead. + """ + settings = _auth_settings( + tmp_path, + EXTERNAL_AUTH_ACCESS_CEILING_ENABLED=True, + EXTERNAL_AUTH_ACCESS_CLAIM="role", + # Every value is an unrecognized level -> _access_claim_mapping returns {}. + EXTERNAL_AUTH_ACCESS_CLAIM_MAPPING='{"can_view":"bogus","can_edit":"typo"}', + ) + identity = identity_from_claims({"sub": "subject-1", "role": "admin"}, settings) + + context = access_context_from_identity(identity, settings) + + assert context is not None + assert context.level == "viewer" # default/floor, NOT admin + assert external_access_allows("read", context) + assert not external_access_allows("write", context) + assert not external_access_allows("delete", context) diff --git a/src/backend/tests/unit/services/auth/test_pluggable_auth.py b/src/backend/tests/unit/services/auth/test_pluggable_auth.py index 29d9bd7023..68f3ca666d 100644 --- a/src/backend/tests/unit/services/auth/test_pluggable_auth.py +++ b/src/backend/tests/unit/services/auth/test_pluggable_auth.py @@ -24,8 +24,8 @@ class DummyAuthService(Service): self.calls.append(call) return {"call": call} - async def get_current_user(self, token, query_param, header_param, db): - call = ("get_current_user", token, query_param, header_param) + async def get_current_user(self, token, query_param, header_param, db, external_token=None): + call = ("get_current_user", token, query_param, header_param, external_token) self.calls.append(call) return {"user": "dummy", "db": db} @@ -77,8 +77,12 @@ async def test_api_key_security_uses_registered_service(dummy_auth_registration) async def test_get_current_user_delegates_to_service(dummy_auth_registration): dummy = dummy_auth_registration db = MagicMock(spec=AsyncSession) - response = await auth_utils.get_current_user(token=None, query_param="q", header_param=None, db=db) + # get_current_user now extracts the external credential from the request and + # threads it to the service as external_token. With external auth disabled the + # extractor returns None, so delegation records external_token=None. + request = SimpleNamespace(headers={}, cookies={}) + response = await auth_utils.get_current_user(request, token=None, query_param="q", header_param=None, db=db) - assert ("get_current_user", None, "q", None) in dummy.calls + assert ("get_current_user", None, "q", None, None) in dummy.calls assert response["user"] == "dummy" assert response["db"] is db diff --git a/src/backend/tests/unit/services/authorization/_common.py b/src/backend/tests/unit/services/authorization/_common.py index 6953706119..139002f972 100644 --- a/src/backend/tests/unit/services/authorization/_common.py +++ b/src/backend/tests/unit/services/authorization/_common.py @@ -28,9 +28,16 @@ from langflow.services.authorization import listing as authz_listing class _StubAuthorizationService: """Minimal stand-in for BaseAuthorizationService that records calls.""" - def __init__(self, *, allow: bool = True, batch_results: list[bool] | None = None) -> None: + def __init__( + self, + *, + allow: bool = True, + batch_results: list[bool] | None = None, + supports_api_key_scopes: bool = False, + ) -> None: self.allow = allow self.batch_results = batch_results + self._supports_api_key_scopes = supports_api_key_scopes self.calls: list[dict] = [] self.batch_calls: list[dict] = [] @@ -44,6 +51,9 @@ class _StubAuthorizationService: return self.batch_results return [self.allow] * len(kwargs.get("requests", [])) + async def supports_api_key_scopes(self) -> bool: + return self._supports_api_key_scopes + def install_settings(monkeypatch, *, authz_enabled: bool, audit_enabled: bool = False) -> None: """Patch settings on every module that calls ``get_settings_service``.""" diff --git a/src/backend/tests/unit/services/authorization/test_access_ceiling_levels.py b/src/backend/tests/unit/services/authorization/test_access_ceiling_levels.py new file mode 100644 index 0000000000..52d01f892a --- /dev/null +++ b/src/backend/tests/unit/services/authorization/test_access_ceiling_levels.py @@ -0,0 +1,65 @@ +"""Regression tests for the external access-ceiling action vocabulary. + +These guard the deny-only ceiling levels enforced by +``langflow.services.authorization.access_ceiling`` (see PR #13293). The +editor level is expected to permit ``delete`` (a user editing their own +resources should be able to remove them) while ``deploy`` stays admin-only. +""" + +from __future__ import annotations + +import pytest +from langflow.services.authorization.access_ceiling import ( + ExternalAccessContext, + external_access_allows, + filter_actions_by_external_access_ceiling, + set_current_external_access_context, +) + +_ALL_ACTIONS = ("read", "write", "create", "delete", "execute", "ingest", "deploy") + + +def _ctx(level: str) -> ExternalAccessContext: + return ExternalAccessContext(provider="openrag", subject="subject-1", level=level) + + +@pytest.mark.parametrize("action", ["read"]) +def test_viewer_ceiling_allows_read(action: str) -> None: + assert external_access_allows(action, _ctx("viewer")) is True + + +@pytest.mark.parametrize("action", ["write", "create", "delete", "execute", "ingest", "deploy"]) +def test_viewer_ceiling_denies_non_read(action: str) -> None: + assert external_access_allows(action, _ctx("viewer")) is False + + +@pytest.mark.parametrize("action", ["read", "write", "create", "delete", "execute", "ingest"]) +def test_editor_ceiling_allows_crud_and_execute(action: str) -> None: + # ``delete`` is included deliberately (regression for PR #13293): an editor + # may remove their own resources. + assert external_access_allows(action, _ctx("editor")) is True + + +def test_editor_ceiling_denies_deploy() -> None: + # Deploy is intentionally reserved for admin. + assert external_access_allows("deploy", _ctx("editor")) is False + + +@pytest.mark.parametrize("action", _ALL_ACTIONS) +def test_admin_ceiling_allows_everything(action: str) -> None: + assert external_access_allows(action, _ctx("admin")) is True + + +@pytest.mark.parametrize("action", _ALL_ACTIONS) +def test_no_ceiling_allows_everything(action: str) -> None: + """When no external ceiling is installed, every action is permitted.""" + assert external_access_allows(action, None) is True + + +def test_filter_actions_through_editor_ceiling_keeps_delete_drops_deploy() -> None: + set_current_external_access_context(_ctx("editor")) + try: + kept = filter_actions_by_external_access_ceiling(["read", "delete", "deploy"]) + finally: + set_current_external_access_context(None) + assert kept == ["read", "delete"] diff --git a/src/backend/tests/unit/services/authorization/test_capability_flag.py b/src/backend/tests/unit/services/authorization/test_capability_flag.py index 66f5fb6586..28f72cfd5a 100644 --- a/src/backend/tests/unit/services/authorization/test_capability_flag.py +++ b/src/backend/tests/unit/services/authorization/test_capability_flag.py @@ -1,4 +1,4 @@ -"""Tests for the cross-user-fetch capability flag.""" +"""Tests for authorization service capability flags.""" from __future__ import annotations @@ -23,6 +23,7 @@ def _settings(*, authz_enabled: bool = False) -> SimpleNamespace: async def test_base_class_default_is_false(): """The class-level constant defaults False so subclasses must opt in.""" assert BaseAuthorizationService.SUPPORTS_CROSS_USER_FETCH is False + assert BaseAuthorizationService.SUPPORTS_API_KEY_SCOPES is False @pytest.mark.anyio @@ -30,6 +31,7 @@ async def test_lfx_default_service_does_not_support_cross_user_fetch(): """The lfx no-op service inherits the safe default.""" service = LfxDefaultService() assert await service.supports_cross_user_fetch() is False + assert await service.supports_api_key_scopes() is False @pytest.mark.anyio @@ -37,14 +39,17 @@ async def test_langflow_pass_through_does_not_support_cross_user_fetch(): """OSS pass-through must NOT opt in — that is the strict-pass-through contract.""" service = LangflowAuthorizationService(_settings()) assert await service.supports_cross_user_fetch() is False + assert await service.supports_api_key_scopes() is False @pytest.mark.anyio async def test_subclass_can_opt_in(): - """Authorization plugins flip ``SUPPORTS_CROSS_USER_FETCH=True``; the base accepts it.""" + """Authorization plugins flip capability constants; the base accepts them.""" class _Plugin(LangflowAuthorizationService): SUPPORTS_CROSS_USER_FETCH = True + SUPPORTS_API_KEY_SCOPES = True service = _Plugin(_settings()) assert await service.supports_cross_user_fetch() is True + assert await service.supports_api_key_scopes() is True diff --git a/src/backend/tests/unit/services/authorization/test_filter_visible.py b/src/backend/tests/unit/services/authorization/test_filter_visible.py index 14b4adc0c2..9d4dc55c69 100644 --- a/src/backend/tests/unit/services/authorization/test_filter_visible.py +++ b/src/backend/tests/unit/services/authorization/test_filter_visible.py @@ -6,6 +6,12 @@ from types import SimpleNamespace from uuid import uuid4 import pytest +from langflow.services.auth.context import ( + AUTH_METHOD_API_KEY, + AuthCredentialContext, + clear_current_auth_context, + set_current_auth_context, +) from langflow.services.authorization import guards as authz_guards from langflow.services.authorization import listing as authz_listing from langflow.services.authorization.actions import FlowAction @@ -170,3 +176,41 @@ async def test_filter_visible_resources_owner_override_skips_enforcer(monkeypatc # Enforcer was consulted only for the non-owned item. assert len(service.batch_calls) == 1 assert len(service.batch_calls[0]["requests"]) == 1 + + +@pytest.mark.anyio +async def test_filter_visible_resources_api_key_scope_plugin_evaluates_owned_rows(monkeypatch, fake_user): + """API-key scope plugins can filter owned rows instead of owner-skipping them.""" + install_settings(monkeypatch, authz_enabled=True) + items = [ + SimpleNamespace(id=uuid4(), user_id=fake_user.id), + SimpleNamespace(id=uuid4(), user_id=fake_user.id), + ] + service = _StubAuthorizationService(batch_results=[True, False], supports_api_key_scopes=True) + install_authz(monkeypatch, service) + set_current_auth_context( + AuthCredentialContext( + method=AUTH_METHOD_API_KEY, + api_key_id=uuid4(), + api_key_source="db", # pragma: allowlist secret + ) + ) + + try: + result = await authz_listing.filter_visible_resources( + fake_user, + resource_type="flow", + candidates=items, + owner_extractor=lambda item: item.user_id, + act=FlowAction.READ, + ) + finally: + clear_current_auth_context() + + assert result == [items[0]] + assert len(service.batch_calls) == 1 + assert service.batch_calls[0]["requests"] == [ + (f"flow:{items[0].id}", "read"), + (f"flow:{items[1].id}", "read"), + ] + assert service.batch_calls[0]["context"]["auth_method"] == "api_key" diff --git a/src/backend/tests/unit/services/authorization/test_guards.py b/src/backend/tests/unit/services/authorization/test_guards.py index 5f85f5a399..5f3843e7f0 100644 --- a/src/backend/tests/unit/services/authorization/test_guards.py +++ b/src/backend/tests/unit/services/authorization/test_guards.py @@ -6,7 +6,17 @@ from uuid import uuid4 import pytest from fastapi import HTTPException +from langflow.services.auth.context import ( + AUTH_METHOD_API_KEY, + AuthCredentialContext, + clear_current_auth_context, + set_current_auth_context, +) from langflow.services.authorization import guards as authz_guards +from langflow.services.authorization.access_ceiling import ( + ExternalAccessContext, + set_current_external_access_context, +) from langflow.services.authorization.actions import ( DeploymentAction, FileAction, @@ -161,6 +171,35 @@ async def test_ensure_permission_writes_audit_on_deny(monkeypatch, fake_user): assert audit_calls[0]["result"] == "deny" +@pytest.mark.anyio +async def test_ensure_permission_forwards_auth_context(monkeypatch, fake_user): + """Credential metadata is available to plugins and audit without route signature churn.""" + install_settings(monkeypatch, authz_enabled=True) + service = _StubAuthorizationService(allow=True) + install_authz(monkeypatch, service) + audit_calls = install_audit_recorder(monkeypatch) + api_key_id = uuid4() + set_current_auth_context( + AuthCredentialContext( + method=AUTH_METHOD_API_KEY, + api_key_id=api_key_id, + api_key_source="db", # pragma: allowlist secret + ) + ) + + try: + await authz_guards.ensure_permission(fake_user, domain="*", obj="flow:abc", act="read") + finally: + clear_current_auth_context() + + forwarded_context = service.calls[0]["context"] + assert forwarded_context["auth_method"] == "api_key" + assert forwarded_context["api_key_id"] == api_key_id + assert forwarded_context["api_key_source"] == "db" # pragma: allowlist secret + assert audit_calls[0]["details"]["auth_method"] == "api_key" + assert audit_calls[0]["details"]["api_key_id"] == str(api_key_id) + + # ----------------------------------------------------------------------------- # # ensure_flow_permission — enum coercion, domain, owner override # ----------------------------------------------------------------------------- # @@ -294,6 +333,88 @@ async def test_owner_override_skips_enforce(monkeypatch, fake_user): assert audit_calls[0]["result"] == "owner_override" +@pytest.mark.anyio +async def test_api_key_scope_plugin_sees_owner_resource_instead_of_owner_override(monkeypatch, fake_user): + """API-key scopes can be narrower than the owning user when a plugin opts in.""" + install_settings(monkeypatch, authz_enabled=True) + service = _StubAuthorizationService(allow=False, supports_api_key_scopes=True) + install_authz(monkeypatch, service) + install_audit_recorder(monkeypatch) + set_current_auth_context( + AuthCredentialContext( + method=AUTH_METHOD_API_KEY, + api_key_id=uuid4(), + api_key_source="db", # pragma: allowlist secret + ) + ) + + try: + with pytest.raises(HTTPException) as exc_info: + await authz_guards.ensure_flow_permission( + fake_user, + FlowAction.DELETE, + flow_id=uuid4(), + flow_user_id=fake_user.id, + ) + finally: + clear_current_auth_context() + + assert exc_info.value.status_code == 403 + assert len(service.calls) == 1 + assert service.calls[0]["context"]["flow_user_id"] == fake_user.id + assert service.calls[0]["context"]["auth_method"] == "api_key" + + +@pytest.mark.anyio +async def test_external_viewer_ceiling_denies_before_owner_override(monkeypatch, fake_user): + """A viewer claim is a deny-only ceiling even when the user owns the flow.""" + install_settings(monkeypatch, authz_enabled=False, audit_enabled=True) + service = _StubAuthorizationService(allow=True) + install_authz(monkeypatch, service) + audit_calls = install_audit_recorder(monkeypatch) + set_current_external_access_context(ExternalAccessContext(provider="openrag", subject="subject-1", level="viewer")) + + try: + with pytest.raises(HTTPException) as exc_info: + await authz_guards.ensure_flow_permission( + fake_user, + FlowAction.WRITE, + flow_id=uuid4(), + flow_user_id=fake_user.id, + ) + finally: + set_current_external_access_context(None) + + assert exc_info.value.status_code == 403 + assert "External credentials" in exc_info.value.detail + assert service.calls == [] + assert audit_calls[0]["result"] == "deny" + assert audit_calls[0]["details"]["external_access_level"] == "viewer" + + +@pytest.mark.anyio +async def test_external_editor_ceiling_allows_write_then_owner_override(monkeypatch, fake_user): + """Editor is still only a ceiling; normal owner/plugin logic continues after it passes.""" + install_settings(monkeypatch, authz_enabled=True, audit_enabled=True) + service = _StubAuthorizationService(allow=False) + install_authz(monkeypatch, service) + audit_calls = install_audit_recorder(monkeypatch) + set_current_external_access_context(ExternalAccessContext(provider="openrag", subject="subject-1", level="editor")) + + try: + await authz_guards.ensure_flow_permission( + fake_user, + FlowAction.WRITE, + flow_id=uuid4(), + flow_user_id=fake_user.id, + ) + finally: + set_current_external_access_context(None) + + assert service.calls == [] + assert audit_calls[0]["result"] == "owner_override" + + @pytest.mark.anyio async def test_owner_override_audits_even_when_authz_disabled(monkeypatch, fake_user): """Owner override on a disabled-authz install still writes an audit row. diff --git a/src/backend/tests/unit/services/authorization/test_route_ceiling_guards.py b/src/backend/tests/unit/services/authorization/test_route_ceiling_guards.py new file mode 100644 index 0000000000..39f3fec715 --- /dev/null +++ b/src/backend/tests/unit/services/authorization/test_route_ceiling_guards.py @@ -0,0 +1,610 @@ +"""Regression tests for access-ceiling guards added to execution / mutation routes. + +PR #13293 added ``ensure_*_permission`` guards to four route areas that +previously escaped the external access ceiling: + +* ``mcp_utils.handle_call_tool`` (flow EXECUTE) +* ``flow_version`` create/activate/delete (flow WRITE/DELETE) +* ``files`` upload/delete (flow WRITE) +* ``memories`` create/update/delete/flush/regenerate (knowledge-base actions) + +These tests drive the real route handlers with the data services mocked, +verifying two invariants for each newly-guarded action: + +1. A viewer-ceiling caller is denied with HTTP 403 *before* the data service is + touched (the deny-only ceiling fires ahead of owner-override and the + ``AUTHZ_ENABLED`` gate). +2. With no ceiling installed, the owner-override path returns early and the + route proceeds to the underlying service — preserving feature-off behavior. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from langflow.services.authorization.access_ceiling import ( + ExternalAccessContext, + set_current_external_access_context, +) + +from ._common import ( + _StubAuthorizationService, + install_audit_recorder, + install_authz, + install_settings, +) + + +@pytest.fixture +def owner(): + """A non-superuser acting as the resource owner.""" + return SimpleNamespace(id=uuid4(), username="owner", is_superuser=False) + + +@pytest.fixture(autouse=True) +def _wire_guard_services(monkeypatch): + """Install pass-through authz services so guards run the real ceiling path. + + ``AUTHZ_ENABLED=False`` mirrors the OSS default: the only thing that can deny + is the external access ceiling, which is checked before the gate. + """ + install_settings(monkeypatch, authz_enabled=False, audit_enabled=True) + install_authz(monkeypatch, _StubAuthorizationService(allow=True)) + install_audit_recorder(monkeypatch) + + +def _viewer_ceiling() -> ExternalAccessContext: + return ExternalAccessContext(provider="openrag", subject="s-1", level="viewer") + + +def _make_flow(owner_id): + return SimpleNamespace( + id=uuid4(), + user_id=owner_id, + workspace_id=None, + folder_id=None, + data={"nodes": [], "edges": []}, + name="flow", + ) + + +# --------------------------------------------------------------------------- # +# C1 — mcp_utils.handle_call_tool (flow EXECUTE) +# --------------------------------------------------------------------------- # + + +@pytest.mark.anyio +async def test_mcp_call_tool_viewer_denied_before_run(monkeypatch, owner): + from langflow.api.v1 import mcp_utils + + flow = _make_flow(owner.id) + monkeypatch.setattr(mcp_utils, "get_flow_snake_case", AsyncMock(return_value=flow)) + monkeypatch.setattr(mcp_utils, "get_mcp_config", lambda: SimpleNamespace(enable_progress_notifications=False)) + run_spy = AsyncMock() + monkeypatch.setattr(mcp_utils, "simple_run_flow", run_spy) + monkeypatch.setattr(mcp_utils, "with_db_session", lambda fn: fn(MagicMock())) + mcp_utils.current_user_ctx.set(owner) + + set_current_external_access_context(_viewer_ceiling()) + try: + with pytest.raises(HTTPException) as exc_info: + await mcp_utils.handle_call_tool(name="flow", arguments={}, server=MagicMock()) + finally: + set_current_external_access_context(None) + + assert exc_info.value.status_code == 403 + run_spy.assert_not_awaited() + + +@pytest.mark.anyio +async def test_mcp_call_tool_owner_runs_without_ceiling(monkeypatch, owner): + from langflow.api.v1 import mcp_utils + + flow = _make_flow(owner.id) + monkeypatch.setattr(mcp_utils, "get_flow_snake_case", AsyncMock(return_value=flow)) + monkeypatch.setattr(mcp_utils, "get_mcp_config", lambda: SimpleNamespace(enable_progress_notifications=False)) + run_spy = AsyncMock(return_value=SimpleNamespace(outputs=[])) + monkeypatch.setattr(mcp_utils, "simple_run_flow", run_spy) + monkeypatch.setattr(mcp_utils, "with_db_session", lambda fn: fn(MagicMock())) + mcp_utils.current_user_ctx.set(owner) + + await mcp_utils.handle_call_tool(name="flow", arguments={}, server=MagicMock()) + + run_spy.assert_awaited_once() + + +# --------------------------------------------------------------------------- # +# H2 — flow_version create / activate / delete +# --------------------------------------------------------------------------- # + + +@pytest.mark.anyio +async def test_flow_version_create_snapshot_viewer_denied(monkeypatch, owner): + from langflow.api.v1 import flow_version + + flow = _make_flow(owner.id) + monkeypatch.setattr(flow_version, "_get_user_flow", AsyncMock(return_value=flow)) + create_spy = AsyncMock() + monkeypatch.setattr(flow_version, "create_flow_version_entry", create_spy) + + set_current_external_access_context(_viewer_ceiling()) + try: + with pytest.raises(HTTPException) as exc_info: + await flow_version.create_snapshot(flow_id=flow.id, current_user=owner, session=MagicMock(), body=None) + finally: + set_current_external_access_context(None) + + assert exc_info.value.status_code == 403 + create_spy.assert_not_awaited() + + +@pytest.mark.anyio +async def test_flow_version_delete_viewer_denied(monkeypatch, owner): + from langflow.api.v1 import flow_version + + flow = _make_flow(owner.id) + monkeypatch.setattr(flow_version, "_get_user_flow", AsyncMock(return_value=flow)) + delete_spy = AsyncMock() + monkeypatch.setattr(flow_version, "delete_flow_version_entry", delete_spy) + monkeypatch.setattr(flow_version, "get_flow_version_entry_or_raise", AsyncMock()) + + set_current_external_access_context(_viewer_ceiling()) + try: + with pytest.raises(HTTPException) as exc_info: + await flow_version.delete_version_entry( + flow_id=flow.id, version_id=uuid4(), current_user=owner, session=MagicMock() + ) + finally: + set_current_external_access_context(None) + + assert exc_info.value.status_code == 403 + delete_spy.assert_not_awaited() + + +@pytest.mark.anyio +async def test_flow_version_create_snapshot_owner_proceeds(monkeypatch, owner): + from langflow.api.v1 import flow_version + + flow = _make_flow(owner.id) + monkeypatch.setattr(flow_version, "_get_user_flow", AsyncMock(return_value=flow)) + entry = SimpleNamespace(id=uuid4()) + create_spy = AsyncMock(return_value=entry) + monkeypatch.setattr(flow_version, "create_flow_version_entry", create_spy) + monkeypatch.setattr(flow_version, "_version_to_read", lambda e: e) + + result = await flow_version.create_snapshot(flow_id=flow.id, current_user=owner, session=MagicMock(), body=None) + + create_spy.assert_awaited_once() + assert result is entry + + +# --------------------------------------------------------------------------- # +# F19 — files upload / delete (flow WRITE) +# --------------------------------------------------------------------------- # + + +@pytest.mark.anyio +async def test_files_upload_viewer_denied(owner): + from langflow.api.v1 import files + + flow = _make_flow(owner.id) + storage = MagicMock(save_file=AsyncMock()) + settings_service = SimpleNamespace(settings=SimpleNamespace(max_file_size_upload=100)) + upload = SimpleNamespace(size=1, filename="x.txt", read=AsyncMock(return_value=b"data")) + + set_current_external_access_context(_viewer_ceiling()) + try: + with pytest.raises(HTTPException) as exc_info: + await files.upload_file( + file=upload, + flow=flow, + current_user=owner, + storage_service=storage, + settings_service=settings_service, + ) + finally: + set_current_external_access_context(None) + + assert exc_info.value.status_code == 403 + storage.save_file.assert_not_awaited() + + +@pytest.mark.anyio +async def test_files_delete_viewer_denied(owner): + from langflow.api.v1 import files + + flow = _make_flow(owner.id) + storage = MagicMock(delete_file=AsyncMock()) + + set_current_external_access_context(_viewer_ceiling()) + try: + with pytest.raises(HTTPException) as exc_info: + await files.delete_file(file_name="x.txt", flow=flow, current_user=owner, storage_service=storage) + finally: + set_current_external_access_context(None) + + assert exc_info.value.status_code == 403 + storage.delete_file.assert_not_awaited() + + +@pytest.mark.anyio +async def test_files_delete_owner_proceeds(owner): + from langflow.api.v1 import files + + flow = _make_flow(owner.id) + storage = MagicMock(delete_file=AsyncMock()) + + result = await files.delete_file(file_name="x.txt", flow=flow, current_user=owner, storage_service=storage) + + storage.delete_file.assert_awaited_once() + assert "deleted successfully" in result["message"] + + +# --------------------------------------------------------------------------- # +# F20 — memories create / update / delete / flush / regenerate +# --------------------------------------------------------------------------- # + + +def _install_memory_service(monkeypatch, **methods): + from langflow.api.v1 import memories + + service = SimpleNamespace(**methods) + monkeypatch.setattr(memories, "get_memory_base_service", lambda: service) + return service + + +@pytest.mark.anyio +async def test_memory_create_viewer_denied(monkeypatch, owner): + from langflow.api.v1 import memories + + create_spy = AsyncMock() + _install_memory_service(monkeypatch, create=create_spy) + payload = SimpleNamespace() + + set_current_external_access_context(_viewer_ceiling()) + try: + with pytest.raises(HTTPException) as exc_info: + await memories.create_memory_base(current_user=owner, payload=payload) + finally: + set_current_external_access_context(None) + + assert exc_info.value.status_code == 403 + create_spy.assert_not_awaited() + + +@pytest.mark.anyio +async def test_memory_delete_viewer_denied(monkeypatch, owner): + from langflow.api.v1 import memories + + delete_spy = AsyncMock(return_value=True) + mb = SimpleNamespace(user_id=owner.id, kb_name="kb") + _install_memory_service(monkeypatch, delete=delete_spy, get=AsyncMock(return_value=mb)) + + set_current_external_access_context(_viewer_ceiling()) + try: + with pytest.raises(HTTPException) as exc_info: + await memories.delete_memory_base(memory_base_id=uuid4(), current_user=owner) + finally: + set_current_external_access_context(None) + + assert exc_info.value.status_code == 403 + delete_spy.assert_not_awaited() + + +@pytest.mark.anyio +async def test_memory_flush_viewer_denied(monkeypatch, owner): + from langflow.api.v1 import memories + + trigger_spy = AsyncMock(return_value=uuid4()) + mb = SimpleNamespace(user_id=owner.id, kb_name="kb") + _install_memory_service(monkeypatch, trigger_ingestion=trigger_spy, get=AsyncMock(return_value=mb)) + + set_current_external_access_context(_viewer_ceiling()) + try: + with pytest.raises(HTTPException) as exc_info: + await memories.flush_memory_base( + memory_base_id=uuid4(), current_user=owner, body=SimpleNamespace(session_id="s") + ) + finally: + set_current_external_access_context(None) + + assert exc_info.value.status_code == 403 + trigger_spy.assert_not_awaited() + + +@pytest.mark.anyio +async def test_memory_regenerate_viewer_denied(monkeypatch, owner): + from langflow.api.v1 import memories + + regen_spy = AsyncMock(return_value=[]) + mb = SimpleNamespace(user_id=owner.id, kb_name="kb") + _install_memory_service(monkeypatch, regenerate=regen_spy, get=AsyncMock(return_value=mb)) + + set_current_external_access_context(_viewer_ceiling()) + try: + with pytest.raises(HTTPException) as exc_info: + await memories.regenerate_memory_base(memory_base_id=uuid4(), current_user=owner) + finally: + set_current_external_access_context(None) + + assert exc_info.value.status_code == 403 + regen_spy.assert_not_awaited() + + +@pytest.mark.anyio +async def test_memory_delete_owner_proceeds(monkeypatch, owner): + """An editor (delete is now in the editor ceiling) deletes their own memory base.""" + from langflow.api.v1 import memories + + delete_spy = AsyncMock(return_value=True) + mb = SimpleNamespace(user_id=owner.id, kb_name="kb") + _install_memory_service(monkeypatch, delete=delete_spy, get=AsyncMock(return_value=mb)) + + set_current_external_access_context(ExternalAccessContext(provider="openrag", subject="s-1", level="editor")) + try: + await memories.delete_memory_base(memory_base_id=uuid4(), current_user=owner) + finally: + set_current_external_access_context(None) + + delete_spy.assert_awaited_once() + + +# --------------------------------------------------------------------------- # +# endpoints.custom_component / custom_component_update (direct ceiling, "create") +# +# These routes instantiate posted component code and are not tied to a single +# owned resource, so they enforce the deny-only ceiling primitive directly +# instead of an ``ensure_*_permission`` guard. +# --------------------------------------------------------------------------- # + + +@pytest.mark.anyio +async def test_custom_component_viewer_denied_before_build(monkeypatch, owner): + from langflow.api.v1 import endpoints + + build_spy = MagicMock() + monkeypatch.setattr(endpoints, "build_custom_component_template", build_spy) + raw_code = SimpleNamespace(code="print('x')", frontend_node=None) + + set_current_external_access_context(_viewer_ceiling()) + try: + with pytest.raises(HTTPException) as exc_info: + await endpoints.custom_component(raw_code=raw_code, user=owner, request=MagicMock()) + finally: + set_current_external_access_context(None) + + assert exc_info.value.status_code == 403 + build_spy.assert_not_called() + + +@pytest.mark.anyio +async def test_custom_component_update_viewer_denied_before_build(monkeypatch, owner): + from langflow.api.v1 import endpoints + + build_spy = MagicMock() + monkeypatch.setattr(endpoints, "build_custom_component_template", build_spy) + code_request = SimpleNamespace(code="print('x')", tool_mode=False, get_template=dict) + + set_current_external_access_context(_viewer_ceiling()) + try: + with pytest.raises(HTTPException) as exc_info: + await endpoints.custom_component_update(code_request=code_request, user=owner, request=MagicMock()) + finally: + set_current_external_access_context(None) + + assert exc_info.value.status_code == 403 + build_spy.assert_not_called() + + +@pytest.mark.anyio +async def test_custom_component_editor_passes_ceiling(monkeypatch, owner): + """An editor ceiling allows component instantiation (create is in the editor set).""" + from langflow.api.v1 import endpoints + + # Stub the build pipeline so the route returns without real execution; the + # point is that the ceiling check does NOT short-circuit for an editor. + instance = MagicMock() + instance.update_frontend_node = AsyncMock(return_value={"tool_mode": False}) + monkeypatch.setattr( + endpoints, + "build_custom_component_template", + MagicMock(return_value=({"tool_mode": False}, instance)), + ) + monkeypatch.setattr(endpoints, "get_instance_name", lambda _i: "MyComponent") + monkeypatch.setattr(endpoints, "_requires_component_hash_lookups", lambda *_a, **_k: False) + # Settings: custom components allowed, no admin-only gate. + settings = SimpleNamespace(allow_custom_components=True, custom_component_admin_only=False) + monkeypatch.setattr(endpoints, "get_settings_service", lambda: SimpleNamespace(settings=settings)) + # ``isinstance(instance, Component)`` must be False so the optional + # run_and_validate_update_outputs branch is skipped for this MagicMock. + raw_code = SimpleNamespace(code="print('x')", frontend_node=None) + request = SimpleNamespace(state=SimpleNamespace(locale="en")) + + set_current_external_access_context(ExternalAccessContext(provider="openrag", subject="s-1", level="editor")) + try: + result = await endpoints.custom_component(raw_code=raw_code, user=owner, request=request) + finally: + set_current_external_access_context(None) + + assert result.type == "MyComponent" + + +# --------------------------------------------------------------------------- # +# endpoints.create_upload_file — deprecated upload (flow WRITE) +# --------------------------------------------------------------------------- # + + +@pytest.mark.anyio +async def test_deprecated_upload_viewer_denied(monkeypatch, owner): + from langflow.api.v1 import endpoints + + flow = _make_flow(owner.id) + save_spy = MagicMock() + monkeypatch.setattr(endpoints, "save_uploaded_file", save_spy) + settings_service = SimpleNamespace(settings=SimpleNamespace(max_file_size_upload=100)) + upload = SimpleNamespace(size=1, filename="x.txt") + + set_current_external_access_context(_viewer_ceiling()) + try: + with pytest.raises(HTTPException) as exc_info: + await endpoints.create_upload_file( + file=upload, flow=flow, current_user=owner, settings_service=settings_service + ) + finally: + set_current_external_access_context(None) + + assert exc_info.value.status_code == 403 + save_spy.assert_not_called() + + +@pytest.mark.anyio +async def test_deprecated_upload_owner_proceeds(monkeypatch, owner): + from pathlib import Path + + from langflow.api.v1 import endpoints + + flow = _make_flow(owner.id) + save_spy = MagicMock(return_value=Path("flow/x.txt")) + monkeypatch.setattr(endpoints, "save_uploaded_file", save_spy) + settings_service = SimpleNamespace(settings=SimpleNamespace(max_file_size_upload=100)) + upload = SimpleNamespace(size=1, filename="x.txt") + + result = await endpoints.create_upload_file( + file=upload, flow=flow, current_user=owner, settings_service=settings_service + ) + + save_spy.assert_called_once() + assert result.flow_id == str(flow.id) + + +# --------------------------------------------------------------------------- # +# mcp_projects.update_project_mcp_settings — project WRITE +# --------------------------------------------------------------------------- # + + +def _install_mcp_session(monkeypatch, project): + """Patch ``session_scope`` so the route's project fetch returns ``project``.""" + from contextlib import asynccontextmanager + + from langflow.api.v1 import mcp_projects + + exec_result = SimpleNamespace(first=lambda: project, all=list) + session = SimpleNamespace( + exec=AsyncMock(return_value=exec_result), + flush=AsyncMock(), + add=MagicMock(), + ) + + @asynccontextmanager + async def _scope(): + yield session + + monkeypatch.setattr(mcp_projects, "session_scope", _scope) + return session + + +@pytest.mark.anyio +async def test_mcp_update_settings_viewer_denied(monkeypatch, owner): + from langflow.api.v1 import mcp_projects + + project = SimpleNamespace( + id=uuid4(), + user_id=owner.id, + workspace_id=None, + auth_settings=None, + flows=[], + name="proj", + ) + session = _install_mcp_session(monkeypatch, project) + request = SimpleNamespace(settings=[], auth_settings=None, model_fields_set=set()) + + set_current_external_access_context(_viewer_ceiling()) + try: + with pytest.raises(HTTPException) as exc_info: + await mcp_projects.update_project_mcp_settings(project_id=project.id, request=request, current_user=owner) + finally: + set_current_external_access_context(None) + + assert exc_info.value.status_code == 403 + session.flush.assert_not_awaited() + + +# --------------------------------------------------------------------------- # +# models.py variable-mutating routes (variable WRITE / DELETE) +# --------------------------------------------------------------------------- # + + +@pytest.mark.anyio +async def test_update_enabled_models_viewer_denied(monkeypatch, owner): + from langflow.api.v1 import models + + svc_spy = MagicMock() + monkeypatch.setattr(models, "get_variable_service", svc_spy) + + set_current_external_access_context(_viewer_ceiling()) + try: + with pytest.raises(HTTPException) as exc_info: + await models.update_enabled_models(session=MagicMock(), current_user=owner, updates=[]) + finally: + set_current_external_access_context(None) + + assert exc_info.value.status_code == 403 + svc_spy.assert_not_called() + + +@pytest.mark.anyio +async def test_set_default_model_viewer_denied(monkeypatch, owner): + from langflow.api.v1 import models + + svc_spy = MagicMock() + monkeypatch.setattr(models, "get_variable_service", svc_spy) + request = SimpleNamespace(model_type="language", model_name="m", provider="p") + + set_current_external_access_context(_viewer_ceiling()) + try: + with pytest.raises(HTTPException) as exc_info: + await models.set_default_model(session=MagicMock(), current_user=owner, request=request) + finally: + set_current_external_access_context(None) + + assert exc_info.value.status_code == 403 + svc_spy.assert_not_called() + + +@pytest.mark.anyio +async def test_clear_default_model_viewer_denied(monkeypatch, owner): + from langflow.api.v1 import models + + svc_spy = MagicMock() + monkeypatch.setattr(models, "get_variable_service", svc_spy) + + set_current_external_access_context(_viewer_ceiling()) + try: + with pytest.raises(HTTPException) as exc_info: + await models.clear_default_model(session=MagicMock(), current_user=owner, model_type="language") + finally: + set_current_external_access_context(None) + + assert exc_info.value.status_code == 403 + svc_spy.assert_not_called() + + +@pytest.mark.anyio +async def test_set_default_model_owner_proceeds(monkeypatch, owner): + """Owner with no ceiling reaches the variable service (owner-override path).""" + from langflow.api.v1 import models + + var_service = MagicMock(spec=models.DatabaseVariableService) + var_service.get_variable_object = AsyncMock(return_value=SimpleNamespace(id=uuid4())) + var_service.update_variable_fields = AsyncMock() + monkeypatch.setattr(models, "get_variable_service", lambda: var_service) + request = SimpleNamespace(model_type="language", model_name="m", provider="p") + + result = await models.set_default_model(session=MagicMock(), current_user=owner, request=request) + + var_service.update_variable_fields.assert_awaited_once() + assert result["default_model"]["model_name"] == "m" diff --git a/src/backend/tests/unit/services/database/models/api_key/test_crud.py b/src/backend/tests/unit/services/database/models/api_key/test_crud.py index c9f5fac837..c700bf1500 100644 --- a/src/backend/tests/unit/services/database/models/api_key/test_crud.py +++ b/src/backend/tests/unit/services/database/models/api_key/test_crud.py @@ -10,6 +10,7 @@ from uuid import uuid4 import pytest from langflow.services.database.models.api_key.crud import ( _check_key_from_db, + authenticate_api_key, create_api_key, hash_api_key, ) @@ -55,6 +56,12 @@ def mock_settings(monkeypatch): settings = SimpleNamespace( auth_settings=SimpleNamespace( SECRET_KEY=SimpleNamespace(get_secret_value=lambda: "a" * 43), + API_KEY_SOURCE="db", # pragma: allowlist secret + # Non-optional AuthSettings fields read directly by create_api_key + # and the external-access-ceiling chokepoint. + EXTERNAL_AUTH_ACCESS_CEILING_ENABLED=False, + EXTERNAL_AUTH_DISABLE_API_KEYS_FOR_EXTERNAL_USERS=True, + EXTERNAL_AUTH_PROVIDER="external", ), settings=SimpleNamespace(disable_track_apikey_usage=False), ) @@ -111,6 +118,32 @@ async def test_check_key_finds_by_hash(async_session, mock_settings): assert result.id == user.id +@pytest.mark.anyio +async def test_authenticate_api_key_returns_db_key_metadata(async_session, mock_settings): # noqa: ARG001 + """The richer resolver preserves the DB API-key id for authorization context.""" + user = _make_user() + async_session.add(user) + await async_session.flush() + + plaintext = "sk-test-12345" # pragma: allowlist secret + api_key = ApiKey( + api_key="encrypted-value", # pragma: allowlist secret + api_key_hash=hash_api_key(plaintext), + name="test", + user_id=user.id, + created_at=datetime.now(timezone.utc), + ) + async_session.add(api_key) + await async_session.flush() + + result = await authenticate_api_key(async_session, plaintext) + + assert result is not None + assert result.user.id == user.id + assert result.api_key_source == "db" # pragma: allowlist secret + assert result.api_key_id == api_key.id + + @pytest.mark.anyio async def test_check_key_fallback_for_legacy_keys(async_session, mock_settings, monkeypatch): """Legacy keys without hash must still match via decrypt-and-compare.""" @@ -390,3 +423,215 @@ async def test_create_api_key_no_expires_at_is_none(async_session, mock_settings assert row is not None assert row.expires_at is None assert result.expires_at is None + + +# ============================================================================= +# External access ceiling chokepoint (H1): API keys disabled for external users +# ============================================================================= + + +async def _seed_external_user_with_key(async_session, *, provider: str = "external"): + """Create a user with an external SSO profile and an active API key.""" + from langflow.services.database.models.auth import SSOUserProfile + + user = _make_user(username=f"ext-{uuid4().hex[:8]}") + async_session.add(user) + await async_session.flush() + + async_session.add( + SSOUserProfile( + user_id=user.id, + sso_provider=provider, + sso_user_id=f"subject-{uuid4().hex[:8]}", + ) + ) + plaintext = f"sk-ext-{uuid4().hex}" # pragma: allowlist secret + async_session.add( + ApiKey( + api_key="encrypted-ext", # pragma: allowlist secret + api_key_hash=hash_api_key(plaintext), + name="ext-key", + user_id=user.id, + created_at=datetime.now(timezone.utc), + ) + ) + await async_session.flush() + return user, plaintext + + +@pytest.mark.anyio +async def test_authenticate_api_key_rejects_external_user_when_ceiling_enabled(async_session, mock_settings): + """When ceiling + disable-keys are ON, an external user's API key fails at auth time.""" + mock_settings.auth_settings.EXTERNAL_AUTH_ACCESS_CEILING_ENABLED = True + mock_settings.auth_settings.EXTERNAL_AUTH_DISABLE_API_KEYS_FOR_EXTERNAL_USERS = True + + _user, plaintext = await _seed_external_user_with_key(async_session) + + # authenticate_api_key is the shared chokepoint used by every API-key caller. + assert await authenticate_api_key(async_session, plaintext) is None + + +@pytest.mark.anyio +async def test_authenticate_api_key_allows_external_user_when_ceiling_disabled(async_session, mock_settings): + """When the ceiling is OFF, the external user's API key still authenticates (no behavior change).""" + mock_settings.auth_settings.EXTERNAL_AUTH_ACCESS_CEILING_ENABLED = False + mock_settings.auth_settings.EXTERNAL_AUTH_DISABLE_API_KEYS_FOR_EXTERNAL_USERS = True + + user, plaintext = await _seed_external_user_with_key(async_session) + + result = await authenticate_api_key(async_session, plaintext) + assert result is not None + assert result.user.id == user.id + + +@pytest.mark.anyio +async def test_authenticate_api_key_allows_external_user_when_disable_keys_disabled(async_session, mock_settings): + """Ceiling ON but disable-keys OFF must not block the external user (feature is opt-in).""" + mock_settings.auth_settings.EXTERNAL_AUTH_ACCESS_CEILING_ENABLED = True + mock_settings.auth_settings.EXTERNAL_AUTH_DISABLE_API_KEYS_FOR_EXTERNAL_USERS = False + + user, plaintext = await _seed_external_user_with_key(async_session) + + result = await authenticate_api_key(async_session, plaintext) + assert result is not None + assert result.user.id == user.id + + +@pytest.mark.anyio +async def test_authenticate_api_key_allows_non_external_user_when_ceiling_enabled(async_session, mock_settings): + """A user with no external SSO profile is unaffected even with the ceiling fully enabled.""" + mock_settings.auth_settings.EXTERNAL_AUTH_ACCESS_CEILING_ENABLED = True + mock_settings.auth_settings.EXTERNAL_AUTH_DISABLE_API_KEYS_FOR_EXTERNAL_USERS = True + + user = _make_user() + async_session.add(user) + await async_session.flush() + plaintext = "sk-native-key" # pragma: allowlist secret + async_session.add( + ApiKey( + api_key="encrypted-native", # pragma: allowlist secret + api_key_hash=hash_api_key(plaintext), + name="native-key", + user_id=user.id, + created_at=datetime.now(timezone.utc), + ) + ) + await async_session.flush() + + result = await authenticate_api_key(async_session, plaintext) + assert result is not None + assert result.user.id == user.id + + +@pytest.mark.anyio +async def test_authenticate_api_key_ignores_profile_for_other_provider(async_session, mock_settings): + """A profile under a different provider key must not trip the configured-provider block.""" + mock_settings.auth_settings.EXTERNAL_AUTH_ACCESS_CEILING_ENABLED = True + mock_settings.auth_settings.EXTERNAL_AUTH_DISABLE_API_KEYS_FOR_EXTERNAL_USERS = True + mock_settings.auth_settings.EXTERNAL_AUTH_PROVIDER = "external" + + # Profile is stored under "some-other-provider", not the configured "external". + user, plaintext = await _seed_external_user_with_key(async_session, provider="some-other-provider") + + result = await authenticate_api_key(async_session, plaintext) + assert result is not None + assert result.user.id == user.id + + +# ============================================================================= +# LOW: a DENIED API-key auth must not mutate usage counters (total_uses/last_used_at) +# ============================================================================= + + +@pytest.mark.anyio +async def test_blocked_external_user_key_does_not_increment_usage(async_session, mock_settings): + """A ceiling-blocked external user's key must NOT bump total_uses / last_used_at (fast hash path).""" + from sqlmodel import select + + mock_settings.auth_settings.EXTERNAL_AUTH_ACCESS_CEILING_ENABLED = True + mock_settings.auth_settings.EXTERNAL_AUTH_DISABLE_API_KEYS_FOR_EXTERNAL_USERS = True + + _user, plaintext = await _seed_external_user_with_key(async_session) + + # Denied auth. + assert await authenticate_api_key(async_session, plaintext) is None + + row = (await async_session.exec(select(ApiKey).where(ApiKey.api_key_hash == hash_api_key(plaintext)))).first() + assert row is not None + assert row.total_uses == 0 + assert row.last_used_at is None + + +@pytest.mark.anyio +async def test_blocked_external_user_legacy_key_does_not_increment_usage(async_session, mock_settings, monkeypatch): + """A ceiling-blocked external user's legacy (hashless) key must NOT bump usage / backfill hash.""" + from langflow.services.database.models.auth import SSOUserProfile + from sqlmodel import select + + mock_settings.auth_settings.EXTERNAL_AUTH_ACCESS_CEILING_ENABLED = True + mock_settings.auth_settings.EXTERNAL_AUTH_DISABLE_API_KEYS_FOR_EXTERNAL_USERS = True + + user = _make_user(username=f"ext-{uuid4().hex[:8]}") + async_session.add(user) + await async_session.flush() + async_session.add( + SSOUserProfile( + user_id=user.id, + sso_provider="external", + sso_user_id=f"subject-{uuid4().hex[:8]}", + ) + ) + + plaintext = "sk-legacy-blocked" # pragma: allowlist secret + legacy = ApiKey( + api_key=plaintext, + api_key_hash=None, + name="legacy-blocked", + user_id=user.id, + created_at=datetime.now(timezone.utc), + ) + async_session.add(legacy) + await async_session.flush() + + monkeypatch.setattr( + "langflow.services.database.models.api_key.crud.auth_utils.decrypt_api_key", + lambda val, **_kwargs: val, + ) + + assert await authenticate_api_key(async_session, plaintext) is None + + row = (await async_session.exec(select(ApiKey).where(ApiKey.id == legacy.id))).first() + assert row is not None + assert row.total_uses == 0 + assert row.last_used_at is None + # The hash backfill is a success-path side effect and must not run on a denial. + assert row.api_key_hash is None + + +@pytest.mark.anyio +async def test_allowed_user_key_still_increments_usage(async_session, mock_settings): # noqa: ARG001 + """Sanity: a non-blocked user's key still records usage (success-path unchanged).""" + from sqlmodel import select + + user = _make_user() + async_session.add(user) + await async_session.flush() + plaintext = "sk-usage-tracked" # pragma: allowlist secret + async_session.add( + ApiKey( + api_key="encrypted-tracked", # pragma: allowlist secret + api_key_hash=hash_api_key(plaintext), + name="tracked", + user_id=user.id, + created_at=datetime.now(timezone.utc), + ) + ) + await async_session.flush() + + result = await authenticate_api_key(async_session, plaintext) + assert result is not None + + row = (await async_session.exec(select(ApiKey).where(ApiKey.api_key_hash == hash_api_key(plaintext)))).first() + assert row is not None + assert row.total_uses == 1 + assert row.last_used_at is not None diff --git a/src/backend/tests/unit/test_chat_endpoint.py b/src/backend/tests/unit/test_chat_endpoint.py index 3df3aafacd..50476a5532 100644 --- a/src/backend/tests/unit/test_chat_endpoint.py +++ b/src/backend/tests/unit/test_chat_endpoint.py @@ -1319,3 +1319,138 @@ def test_scope_session_to_namespace_helper(): assert scope_session_to_namespace("victim-session", "namespace-B") == "namespace-B:victim-session" # A foreign-namespace prefix is treated as out-of-namespace and gets re-wrapped. assert scope_session_to_namespace("namespace-B:victim", "namespace-A") == "namespace-A:namespace-B:victim" + + +# ── Public job registry unit tests ─────────────────────────────────────────── +# CVE fix: unauthenticated callers must not access private-flow job streams +# by guessing or leaking a job_id from the authenticated build endpoint. + + +async def test_job_queue_service_register_and_check_public_job(): + """register_public_job marks a job as public; is_public_job reflects that.""" + svc = JobQueueService() + job_id = str(uuid.uuid4()) + + # Why: job not registered yet — must return False before registration + assert svc.is_public_job(job_id) is False + + await svc.register_public_job(job_id) + + # Why: job registered — must return True after registration + assert svc.is_public_job(job_id) is True + + +def test_job_queue_service_unregistered_job_not_public(): + """A job_id that was never registered is not considered public.""" + svc = JobQueueService() + assert svc.is_public_job(str(uuid.uuid4())) is False + + +async def test_job_queue_service_is_public_job_async_base(): + """is_public_job_async on base class delegates to in-memory is_public_job.""" + svc = JobQueueService() + job_id = str(uuid.uuid4()) + + # Why: async variant must mirror sync variant — False before, True after + assert await svc.is_public_job_async(job_id) is False + await svc.register_public_job(job_id) + assert await svc.is_public_job_async(job_id) is True + + +async def test_job_queue_service_cleanup_removes_public_registration(): + """cleanup_job discards the public registration so the job_id cannot be reused. + + Why: tests the actual cleanup_job contract — not the internal set. + If cleanup_job stops calling discard, this test must catch it. + """ + svc = JobQueueService() + job_id = str(uuid.uuid4()) + await svc.register_public_job(job_id) + assert svc.is_public_job(job_id) is True + + # Call the real cleanup path — not svc._public_jobs.discard directly. + # cleanup_job early-returns when job_id is not in _queues, but the + # _public_jobs.discard call is unconditional (after the early-return guard), + # so we need to reach it. Seed a minimal queue entry first. + svc._queues[job_id] = (asyncio.Queue(), None, None, None) # type: ignore[arg-type] + await svc.cleanup_job(job_id) + + # Why: if cleanup_job ever drops the discard call, is_public_job still returns True here + assert svc.is_public_job(job_id) is False + + +@pytest.mark.benchmark +@pytest.mark.security +async def test_private_job_id_blocked_on_public_events_endpoint(client, json_memory_chatbot_no_llm, logged_in_headers): + """A job_id started via the authenticated build endpoint must be rejected by the public events endpoint. + + Security proof: before the fix, any caller who knew or guessed a private job_id + could read the live event stream (LLM output, API keys, tracebacks) without auth. + After the fix, _assert_public_job returns HTTP 404 because the job was never + registered via register_public_job. + + Why 404 not 403: returning 403 would confirm the job exists under a different + access tier, leaking information about private builds. + """ + flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers) + + # Start a PRIVATE (authenticated) build — job_id never passed through build_public_tmp + private_start = await client.post( + f"api/v1/build/{flow_id}/flow", + json={}, + headers={**logged_in_headers, "Content-Type": "application/json"}, + ) + assert private_start.status_code == codes.OK + private_job_id = private_start.json()["job_id"] + + # Why: the shared AsyncClient persists access-token cookies from logged_in_headers. + # Without clearing them, get_current_user_optional could resolve a user on this + # "public" request, which would not exercise the unauthenticated attack path. + client.cookies.clear() + + # Attempt to read the private job's events via the unauthenticated public endpoint + # Why: this is the exact attack vector — attacker has job_id, tries public endpoint + events_response = await client.get( + f"api/v1/build_public_tmp/{private_job_id}/events?event_delivery=polling", + headers={"Accept": "application/x-ndjson"}, + ) + + # Must be 404 — gate blocks private job from public endpoint + assert events_response.status_code == codes.NOT_FOUND + assert events_response.json()["detail"] == "Job not found" + + +@pytest.mark.benchmark +@pytest.mark.security +async def test_private_job_id_blocked_on_public_cancel_endpoint(client, json_memory_chatbot_no_llm, logged_in_headers): + """A job_id started via the authenticated build endpoint must be rejected by the public cancel endpoint. + + Security proof: before the fix, an unauthenticated attacker could cancel any + in-flight private build as a denial-of-service by supplying a known job_id. + After the fix, _assert_public_job returns HTTP 404. + """ + flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers) + + # Start a PRIVATE (authenticated) build + private_start = await client.post( + f"api/v1/build/{flow_id}/flow", + json={}, + headers={**logged_in_headers, "Content-Type": "application/json"}, + ) + assert private_start.status_code == codes.OK + private_job_id = private_start.json()["job_id"] + + # Why: the shared AsyncClient persists access-token cookies from logged_in_headers. + # Without clearing them, get_current_user_optional could resolve a user on this + # "public" request, which would not exercise the unauthenticated attack path. + client.cookies.clear() + + # Attempt to cancel the private job via the unauthenticated public endpoint + cancel_response = await client.post( + f"api/v1/build_public_tmp/{private_job_id}/cancel", + headers={"Content-Type": "application/json"}, + ) + + # Must be 404 — gate blocks private job from public cancel endpoint + assert cancel_response.status_code == codes.NOT_FOUND + assert cancel_response.json()["detail"] == "Job not found" diff --git a/src/backend/tests/unit/test_login.py b/src/backend/tests/unit/test_login.py index 03af2b93ec..1316a78496 100644 --- a/src/backend/tests/unit/test_login.py +++ b/src/backend/tests/unit/test_login.py @@ -1,7 +1,78 @@ +from datetime import datetime, timedelta, timezone + +import jwt import pytest +from langflow.services.database.models.auth import SSOUserProfile from langflow.services.database.models.user import User -from langflow.services.deps import get_auth_service, session_scope +from langflow.services.deps import get_auth_service, get_settings_service, session_scope from sqlalchemy.exc import IntegrityError +from sqlmodel import select + +_EXTERNAL_TEST_SECRET = "external-test-secret-with-enough-length" # noqa: S105 # pragma: allowlist secret +_EXTERNAL_AUTH_HEADER = "X-Langflow-External-Auth" +_EXTERNAL_AUTH_COOKIE = "external-session" + + +def _external_token(**claims) -> str: + payload = { + "exp": datetime.now(timezone.utc) + timedelta(minutes=5), + **claims, + } + return jwt.encode(payload, _EXTERNAL_TEST_SECRET, algorithm="HS256") + + +_EXTERNAL_FIELDS = ( + "EXTERNAL_AUTH_ENABLED", + "EXTERNAL_AUTH_PROVIDER", + "EXTERNAL_AUTH_TOKEN_HEADER", + "EXTERNAL_AUTH_TOKEN_COOKIE", + "EXTERNAL_AUTH_IDENTITY_RESOLVER", + "EXTERNAL_AUTH_TRUSTED_JWT_DECODE", + "EXTERNAL_AUTH_JWKS_URL", + "EXTERNAL_AUTH_ISSUER", + "EXTERNAL_AUTH_AUDIENCE", + "EXTERNAL_AUTH_ALGORITHMS", + "EXTERNAL_AUTH_SUBJECT_CLAIM", + "EXTERNAL_AUTH_USERNAME_CLAIM", + "EXTERNAL_AUTH_EMAIL_CLAIM", + "EXTERNAL_AUTH_NAME_CLAIM", + "EXTERNAL_AUTH_ACCESS_CEILING_ENABLED", + "EXTERNAL_AUTH_ACCESS_CLAIM", + "EXTERNAL_AUTH_ACCESS_CLAIM_MAPPING", + "EXTERNAL_AUTH_DEFAULT_ACCESS_LEVEL", + "EXTERNAL_AUTH_DISABLE_API_KEYS_FOR_EXTERNAL_USERS", +) + + +@pytest.fixture +async def external_auth_settings(client): # noqa: ARG001 + auth_settings = get_settings_service().auth_settings + original = {field: getattr(auth_settings, field) for field in _EXTERNAL_FIELDS} + + auth_settings.EXTERNAL_AUTH_ENABLED = True + auth_settings.EXTERNAL_AUTH_PROVIDER = "test-provider" + auth_settings.EXTERNAL_AUTH_TOKEN_HEADER = _EXTERNAL_AUTH_HEADER + auth_settings.EXTERNAL_AUTH_TOKEN_COOKIE = None + auth_settings.EXTERNAL_AUTH_IDENTITY_RESOLVER = None + auth_settings.EXTERNAL_AUTH_TRUSTED_JWT_DECODE = True + auth_settings.EXTERNAL_AUTH_JWKS_URL = None + auth_settings.EXTERNAL_AUTH_ISSUER = None + auth_settings.EXTERNAL_AUTH_AUDIENCE = None + auth_settings.EXTERNAL_AUTH_ALGORITHMS = "RS256" + auth_settings.EXTERNAL_AUTH_SUBJECT_CLAIM = "sub" + auth_settings.EXTERNAL_AUTH_USERNAME_CLAIM = "preferred_username" + auth_settings.EXTERNAL_AUTH_EMAIL_CLAIM = "email" + auth_settings.EXTERNAL_AUTH_NAME_CLAIM = "name" + auth_settings.EXTERNAL_AUTH_ACCESS_CEILING_ENABLED = False + auth_settings.EXTERNAL_AUTH_ACCESS_CLAIM = None + auth_settings.EXTERNAL_AUTH_ACCESS_CLAIM_MAPPING = None + auth_settings.EXTERNAL_AUTH_DEFAULT_ACCESS_LEVEL = "viewer" + auth_settings.EXTERNAL_AUTH_DISABLE_API_KEYS_FOR_EXTERNAL_USERS = True + + yield auth_settings + + for field, value in original.items(): + setattr(auth_settings, field, value) @pytest.fixture @@ -77,3 +148,171 @@ async def test_session_endpoint_no_api_key_in_response(client, logged_in_headers assert data["authenticated"] is True # Verify store_api_key field is None or not present in response assert data.get("store_api_key") is None + + +# --------------------------------------------------------------------------- +# External trusted auth + JIT provisioning +# --------------------------------------------------------------------------- + + +async def test_session_endpoint_jit_creates_user_for_external_header(client, external_auth_settings): # noqa: ARG001 + token = _external_token( + sub="subject-1", + preferred_username="external-user", + email="external@example.com", + name="External User", + ) + + response = await client.get("api/v1/session", headers={_EXTERNAL_AUTH_HEADER: f"Bearer {token}"}) + + assert response.status_code == 200 + data = response.json() + assert data["authenticated"] is True + assert data["user"]["username"] == "external-user" + assert data["user"]["is_active"] is True + + async with session_scope() as session: + statement = select(SSOUserProfile).where( + SSOUserProfile.sso_provider == "test-provider", + SSOUserProfile.sso_user_id == "subject-1", + ) + profiles = (await session.exec(statement)).all() + assert len(profiles) == 1 + assert str(profiles[0].user_id) == data["user"]["id"] + assert profiles[0].email == "external@example.com" + + # Second request reuses the same user. + second_response = await client.get("api/v1/session", headers={_EXTERNAL_AUTH_HEADER: f"Bearer {token}"}) + assert second_response.status_code == 200 + assert second_response.json()["user"]["id"] == data["user"]["id"] + + +async def test_session_endpoint_accepts_external_cookie_with_custom_claim_mapping(client, external_auth_settings): + external_auth_settings.EXTERNAL_AUTH_TOKEN_HEADER = "X-Unused-External-Auth" # noqa: S105 + external_auth_settings.EXTERNAL_AUTH_TOKEN_COOKIE = _EXTERNAL_AUTH_COOKIE + external_auth_settings.EXTERNAL_AUTH_USERNAME_CLAIM = "username" + external_auth_settings.EXTERNAL_AUTH_EMAIL_CLAIM = "username" + external_auth_settings.EXTERNAL_AUTH_NAME_CLAIM = "display_name" + + token = _external_token(sub="cookie-subject", username="person@example.com", display_name="Person Name") + + response = await client.get("api/v1/session", headers={"Cookie": f"{_EXTERNAL_AUTH_COOKIE}={token}"}) + + assert response.status_code == 200 + data = response.json() + assert data["authenticated"] is True + assert data["user"]["username"] == "person@example.com" + + async with session_scope() as session: + statement = select(SSOUserProfile).where( + SSOUserProfile.sso_provider == "test-provider", + SSOUserProfile.sso_user_id == "cookie-subject", + ) + profile = (await session.exec(statement)).first() + assert profile is not None + assert profile.email == "person@example.com" + + +async def test_session_endpoint_rejects_expired_external_token(client, external_auth_settings): # noqa: ARG001 + token = jwt.encode( + { + "sub": "expired-subject", + "preferred_username": "expired-user", + "exp": datetime.now(timezone.utc) - timedelta(minutes=1), + }, + _EXTERNAL_TEST_SECRET, + algorithm="HS256", + ) + + response = await client.get("api/v1/session", headers={_EXTERNAL_AUTH_HEADER: f"Bearer {token}"}) + + assert response.status_code == 200 + assert response.json()["authenticated"] is False + + +async def test_external_access_ceiling_filters_effective_permissions(client, external_auth_settings): + external_auth_settings.EXTERNAL_AUTH_ACCESS_CEILING_ENABLED = True + external_auth_settings.EXTERNAL_AUTH_ACCESS_CLAIM = "openrag_mode" + external_auth_settings.EXTERNAL_AUTH_ACCESS_CLAIM_MAPPING = '{"view":"viewer","edit":"editor"}' + token = _external_token( + sub="viewer-subject", + preferred_username="viewer-user", + openrag_mode="view", + ) + + response = await client.post( + "api/v1/authz/me/permissions", + headers={_EXTERNAL_AUTH_HEADER: f"Bearer {token}"}, + json={ + "resource_type": "flow", + "resource_ids": ["00000000-0000-0000-0000-000000000001"], + "actions": ["read", "write", "delete"], + }, + ) + + assert response.status_code == 200 + permissions = response.json()["permissions"] + assert permissions["00000000-0000-0000-0000-000000000001"] == ["read"] + + +async def test_session_external_recovers_despite_stale_native_cookie(client, external_auth_settings): # noqa: ARG001 + """A stale/invalid native cookie must not shadow a valid external credential on /session (P1).""" + token = _external_token( + sub="recover-subject", + preferred_username="recover-user", + email="recover@example.com", + ) + + response = await client.get( + "api/v1/session", + headers={ + # A stale/invalid native cookie is present alongside the valid external header. + "Cookie": "access_token_lf=stale-invalid-native-token", + _EXTERNAL_AUTH_HEADER: f"Bearer {token}", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["authenticated"] is True + assert data["user"]["username"] == "recover-user" + + +async def test_get_current_user_external_recovers_despite_stale_native_cookie(client, external_auth_settings): # noqa: ARG001 + """A get_current_user-protected endpoint accepts a valid external credential despite a stale cookie (P1).""" + token = _external_token( + sub="recover-subject-2", + preferred_username="recover-user-2", + email="recover2@example.com", + ) + + # /api/v1/users/whoami is guarded by CurrentActiveUser -> get_current_user. + response = await client.get( + "api/v1/users/whoami", + headers={ + "Cookie": "access_token_lf=stale-invalid-native-token", + _EXTERNAL_AUTH_HEADER: f"Bearer {token}", + }, + ) + + assert response.status_code == 200 + assert response.json()["username"] == "recover-user-2" + + +async def test_external_access_ceiling_blocks_api_key_creation(client, external_auth_settings): + external_auth_settings.EXTERNAL_AUTH_ACCESS_CEILING_ENABLED = True + external_auth_settings.EXTERNAL_AUTH_ACCESS_CLAIM = "openrag_mode" + token = _external_token( + sub="viewer-api-key-subject", + preferred_username="viewer-api-key-user", + openrag_mode="viewer", + ) + + response = await client.post( + "api/v1/api_key/", + headers={_EXTERNAL_AUTH_HEADER: f"Bearer {token}"}, + json={"name": "should-not-work"}, + ) + + assert response.status_code == 403 + assert "API key creation is disabled" in response.json()["detail"] diff --git a/src/backend/tests/unit/test_memory_bases.py b/src/backend/tests/unit/test_memory_bases.py index 2001e7788e..82bfa0bb07 100644 --- a/src/backend/tests/unit/test_memory_bases.py +++ b/src/backend/tests/unit/test_memory_bases.py @@ -451,6 +451,164 @@ class TestMemoryBaseCreateFlowOwnership: assert exc_info.value.status_code == 404 +class TestMemoryBaseGuardPassesRealKbIdentity: + """The ID-bearing guards must pass the REAL kb identity, not actor-as-owner. + + Previously the five F20 guards called ensure_knowledge_base_permission with + kb_user_id=current_user.id and no kb_id, so the owner-override path always + fired (a registered plugin's enforce never ran) and audit rows lacked the kb + id. The fix resolves the memory base first (via the owner-scoped service.get) + and passes kb_id / kb_user_id (the real owner) / kb_name so owner-override is + taken only for genuine owners. + """ + + @pytest.mark.asyncio + async def test_update_passes_real_kb_identity_to_guard(self): + from langflow.api.v1.memories import update_memory_base + from langflow.services.database.models.user.model import User + + owner_id = uuid.uuid4() + mb = _make_mb(user_id=owner_id) + actor = User(id=uuid.uuid4(), username="actor") + + mock_service = MagicMock() + mock_service.get = AsyncMock(return_value=mb) + mock_service.update = AsyncMock(return_value=mb) + captured = {} + + async def _capture_guard(_user, _act, **kwargs): + captured.update(kwargs) + + with ( + patch("langflow.api.v1.memories.get_memory_base_service", return_value=mock_service), + patch("langflow.api.v1.memories.ensure_knowledge_base_permission", _capture_guard), + ): + await update_memory_base( + memory_base_id=mb.id, + current_user=actor, + patch=MemoryBaseUpdate(threshold=99), + ) + + assert captured["kb_id"] == mb.id + assert captured["kb_user_id"] == owner_id, "guard must receive the real owner, not the actor" + assert captured["kb_name"] == mb.kb_name + + @pytest.mark.asyncio + async def test_delete_passes_real_kb_identity_to_guard(self): + from langflow.api.v1.memories import delete_memory_base + from langflow.services.database.models.user.model import User + + owner_id = uuid.uuid4() + mb = _make_mb(user_id=owner_id) + actor = User(id=uuid.uuid4(), username="actor") + + mock_service = MagicMock() + mock_service.get = AsyncMock(return_value=mb) + mock_service.delete = AsyncMock(return_value=True) + captured = {} + + async def _capture_guard(_user, _act, **kwargs): + captured.update(kwargs) + + with ( + patch("langflow.api.v1.memories.get_memory_base_service", return_value=mock_service), + patch("langflow.api.v1.memories.ensure_knowledge_base_permission", _capture_guard), + ): + await delete_memory_base(memory_base_id=mb.id, current_user=actor) + + assert captured["kb_id"] == mb.id + assert captured["kb_user_id"] == owner_id + assert captured["kb_name"] == mb.kb_name + + @pytest.mark.asyncio + async def test_flush_passes_real_kb_identity_to_guard(self): + from langflow.api.v1.memories import FlushRequest, flush_memory_base + from langflow.services.database.models.user.model import User + + owner_id = uuid.uuid4() + mb = _make_mb(user_id=owner_id) + actor = User(id=uuid.uuid4(), username="actor") + + mock_service = MagicMock() + mock_service.get = AsyncMock(return_value=mb) + mock_service.trigger_ingestion = AsyncMock(return_value="job-1") + captured = {} + + async def _capture_guard(_user, _act, **kwargs): + captured.update(kwargs) + + with ( + patch("langflow.api.v1.memories.get_memory_base_service", return_value=mock_service), + patch("langflow.api.v1.memories.ensure_knowledge_base_permission", _capture_guard), + ): + await flush_memory_base( + memory_base_id=mb.id, + current_user=actor, + body=FlushRequest(session_id="sess-1"), + ) + + assert captured["kb_id"] == mb.id + assert captured["kb_user_id"] == owner_id + assert captured["kb_name"] == mb.kb_name + + @pytest.mark.asyncio + async def test_regenerate_passes_real_kb_identity_to_guard(self): + from langflow.api.v1.memories import regenerate_memory_base + from langflow.services.database.models.user.model import User + + owner_id = uuid.uuid4() + mb = _make_mb(user_id=owner_id) + actor = User(id=uuid.uuid4(), username="actor") + + mock_service = MagicMock() + mock_service.get = AsyncMock(return_value=mb) + mock_service.regenerate = AsyncMock(return_value=["job-1"]) + captured = {} + + async def _capture_guard(_user, _act, **kwargs): + captured.update(kwargs) + + with ( + patch("langflow.api.v1.memories.get_memory_base_service", return_value=mock_service), + patch("langflow.api.v1.memories.ensure_knowledge_base_permission", _capture_guard), + ): + await regenerate_memory_base(memory_base_id=mb.id, current_user=actor) + + assert captured["kb_id"] == mb.id + assert captured["kb_user_id"] == owner_id + assert captured["kb_name"] == mb.kb_name + + @pytest.mark.asyncio + async def test_update_returns_404_when_memory_base_not_found(self): + """If the resolve lookup returns None, the handler 404s before the guard runs.""" + from fastapi import HTTPException + from langflow.api.v1.memories import update_memory_base + from langflow.services.database.models.user.model import User + + actor = User(id=uuid.uuid4(), username="actor") + mock_service = MagicMock() + mock_service.get = AsyncMock(return_value=None) + guard_called = False + + async def _guard(*_a, **_k): + nonlocal guard_called + guard_called = True + + with ( + patch("langflow.api.v1.memories.get_memory_base_service", return_value=mock_service), + patch("langflow.api.v1.memories.ensure_knowledge_base_permission", _guard), + pytest.raises(HTTPException) as exc_info, + ): + await update_memory_base( + memory_base_id=uuid.uuid4(), + current_user=actor, + patch=MemoryBaseUpdate(threshold=1), + ) + + assert exc_info.value.status_code == 404 + assert not guard_called, "guard must not run for a non-existent memory base" + + class TestMemoryBaseServiceConcurrency: """409 guard: only one active ingestion per (memory_base_id, session_id).""" @@ -1510,12 +1668,15 @@ class TestMemoriesAPIRouting: """trigger_ingestion raising RuntimeError should map to HTTP 409.""" from langflow.api.v1.memories import flush_memory_base - patched_service.trigger_ingestion = AsyncMock(side_effect=RuntimeError("already in progress")) - # We call the handler directly to test the error mapping mock_user = MagicMock() mock_user.id = uuid.uuid4() + # The guard now resolves the memory base first; let it pass through so the + # error mapping under test (trigger_ingestion -> 409) is exercised. + patched_service.get = AsyncMock(return_value=_make_mb(user_id=mock_user.id)) + patched_service.trigger_ingestion = AsyncMock(side_effect=RuntimeError("already in progress")) + from fastapi import HTTPException from langflow.api.v1.memories import FlushRequest @@ -1607,6 +1768,7 @@ class TestMemoriesAPIHandlers: from langflow.services.memory_base.service import PreprocessingValidationError svc = MagicMock() + svc.get = AsyncMock(return_value=_make_mb(user_id=mock_user.id)) svc.update = AsyncMock(side_effect=PreprocessingValidationError("No API key found for provider 'OpenAI'")) with ( patch("langflow.api.v1.memories.get_memory_base_service", return_value=svc), @@ -1664,6 +1826,7 @@ class TestMemoriesAPIHandlers: mb = _make_mb(user_id=mock_user.id, threshold=99) svc = MagicMock() + svc.get = AsyncMock(return_value=mb) svc.update = AsyncMock(return_value=mb) with patch("langflow.api.v1.memories.get_memory_base_service", return_value=svc): result = await update_memory_base( @@ -1680,6 +1843,7 @@ class TestMemoriesAPIHandlers: from langflow.api.v1.memories import update_memory_base svc = MagicMock() + svc.get = AsyncMock(return_value=_make_mb(user_id=mock_user.id)) svc.update = AsyncMock(return_value=None) with ( patch("langflow.api.v1.memories.get_memory_base_service", return_value=svc), @@ -1702,6 +1866,7 @@ class TestMemoriesAPIHandlers: from langflow.api.v1.memories import delete_memory_base svc = MagicMock() + svc.get = AsyncMock(return_value=_make_mb(user_id=mock_user.id)) svc.delete = AsyncMock(return_value=True) with patch("langflow.api.v1.memories.get_memory_base_service", return_value=svc): result = await delete_memory_base(memory_base_id=uuid.uuid4(), current_user=mock_user) @@ -1714,6 +1879,7 @@ class TestMemoriesAPIHandlers: from langflow.api.v1.memories import delete_memory_base svc = MagicMock() + svc.get = AsyncMock(return_value=_make_mb(user_id=mock_user.id)) svc.delete = AsyncMock(return_value=False) with ( patch("langflow.api.v1.memories.get_memory_base_service", return_value=svc), @@ -1734,6 +1900,7 @@ class TestMemoriesAPIHandlers: job_id = str(uuid.uuid4()) svc = MagicMock() + svc.get = AsyncMock(return_value=_make_mb(user_id=mock_user.id)) svc.trigger_ingestion = AsyncMock(return_value=job_id) with patch("langflow.api.v1.memories.get_memory_base_service", return_value=svc): result = await flush_memory_base( @@ -1750,6 +1917,7 @@ class TestMemoriesAPIHandlers: from langflow.api.v1.memories import FlushRequest, flush_memory_base svc = MagicMock() + svc.get = AsyncMock(return_value=_make_mb(user_id=mock_user.id)) svc.trigger_ingestion = AsyncMock(side_effect=ValueError("memory base not found")) with ( patch("langflow.api.v1.memories.get_memory_base_service", return_value=svc), @@ -1770,6 +1938,7 @@ class TestMemoriesAPIHandlers: from langflow.services.jobs import DuplicateJobError svc = MagicMock() + svc.get = AsyncMock(return_value=_make_mb(user_id=mock_user.id)) svc.trigger_ingestion = AsyncMock(side_effect=DuplicateJobError("already running")) with ( patch("langflow.api.v1.memories.get_memory_base_service", return_value=svc), @@ -1835,6 +2004,7 @@ class TestMemoriesAPIHandlers: job_ids = [str(uuid.uuid4()), str(uuid.uuid4())] svc = MagicMock() + svc.get = AsyncMock(return_value=_make_mb(user_id=mock_user.id)) svc.regenerate = AsyncMock(return_value=job_ids) with patch("langflow.api.v1.memories.get_memory_base_service", return_value=svc): result = await regenerate_memory_base(memory_base_id=uuid.uuid4(), current_user=mock_user) @@ -1847,6 +2017,7 @@ class TestMemoriesAPIHandlers: from langflow.api.v1.memories import regenerate_memory_base svc = MagicMock() + svc.get = AsyncMock(return_value=_make_mb(user_id=mock_user.id)) svc.regenerate = AsyncMock(side_effect=ValueError("not found")) with ( patch("langflow.api.v1.memories.get_memory_base_service", return_value=svc), diff --git a/src/backend/tests/unit/test_redis_job_queue_service.py b/src/backend/tests/unit/test_redis_job_queue_service.py index 403c27202c..83180a7ef7 100644 --- a/src/backend/tests/unit/test_redis_job_queue_service.py +++ b/src/backend/tests/unit/test_redis_job_queue_service.py @@ -2408,6 +2408,200 @@ async def test_polling_watchdog_runs_when_cancel_channel_disabled(): await fake_client.aclose() +# ── Public job registry — Redis-specific tests ─────────────────────────────── +# Complement the base-class unit tests in test_chat_endpoint.py. +# These tests verify the Redis-specific paths: cross-worker fallback (#6) +# and cleanup deleting the Redis key (#7). + + +async def test_redis_public_job_cross_worker_fallback(): + """is_public_job_async returns True for Worker B even when its in-memory set is empty. + + Why: Worker A registers the job (writes local memory + Redis key via background task). + Worker B has no local memory entry — it must fall back to Redis. + This is the multi-worker correctness guarantee of RedisJobQueueService. + """ + fake_client = fakeredis_aio.FakeRedis() + # Worker A — registers the public job + svc_a, _ = await _make_service(shared_client=fake_client, cancel_channel_enabled=False) + # Worker B — shares same Redis, but has empty local memory + svc_b, _ = await _make_service(shared_client=fake_client, cancel_channel_enabled=False) + + try: + job_id = str(uuid.uuid4()) + # register_public_job awaits the Redis write directly (no background task + # to drain) — see Why comment on RedisJobQueueService.register_public_job. + await svc_a.register_public_job(job_id) + + # Worker B must have no in-memory entry (no shared memory between workers) + assert not svc_b.is_public_job(job_id), "Worker B must have no in-memory entry" + + # Why: is_public_job_async on Worker B must hit Redis fallback and return True + assert await svc_b.is_public_job_async(job_id) is True + finally: + await _stop_service(svc_a) + await _stop_service(svc_b) + await fake_client.aclose() + + +async def test_redis_cleanup_removes_public_job_key(): + """cleanup_job deletes the public_job Redis key so the job_id cannot be reused cross-worker. + + Why: after cleanup the in-memory discard is proven by the base-class test in + test_chat_endpoint.py. This test proves the Redis key (cross-worker marker) is + also removed. A missing delete would let a cross-worker is_public_job_async + return True for a finished/evicted job. + """ + svc, fake_client = await _make_service(cancel_channel_enabled=False) + + try: + job_id = str(uuid.uuid4()) + # register_public_job awaits the Redis write directly (no background task + # to drain) — see Why comment on RedisJobQueueService.register_public_job. + await svc.register_public_job(job_id) + + # Confirm the key exists in Redis before cleanup + pub_key = svc._public_job_key(job_id) + assert await fake_client.exists(pub_key), "public_job Redis key must exist after registration" + + # Seed a minimal queue entry so cleanup_job doesn't early-return + svc._queues[job_id] = (asyncio.Queue(), None, None, None) # type: ignore[arg-type] + await svc.cleanup_job(job_id) + + # Drain any background tasks spawned by cleanup + for task in list(svc._background_tasks): + with contextlib.suppress(Exception): + await task + + # Why: if cleanup_job's Redis DEL call ever drops public_job_key, this catches it + assert not await fake_client.exists(pub_key), "public_job Redis key must be deleted after cleanup" + # In-memory also cleared + assert svc.is_public_job(job_id) is False + finally: + await _stop_service(svc) + await fake_client.aclose() + + +@pytest.mark.asyncio +async def test_register_public_job_raises_backend_unavailable_when_marker_write_fails() -> None: + """register_public_job must surface (not swallow) a failed Redis marker write. + + Swallowing the failure would let build_public_tmp return a job_id that only + this worker recognizes — on a multi-worker deployment the public events/cancel + endpoints would 404 it on every other worker. With a Redis client configured, + the failure must raise JobQueueBackendUnavailableError. + """ + from langflow.services.job_queue.service import JobQueueBackendUnavailableError + + service = RedisJobQueueService() + service._client = _PingFailRedis() + with pytest.raises(JobQueueBackendUnavailableError): + await service.register_public_job(str(uuid.uuid4())) + + +@pytest.mark.asyncio +async def test_register_public_job_is_noop_success_for_in_memory_backend() -> None: + """The in-memory base class stays a pure no-op success (no shared marker to persist). + + Single-worker deployments have no Redis client; there is no shared marker, so + register_public_job must not raise and must record the job locally. + """ + service = JobQueueService() + job_id = str(uuid.uuid4()) + await service.register_public_job(job_id) # must not raise + assert service.is_public_job(job_id) is True + + +@pytest.mark.asyncio +async def test_build_public_tmp_returns_503_when_public_marker_persist_fails(monkeypatch) -> None: + """build_public_tmp returns 503 (not an un-shareable job_id) when the marker write fails. + + The build task is started before the public marker is persisted. If the shared + backend write fails, the handler must cancel the just-started build and surface + a clean 503 instead of returning a job_id that other workers cannot resolve. + """ + from fastapi import HTTPException + from langflow.api.v1 import chat as chat_module + + service, fake_client = await _make_service(cancel_channel_enabled=False) + service._POST_CANCEL_CLEANUP_TIMEOUT_S = 0.5 + try: + flow_id = uuid.uuid4() + new_flow_id = uuid.uuid4() + job_id = str(uuid.uuid4()) + service.create_queue(job_id) + + started = asyncio.Event() + cancelled = asyncio.Event() + + async def _build() -> None: + started.set() + try: + await asyncio.sleep(30) + except asyncio.CancelledError: + cancelled.set() + raise + + class _Owner: + id = uuid.uuid4() + + async def _fake_verify_public_flow_and_get_user(**_kwargs): + return _Owner(), new_flow_id + + async def _fake_start_flow_build(**_kwargs): + service.start_job(job_id, _build()) + await asyncio.wait_for(started.wait(), timeout=5) + return job_id + + class _FakeFlow: + data = None + + class _FakeSession: + async def get(self, *_args, **_kwargs): + return _FakeFlow() + + @contextlib.asynccontextmanager + async def _fake_session_scope(): + yield _FakeSession() + + class _FakeSettingsService: + class auth_settings: # noqa: N801 + AUTO_LOGIN = True + + monkeypatch.setattr(chat_module, "verify_public_flow_and_get_user", _fake_verify_public_flow_and_get_user) + monkeypatch.setattr(chat_module, "start_flow_build", _fake_start_flow_build) + monkeypatch.setattr(chat_module, "session_scope", _fake_session_scope) + monkeypatch.setattr(chat_module, "get_settings_service", lambda: _FakeSettingsService()) + + # Redis marker write fails: register_public_job raises JobQueueBackendUnavailableError. + service._client = _PingFailRedis() + + class _FakeRequest: + cookies: dict[str, str] = {"client_id": "test-client"} + + with pytest.raises(HTTPException) as exc_info: + await chat_module.build_public_tmp( + background_tasks=None, + flow_id=flow_id, + inputs=None, + files=None, + stop_component_id=None, + start_component_id=None, + log_builds=False, + flow_name=None, + request=_FakeRequest(), + queue_service=service, + authenticated_user=None, + event_delivery=EventDeliveryType.POLLING, + ) + assert exc_info.value.status_code == 503 + # The just-started build must have been cancelled, not left running unreachable. + await asyncio.wait_for(cancelled.wait(), timeout=5) + finally: + await _stop_service(service) + await fake_client.aclose() + + # --------------------------------------------------------------------------- # Startup connectivity probe + runtime backstop (LE-1396) # --------------------------------------------------------------------------- diff --git a/src/frontend/src/CustomNodes/NoteNode/components/select-items.tsx b/src/frontend/src/CustomNodes/NoteNode/components/select-items.tsx index 183a6c54d6..f6065ac3a8 100644 --- a/src/frontend/src/CustomNodes/NoteNode/components/select-items.tsx +++ b/src/frontend/src/CustomNodes/NoteNode/components/select-items.tsx @@ -9,7 +9,13 @@ import ToolbarSelectItem from "@/pages/FlowPage/components/nodeToolbarComponent/ import type { NoteDataType } from "@/types/flow"; export const SelectItems = memo( - ({ shortcuts, data }: { shortcuts: any[]; data: NoteDataType }) => { + ({ + shortcuts, + data, + }: { + shortcuts: Array<{ name: string; display_name: string; shortcut: string }>; + data: NoteDataType; + }) => { const { t } = useTranslation(); return (