mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-26 04:29:36 +08:00
Merge branch 'release-1.9.0' into feat/globalization-pipeline
This commit is contained in:
16
.github/workflows/nightly_build.yml
vendored
16
.github/workflows/nightly_build.yml
vendored
@ -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 }}
|
||||
|
||||
6
.github/workflows/typescript_test.yml
vendored
6
.github/workflows/typescript_test.yml
vendored
@ -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
|
||||
|
||||
|
||||
1593
.secrets.baseline
1593
.secrets.baseline
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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."
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user