Skip to content

Commit 86a15ee

Browse files
Merge branch 'feat/a11y' into a11y/auth-regression-coverage
2 parents ea900a9 + d82723e commit 86a15ee

66 files changed

Lines changed: 5592 additions & 190 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/lint-js.yml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,16 @@ jobs:
9090
RELATIVE_FILES=$(echo "$CHANGED_FILES" | sed 's|^src/frontend/||')
9191
9292
cd src/frontend
93+
# NUL-delimit so paths containing spaces (e.g. starter-project
94+
# specs like "Basic Prompting.spec.ts") aren't split by xargs.
9395
if ${{ inputs.allow-failure || 'false' }}; then
94-
echo "$RELATIVE_FILES" | xargs npx @biomejs/biome check \
95-
--files-ignore-unknown=true \
96-
--diagnostic-level=error || echo "Biome found errors (non-blocking for this run)."
96+
printf '%s\n' "$RELATIVE_FILES" | tr '\n' '\0' \
97+
| xargs -0 npx @biomejs/biome check \
98+
--files-ignore-unknown=true \
99+
--diagnostic-level=error || echo "Biome found errors (non-blocking for this run)."
97100
else
98-
echo "$RELATIVE_FILES" | xargs npx @biomejs/biome check \
99-
--files-ignore-unknown=true \
100-
--diagnostic-level=error
101+
printf '%s\n' "$RELATIVE_FILES" | tr '\n' '\0' \
102+
| xargs -0 npx @biomejs/biome check \
103+
--files-ignore-unknown=true \
104+
--diagnostic-level=error
101105
fi

.secrets.baseline

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2817,7 +2817,7 @@
28172817
"filename": "src/backend/base/langflow/services/auth/utils.py",
28182818
"hashed_secret": "b894b81be94cf8fa8d7536475aaec876addf05c8",
28192819
"is_verified": false,
2820-
"line_number": 59,
2820+
"line_number": 75,
28212821
"is_secret": false
28222822
}
28232823
],
@@ -2827,7 +2827,7 @@
28272827
"filename": "src/backend/base/langflow/services/database/models/api_key/crud.py",
28282828
"hashed_secret": "920f8f5815b381ea692e9e7c2f7119f2b1aa620a",
28292829
"is_verified": false,
2830-
"line_number": 112,
2830+
"line_number": 134,
28312831
"is_secret": false
28322832
}
28332833
],
@@ -4007,15 +4007,15 @@
40074007
"filename": "src/backend/tests/unit/test_login.py",
40084008
"hashed_secret": "8bb6118f8fd6935ad0876a3be34a717d32708ffd",
40094009
"is_verified": false,
4010-
"line_number": 26,
4010+
"line_number": 97,
40114011
"is_secret": false
40124012
},
40134013
{
40144014
"type": "Secret Keyword",
40154015
"filename": "src/backend/tests/unit/test_login.py",
40164016
"hashed_secret": "d8ecf7db8fc9ec9c31bc5c9ae2929cc599c75f8d",
40174017
"is_verified": false,
4018-
"line_number": 42,
4018+
"line_number": 113,
40194019
"is_secret": false
40204020
}
40214021
],
@@ -9287,5 +9287,5 @@
92879287
}
92889288
]
92899289
},
9290-
"generated_at": "2026-06-10T18:13:35Z"
9290+
"generated_at": "2026-06-17T19:13:55Z"
92919291
}

