Merge branch 'release-1.9.0' into feat/globalization-pipeline

This commit is contained in:
Ram Gopal Srikar Katakam
2026-03-23 09:21:03 -04:00
committed by GitHub
7 changed files with 354 additions and 1450 deletions

View File

@ -208,12 +208,13 @@ jobs:
frontend-tests-linux:
if: github.repository == 'langflow-ai/langflow' && !inputs.skip_frontend_tests
name: Run Frontend Tests - Linux
needs: create-nightly-tag
needs: [resolve-release-branch, create-nightly-tag]
uses: ./.github/workflows/typescript_test.yml
with:
tests_folder: "tests"
release: true
runs-on: ubuntu-latest
ref: ${{ needs.resolve-release-branch.outputs.branch }}
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
STORE_API_KEY: ${{ secrets.STORE_API_KEY }}
@ -223,16 +224,16 @@ jobs:
frontend-tests-windows:
if: github.repository == 'langflow-ai/langflow' && !inputs.skip_frontend_tests
name: Run Frontend Tests - Windows
needs: create-nightly-tag
# Allow Windows tests to fail without blocking the nightly build
# This gives us visibility into Windows-specific issues while we stabilize the tests
# Remove this once Windows tests are consistently passing
continue-on-error: true
needs: [resolve-release-branch, create-nightly-tag]
# Windows tests are non-blocking - the release-nightly-build job only checks
# frontend-tests-linux.result, allowing Windows tests to fail without blocking the build.
# This gives us visibility into Windows-specific issues while we stabilize the tests.
uses: ./.github/workflows/typescript_test.yml
with:
tests_folder: "tests"
release: true
runs-on: windows-latest
ref: ${{ needs.resolve-release-branch.outputs.branch }}
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
STORE_API_KEY: ${{ secrets.STORE_API_KEY }}
@ -242,11 +243,12 @@ jobs:
backend-unit-tests:
if: github.repository == 'langflow-ai/langflow' && !inputs.skip_backend_tests
name: Run Backend Unit Tests
needs: create-nightly-tag
needs: [resolve-release-branch, create-nightly-tag]
uses: ./.github/workflows/python_test.yml
with:
python-versions: '["3.10", "3.11", "3.12", "3.13"]'
runs-on: ${{ inputs['runs_on'] || github.event.inputs['runs_on'] || 'ubuntu-latest' }}
ref: ${{ needs.resolve-release-branch.outputs.branch }}
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

View File

@ -314,14 +314,14 @@ jobs:
playwright-${{ env.PLAYWRIGHT_VERSION }}-chromium-${{ runner.os }}
- name: Install Playwright Browser Dependencies (Linux)
if: steps.cache-playwright.outputs.cache-hit != 'true' && runner.os == 'Linux' && inputs.runs-on != 'self-hosted'
if: steps.cache-playwright.outputs.cache-hit != 'true' && runner.os == 'Linux' && !contains(inputs.runs-on, 'self-hosted')
shell: bash
run: |
cd ./src/frontend
npx playwright install --with-deps chromium
- name: Install Playwright Browsers (Windows/macOS/Self-Hosted)
if: steps.cache-playwright.outputs.cache-hit != 'true' && (runner.os != 'Linux' || inputs.runs-on == 'self-hosted')
if: steps.cache-playwright.outputs.cache-hit != 'true' && (runner.os != 'Linux' || contains(inputs.runs-on, 'self-hosted'))
shell: bash
run: |
cd ./src/frontend
@ -362,7 +362,7 @@ jobs:
if: always()
uses: actions/upload-artifact@v6
with:
name: blob-report-${{ matrix.shardIndex }}
name: blob-report-${{ runner.os }}-${{ matrix.shardIndex }}
path: src/frontend/blob-report
retention-days: 1

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@ from langflow.api.utils.core import (
DbSessionReadOnly,
EventDeliveryType,
ValidatedFileName,
ValidatedFolderName,
build_and_cache_graph_from_data,
build_graph_from_data,
build_graph_from_db,
@ -55,6 +56,7 @@ __all__ = [
# Enums
"EventDeliveryType",
"ValidatedFileName",
"ValidatedFolderName",
"build_and_cache_graph_from_data",
"build_graph_from_data",
"build_graph_from_db",

View File

@ -55,6 +55,19 @@ def _get_validated_file_name(file_name: str = Path()) -> str:
ValidatedFileName = Annotated[str, Depends(_get_validated_file_name)]
def _get_validated_folder_name(folder_name: str = Path()) -> str:
"""Validate folder_name path parameter to prevent path traversal attacks."""
if ".." in folder_name or "/" in folder_name or "\\" in folder_name:
raise HTTPException(
status_code=400,
detail="Invalid folder name. Use a simple folder name without directory paths or '..'.",
)
return folder_name
ValidatedFolderName = Annotated[str, Depends(_get_validated_folder_name)]
# Message to raise if we're in an Astra cloud environment and a component or endpoint is not supported
disable_endpoint_in_astra_cloud_msg = "This endpoint is not supported in Astra cloud environment."

View File

@ -12,7 +12,7 @@ from fastapi.responses import StreamingResponse
from lfx.services.settings.service import SettingsService
from lfx.utils.helpers import build_content_type_from_extension
from langflow.api.utils import CurrentActiveUser, DbSession, ValidatedFileName
from langflow.api.utils import CurrentActiveUser, DbSession, ValidatedFileName, ValidatedFolderName
from langflow.api.v1.schemas import UploadFileResponse
from langflow.services.database.models.flow.model import Flow
from langflow.services.deps import get_settings_service, get_storage_service
@ -138,12 +138,13 @@ async def download_file(
@router.get("/images/{flow_id}/{file_name}")
async def download_image(
file_name: ValidatedFileName,
flow_id: UUID,
# Security: resolve flow through get_flow so image access requires authentication and owner match.
flow: Annotated[Flow, Depends(get_flow)],
storage_service: Annotated[StorageService, Depends(get_storage_service)],
):
"""Download image from storage for browser rendering."""
storage_service = get_storage_service()
extension = file_name.split(".")[-1]
flow_id_str = str(flow_id)
flow_id_str = str(flow.id)
if not extension:
raise HTTPException(status_code=500, detail=f"Extension not found for file {file_name}")
@ -166,8 +167,8 @@ async def download_image(
@router.get("/profile_pictures/{folder_name}/{file_name}")
async def download_profile_picture(
folder_name: str,
file_name: str,
folder_name: ValidatedFolderName,
file_name: ValidatedFileName,
settings_service: Annotated[SettingsService, Depends(get_settings_service)],
):
"""Download profile picture from local filesystem.
@ -176,34 +177,27 @@ async def download_profile_picture(
then fallback to the package's bundled profile_pictures directory.
"""
try:
# SECURITY: Validate inputs to prevent path traversal attacks
# Reject any path components that contain directory traversal sequences
if ".." in folder_name or ".." in file_name:
raise HTTPException(
status_code=400, detail="Path traversal patterns ('..') are not allowed in folder or file names"
)
# Only allow specific folder names (dynamic from config + package)
allowed_folders = _get_allowed_profile_picture_folders(settings_service)
if folder_name not in allowed_folders:
raise HTTPException(status_code=400, detail=f"Folder must be one of: {', '.join(sorted(allowed_folders))}")
# Validate file name contains no path separators
if "/" in file_name or "\\" in file_name:
raise HTTPException(status_code=400, detail="File name cannot contain path separators ('/' or '\\')")
# SECURITY: Extract only the final path component to prevent path traversal.
# This is defense-in-depth on top of ValidatedFileName/ValidatedFolderName.
safe_folder = Path(folder_name).name
safe_file = Path(file_name).name
extension = file_name.split(".")[-1]
extension = safe_file.split(".")[-1]
config_dir = settings_service.settings.config_dir
config_path = Path(config_dir).resolve() # type: ignore[arg-type]
# Construct the file path
file_path = (config_path / "profile_pictures" / folder_name / file_name).resolve()
file_path = (config_path / "profile_pictures" / safe_folder / safe_file).resolve()
# SECURITY: Verify the resolved path is still within the allowed directory
# This prevents path traversal even if symbolic links are involved
allowed_base = (config_path / "profile_pictures").resolve()
if not str(file_path).startswith(str(allowed_base)):
# Return 404 to prevent path traversal attempts from revealing system structure
if not file_path.is_relative_to(allowed_base):
raise HTTPException(status_code=404, detail="Profile picture not found")
# Fallback to package bundled profile pictures if not found in config_dir
@ -211,18 +205,17 @@ async def download_profile_picture(
from langflow.initial_setup import setup
package_base = Path(setup.__file__).parent / "profile_pictures"
package_path = (package_base / folder_name / file_name).resolve()
package_path = (package_base / safe_folder / safe_file).resolve()
# SECURITY: Verify package path is also within allowed directory
allowed_package_base = package_base.resolve()
if not str(package_path).startswith(str(allowed_package_base)):
# Return 404 to prevent path traversal attempts from revealing system structure
if not package_path.is_relative_to(allowed_package_base):
raise HTTPException(status_code=404, detail="Profile picture not found")
if package_path.exists():
file_path = package_path
else:
raise HTTPException(status_code=404, detail=f"Profile picture {folder_name}/{file_name} not found")
raise HTTPException(status_code=404, detail=f"Profile picture {safe_folder}/{safe_file} not found")
content_type = build_content_type_from_extension(extension)
# Read file directly from local filesystem using async file operations

View File

@ -10,6 +10,7 @@ from pathlib import Path
import anyio
import pytest
from asgi_lifespan import LifespanManager
from fastapi import status
from httpx import ASGITransport, AsyncClient
from langflow.main import create_app
from langflow.services.auth.utils import get_password_hash
@ -50,6 +51,32 @@ async def files_created_api_key(files_client, files_active_user): # noqa: ARG00
await session.delete(key_to_delete)
@pytest.fixture(name="files_other_created_api_key")
async def files_other_created_api_key(files_client, files_other_active_user): # noqa: ARG001
# Separate API key to emulate requests from a second tenant.
hashed = get_password_hash("other_random_key")
api_key = ApiKey(
name="files_other_created_api_key",
user_id=files_other_active_user.id,
api_key="other_random_key",
hashed_api_key=hashed,
)
# Reuse an existing key when present, otherwise create one, then clean it up after the test.
async with session_scope() as session:
stmt = select(ApiKey).where(ApiKey.api_key == api_key.api_key)
if existing_api_key := (await session.exec(stmt)).first():
api_key = existing_api_key
else:
session.add(api_key)
await session.flush()
await session.refresh(api_key)
yield api_key
async with session_scope() as session:
key_to_delete = await session.get(ApiKey, api_key.id)
if key_to_delete:
await session.delete(key_to_delete)
@pytest.fixture(name="files_active_user")
async def files_active_user(files_client): # noqa: ARG001
async with session_scope() as session:
@ -76,6 +103,31 @@ async def files_active_user(files_client): # noqa: ARG001
await session.delete(user)
@pytest.fixture(name="files_other_active_user")
async def files_other_active_user(files_client): # noqa: ARG001
# Secondary user used to validate cross-user access restrictions.
async with session_scope() as session:
user = User(
username="files_other_active_user",
password=get_password_hash("testpassword"),
is_active=True,
is_superuser=False,
)
stmt = select(User).where(User.username == user.username)
if active_user := (await session.exec(stmt)).first():
user = active_user
else:
session.add(user)
await session.flush()
await session.refresh(user)
user = UserRead.model_validate(user, from_attributes=True)
yield user
async with session_scope() as session:
user = await session.get(User, user.id, options=[selectinload(User.flows)])
await _delete_transactions_and_vertex_builds(session, user.flows)
await session.delete(user)
@pytest.fixture(name="files_flow")
async def files_flow(
files_client, # noqa: ARG001
@ -642,8 +694,8 @@ async def test_download_profile_picture_path_traversal_attempt(empty_config_dir,
"""
# Try path traversal to access parent directories
response = await files_client.get("api/v1/files/profile_pictures/../../../etc/passwd")
# Should either be 404 (file not found) or the path should be sanitized
assert response.status_code in [404, 500]
# Should be 400 (invalid input), 404 (not found), or 422 (validation error)
assert response.status_code in [400, 404, 422, 500]
async def test_download_profile_picture_special_characters_in_filename(empty_config_dir, files_client): # noqa: ARG001
@ -781,6 +833,7 @@ async def test_download_image_for_browser(files_client, files_created_api_key, f
# Download the image - simulates browser <img> tag behavior
response = await files_client.get(
f"api/v1/files/images/{files_flow.id}/{file_name}",
headers=headers,
)
assert response.status_code == 200, (
@ -813,7 +866,7 @@ async def test_download_image_returns_correct_content_type(files_client, files_c
file_name = file_path.split("/")[-1]
# Download image
response = await files_client.get(f"api/v1/files/images/{files_flow.id}/{file_name}")
response = await files_client.get(f"api/v1/files/images/{files_flow.id}/{file_name}", headers=headers)
assert response.status_code == 200
assert "image/png" in response.headers.get("content-type", "")
@ -835,33 +888,58 @@ async def test_download_image_rejects_non_image_files(files_client, files_create
file_name = file_path.split("/")[-1]
# Try to download via /images endpoint (should fail)
response = await files_client.get(f"api/v1/files/images/{files_flow.id}/{file_name}")
response = await files_client.get(f"api/v1/files/images/{files_flow.id}/{file_name}", headers=headers)
# Should reject non-image content types
assert response.status_code == 500
assert "not an image" in response.json().get("detail", "").lower()
async def test_download_image_with_invalid_flow_id(files_client):
"""Test that /images returns 500 for non-existent flow_id."""
async def test_download_image_requires_authentication(files_client, files_created_api_key, files_flow):
"""Test that /images rejects unauthenticated requests."""
headers = {"x-api-key": files_created_api_key.api_key}
png_content = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01"
b"\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
)
response = await files_client.post(
f"api/v1/files/upload/{files_flow.id}",
files={"file": ("auth_required.png", png_content, "image/png")},
headers=headers,
)
assert response.status_code == status.HTTP_201_CREATED
file_path = response.json()["file_path"]
file_name = file_path.split("/")[-1]
# No auth header: browser-like unauthenticated request must be rejected.
response = await files_client.get(f"api/v1/files/images/{files_flow.id}/{file_name}")
assert response.status_code == status.HTTP_403_FORBIDDEN
async def test_download_image_with_invalid_flow_id(files_client, files_created_api_key):
"""Test that /images returns 404 for non-existent flow_id when authenticated."""
import uuid
fake_flow_id = uuid.uuid4()
response = await files_client.get(f"api/v1/files/images/{fake_flow_id}/nonexistent.png")
# Should return 500 (file not found)
assert response.status_code == 500
async def test_download_image_browser_compatible(files_client, files_created_api_key, files_flow):
"""Test that /images endpoint works for browser <img> tag rendering.
This ensures the endpoint correctly serves images for chat display.
"""
headers = {"x-api-key": files_created_api_key.api_key}
# Upload an image
response = await files_client.get(f"api/v1/files/images/{fake_flow_id}/nonexistent.png", headers=headers)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_download_image_returns_404_for_other_users_flow(
files_client, files_created_api_key, files_other_created_api_key, files_flow
):
"""Test that /images does not disclose images across tenants."""
owner_headers = {"x-api-key": files_created_api_key.api_key}
other_headers = {"x-api-key": files_other_created_api_key.api_key}
png_content = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01"
@ -871,22 +949,21 @@ async def test_download_image_browser_compatible(files_client, files_created_api
upload_response = await files_client.post(
f"api/v1/files/upload/{files_flow.id}",
files={"file": ("browser_test.png", png_content, "image/png")},
headers=headers,
headers=owner_headers,
)
assert upload_response.status_code == 201
assert upload_response.status_code == status.HTTP_201_CREATED
file_path = upload_response.json()["file_path"]
file_name = file_path.split("/")[-1]
# Download - simulates browser <img> tag
download_response = await files_client.get(f"api/v1/files/images/{files_flow.id}/{file_name}")
assert download_response.status_code == 200, (
f"REGRESSION: /images endpoint broken! "
f"Got status {download_response.status_code}. "
"This breaks browser <img> tags in chat."
# Hide resource existence across tenants by returning 404 for non-owners.
download_response = await files_client.get(
f"api/v1/files/images/{files_flow.id}/{file_name}",
headers=other_headers,
)
assert download_response.status_code == status.HTTP_404_NOT_FOUND
# ============================================================================
# Tests for path traversal protection (ValidatedFileName)