src/backend/base/langflow/api/v1/api_key.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ async def create_api_key_route(
3636
try:
3737
user_id = current_user.id
3838
return await create_api_key(db, req, user_id=user_id)
39+
except PermissionError as e:
40+
raise HTTPException(status_code=403, detail=str(e)) from e
3941
except Exception as e:
4042
raise HTTPException(status_code=400, detail=str(e)) from e
4143

src/backend/base/langflow/api/v1/authz_me.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from pydantic import BaseModel, Field, field_validator
1818

1919
from langflow.api.utils import CurrentActiveUser
20+
from langflow.services.auth.context import current_auth_context_for_authz
21+
from langflow.services.authorization.access_ceiling import filter_actions_by_external_access_ceiling
2022
from langflow.services.deps import get_authorization_service
2123

2224
router = APIRouter(prefix="/authz/me", tags=["Authorization"])
@@ -127,8 +129,15 @@ async def get_effective_permissions(
127129
resource_ids=body.resource_ids,
128130
actions=actions,
129131
domain=body.domain,
130-
context={"is_superuser": getattr(current_user, "is_superuser", False)},
132+
context={
133+
**current_auth_context_for_authz(),
134+
"is_superuser": current_user.is_superuser,
135+
},
131136
)
137+
permissions = {
138+
resource_id: filter_actions_by_external_access_ceiling(allowed_actions)
139+
for resource_id, allowed_actions in permissions.items()
140+
}
132141
return EffectivePermissionsResponse(
133142
resource_type=body.resource_type,
134143
permissions=permissions,

src/backend/base/langflow/api/v1/chat.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,9 +887,26 @@ async def build_public_tmp(
887887
queue_service=queue_service,
888888
flow_name=flow_name or f"{authenticated_user_id or client_id}_{flow_id}",
889889
)
890+
# Gate the public events/cancel endpoints to jobs that were actually
891+
# started through this public build path, preventing unauthenticated
892+
# callers from reading or cancelling private-flow builds by job_id.
893+
await queue_service.register_public_job(job_id)
890894
except CustomComponentValidationError as exc:
891895
await logger.awarning(f"Public flow validation failed: {exc}")
892896
raise HTTPException(status_code=400, detail="This flow cannot be executed.") from exc
897+
except JobQueueBackendUnavailableError as exc:
898+
# The public marker could not be persisted to the shared (Redis) backend.
899+
# Returning the job_id anyway would hand back an un-shareable id: on a
900+
# multi-worker deployment every other worker's public events/cancel
901+
# endpoints would 404 it. Cancel the just-started build (best-effort) and
902+
# surface a clean 503 instead of a 500 / an unusable job_id.
903+
try:
904+
await queue_service.cancel_job(job_id)
905+
except Exception as cancel_exc: # noqa: BLE001
906+
await logger.awarning(
907+
f"Failed to cancel public job {job_id} after marker persistence failed: {cancel_exc!r}"
908+
)
909+
raise HTTPException(status_code=503, detail=str(exc)) from exc
893910
except ValueError as exc:
894911
raise HTTPException(status_code=400, detail=str(exc)) from exc
895912
except Exception as exc:
@@ -906,6 +923,20 @@ async def build_public_tmp(
906923
)
907924

908925

926+
async def _assert_public_job(job_id: str, queue_service: JobQueueService) -> None:
927+
"""Raise HTTP 404 if job_id was not registered through the public build endpoint.
928+
929+
Prevents unauthenticated callers from reading or cancelling private-flow
930+
builds by guessing or leaking a job_id.
931+
932+
Why 404 not 403: returning 403 would confirm the job exists under a different
933+
access tier, leaking information about private builds. 404 is neutral.
934+
"""
935+
if not await queue_service.is_public_job_async(job_id):
936+
# Static detail — do not reflect job_id back; avoid confirming which IDs exist.
937+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
938+
939+
909940
@router.get("/build_public_tmp/{job_id}/events")
910941
async def get_build_events_public(
911942
job_id: str,
@@ -918,6 +949,7 @@ async def get_build_events_public(
918949
This endpoint does not require authentication, matching the public build endpoint.
919950
It is used by the shareable playground to consume build events.
920951
"""
952+
await _assert_public_job(job_id, queue_service)
921953
return await get_flow_events_response(
922954
job_id=job_id,
923955
queue_service=queue_service,
@@ -938,6 +970,7 @@ async def cancel_build_public(
938970
This endpoint does not require authentication, matching the public build endpoint.
939971
It is used by the shareable playground to cancel builds.
940972
"""
973+
await _assert_public_job(job_id, queue_service)
941974
try:
942975
cancellation_success = await cancel_flow_build(job_id=job_id, queue_service=queue_service)
943976

src/backend/base/langflow/api/v1/endpoints.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@
6161
get_optional_user,
6262
)
6363
from langflow.services.authorization import FlowAction, ensure_flow_permission
64+
from langflow.services.authorization.access_ceiling import (
65+
external_access_allows,
66+
get_current_external_access_context,
67+
)
6468
from langflow.services.cache.utils import save_uploaded_file
6569
from langflow.services.database.models.flow.model import Flow, FlowRead
6670
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:
12261230
async def create_upload_file(
12271231
file: UploadFile,
12281232
flow: Annotated[Flow, Depends(get_flow)],
1233+
current_user: CurrentActiveUser,
12291234
settings_service: Annotated[SettingsService, Depends(get_settings_service)],
12301235
) -> UploadFileResponse:
12311236
"""Upload a file for a specific flow (Deprecated).
@@ -1237,6 +1242,17 @@ async def create_upload_file(
12371242
``/api/v1/files/upload/{flow_id}`` so authenticated callers can't fill
12381243
disk through this route either.
12391244
"""
1245+
# Writing a file to a flow's storage is a flow mutation: enforce WRITE so
1246+
# the external access ceiling (e.g. a "viewer") cannot upload via this
1247+
# deprecated route. Mirrors the non-deprecated twin in files.py.
1248+
await ensure_flow_permission(
1249+
current_user,
1250+
FlowAction.WRITE,
1251+
flow_id=flow.id,
1252+
flow_user_id=flow.user_id,
1253+
workspace_id=flow.workspace_id,
1254+
folder_id=flow.folder_id,
1255+
)
12401256
try:
12411257
max_file_size_upload = settings_service.settings.max_file_size_upload
12421258
except Exception as exc:
@@ -1273,6 +1289,19 @@ async def custom_component(
12731289
user: CurrentActiveUser,
12741290
request: Request,
12751291
) -> CustomComponentResponse:
1292+
# Building a custom component instantiates (and partially executes) posted
1293+
# code. That is a create/write-class action, so enforce the external access
1294+
# ceiling directly: a "viewer" external identity is denied while
1295+
# editor/admin (and all non-external users) pass unchanged. This route is
1296+
# not tied to a single owned resource, so the deny-only primitive is used
1297+
# instead of an ``ensure_*_permission`` guard.
1298+
external_context = get_current_external_access_context()
1299+
if external_context is not None and not external_access_allows("create", external_context):
1300+
raise HTTPException(
1301+
status_code=status.HTTP_403_FORBIDDEN,
1302+
detail="External credentials do not allow this action",
1303+
)
1304+
12761305
settings_service = get_settings_service()
12771306
settings = settings_service.settings
12781307
all_known = None
@@ -1348,6 +1377,17 @@ async def custom_component_update(
13481377
HTTPException: If an error occurs during component building or updating.
13491378
SerializationError: If serialization of the updated component node fails.
13501379
"""
1380+
# Updating a custom component instantiates (and partially executes) posted
1381+
# code, a create/write-class action. Enforce the external access ceiling
1382+
# directly so a "viewer" external identity is denied; editor/admin (and all
1383+
# non-external users) pass unchanged. Same action string as ``custom_component``.
1384+
external_context = get_current_external_access_context()
1385+
if external_context is not None and not external_access_allows("create", external_context):
1386+
raise HTTPException(
1387+
status_code=status.HTTP_403_FORBIDDEN,
1388+
detail="External credentials do not allow this action",
1389+
)
1390+
13511391
settings_service = get_settings_service()
13521392
all_known = None
13531393
if _requires_component_hash_lookups(settings_service.settings, user):

src/backend/base/langflow/api/v1/files.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
build_content_disposition,
2222
)
2323
from langflow.api.v1.schemas import UploadFileResponse
24+
from langflow.services.authorization import FlowAction, ensure_flow_permission
2425
from langflow.services.database.models.flow.model import Flow
2526
from langflow.services.deps import get_settings_service, get_storage_service
2627
from langflow.services.storage.service import StorageService
@@ -84,9 +85,20 @@ async def upload_file(
8485
*,
8586
file: UploadFile,
8687
flow: Annotated[Flow, Depends(get_flow)],
88+
current_user: CurrentActiveUser,
8789
storage_service: Annotated[StorageService, Depends(get_storage_service)],
8890
settings_service: Annotated[SettingsService, Depends(get_settings_service)],
8991
) -> UploadFileResponse:
92+
# Writing a file to a flow's storage is a flow mutation: enforce WRITE so
93+
# the external access ceiling (e.g. a "viewer") cannot upload via this route.
94+
await ensure_flow_permission(
95+
current_user,
96+
FlowAction.WRITE,
97+
flow_id=flow.id,
98+
flow_user_id=flow.user_id,
99+
workspace_id=flow.workspace_id,
100+
folder_id=flow.folder_id,
101+
)
90102
try:
91103
max_file_size_upload = settings_service.settings.max_file_size_upload
92104
except Exception as e:
@@ -290,8 +302,19 @@ async def list_files(
290302
async def delete_file(
291303
file_name: ValidatedFileName,
292304
flow: Annotated[Flow, Depends(get_flow)],
305+
current_user: CurrentActiveUser,
293306
storage_service: Annotated[StorageService, Depends(get_storage_service)],
294307
):
308+
# Deleting a file from a flow's storage mutates the flow's attachments;
309+
# enforce WRITE so the external access ceiling (e.g. a "viewer") is honored.
310+
await ensure_flow_permission(
311+
current_user,
312+
FlowAction.WRITE,
313+
flow_id=flow.id,
314+
flow_user_id=flow.user_id,
315+
workspace_id=flow.workspace_id,
316+
folder_id=flow.folder_id,
317+
)
295318
try:
296319
await storage_service.delete_file(flow_id=str(flow.id), file_name=file_name)
297320
except Exception as e:

src/backend/base/langflow/api/v1/flow_version.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from langflow.api.utils.core import remove_api_keys
1515
from langflow.api.v1.mappers.deployments.helpers import get_owned_provider_account_or_404
1616
from langflow.api.v1.mappers.deployments.sync import sync_flow_version_attachments
17+
from langflow.services.authorization import FlowAction, ensure_flow_permission
1718
from langflow.services.database.models.flow.model import Flow, FlowRead
1819
from langflow.services.database.models.flow_version.crud import (
1920
create_flow_version_entry,
@@ -201,6 +202,14 @@ async def create_snapshot(
201202
body: FlowVersionCreate | None = None,
202203
) -> FlowVersionRead:
203204
flow = await _get_user_flow(session, flow_id, current_user.id)
205+
await ensure_flow_permission(
206+
current_user,
207+
FlowAction.WRITE,
208+
flow_id=flow.id,
209+
flow_user_id=flow.user_id,
210+
workspace_id=flow.workspace_id,
211+
folder_id=flow.folder_id,
212+
)
204213
description = body.description if body else None
205214

206215
try:
@@ -234,6 +243,14 @@ async def activate_version(
234243
save_draft: Annotated[bool, Query()] = True,
235244
) -> FlowRead:
236245
flow = await _get_user_flow(session, flow_id, current_user.id)
246+
await ensure_flow_permission(
247+
current_user,
248+
FlowAction.WRITE,
249+
flow_id=flow.id,
250+
flow_user_id=flow.user_id,
251+
workspace_id=flow.workspace_id,
252+
folder_id=flow.folder_id,
253+
)
237254

238255
# Verify version entry belongs to this flow
239256
try:
@@ -299,7 +316,15 @@ async def delete_version_entry(
299316
current_user: CurrentActiveUser,
300317
session: DbSession,
301318
) -> None:
302-
await _get_user_flow(session, flow_id, current_user.id)
319+
flow = await _get_user_flow(session, flow_id, current_user.id)
320+
await ensure_flow_permission(
321+
current_user,
322+
FlowAction.DELETE,
323+
flow_id=flow.id,
324+
flow_user_id=flow.user_id,
325+
workspace_id=flow.workspace_id,
326+
folder_id=flow.folder_id,
327+
)
303328

304329
# Verify entry belongs to this flow, then delete
305330
try:

src/backend/base/langflow/api/v1/login.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from langflow.api.utils import DbSession
1313
from langflow.api.v1.schemas import Token
1414
from langflow.initial_setup.setup import get_or_create_default_folder
15+
from langflow.services.auth.exceptions import AuthenticationError
1516
from langflow.services.database.models.user.crud import get_user_by_id
1617
from langflow.services.database.models.user.model import UserRead
1718
from langflow.services.deps import get_auth_service, get_settings_service, get_variable_service
@@ -235,24 +236,30 @@ async def get_session(
235236
It does not raise an error if unauthenticated, allowing the frontend to gracefully
236237
handle the session state.
237238
"""
238-
from langflow.services.auth.utils import oauth2_login
239+
from langflow.services.auth.utils import _get_external_token, oauth2_login
239240

240241
# Try to get the token from the request (cookie or Authorization header)
241242
try:
242243
token = await oauth2_login(request)
243-
if not token:
244+
# Extract the external credential separately so a present-but-invalid
245+
# native cookie can't shadow a valid external one (mirrors get_current_user
246+
# and the WS/SSE paths). oauth2_login may already have collapsed to the
247+
# external credential, in which case the service's dedup guard makes the
248+
# fallback a no-op.
249+
external_token = _get_external_token(request.headers, request.cookies)
250+
if not token and not external_token:
244251
return SessionResponse(authenticated=False)
245252

246253
# Validate the token and get user
247-
user = await get_auth_service().get_current_user_from_access_token(token, db)
254+
user = await get_auth_service().get_current_user_from_access_token(token, db, external_token=external_token)
248255
if not user or not user.is_active:
249256
return SessionResponse(authenticated=False)
250257

251258
return SessionResponse(
252259
authenticated=True,
253260
user=UserRead.model_validate(user, from_attributes=True),
254261
)
255-
except (HTTPException, ValueError) as _:
262+
except (AuthenticationError, HTTPException, ValueError) as _:
256263
# Any authentication error means not authenticated
257264
return SessionResponse(authenticated=False)
258265

0 commit comments

Comments
 (0)