From 1cc068163c3faab187ff4b96c6daaf53c095cf29 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Tue, 5 May 2026 12:23:05 -0400 Subject: [PATCH 1/2] auth hardening and xss hardening updates --- ...-access-control-prevention.instructions.md | 107 +++ .../xss-prevention.instructions.md | 133 ++++ .../workflows/broken-access-control-check.yml | 66 ++ .github/workflows/xss-sink-check.yml | 70 ++ application/single_app/config.py | 3 +- .../single_app/functions_agent_templates.py | 33 +- application/single_app/functions_approvals.py | 47 +- application/single_app/functions_documents.py | 82 ++- application/single_app/functions_group.py | 27 +- application/single_app/functions_keyvault.py | 206 +++++- .../single_app/functions_public_workspaces.py | 91 ++- application/single_app/functions_search.py | 92 ++- application/single_app/functions_settings.py | 165 +++-- application/single_app/route_backend_chats.py | 665 +++++++++++------ .../route_backend_control_center.py | 135 ++-- .../single_app/route_backend_conversations.py | 45 +- .../single_app/route_backend_documents.py | 36 +- .../single_app/route_backend_feedback.py | 49 +- .../route_backend_group_documents.py | 27 +- .../single_app/route_backend_group_prompts.py | 56 +- .../single_app/route_backend_groups.py | 7 + .../single_app/route_backend_plugins.py | 45 +- .../route_backend_public_documents.py | 27 +- .../route_backend_public_prompts.py | 79 +- .../route_backend_public_workspaces.py | 20 +- application/single_app/route_backend_users.py | 64 +- .../route_frontend_admin_settings.py | 2 +- .../route_frontend_conversations.py | 31 +- .../route_frontend_group_workspaces.py | 18 +- .../route_frontend_public_workspaces.py | 10 +- .../single_app/route_plugin_logging.py | 5 +- .../single_app/semantic_kernel_loader.py | 112 ++- .../fact_memory_plugin.py | 101 ++- .../log_analytics_plugin.py | 33 +- .../tabular_processing_plugin.py | 218 +++++- .../single_app/static/images/custom_logo.png | Bin 11877 -> 149607 bytes .../static/images/custom_logo_dark.png | Bin 13468 -> 109738 bytes .../static/js/admin/admin_agents.js | 15 +- .../static/js/agent_templates_gallery.js | 7 +- .../static/js/chat/chat-citations.js | 12 +- .../js/chat/chat-conversation-details.js | 125 +++- .../static/js/chat/chat-documents.js | 33 +- .../static/js/chat/chat-input-actions.js | 211 +++--- .../static/js/chat/chat-messages.js | 56 +- .../single_app/static/js/chat/chat-toast.js | 36 +- .../single_app/static/js/control-center.js | 32 +- .../static/js/group/manage_group.js | 118 +-- .../js/public/manage_public_workspace.js | 170 ++++- .../static/js/public/public_directory.js | 2 +- .../static/js/public/public_workspace.js | 422 ++++++++--- .../single_app/static/js/workspace-manager.js | 26 +- .../js/workspace/group-documents-sharing.js | 140 +++- .../workspace/workspace-documents-sharing.js | 141 +++- .../single_app/templates/admin_settings.html | 4 +- application/single_app/templates/chats.html | 2 +- .../single_app/templates/control_center.html | 8 +- .../BROKEN_ACCESS_CONTROL_PR_GUARDRAILS.md | 94 +++ .../features/v0.241.022/XSS_PR_GUARDRAILS.md | 99 +++ ...EMPLATE_GALLERY_ACTIONS_TO_LOAD_XSS_FIX.md | 64 ++ ...HORIZATION_STATE_CONFUSION_SETTINGS_FIX.md | 144 ++++ ...ROKEN_ACCESS_CONTROL_IDOR_HARDENING_FIX.md | 146 ++++ ...E_LOCK_AND_CONVERSATION_DETAILS_XSS_FIX.md | 42 ++ ...TED_DOCUMENT_METADATA_AUTHORIZATION_FIX.md | 82 +++ ...ITATION_AND_FILE_MODAL_FILENAME_XSS_FIX.md | 53 ++ ...CENTER_PUBLIC_WORKSPACE_MEMBERS_XSS_FIX.md | 74 ++ ...DBACK_AND_PLUGIN_LOG_ACCESS_CONTROL_FIX.md | 113 +++ ...ULT_PLUGIN_SECRET_SCOPE_ENFORCEMENT_FIX.md | 52 ++ ...YTICS_PLUGIN_USER_SCOPE_ENFORCEMENT_FIX.md | 56 ++ ...ONVERSATION_AUTHORIZATION_FOLLOW_UP_FIX.md | 122 ++++ ...NAL_CONVERSATION_READ_AUTHORIZATION_FIX.md | 81 +++ ...PLUGIN_LOG_RECENT_INVOCATIONS_ADMIN_FIX.md | 83 +++ ...PUBLIC_WORKSPACE_DETAILS_DISCLOSURE_FIX.md | 71 ++ .../PUBLIC_WORKSPACE_TAG_COLOR_XSS_FIX.md | 62 ++ .../SECURITY_AUTHORIZATION_HARDENING_FIX.md | 241 +++++++ .../STORED_XSS_ADMIN_RENDERING_FIX.md | 50 ++ ...ORED_XSS_AGENT_AND_MEMBER_RENDERING_FIX.md | 62 ++ ...ORED_XSS_SHARE_ACTIVITY_AND_MASKING_FIX.md | 47 ++ .../UPLOADED_FILE_PREVIEW_XSS_FIX.md | 56 ++ .../WEB_SEARCH_EGRESS_HARDENING_FIX.md | 147 ++++ docs/explanation/release_notes.md | 142 ++++ ...emplate_gallery_actions_to_load_xss_fix.py | 176 +++++ ...roken_access_control_guardrails_checker.py | 225 ++++++ ...elected_document_metadata_authorization.py | 357 +++++++++ ...versations_read_ownership_authorization.py | 448 ++++++++++++ .../test_feedback_submission_authorization.py | 382 ++++++++++ ...eyvault_plugin_secret_scope_enforcement.py | 274 +++++++ ...analytics_plugin_user_scope_enforcement.py | 195 +++++ .../test_multimedia_support_reorganization.py | 8 +- ...nal_conversation_followup_authorization.py | 680 ++++++++++++++++++ ...plugin_logging_clear_logs_authorization.py | 344 +++++++++ ...test_public_workspace_tag_color_xss_fix.py | 212 ++++++ .../test_security_authorization_hardening.py | 577 +++++++++++++++ .../test_stored_xss_admin_rendering_fix.py | 196 +++++ ...test_stored_xss_chat_modal_filename_fix.py | 123 ++++ ...chat_scope_and_conversation_details_fix.py | 123 ++++ ...stored_xss_chat_workspace_rendering_fix.py | 215 ++++++ ...ored_xss_share_activity_and_masking_fix.py | 240 +++++++ ..._tabular_all_scope_group_source_context.py | 13 +- .../test_uploaded_file_preview_xss_fix.py | 107 +++ .../test_web_search_current_message_only.py | 138 ++++ .../test_xss_guardrails_checker.py | 163 +++++ scripts/check_broken_access_control.py | 630 ++++++++++++++++ scripts/check_xss_sinks.py | 481 +++++++++++++ ...agent_template_gallery_actions_escaping.py | 138 ++++ .../test_chat_messages_authorization_error.py | 63 ++ ui_tests/test_chat_modal_filename_escaping.py | 117 +++ ..._lock_and_conversation_details_escaping.py | 250 +++++++ ...t_control_center_group_members_escaping.py | 93 +++ ...ontrol_center_public_workspace_escaping.py | 100 +++ ...enter_public_workspace_members_escaping.py | 92 +++ .../test_document_share_modal_escaping.py | 253 +++++++ ...oup_workspace_member_rendering_escaping.py | 191 +++++ ...lic_workspace_member_rendering_escaping.py | 182 +++++ ...blic_workspace_projection_non_member_ui.py | 158 ++++ ...st_public_workspace_tag_color_rendering.py | 169 +++++ .../test_uploaded_file_preview_escaping.py | 143 ++++ ui_tests/test_web_search_notice_copy.py | 56 ++ 117 files changed, 13922 insertions(+), 1040 deletions(-) create mode 100644 .github/instructions/broken-access-control-prevention.instructions.md create mode 100644 .github/instructions/xss-prevention.instructions.md create mode 100644 .github/workflows/broken-access-control-check.yml create mode 100644 .github/workflows/xss-sink-check.yml create mode 100644 docs/explanation/features/v0.241.022/BROKEN_ACCESS_CONTROL_PR_GUARDRAILS.md create mode 100644 docs/explanation/features/v0.241.022/XSS_PR_GUARDRAILS.md create mode 100644 docs/explanation/fixes/v0.241.022/AGENT_TEMPLATE_GALLERY_ACTIONS_TO_LOAD_XSS_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/AUTHORIZATION_STATE_CONFUSION_SETTINGS_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/BROKEN_ACCESS_CONTROL_IDOR_HARDENING_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/CHAT_SCOPE_LOCK_AND_CONVERSATION_DETAILS_XSS_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/CHAT_SELECTED_DOCUMENT_METADATA_AUTHORIZATION_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/CITATION_AND_FILE_MODAL_FILENAME_XSS_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/CONTROL_CENTER_PUBLIC_WORKSPACE_MEMBERS_XSS_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/FEEDBACK_AND_PLUGIN_LOG_ACCESS_CONTROL_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/KEY_VAULT_PLUGIN_SECRET_SCOPE_ENFORCEMENT_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/LOG_ANALYTICS_PLUGIN_USER_SCOPE_ENFORCEMENT_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/PERSONAL_CONVERSATION_AUTHORIZATION_FOLLOW_UP_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/PERSONAL_CONVERSATION_READ_AUTHORIZATION_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/PLUGIN_LOG_RECENT_INVOCATIONS_ADMIN_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/PUBLIC_WORKSPACE_DETAILS_DISCLOSURE_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/PUBLIC_WORKSPACE_TAG_COLOR_XSS_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/SECURITY_AUTHORIZATION_HARDENING_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/STORED_XSS_ADMIN_RENDERING_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/STORED_XSS_AGENT_AND_MEMBER_RENDERING_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/STORED_XSS_SHARE_ACTIVITY_AND_MASKING_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/UPLOADED_FILE_PREVIEW_XSS_FIX.md create mode 100644 docs/explanation/fixes/v0.241.022/WEB_SEARCH_EGRESS_HARDENING_FIX.md create mode 100644 functional_tests/test_agent_template_gallery_actions_to_load_xss_fix.py create mode 100644 functional_tests/test_broken_access_control_guardrails_checker.py create mode 100644 functional_tests/test_chat_selected_document_metadata_authorization.py create mode 100644 functional_tests/test_conversations_read_ownership_authorization.py create mode 100644 functional_tests/test_feedback_submission_authorization.py create mode 100644 functional_tests/test_keyvault_plugin_secret_scope_enforcement.py create mode 100644 functional_tests/test_log_analytics_plugin_user_scope_enforcement.py create mode 100644 functional_tests/test_personal_conversation_followup_authorization.py create mode 100644 functional_tests/test_plugin_logging_clear_logs_authorization.py create mode 100644 functional_tests/test_public_workspace_tag_color_xss_fix.py create mode 100644 functional_tests/test_security_authorization_hardening.py create mode 100644 functional_tests/test_stored_xss_admin_rendering_fix.py create mode 100644 functional_tests/test_stored_xss_chat_modal_filename_fix.py create mode 100644 functional_tests/test_stored_xss_chat_scope_and_conversation_details_fix.py create mode 100644 functional_tests/test_stored_xss_chat_workspace_rendering_fix.py create mode 100644 functional_tests/test_stored_xss_share_activity_and_masking_fix.py create mode 100644 functional_tests/test_uploaded_file_preview_xss_fix.py create mode 100644 functional_tests/test_web_search_current_message_only.py create mode 100644 functional_tests/test_xss_guardrails_checker.py create mode 100644 scripts/check_broken_access_control.py create mode 100644 scripts/check_xss_sinks.py create mode 100644 ui_tests/test_agent_template_gallery_actions_escaping.py create mode 100644 ui_tests/test_chat_messages_authorization_error.py create mode 100644 ui_tests/test_chat_modal_filename_escaping.py create mode 100644 ui_tests/test_chat_scope_lock_and_conversation_details_escaping.py create mode 100644 ui_tests/test_control_center_group_members_escaping.py create mode 100644 ui_tests/test_control_center_public_workspace_escaping.py create mode 100644 ui_tests/test_control_center_public_workspace_members_escaping.py create mode 100644 ui_tests/test_document_share_modal_escaping.py create mode 100644 ui_tests/test_group_workspace_member_rendering_escaping.py create mode 100644 ui_tests/test_public_workspace_member_rendering_escaping.py create mode 100644 ui_tests/test_public_workspace_projection_non_member_ui.py create mode 100644 ui_tests/test_public_workspace_tag_color_rendering.py create mode 100644 ui_tests/test_uploaded_file_preview_escaping.py create mode 100644 ui_tests/test_web_search_notice_copy.py diff --git a/.github/instructions/broken-access-control-prevention.instructions.md b/.github/instructions/broken-access-control-prevention.instructions.md new file mode 100644 index 000000000..394e6cd3e --- /dev/null +++ b/.github/instructions/broken-access-control-prevention.instructions.md @@ -0,0 +1,107 @@ +--- +applyTo: '**/*.py' +--- + +# Security: Broken Access Control Prevention + +## Critical Requirement + +**NEVER treat caller-supplied ids or stored active-scope settings as authorization decisions after login.** + +Treat all of the following as untrusted authorization inputs unless the code proves otherwise: + +- `conversation_id`, `message_id`, `document_id`, `file_id`, `approval_id`, `group_id`, and `public_workspace_id` +- `activeGroupOid` and `activePublicWorkspaceOid` values loaded from user settings +- Plugin or tool-call arguments such as `user_id`, `conversation_id`, `group_id`, `public_workspace_id`, `scope_id`, and `scope_type` + +## Preferred Safe Patterns + +Use these patterns by default: + +- Revalidate personal conversation ownership with `_authorize_personal_conversation_read(...)`, `_authorize_personal_conversation_access(...)`, or an explicit owner check before reading dependent data. +- Route `activeGroupOid` writes through `update_active_group_for_user(...)`. +- Route `activePublicWorkspaceOid` writes through `update_active_public_workspace_for_user(...)`. +- Resolve active group scope through `require_active_group(...)` instead of raw settings reads in backend and plugin code. +- Resolve active public workspace scope through `require_active_public_workspace(...)` instead of raw settings reads in backend and plugin code. +- In Semantic Kernel plugins, normalize tool-call scope ids through `_resolve_authorized_scope_arguments(...)`, `_resolve_blob_location_with_fallback(...)`, or `_resolve_authorized_fact_memory_call(...)` before storage, blob, or Cosmos access. +- Prefer request-scoped authorization context such as `g.authorized_chat_context` over raw tool arguments. + +## Disallowed Patterns For New Code + +Do not add new code that does any of the following without a reviewed exception: + +- Call `update_user_settings(...)` with a literal `{"activeGroupOid": ...}` payload outside `update_active_group_for_user(...)` +- Call `update_user_settings(...)` with a literal `{"activePublicWorkspaceOid": ...}` payload outside `update_active_public_workspace_for_user(...)` +- Read `activeGroupOid` or `activePublicWorkspaceOid` directly from raw settings in backend routes or plugins when a shared validator exists +- Expose `user_id`, `conversation_id`, `group_id`, `public_workspace_id`, `scope_id`, or `scope_type` in a `@kernel_function` surface without immediately rebinding those values to the authorized request context +- Read a personal conversation by request-derived `conversation_id` and continue to message, blob, or feedback work without an explicit ownership boundary + +## Safe Examples + +```python +conversation_item = _authorize_personal_conversation_read(user_id, conversation_id) +messages = list( + cosmos_messages_container.query_items( + query=query, + partition_key=conversation_item['id'], + ) +) +``` + +```python +update_active_group_for_user(requested_active_group, user_id=user_id) +active_group_id = require_active_group(user_id) +``` + +```python +authorized_scope = self._resolve_authorized_fact_memory_call( + scope_type=scope_type, + scope_id=scope_id, + conversation_id=conversation_id, +) +``` + +## Unsafe Examples + +```python +update_user_settings(user_id, {'activeGroupOid': group_id}) +``` + +```python +active_group_id = settings.get('settings', {}).get('activeGroupOid') +``` + +```python +@kernel_function(name='unsafe_tool') +def unsafe_tool(self, user_id: str, conversation_id: str, group_id: str = ''): + return self.store.lookup(user_id=user_id, conversation_id=conversation_id, group_id=group_id) +``` + +```python +conversation_item = cosmos_conversations_container.read_item( + item=conversation_id, + partition_key=conversation_id, +) +``` + +## PR Review Checklist + +For any Python change that reads or mutates user, group, workspace, conversation, or plugin-scoped data: + +1. Identify every caller-controlled id that crosses into a data read or mutation. +2. Revalidate ownership or membership at the sensitive operation boundary, not just at route entry. +3. Use the dedicated active-scope validators instead of raw settings reads and writes. +4. Rebind plugin scope parameters to the authorized request context before storage, blob, or Cosmos access. +5. Add or update a regression test when the change touches an authorization boundary. + +## Workflow Guardrail + +This repository includes a Development PR check in `.github/workflows/broken-access-control-check.yml` backed by `scripts/check_broken_access_control.py`. + +If a reviewed exception is unavoidable, add the suppression token below near the specific line and include a justification comment: + +```text +bac-check: ignore +``` + +Use that escape hatch rarely. It is for reviewed legacy exceptions, not normal route or plugin code. \ No newline at end of file diff --git a/.github/instructions/xss-prevention.instructions.md b/.github/instructions/xss-prevention.instructions.md new file mode 100644 index 000000000..aa9d156d0 --- /dev/null +++ b/.github/instructions/xss-prevention.instructions.md @@ -0,0 +1,133 @@ +--- +applyTo: '**/*.js, **/*.html, **/*.py' +--- + +# Security: XSS Prevention and Browser Rendering + +## Critical Requirement + +**NEVER pass untrusted data into browser HTML or JavaScript execution sinks without an explicit safe boundary.** + +Treat all of the following as untrusted unless the code proves otherwise: + +- User profile fields, workspace names, group names, agent names, document titles, filenames, tags, descriptions, emails, and ids +- API response values returned from storage, Microsoft Graph, Cosmos DB, Azure AI Search, or any plugin/tool response +- Markdown, rich text, uploaded text files, generated summaries, model output, and any server-returned error string + +## Preferred Safe Patterns + +Use these patterns by default: + +- Create DOM nodes with `document.createElement(...)` +- Set untrusted text with `textContent` +- Set trusted static classes with `className` +- Use `setAttribute(...)` or `dataset` for inert data only when DOM node creation is not practical +- Attach behavior with `addEventListener(...)` +- Normalize dynamic HTTP links with a helper such as `sanitizeHttpUrl(...)` before assigning `href` or `src` +- Sanitize rendered markdown with `DOMPurify.sanitize(marked.parse(...))` before inserting HTML +- Keep static modal or card shells fully static, then populate untrusted fields with DOM APIs after creation + +## Disallowed Patterns For New Code + +Do not add new code that does any of the following with untrusted values: + +- `innerHTML`, `outerHTML`, `insertAdjacentHTML`, or jQuery `.html(...)` +- Inline event handlers such as `onclick=`, `onerror=`, `onload=`, or `setAttribute('onclick', ...)` +- Dynamic interpolation into HTML attributes such as `href`, `src`, `title`, `style`, or `data-*` +- `javascript:` URLs +- `Markup(...)` in Python on untrusted content +- Jinja `|safe` on untrusted content +- `marked.parse(...)` output rendered without `DOMPurify.sanitize(...)` + +## Safe Examples + +### JavaScript + +```javascript +const row = document.createElement('tr'); +const nameCell = document.createElement('td'); +nameCell.textContent = user.displayName || 'Unknown User'; + +const actionButton = document.createElement('button'); +actionButton.type = 'button'; +actionButton.dataset.userId = user.id || ''; +actionButton.addEventListener('click', handleUserClick); + +row.appendChild(nameCell); +row.appendChild(actionButton); +``` + +```javascript +const renderedHtml = DOMPurify.sanitize(marked.parse(markdownText || '')); +markdownContainer.innerHTML = renderedHtml; +``` + +### HTML / Jinja + +```html + +``` + +### Python + +```python +return render_template( + 'page.html', + title=page_title, + items=items, +) +``` + +## Unsafe Examples + +```javascript +row.innerHTML = `${user.displayName}`; +``` + +```javascript +button.setAttribute('onclick', `selectUser('${user.id}', '${user.displayName}')`); +``` + +```html +Run +``` + +```python +return Markup(user_supplied_html) +``` + +```html +{{ user_supplied_html|safe }} +``` + +## Static HTML Shell Exception + +When a static HTML shell is genuinely simpler, it is acceptable only if: + +- The HTML string is fully static +- It contains no `${...}` interpolation or dynamic concatenation +- Untrusted values are populated afterward with `textContent`, `setAttribute(...)`, or `dataset` + +## PR Review Checklist + +For any JavaScript, HTML, or Python change that affects browser rendering: + +1. Identify the trust boundary for every value that reaches the browser. +2. Prefer DOM node creation and `textContent` for untrusted text. +3. Normalize dynamic URLs before assigning them to clickable or loadable attributes. +4. If HTML rendering is required, document the sanitizer boundary explicitly. +5. Add or update a regression test when untrusted data reaches a browser-rendering path. + +## Workflow Guardrail + +This repository includes a Development PR check in `.github/workflows/xss-sink-check.yml` backed by `scripts/check_xss_sinks.py`. + +If a reviewed exception is unavoidable, add the suppression token below near the specific line and include a justification comment: + +```text +xss-check: ignore +``` + +Use that escape hatch rarely. It is for reviewed legacy exceptions, not for normal rendering code. \ No newline at end of file diff --git a/.github/workflows/broken-access-control-check.yml b/.github/workflows/broken-access-control-check.yml new file mode 100644 index 000000000..33869b28f --- /dev/null +++ b/.github/workflows/broken-access-control-check.yml @@ -0,0 +1,66 @@ +name: Broken Access Control Check + +on: + pull_request: + branches: + - Development + paths: + - 'application/**/*.py' + - 'scripts/check_broken_access_control.py' + - 'functional_tests/test_broken_access_control_guardrails_checker.py' + - '.github/workflows/broken-access-control-check.yml' + - '.github/instructions/broken-access-control-prevention.instructions.md' + +jobs: + broken-access-control-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Get changed Python files + id: changed-files + uses: tj-actions/changed-files@v46.0.1 + with: + files_yaml: | + bac_surface: + - 'application/**/*.py' + bac_guardrails: + - 'scripts/check_broken_access_control.py' + - 'functional_tests/test_broken_access_control_guardrails_checker.py' + - '.github/workflows/broken-access-control-check.yml' + - '.github/instructions/broken-access-control-prevention.instructions.md' + - 'docs/explanation/features/v0.241.022/BROKEN_ACCESS_CONTROL_PR_GUARDRAILS.md' + + - name: Run Broken Access Control validation + env: + CHANGED_BAC_FILES: ${{ steps.changed-files.outputs.bac_surface_all_changed_files }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.sha }} + run: | + if [[ -z "$CHANGED_BAC_FILES" ]]; then + echo "No changed application files detected for Broken Access Control validation." + exit 0 + fi + + echo "Changed application files:" + printf '%s\n' "$CHANGED_BAC_FILES" | tr ' ' '\n' + + python scripts/check_broken_access_control.py \ + --base-sha "$GITHUB_BASE_SHA" \ + --head-sha "$GITHUB_HEAD_SHA" \ + $CHANGED_BAC_FILES + + - name: Run Broken Access Control guardrail self-test (advisory) + if: steps.changed-files.outputs.bac_guardrails_any_changed == 'true' + continue-on-error: true + run: | + python functional_tests/test_broken_access_control_guardrails_checker.py \ No newline at end of file diff --git a/.github/workflows/xss-sink-check.yml b/.github/workflows/xss-sink-check.yml new file mode 100644 index 000000000..938df6061 --- /dev/null +++ b/.github/workflows/xss-sink-check.yml @@ -0,0 +1,70 @@ +name: XSS Sink Check + +on: + pull_request: + branches: + - Development + paths: + - 'application/**/*.js' + - 'application/**/*.html' + - 'application/**/*.py' + - 'scripts/check_xss_sinks.py' + - 'functional_tests/test_xss_guardrails_checker.py' + - '.github/workflows/xss-sink-check.yml' + - '.github/instructions/xss-prevention.instructions.md' + +jobs: + xss-sink-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Get changed XSS-related files + id: changed-files + uses: tj-actions/changed-files@v46.0.1 + with: + files_yaml: | + xss_surface: + - 'application/**/*.js' + - 'application/**/*.html' + - 'application/**/*.py' + xss_guardrails: + - 'scripts/check_xss_sinks.py' + - 'functional_tests/test_xss_guardrails_checker.py' + - '.github/workflows/xss-sink-check.yml' + - '.github/instructions/xss-prevention.instructions.md' + - 'docs/explanation/features/v0.241.021/XSS_PR_GUARDRAILS.md' + + - name: Run XSS sink validation + env: + CHANGED_XSS_FILES: ${{ steps.changed-files.outputs.xss_surface_all_changed_files }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.sha }} + run: | + if [[ -z "$CHANGED_XSS_FILES" ]]; then + echo "No changed application files detected for XSS sink validation." + exit 0 + fi + + echo "Changed application files:" + printf '%s\n' "$CHANGED_XSS_FILES" | tr ' ' '\n' + + python scripts/check_xss_sinks.py \ + --base-sha "$GITHUB_BASE_SHA" \ + --head-sha "$GITHUB_HEAD_SHA" \ + $CHANGED_XSS_FILES + + - name: Run XSS guardrail self-test (advisory) + if: steps.changed-files.outputs.xss_guardrails_any_changed == 'true' + continue-on-error: true + run: | + python functional_tests/test_xss_guardrails_checker.py \ No newline at end of file diff --git a/application/single_app/config.py b/application/single_app/config.py index 7196cfe80..4d0cb7f0a 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.241.006" +VERSION = "0.241.022" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') @@ -150,6 +150,7 @@ def get_allowed_extensions(enable_video=False, enable_audio=False): Args: enable_video: Whether video file support is enabled + enable_audio: Whether audio file support is enabled Returns: set: Allowed file extensions """ diff --git a/application/single_app/functions_agent_templates.py b/application/single_app/functions_agent_templates.py index f5cda8a3f..af566ef88 100644 --- a/application/single_app/functions_agent_templates.py +++ b/application/single_app/functions_agent_templates.py @@ -102,10 +102,32 @@ def _serialize_additional_settings(raw: Any) -> str: return json.dumps(parsed, indent=2, sort_keys=True) +def _normalize_actions_to_load(actions: Any, strict: bool = False) -> List[str]: + if actions in (None, ""): + return [] + if not isinstance(actions, list): + if strict: + raise ValueError("actions_to_load must be an array of strings") + return [] + + cleaned: List[str] = [] + for action in actions: + if isinstance(action, str): + trimmed = action.strip() + elif strict: + raise ValueError("actions_to_load entries must be strings") + else: + trimmed = str(action).strip() + + if trimmed: + cleaned.append(trimmed) + + return cleaned + + def _sanitize_template(doc: Dict[str, Any], include_internal: bool = False) -> Dict[str, Any]: cleaned = _strip_metadata(doc) - cleaned.setdefault('actions_to_load', []) - cleaned['actions_to_load'] = [a for a in cleaned['actions_to_load'] if a] + cleaned['actions_to_load'] = _normalize_actions_to_load(cleaned.get('actions_to_load')) cleaned.setdefault('tags', []) cleaned['tags'] = [str(tag)[:64] for tag in cleaned['tags']] cleaned['helper_text'] = _normalize_helper_text( @@ -287,7 +309,7 @@ def _base_template_from_payload(payload: Dict[str, Any], user_info: Optional[Dic tags = payload.get('tags') or [] tags = [str(tag)[:64] for tag in tags] - actions = [str(action) for action in (payload.get('actions_to_load') or []) if action] + actions = _normalize_actions_to_load(payload.get('actions_to_load'), strict=True) template = { 'id': payload.get('id') or str(uuid.uuid4()), @@ -366,6 +388,11 @@ def update_agent_template(template_id: str, updates: Dict[str, Any]) -> Optional else: payload['additional_settings'] = _parse_additional_settings(doc.get('additional_settings')) + if 'actions_to_load' in payload: + payload['actions_to_load'] = _normalize_actions_to_load(payload['actions_to_load'], strict=True) + else: + payload['actions_to_load'] = _normalize_actions_to_load(doc.get('actions_to_load')) + if 'tags' in payload: payload['tags'] = [str(tag)[:64] for tag in payload['tags']] diff --git a/application/single_app/functions_approvals.py b/application/single_app/functions_approvals.py index a6c733467..b755d5c2c 100644 --- a/application/single_app/functions_approvals.py +++ b/application/single_app/functions_approvals.py @@ -277,7 +277,8 @@ def approve_request( approver_id: str, approver_email: str, approver_name: str, - comment: Optional[str] = None + comment: Optional[str] = None, + approval: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """ Approve an approval request. @@ -295,10 +296,11 @@ def approve_request( """ try: # Get the approval request - approval = cosmos_approvals_container.read_item( - item=approval_id, - partition_key=group_id - ) + if approval is None: + approval = cosmos_approvals_container.read_item( + item=approval_id, + partition_key=group_id + ) # Validate status if approval['status'] != STATUS_PENDING: @@ -368,7 +370,8 @@ def deny_request( denier_email: str, denier_name: str, comment: str, - auto_denied: bool = False + auto_denied: bool = False, + approval: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """ Deny an approval request. @@ -387,10 +390,11 @@ def deny_request( """ try: # Get the approval request - approval = cosmos_approvals_container.read_item( - item=approval_id, - partition_key=group_id - ) + if approval is None: + approval = cosmos_approvals_container.read_item( + item=approval_id, + partition_key=group_id + ) # Validate status (allow denying pending requests) if approval['status'] not in [STATUS_PENDING]: @@ -543,6 +547,29 @@ def get_approval_by_id(approval_id: str, group_id: str) -> Optional[Dict[str, An return None +def get_authorized_approval( + approval_id: str, + group_id: str, + user_id: str, + user_roles: List[str], + require_approval_rights: bool = False, +) -> Dict[str, Any]: + """Return an approval only if the current user is allowed to view or approve it.""" + approval = get_approval_by_id(approval_id, group_id) + if not approval: + raise LookupError("Approval not found") + + is_authorized = ( + _can_user_approve(approval, user_id, user_roles) + if require_approval_rights + else _can_user_view(approval, user_id, user_roles) + ) + if not is_authorized: + raise PermissionError("You are not authorized to access this approval") + + return approval + + def auto_deny_expired_approvals() -> int: """ Auto-deny approval requests that have expired (older than 3 days). diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py index 7c6e4a272..362729ffd 100644 --- a/application/single_app/functions_documents.py +++ b/application/single_app/functions_documents.py @@ -1,5 +1,6 @@ # functions_documents.py that has some changes I need to merge into Development +import re import traceback from config import * from functions_content import * @@ -20,6 +21,7 @@ def allowed_file(filename, allowed_extensions=None): ARCHIVED_SCOPE_PREFIX = "__archived__::" CURRENT_ALIAS_BLOB_PATH_MODE = "current_alias" ARCHIVED_REVISION_BLOB_PATH_MODE = "archived_revision" +TAG_COLOR_PATTERN = re.compile(r'^#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$') def _get_blob_container_name(group_id=None, public_workspace_id=None): @@ -7566,6 +7568,58 @@ def sanitize_tags_for_filter(raw_tags): return valid_tags +def normalize_tag_color(color): + """ + Normalize a tag color to a canonical 6-digit lowercase hex code. + Returns None for invalid values. + """ + if not isinstance(color, str): + return None + + normalized_color = color.strip() + if not normalized_color: + return None + + if not TAG_COLOR_PATTERN.fullmatch(normalized_color): + return None + + if not normalized_color.startswith('#'): + normalized_color = f'#{normalized_color}' + + if len(normalized_color) == 4: + normalized_color = '#' + ''.join(component * 2 for component in normalized_color[1:]) + + return normalized_color.lower() + + +def get_safe_tag_color(color, tag_name): + """ + Return a normalized tag color or the deterministic default for the tag. + """ + normalized_color = normalize_tag_color(color) + if normalized_color: + return normalized_color + + safe_tag_name = normalize_tag(tag_name) or str(tag_name or '') + return get_default_tag_color(safe_tag_name) + + +def validate_tag_color(color, tag_name): + """ + Validate a requested tag color. + Returns (is_valid, error_message, normalized_color). + Missing colors resolve to the deterministic default for the tag. + """ + if color is None: + return True, None, get_safe_tag_color(None, tag_name) + + normalized_color = normalize_tag_color(color) + if not normalized_color: + return False, 'Tag color must be a valid 3- or 6-digit hex color', None + + return True, None, normalized_color + + def get_workspace_tags(user_id, group_id=None, public_workspace_id=None): """ Get all unique tags used in a workspace with document counts. @@ -7662,7 +7716,7 @@ def get_workspace_tags(user_id, group_id=None, public_workspace_id=None): results.append({ 'name': tag_name, 'count': count, - 'color': tag_def.get('color', get_default_tag_color(tag_name)) + 'color': get_safe_tag_color(tag_def.get('color'), tag_name) }) # Add defined tags that haven't been used yet (count = 0) @@ -7671,7 +7725,7 @@ def get_workspace_tags(user_id, group_id=None, public_workspace_id=None): results.append({ 'name': tag_name, 'count': 0, - 'color': tag_def.get('color', get_default_tag_color(tag_name)) + 'color': get_safe_tag_color(tag_def.get('color'), tag_name) }) # Sort by count descending, then name ascending @@ -7728,34 +7782,40 @@ def get_or_create_tag_definition(user_id, tag_name, workspace_type='personal', c """ from datetime import datetime, timezone + safe_color = get_safe_tag_color(color, tag_name) + if workspace_type == 'group' and group_id: from functions_group import find_group_by_id group_doc = find_group_by_id(group_id) if not group_doc: - return {'color': color or get_default_tag_color(tag_name)} + return {'color': safe_color} tag_defs = group_doc.get('tag_definitions', {}) if tag_name not in tag_defs: tag_defs[tag_name] = { - 'color': color if color else get_default_tag_color(tag_name), + 'color': safe_color, 'created_at': datetime.now(timezone.utc).isoformat() } group_doc['tag_definitions'] = tag_defs cosmos_groups_container.upsert_item(group_doc) - return tag_defs[tag_name] + stored_tag_def = dict(tag_defs[tag_name]) + stored_tag_def['color'] = get_safe_tag_color(stored_tag_def.get('color'), tag_name) + return stored_tag_def elif workspace_type == 'public' and public_workspace_id: from functions_public_workspaces import find_public_workspace_by_id ws_doc = find_public_workspace_by_id(public_workspace_id) if not ws_doc: - return {'color': color or get_default_tag_color(tag_name)} + return {'color': safe_color} tag_defs = ws_doc.get('tag_definitions', {}) if tag_name not in tag_defs: tag_defs[tag_name] = { - 'color': color if color else get_default_tag_color(tag_name), + 'color': safe_color, 'created_at': datetime.now(timezone.utc).isoformat() } ws_doc['tag_definitions'] = tag_defs cosmos_public_workspaces_container.upsert_item(ws_doc) - return tag_defs[tag_name] + stored_tag_def = dict(tag_defs[tag_name]) + stored_tag_def['color'] = get_safe_tag_color(stored_tag_def.get('color'), tag_name) + return stored_tag_def else: # Personal: store in user settings from functions_settings import get_user_settings, update_user_settings @@ -7771,12 +7831,14 @@ def get_or_create_tag_definition(user_id, tag_name, workspace_type='personal', c if tag_name not in workspace_tags: workspace_tags[tag_name] = { - 'color': color if color else get_default_tag_color(tag_name), + 'color': safe_color, 'created_at': datetime.now(timezone.utc).isoformat() } update_user_settings(user_id, {'tag_definitions': tag_definitions}) - return workspace_tags[tag_name] + stored_tag_def = dict(workspace_tags[tag_name]) + stored_tag_def['color'] = get_safe_tag_color(stored_tag_def.get('color'), tag_name) + return stored_tag_def def propagate_tags_to_blob_metadata(document_id, tags, user_id, group_id=None, public_workspace_id=None): diff --git a/application/single_app/functions_group.py b/application/single_app/functions_group.py index e50b09f60..2c60fab58 100644 --- a/application/single_app/functions_group.py +++ b/application/single_app/functions_group.py @@ -1,6 +1,8 @@ # functions_group.py from config import * +import functions_authentication +import functions_settings from functions_authentication import * from functions_settings import * from typing import Iterable @@ -103,12 +105,20 @@ def find_group_by_id(group_id): except exceptions.CosmosResourceNotFoundError: return None -def update_active_group_for_user(group_id): - user_id = get_current_user_id() +def update_active_group_for_user(group_id, user_id=None): + if not user_id: + user_id = functions_authentication.get_current_user_id() + + assert_group_role( + user_id, + group_id, + allowed_roles=("Owner", "Admin", "DocumentManager", "User"), + ) + new_settings = { "activeGroupOid": group_id } - update_user_settings(user_id, new_settings) + functions_settings.update_user_settings(user_id, new_settings) def get_user_role_in_group(group_doc, user_id): """Determine the user's role in the given group doc.""" @@ -129,12 +139,17 @@ def get_user_role_in_group(group_doc, user_id): return None -def require_active_group(user_id: str) -> str: - """Return the active group id for a user or raise ValueError if missing.""" - settings = get_user_settings(user_id) +def require_active_group( + user_id: str, + allowed_roles: Iterable[str] = ("Owner", "Admin", "DocumentManager", "User"), +) -> str: + """Return the active group id for a user after validating current membership.""" + settings = functions_settings.get_user_settings(user_id) active_group_id = settings.get("settings", {}).get("activeGroupOid") if not active_group_id: raise ValueError("No active group selected") + + assert_group_role(user_id, active_group_id, allowed_roles=allowed_roles) return active_group_id diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index a523eeaa9..c40977507 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -60,6 +60,109 @@ class SecretReturnType(Enum): NAME = "name" +def _normalize_allowed_sources(allowed_sources): + """Normalize one or many allowed sources into a comparable set.""" + if allowed_sources is None: + return None + if isinstance(allowed_sources, str): + return {allowed_sources} + return { + str(source).strip() + for source in allowed_sources + if str(source).strip() + } + + +def parse_secret_name_dynamic(secret_name): + """Return parsed Key Vault secret reference parts when the name is valid.""" + scopes_pattern = '|'.join(re.escape(scope) for scope in supported_scopes) + sources_pattern = '|'.join(re.escape(source) for source in supported_sources) + pattern = ( + rf"^(?P.+?)--(?P{sources_pattern})--" + rf"(?P{scopes_pattern})--(?P.+)$" + ) + match = re.match(pattern, secret_name) + if not match: + return None + if len(secret_name) > 127: + return None + return match.groupdict() + + +def secret_reference_matches_context(secret_name, scope_value=None, scope=None, allowed_sources=None): + """Return True when a secret reference belongs to the expected scope and source.""" + parsed = parse_secret_name_dynamic(secret_name) + if not parsed: + return False + + normalized_sources = _normalize_allowed_sources(allowed_sources) + expected_scope_value = None + if scope_value is not None: + expected_scope_value = clean_name_for_keyvault(str(scope_value)) + + if expected_scope_value is not None and parsed["scope_value"] != expected_scope_value: + return False + if scope is not None and parsed["scope"] != scope: + return False + if normalized_sources is not None and parsed["source"] not in normalized_sources: + return False + return True + + +def _log_secret_reference_context_mismatch(secret_name, context_label, scope_value=None, scope=None, allowed_sources=None): + """Emit a warning when a stored secret reference does not match its expected context.""" + parsed = parse_secret_name_dynamic(secret_name) or {} + expected_scope_value = None + if scope_value is not None: + expected_scope_value = clean_name_for_keyvault(str(scope_value)) + + log_event( + f"[KeyVault] Rejected mismatched secret reference for {context_label}.", + extra={ + "context_label": context_label, + "expected_scope_value": expected_scope_value, + "expected_scope": scope, + "allowed_sources": sorted(_normalize_allowed_sources(allowed_sources) or []), + "provided_scope_value": parsed.get("scope_value"), + "provided_scope": parsed.get("scope"), + "provided_source": parsed.get("source"), + }, + level=logging.WARNING, + ) + + +def resolve_secret_reference_for_context( + secret_name, + scope_value=None, + scope=None, + allowed_sources=None, + context_label="secret reference", +): + """Resolve a Key Vault reference only when it matches the expected context.""" + if not validate_secret_name_dynamic(secret_name): + return secret_name + + if not secret_reference_matches_context( + secret_name, + scope_value=scope_value, + scope=scope, + allowed_sources=allowed_sources, + ): + _log_secret_reference_context_mismatch( + secret_name, + context_label, + scope_value=scope_value, + scope=scope, + allowed_sources=allowed_sources, + ) + raise ValueError(f"Stored Key Vault reference for {context_label} does not match the expected scope.") + + resolved_value = retrieve_secret_from_key_vault_by_full_name(secret_name) + if validate_secret_name_dynamic(resolved_value): + raise ValueError(f"Unable to resolve stored Key Vault secret for {context_label}.") + return resolved_value + + def _get_nested_dict_value(data, path): """Return a nested dictionary value, or None when the path is missing.""" current = data @@ -119,10 +222,28 @@ def _store_plugin_secret_reference(updated_plugin, existing_plugin, path, secret if not value: return + path_label = ".".join(path) + existing_reference = _get_existing_secret_reference(existing_plugin, path) if value == ui_trigger_word: if existing_reference: + if not secret_reference_matches_context( + existing_reference, + scope_value=scope_value, + scope=scope, + allowed_sources={source}, + ): + _log_secret_reference_context_mismatch( + existing_reference, + f"plugin field '{path_label}' existing reference", + scope_value=scope_value, + scope=scope, + allowed_sources={source}, + ) + raise ValueError( + f"Stored Key Vault reference for '{path_label}' no longer matches the expected scope. Re-enter the secret value." + ) _set_nested_dict_value(updated_plugin, path, existing_reference) return _set_nested_dict_value( @@ -133,6 +254,22 @@ def _store_plugin_secret_reference(updated_plugin, existing_plugin, path, secret return if validate_secret_name_dynamic(value): + if not secret_reference_matches_context( + value, + scope_value=scope_value, + scope=scope, + allowed_sources={source}, + ): + _log_secret_reference_context_mismatch( + value, + f"plugin field '{path_label}'", + scope_value=scope_value, + scope=scope, + allowed_sources={source}, + ) + raise ValueError( + f"Stored Key Vault reference for '{path_label}' does not match the expected scope." + ) _set_nested_dict_value(updated_plugin, path, value) return @@ -377,18 +514,7 @@ def validate_secret_name_dynamic(secret_name): Returns: bool: True if valid, False otherwise. """ - # Build regex pattern dynamically - scopes_pattern = '|'.join(re.escape(scope) for scope in supported_scopes) - sources_pattern = '|'.join(re.escape(source) for source in supported_sources) - # Wildcards for secret_name and scope_value - pattern = rf"^(.+)--({sources_pattern})--({scopes_pattern})--(.+)$" - match = re.match(pattern, secret_name) - if not match: - return False - # Optionally, check length - if len(secret_name) > 127: - return False - return True + return parse_secret_name_dynamic(secret_name) is not None def keyvault_agent_save_helper(agent_dict, scope_value, scope="global"): """ @@ -616,8 +742,20 @@ def keyvault_plugin_get_helper(plugin_dict, scope_value, scope="global", return_ value = auth.get(auth_field) if value and validate_secret_name_dynamic(value): try: + is_expected_reference = secret_reference_matches_context( + value, + scope_value=scope_value, + scope=scope, + allowed_sources={"action"}, + ) if return_type == SecretReturnType.VALUE: - new_auth[auth_field] = retrieve_secret_from_key_vault_by_full_name(value) + new_auth[auth_field] = resolve_secret_reference_for_context( + value, + scope_value=scope_value, + scope=scope, + allowed_sources={"action"}, + context_label=f"action auth field '{auth_field}'", + ) elif return_type == SecretReturnType.NAME: new_auth[auth_field] = value else: @@ -635,8 +773,20 @@ def keyvault_plugin_get_helper(plugin_dict, scope_value, scope="global", return_ for k, v in additional_fields.items(): if (k.endswith('__Secret') or _is_sql_sensitive_additional_field(updated, k)) and v and validate_secret_name_dynamic(v): try: + is_expected_reference = secret_reference_matches_context( + v, + scope_value=scope_value, + scope=scope, + allowed_sources={"action-addset"}, + ) if return_type == SecretReturnType.VALUE: - new_additional_fields[k] = retrieve_secret_from_key_vault_by_full_name(v) + new_additional_fields[k] = resolve_secret_reference_for_context( + v, + scope_value=scope_value, + scope=scope, + allowed_sources={"action-addset"}, + context_label=f"action additional field '{k}'", + ) elif return_type == SecretReturnType.NAME: new_additional_fields[k] = v else: @@ -834,6 +984,20 @@ def keyvault_plugin_delete_helper(plugin_dict, scope_value, scope="global"): for auth_field in ('key', *SQL_PLUGIN_SENSITIVE_AUTH_FIELDS): secret_name = auth.get(auth_field) if secret_name and validate_secret_name_dynamic(secret_name): + if not secret_reference_matches_context( + secret_name, + scope_value=scope_value, + scope=scope, + allowed_sources={"action"}, + ): + _log_secret_reference_context_mismatch( + secret_name, + f"action auth field '{auth_field}' deletion", + scope_value=scope_value, + scope=scope, + allowed_sources={"action"}, + ) + continue try: key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" log_event(f"Deleting action auth secret '{auth_field}' for action '{plugin_name}' for '{scope}' '{scope_value}'", level=logging.INFO) @@ -847,6 +1011,20 @@ def keyvault_plugin_delete_helper(plugin_dict, scope_value, scope="global"): if isinstance(additional_fields, dict): for k, v in additional_fields.items(): if (k.endswith('__Secret') or _is_sql_sensitive_additional_field(plugin_dict, k)) and v and validate_secret_name_dynamic(v): + if not secret_reference_matches_context( + v, + scope_value=scope_value, + scope=scope, + allowed_sources={"action-addset"}, + ): + _log_secret_reference_context_mismatch( + v, + f"action additional field '{k}' deletion", + scope_value=scope_value, + scope=scope, + allowed_sources={"action-addset"}, + ) + continue try: key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" log_event(f"Deleting action additionalField secret '{k}' for action '{plugin_name}' for '{scope}' '{scope_value}'", level=logging.INFO) diff --git a/application/single_app/functions_public_workspaces.py b/application/single_app/functions_public_workspaces.py index 45e5f80e6..8039845f4 100644 --- a/application/single_app/functions_public_workspaces.py +++ b/application/single_app/functions_public_workspaces.py @@ -1,8 +1,10 @@ # functions_public_workspaces.py from config import * +import functions_settings from functions_authentication import * from functions_group import * +from typing import Iterable def create_public_workspace(name: str, description: str) -> dict: """ @@ -114,13 +116,57 @@ def get_user_role_in_public_workspace(ws_doc: dict, user_id: str) -> str | None: return None if ws_doc.get("owner", {}).get("userId") == user_id: return "Owner" - if user_id in ws_doc.get("admins", []): - return "Admin" + for admin in ws_doc.get("admins", []): + if isinstance(admin, str) and admin == user_id: + return "Admin" + if isinstance(admin, dict) and admin.get("userId") == user_id: + return "Admin" if any(dm["userId"] == user_id for dm in ws_doc.get("documentManagers", [])): return "DocumentManager" return None +def build_public_workspace_public_summary(ws_doc: dict) -> dict: + """Return the non-sensitive workspace fields safe for any authenticated caller.""" + owner = ws_doc.get("owner", {}) or {} + return { + "id": ws_doc.get("id", ""), + "name": ws_doc.get("name", ""), + "description": ws_doc.get("description", ""), + "owner": { + "displayName": owner.get("displayName", ""), + }, + "status": ws_doc.get("status", "active"), + "heroColor": ws_doc.get("heroColor", "#0078d4"), + "userRole": None, + "isMember": False, + } + + +def build_public_workspace_member_payload(ws_doc: dict, user_id: str) -> dict: + """Return the workspace fields required by member-facing workspace pages.""" + role = get_user_role_in_public_workspace(ws_doc, user_id) + owner = ws_doc.get("owner", {}) or {} + payload = { + "id": ws_doc.get("id", ""), + "name": ws_doc.get("name", ""), + "description": ws_doc.get("description", ""), + "owner": { + "displayName": owner.get("displayName", ""), + "email": owner.get("email", ""), + }, + "status": ws_doc.get("status", "active"), + "heroColor": ws_doc.get("heroColor", "#0078d4"), + "userRole": role, + "isMember": bool(role), + } + + if role in ("Owner", "Admin") and "retention_policy" in ws_doc: + payload["retention_policy"] = ws_doc.get("retention_policy") + + return payload + + def is_user_in_public_workspace(ws_doc: dict, user_id: str) -> bool: """ Check if a user has any role in the workspace. @@ -224,9 +270,46 @@ def count_public_workspace_documents(ws_id: str) -> int: def update_active_public_workspace_for_user(user_id: str, ws_id: str) -> None: """ - Persist the user's activePublicWorkspaceOid in their settings. + Persist the user's activePublicWorkspaceOid after validating the workspace. """ - update_user_settings(user_id, {"activePublicWorkspaceOid": ws_id}) + normalized_workspace_id = str(ws_id or "").strip() + if not normalized_workspace_id: + functions_settings.update_user_settings(user_id, {"activePublicWorkspaceOid": ""}) + return + + workspace_doc = find_public_workspace_by_id(normalized_workspace_id) + if not workspace_doc: + raise LookupError("Workspace not found") + + functions_settings.update_user_settings( + user_id, + {"activePublicWorkspaceOid": normalized_workspace_id}, + ) + + +def require_active_public_workspace( + user_id: str, + allowed_roles: Iterable[str] = ("Owner", "Admin", "DocumentManager"), +) -> tuple[str, dict, str]: + """Return the active public workspace after validating it still exists and the user can access it.""" + settings = functions_settings.get_user_settings(user_id) + active_workspace_id = str(settings.get("settings", {}).get("activePublicWorkspaceOid") or "").strip() + if not active_workspace_id: + raise ValueError("No active public workspace selected") + + workspace_doc = find_public_workspace_by_id(active_workspace_id) + if not workspace_doc: + raise LookupError("Active public workspace not found") + + role = get_user_role_in_public_workspace(workspace_doc, user_id) + if not role: + raise PermissionError("Access denied") + + allowed = {allowed_role.lower() for allowed_role in allowed_roles} + if role.lower() not in allowed: + raise PermissionError("Access denied") + + return active_workspace_id, workspace_doc, role def get_user_visible_public_workspaces(user_id: str) -> list: diff --git a/application/single_app/functions_search.py b/application/single_app/functions_search.py index 6851778f0..0859cf064 100644 --- a/application/single_app/functions_search.py +++ b/application/single_app/functions_search.py @@ -94,6 +94,22 @@ def build_tags_filter(tags_filter): tag_conditions = [f"document_tags/any(t: t eq '{tag}')" for tag in safe_tags] return " and ".join(tag_conditions) + +def _escape_odata_literal(value: Any) -> str: + """Escape a value for safe inclusion inside an OData single-quoted literal.""" + return str(value or "").replace("'", "''") + + +def _build_odata_eq(field_name: str, value: Any) -> str: + """Build a simple equality clause with an escaped OData literal.""" + return f"{field_name} eq '{_escape_odata_literal(value)}'" + + +def _build_odata_any_eq(collection_field: str, iterator_name: str, value: Any) -> str: + """Build an OData any(...) equality clause with an escaped literal.""" + escaped_value = _escape_odata_literal(value) + return f"{collection_field}/any({iterator_name}: {iterator_name} eq '{escaped_value}')" + def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, doc_scope="all", active_group_id=None, active_group_ids=None, active_public_workspace_id=None, enable_file_sharing=True, tags_filter=None): """ Hybrid search that queries the user doc index, group doc index, or public doc index @@ -155,9 +171,9 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, doc_id_filter = None if document_ids and len(document_ids) > 0: if len(document_ids) == 1: - doc_id_filter = f"document_id eq '{document_ids[0]}'" + doc_id_filter = _build_odata_eq("document_id", document_ids[0]) else: - conditions = " or ".join([f"document_id eq '{did}'" for did in document_ids]) + conditions = " or ".join([_build_odata_eq("document_id", did) for did in document_ids]) doc_id_filter = f"({conditions})" # Generate cache key including document set fingerprints and tags filter @@ -237,9 +253,9 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, # Build user filter with optional tags user_base_filter = ( ( - f"(user_id eq '{user_id}' or shared_user_ids/any(u: u eq '{user_id},approved')) " + f"({_build_odata_eq('user_id', user_id)} or {_build_odata_any_eq('shared_user_ids', 'u', f'{user_id},approved')}) " if enable_file_sharing else - f"user_id eq '{user_id}' " + f"{_build_odata_eq('user_id', user_id)} " ) + f"and {doc_id_filter}" ) @@ -258,8 +274,11 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, # Only search group index if active_group_ids is provided if active_group_ids: - group_conditions = " or ".join([f"group_id eq '{gid}'" for gid in active_group_ids]) - shared_conditions = " or ".join([f"shared_group_ids/any(g: g eq '{gid},approved')" for gid in active_group_ids]) + group_conditions = " or ".join([_build_odata_eq("group_id", gid) for gid in active_group_ids]) + shared_conditions = " or ".join([ + _build_odata_any_eq("shared_group_ids", "g", f"{gid},approved") + for gid in active_group_ids + ]) group_base_filter = f"({group_conditions} or {shared_conditions}) and {doc_id_filter}" group_filter = f"{group_base_filter} and {tags_filter_clause}" if tags_filter_clause else group_base_filter @@ -282,11 +301,14 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, # Create filter for visible public workspaces if visible_public_workspace_ids: # Use 'or' conditions instead of 'in' operator for OData compatibility - workspace_conditions = " or ".join([f"public_workspace_id eq '{id}'" for id in visible_public_workspace_ids]) + workspace_conditions = " or ".join([ + _build_odata_eq("public_workspace_id", workspace_id) + for workspace_id in visible_public_workspace_ids + ]) public_base_filter = f"({workspace_conditions}) and {doc_id_filter}" else: # Fallback to active_public_workspace_id if no visible workspaces - public_base_filter = f"public_workspace_id eq '{active_public_workspace_id}' and {doc_id_filter}" + public_base_filter = f"{_build_odata_eq('public_workspace_id', active_public_workspace_id)} and {doc_id_filter}" public_filter = f"{public_base_filter} and {tags_filter_clause}" if tags_filter_clause else public_base_filter @@ -303,9 +325,9 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, else: # Build user filter with optional tags user_base_filter = ( - f"(user_id eq '{user_id}' or shared_user_ids/any(u: u eq '{user_id},approved')) " + f"({_build_odata_eq('user_id', user_id)} or {_build_odata_any_eq('shared_user_ids', 'u', f'{user_id},approved')}) " if enable_file_sharing else - f"user_id eq '{user_id}' " + f"{_build_odata_eq('user_id', user_id)} " ) user_filter = f"{user_base_filter} and {tags_filter_clause}" if tags_filter_clause else user_base_filter.strip() @@ -322,8 +344,11 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, # Only search group index if active_group_ids is provided if active_group_ids: - group_conditions = " or ".join([f"group_id eq '{gid}'" for gid in active_group_ids]) - shared_conditions = " or ".join([f"shared_group_ids/any(g: g eq '{gid},approved')" for gid in active_group_ids]) + group_conditions = " or ".join([_build_odata_eq("group_id", gid) for gid in active_group_ids]) + shared_conditions = " or ".join([ + _build_odata_any_eq("shared_group_ids", "g", f"{gid},approved") + for gid in active_group_ids + ]) group_base_filter = f"({group_conditions} or {shared_conditions})" group_filter = f"{group_base_filter} and {tags_filter_clause}" if tags_filter_clause else group_base_filter @@ -346,11 +371,14 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, # Create filter for visible public workspaces if visible_public_workspace_ids: # Use 'or' conditions instead of 'in' operator for OData compatibility - workspace_conditions = " or ".join([f"public_workspace_id eq '{id}'" for id in visible_public_workspace_ids]) + workspace_conditions = " or ".join([ + _build_odata_eq("public_workspace_id", workspace_id) + for workspace_id in visible_public_workspace_ids + ]) public_base_filter = f"({workspace_conditions})" else: # Fallback to active_public_workspace_id if no visible workspaces - public_base_filter = f"public_workspace_id eq '{active_public_workspace_id}'" + public_base_filter = _build_odata_eq("public_workspace_id", active_public_workspace_id) public_filter = f"{public_base_filter} and {tags_filter_clause}" if tags_filter_clause else public_base_filter @@ -396,9 +424,9 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, if doc_id_filter: user_base_filter = ( ( - f"(user_id eq '{user_id}' or shared_user_ids/any(u: u eq '{user_id},approved')) " + f"({_build_odata_eq('user_id', user_id)} or {_build_odata_any_eq('shared_user_ids', 'u', f'{user_id},approved')}) " if enable_file_sharing else - f"user_id eq '{user_id}' " + f"{_build_odata_eq('user_id', user_id)} " ) + f"and {doc_id_filter}" ) @@ -417,9 +445,9 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, results = extract_search_results(user_results, top_n) else: user_base_filter = ( - f"(user_id eq '{user_id}' or shared_user_ids/any(u: u eq '{user_id},approved')) " + f"({_build_odata_eq('user_id', user_id)} or {_build_odata_any_eq('shared_user_ids', 'u', f'{user_id},approved')}) " if enable_file_sharing else - f"user_id eq '{user_id}' " + f"{_build_odata_eq('user_id', user_id)} " ) user_filter = f"{user_base_filter} and {tags_filter_clause}" if tags_filter_clause else user_base_filter.strip() @@ -439,8 +467,11 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, if not active_group_ids: results = [] elif doc_id_filter: - group_conditions = " or ".join([f"group_id eq '{gid}'" for gid in active_group_ids]) - shared_conditions = " or ".join([f"shared_group_ids/any(g: g eq '{gid},approved')" for gid in active_group_ids]) + group_conditions = " or ".join([_build_odata_eq("group_id", gid) for gid in active_group_ids]) + shared_conditions = " or ".join([ + _build_odata_any_eq("shared_group_ids", "g", f"{gid},approved") + for gid in active_group_ids + ]) group_base_filter = f"({group_conditions} or {shared_conditions}) and {doc_id_filter}" group_filter = f"{group_base_filter} and {tags_filter_clause}" if tags_filter_clause else group_base_filter @@ -456,8 +487,11 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, ) results = extract_search_results(group_results, top_n) else: - group_conditions = " or ".join([f"group_id eq '{gid}'" for gid in active_group_ids]) - shared_conditions = " or ".join([f"shared_group_ids/any(g: g eq '{gid},approved')" for gid in active_group_ids]) + group_conditions = " or ".join([_build_odata_eq("group_id", gid) for gid in active_group_ids]) + shared_conditions = " or ".join([ + _build_odata_any_eq("shared_group_ids", "g", f"{gid},approved") + for gid in active_group_ids + ]) group_base_filter = f"({group_conditions} or {shared_conditions})" group_filter = f"{group_base_filter} and {tags_filter_clause}" if tags_filter_clause else group_base_filter @@ -481,11 +515,14 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, # Create filter for visible public workspaces if visible_public_workspace_ids: # Use 'or' conditions instead of 'in' operator for OData compatibility - workspace_conditions = " or ".join([f"public_workspace_id eq '{id}'" for id in visible_public_workspace_ids]) + workspace_conditions = " or ".join([ + _build_odata_eq("public_workspace_id", workspace_id) + for workspace_id in visible_public_workspace_ids + ]) public_base_filter = f"({workspace_conditions}) and {doc_id_filter}" else: # Fallback to active_public_workspace_id if no visible workspaces - public_base_filter = f"public_workspace_id eq '{active_public_workspace_id}' and {doc_id_filter}" + public_base_filter = f"{_build_odata_eq('public_workspace_id', active_public_workspace_id)} and {doc_id_filter}" public_filter = f"{public_base_filter} and {tags_filter_clause}" if tags_filter_clause else public_base_filter @@ -507,11 +544,14 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, # Create filter for visible public workspaces if visible_public_workspace_ids: # Use 'or' conditions instead of 'in' operator for OData compatibility - workspace_conditions = " or ".join([f"public_workspace_id eq '{id}'" for id in visible_public_workspace_ids]) + workspace_conditions = " or ".join([ + _build_odata_eq("public_workspace_id", workspace_id) + for workspace_id in visible_public_workspace_ids + ]) public_base_filter = f"({workspace_conditions})" else: # Fallback to active_public_workspace_id if no visible workspaces - public_base_filter = f"public_workspace_id eq '{active_public_workspace_id}'" + public_base_filter = _build_odata_eq("public_workspace_id", active_public_workspace_id) public_filter = f"{public_base_filter} and {tags_filter_clause}" if tags_filter_clause else public_base_filter diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 8d09ee614..304660f42 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -1,5 +1,7 @@ # functions_settings.py +from flask import has_request_context + from config import * from functions_appinsights import log_event import app_settings_cache @@ -15,6 +17,43 @@ def is_tabular_processing_enabled(settings): """Tabular processing is available whenever enhanced citations is enabled.""" return bool((settings or {}).get('enable_enhanced_citations', False)) + + +def _authorize_user_settings_access(user_id, operation, allow_cross_user=False): + """Authorize user-settings access for the current request context.""" + normalized_user_id = str(user_id or '').strip() + if allow_cross_user or not has_request_context(): + return None + + try: + # Import locally to avoid a circular dependency during app startup. + from functions_authentication import get_current_user_id + except ImportError: + from application.single_app.functions_authentication import get_current_user_id + + actor_user_id = str(get_current_user_id() or '').strip() + if actor_user_id and normalized_user_id and actor_user_id != normalized_user_id: + log_event( + f"[UserSettings] Denied cross-user {operation}", + { + "actor_user_id": actor_user_id, + "target_user_id": normalized_user_id, + "operation": operation, + }, + level=logging.WARNING, + ) + raise PermissionError(f"Cannot {operation} settings for another user.") + + return actor_user_id or None + + +def _should_sync_session_profile(target_user_id, actor_user_id, allow_cross_user=False): + """Return True when session-derived profile data should update the target settings doc.""" + if allow_cross_user or not has_request_context(): + return False + normalized_target_user_id = str(target_user_id or '').strip() + normalized_actor_user_id = str(actor_user_id or '').strip() + return bool(normalized_target_user_id and normalized_actor_user_id and normalized_target_user_id == normalized_actor_user_id) import copy from support_menu_config import ( get_default_support_latest_features_visibility, @@ -306,7 +345,7 @@ def get_settings(use_cosmos=False, include_source=False): 'enable_web_search': False, 'web_search_consent_accepted': False, 'enable_web_search_user_notice': False, # Show popup to users explaining their message will be sent to Bing - 'web_search_user_notice_text': 'Your message will be sent to Microsoft Bing for web search. Only your current message is sent, not your conversation history.', + 'web_search_user_notice_text': 'Your current message will be sent to Microsoft Bing for web search. Conversation history is not sent for web search, but any sensitive content you paste into this message may be sent.', 'web_search_agent': { 'agent_type': 'aifoundry', 'azure_openai_gpt_endpoint': '', @@ -1035,9 +1074,14 @@ def decrypt_key(encrypted_key): ) return None -def get_user_settings(user_id): +def get_user_settings(user_id, allow_cross_user=False): """Fetches the user settings document from Cosmos DB, ensuring email and display_name are present if possible.""" - from flask import session + actor_user_id = _authorize_user_settings_access(user_id, "read", allow_cross_user=allow_cross_user) + should_sync_session_profile = _should_sync_session_profile( + user_id, + actor_user_id, + allow_cross_user=allow_cross_user, + ) try: doc = cosmos_user_settings_container.read_item(item=user_id, partition_key=user_id) updated = False @@ -1058,27 +1102,62 @@ def get_user_settings(user_id): doc['settings']['showTutorialButtons'] = True updated = True - # Try to update email/display_name if missing and available in session - user = session.get("user", {}) - email = user.get("preferred_username") or user.get("email") - display_name = user.get("name") - if email and doc.get("email") != email: - doc["email"] = email - updated = True - if display_name and doc.get("display_name") != display_name: - doc["display_name"] = display_name - updated = True - - # Check if profile image needs to be fetched - if 'profileImage' not in doc['settings']: + if should_sync_session_profile: + # Try to update email/display_name if missing and available in session + user = session.get("user", {}) + email = user.get("preferred_username") or user.get("email") + display_name = user.get("name") + if email and doc.get("email") != email: + doc["email"] = email + updated = True + if display_name and doc.get("display_name") != display_name: + doc["display_name"] = display_name + updated = True + + # Check if profile image needs to be fetched + if 'profileImage' not in doc['settings']: + from functions_authentication import get_user_profile_image + try: + profile_image = get_user_profile_image() + doc['settings']['profileImage'] = profile_image + updated = True + except Exception as e: + log_event( + "Could not fetch profile image for existing user.", + extra={ + "user_id": user_id, + "error": str(e) + }, + level=logging.WARNING + ) + doc['settings']['profileImage'] = None + updated = True + + if updated: + cosmos_user_settings_container.upsert_item(body=doc) + return doc + except exceptions.CosmosResourceNotFoundError: + # Return a default structure if the user has no settings saved yet + doc = {"id": user_id, "settings": {}} + doc["settings"]["personal_model_endpoints"] = [] + doc["settings"]["showTutorialButtons"] = True + if should_sync_session_profile: + user = session.get("user", {}) + email = user.get("preferred_username") or user.get("email") + display_name = user.get("name") + if email: + doc["email"] = email + if display_name: + doc["display_name"] = display_name + + # Try to fetch profile image for new user from functions_authentication import get_user_profile_image try: profile_image = get_user_profile_image() doc['settings']['profileImage'] = profile_image - updated = True except Exception as e: log_event( - "Could not fetch profile image for existing user.", + "Could not fetch profile image for new user.", extra={ "user_id": user_id, "error": str(e) @@ -1086,39 +1165,6 @@ def get_user_settings(user_id): level=logging.WARNING ) doc['settings']['profileImage'] = None - updated = True - - if updated: - cosmos_user_settings_container.upsert_item(body=doc) - return doc - except exceptions.CosmosResourceNotFoundError: - # Return a default structure if the user has no settings saved yet - user = session.get("user", {}) - email = user.get("preferred_username") or user.get("email") - display_name = user.get("name") - doc = {"id": user_id, "settings": {}} - doc["settings"]["personal_model_endpoints"] = [] - doc["settings"]["showTutorialButtons"] = True - if email: - doc["email"] = email - if display_name: - doc["display_name"] = display_name - - # Try to fetch profile image for new user - from functions_authentication import get_user_profile_image - try: - profile_image = get_user_profile_image() - doc['settings']['profileImage'] = profile_image - except Exception as e: - log_event( - "Could not fetch profile image for new user.", - extra={ - "user_id": user_id, - "error": str(e) - }, - level=logging.WARNING - ) - doc['settings']['profileImage'] = None cosmos_user_settings_container.upsert_item(body=doc) return doc @@ -1134,7 +1180,7 @@ def get_user_settings(user_id): ) raise # Re-raise the exception to be handled by the route -def update_user_settings(user_id, settings_to_update): +def update_user_settings(user_id, settings_to_update, allow_cross_user=False): """ Updates or creates user settings in Cosmos DB, merging new settings into the existing 'settings' sub-dictionary and updating 'lastUpdated'. @@ -1147,8 +1193,21 @@ def update_user_settings(user_id, settings_to_update): Returns: bool: True if the update was successful, False otherwise. """ + actor_user_id = _authorize_user_settings_access( + user_id, + "update", + allow_cross_user=allow_cross_user, + ) sanitized_settings_to_update = sanitize_settings_for_logging(settings_to_update) - log_event("[UserSettings] Update Attempt", {"user_id": user_id, "settings_to_update": sanitized_settings_to_update}) + log_event( + "[UserSettings] Update Attempt", + { + "user_id": user_id, + "actor_user_id": actor_user_id, + "allow_cross_user": allow_cross_user, + "settings_to_update": sanitized_settings_to_update, + }, + ) try: diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index e16d72423..4d31db45f 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -218,6 +218,235 @@ def build_fact_memory_citation(query_text, matched_facts, search_mode): } +def _normalize_requested_scope_ids(*scope_values): + """Normalize single-value and list-based scope ids into a de-duplicated list.""" + normalized_values = [] + for scope_value in scope_values: + if scope_value is None: + continue + + if isinstance(scope_value, (list, tuple, set)): + candidates = list(scope_value) + else: + candidates = [scope_value] + + for candidate in candidates: + normalized_candidate = str(candidate or '').strip() + if not normalized_candidate or normalized_candidate in normalized_values: + continue + normalized_values.append(normalized_candidate) + + return normalized_values + + +def _get_authorized_chat_scope_context( + user_id, + active_group_id=None, + active_group_ids=None, + active_public_workspace_id=None, + active_public_workspace_ids=None, +): + """Filter request-provided chat scopes down to the caller's current access.""" + requested_group_ids = _normalize_requested_scope_ids(active_group_ids, active_group_id) + allowed_group_ids = [] + for group_id in requested_group_ids: + group_doc = find_group_by_id(group_id) + if group_doc and get_user_role_in_group(group_doc, user_id): + allowed_group_ids.append(group_id) + + requested_public_workspace_ids = _normalize_requested_scope_ids( + active_public_workspace_ids, + active_public_workspace_id, + ) + visible_public_workspace_ids = set( + _normalize_requested_scope_ids(get_user_visible_public_workspace_ids_from_settings(user_id) or []) + ) + allowed_public_workspace_ids = [ + workspace_id + for workspace_id in requested_public_workspace_ids + if workspace_id in visible_public_workspace_ids + ] + + return { + 'active_group_ids': allowed_group_ids, + 'active_group_id': allowed_group_ids[0] if allowed_group_ids else None, + 'active_public_workspace_ids': allowed_public_workspace_ids, + 'active_public_workspace_id': ( + allowed_public_workspace_ids[0] if allowed_public_workspace_ids else None + ), + } + + +def _set_authorized_chat_request_context(user_id, conversation_id, scope_context): + """Persist the canonical request authorization context for downstream plugin checks.""" + authorized_context = { + 'user_id': user_id, + 'conversation_id': conversation_id, + 'active_group_ids': list(scope_context.get('active_group_ids') or []), + 'active_group_id': scope_context.get('active_group_id'), + 'active_public_workspace_ids': list(scope_context.get('active_public_workspace_ids') or []), + 'active_public_workspace_id': scope_context.get('active_public_workspace_id'), + } + authorized_context['fact_memory_scope_id'] = authorized_context['active_group_id'] or user_id + authorized_context['fact_memory_scope_type'] = ( + 'group' if authorized_context['active_group_id'] else 'user' + ) + + g.conversation_id = conversation_id + g.authorized_chat_context = authorized_context + return authorized_context + + +def _resolve_chat_selected_document_metadata(document_id, user_id=None, document_scope='personal', + active_group_id=None, active_group_ids=None, + active_public_workspace_id=None, + active_public_workspace_ids=None): + """Resolve selected-document metadata using the authorized chat scope model.""" + normalized_document_id = str(document_id or '').strip() + if not normalized_document_id or normalized_document_id == 'all': + return None + + normalized_scope = str(document_scope or 'personal').strip().lower() + authorized_group_ids = _normalize_requested_scope_ids(active_group_ids, active_group_id) + authorized_public_workspace_ids = _normalize_requested_scope_ids( + active_public_workspace_ids, + active_public_workspace_id, + ) + + resolution_queries = [] + + if normalized_scope in {'personal', 'workspace', 'all'} and user_id: + resolution_queries.append({ + 'source_hint': 'workspace', + 'cosmos_container': cosmos_user_documents_container, + 'query': """ + SELECT TOP 1 c.id, c.file_name, c.title, c.group_id, c.public_workspace_id + FROM c + WHERE c.id = @doc_id + AND ( + c.user_id = @user_id + OR ARRAY_CONTAINS(c.shared_user_ids, @user_id) + OR EXISTS(SELECT VALUE s FROM s IN c.shared_user_ids WHERE STARTSWITH(s, @user_id_prefix)) + ) + ORDER BY c.version DESC + """, + 'parameters': [ + {'name': '@doc_id', 'value': normalized_document_id}, + {'name': '@user_id', 'value': user_id}, + {'name': '@user_id_prefix', 'value': f"{user_id},"}, + ], + }) + + if normalized_scope in {'group', 'all'}: + for group_id in authorized_group_ids: + resolution_queries.append({ + 'source_hint': 'group', + 'cosmos_container': cosmos_group_documents_container, + 'query': """ + SELECT TOP 1 c.id, c.file_name, c.title, c.group_id, c.public_workspace_id + FROM c + WHERE c.id = @doc_id + AND ( + c.group_id = @group_id + OR ARRAY_CONTAINS(c.shared_group_ids, @group_id) + OR ARRAY_CONTAINS(c.shared_group_ids, @group_id_approved) + ) + ORDER BY c.version DESC + """, + 'parameters': [ + {'name': '@doc_id', 'value': normalized_document_id}, + {'name': '@group_id', 'value': group_id}, + {'name': '@group_id_approved', 'value': f"{group_id},approved"}, + ], + }) + + if normalized_scope in {'public', 'all'}: + for public_workspace_id in authorized_public_workspace_ids: + resolution_queries.append({ + 'source_hint': 'public', + 'cosmos_container': cosmos_public_documents_container, + 'query': """ + SELECT TOP 1 c.id, c.file_name, c.title, c.group_id, c.public_workspace_id + FROM c + WHERE c.id = @doc_id + AND c.public_workspace_id = @public_workspace_id + ORDER BY c.version DESC + """, + 'parameters': [ + {'name': '@doc_id', 'value': normalized_document_id}, + {'name': '@public_workspace_id', 'value': public_workspace_id}, + ], + }) + + for resolution_query in resolution_queries: + doc_results = list(resolution_query['cosmos_container'].query_items( + query=resolution_query['query'], + parameters=resolution_query['parameters'], + enable_cross_partition_query=True, + )) + if not doc_results: + continue + + doc_info = dict(doc_results[0]) + doc_info['source_hint'] = resolution_query['source_hint'] + return doc_info + + return None + + +def _create_personal_conversation(user_id, conversation_id=None): + """Create and persist a new personal conversation owned by the current user.""" + resolved_conversation_id = str(conversation_id or uuid.uuid4()) + conversation_item = { + 'id': resolved_conversation_id, + 'user_id': user_id, + 'last_updated': datetime.utcnow().isoformat(), + 'title': 'New Conversation', + 'context': [], + 'tags': [], + 'strict': False, + 'chat_type': 'new' + } + cosmos_conversations_container.upsert_item(conversation_item) + + log_conversation_creation( + user_id=user_id, + conversation_id=resolved_conversation_id, + title='New Conversation', + workspace_type='personal' + ) + + conversation_item['added_to_activity_log'] = True + cosmos_conversations_container.upsert_item(conversation_item) + return conversation_item + + +def _authorize_personal_conversation_access(user_id, conversation_id): + """Load a personal conversation and ensure the caller owns it.""" + try: + conversation_item = cosmos_conversations_container.read_item( + item=conversation_id, + partition_key=conversation_id, + ) + except CosmosResourceNotFoundError as exc: + raise LookupError(f"Conversation {conversation_id} not found") from exc + + if conversation_item.get('user_id') != user_id: + raise PermissionError('You can only access your own conversations') + + return conversation_item + + +def _resolve_or_create_authorized_personal_conversation(user_id, conversation_id): + """Create new personal conversations server-side or load an authorized existing one.""" + if not conversation_id: + conversation_item = _create_personal_conversation(user_id) + return conversation_item, conversation_item['id'] + + conversation_item = _authorize_personal_conversation_access(user_id, conversation_id) + return conversation_item, conversation_id + + def build_instruction_memory_payload( scope_id, scope_type, @@ -5127,8 +5356,10 @@ def infer_tabular_source_context_from_document(source_doc, document_scope='perso def get_selected_workspace_tabular_file_contexts(selected_document_ids=None, selected_document_id=None, - document_scope='personal', active_group_id=None, - active_public_workspace_id=None): + document_scope='personal', user_id=None, + active_group_id=None, active_group_ids=None, + active_public_workspace_id=None, + active_public_workspace_ids=None): """Resolve explicitly selected workspace documents and return tabular source contexts.""" selected_ids = list(selected_document_ids or []) if not selected_ids and selected_document_id and selected_document_id != 'all': @@ -5144,33 +5375,26 @@ def get_selected_workspace_tabular_file_contexts(selected_document_ids=None, sel continue try: - doc_query = ( - "SELECT TOP 1 c.file_name, c.title, c.group_id, c.public_workspace_id " - "FROM c WHERE c.id = @doc_id " - "ORDER BY c.version DESC" + doc_info = _resolve_chat_selected_document_metadata( + doc_id, + user_id=user_id, + document_scope=document_scope, + active_group_id=active_group_id, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + active_public_workspace_ids=active_public_workspace_ids, ) - doc_params = [{"name": "@doc_id", "value": doc_id}] - - for source_hint, cosmos_container in get_document_containers_for_scope(document_scope): - doc_results = list(cosmos_container.query_items( - query=doc_query, - parameters=doc_params, - enable_cross_partition_query=True - )) - - if not doc_results: - continue + if not doc_info: + continue - doc_info = doc_results[0] - file_context = build_tabular_file_context( - doc_info.get('file_name') or doc_info.get('title'), - source_hint=source_hint, - group_id=doc_info.get('group_id') or active_group_id, - public_workspace_id=doc_info.get('public_workspace_id') or active_public_workspace_id, - ) - if file_context: - tabular_file_contexts.append(file_context) - break + file_context = build_tabular_file_context( + doc_info.get('file_name') or doc_info.get('title'), + source_hint=doc_info.get('source_hint', 'workspace'), + group_id=doc_info.get('group_id') or active_group_id, + public_workspace_id=doc_info.get('public_workspace_id') or active_public_workspace_id, + ) + if file_context: + tabular_file_contexts.append(file_context) except Exception as e: log_event( f"[Tabular SK Analysis] Failed to resolve selected document '{doc_id}': {e}", @@ -5182,7 +5406,10 @@ def get_selected_workspace_tabular_file_contexts(selected_document_ids=None, sel def collect_workspace_tabular_file_contexts(combined_documents=None, selected_document_ids=None, selected_document_id=None, document_scope='personal', - active_group_id=None, active_public_workspace_id=None): + user_id=None, active_group_id=None, + active_group_ids=None, + active_public_workspace_id=None, + active_public_workspace_ids=None): """Collect tabular source contexts from search results and explicit workspace selection.""" tabular_file_contexts = [] @@ -5200,8 +5427,11 @@ def collect_workspace_tabular_file_contexts(combined_documents=None, selected_do selected_document_ids=selected_document_ids, selected_document_id=selected_document_id, document_scope=document_scope, + user_id=user_id, active_group_id=active_group_id, + active_group_ids=active_group_ids, active_public_workspace_id=active_public_workspace_id, + active_public_workspace_ids=active_public_workspace_ids, )) return dedupe_tabular_file_contexts(tabular_file_contexts) @@ -5209,15 +5439,21 @@ def collect_workspace_tabular_file_contexts(combined_documents=None, selected_do def collect_workspace_tabular_filenames(combined_documents=None, selected_document_ids=None, selected_document_id=None, document_scope='personal', - active_group_id=None, active_public_workspace_id=None): + user_id=None, active_group_id=None, + active_group_ids=None, + active_public_workspace_id=None, + active_public_workspace_ids=None): """Collect unique tabular filenames from search results and explicit workspace selection.""" tabular_file_contexts = collect_workspace_tabular_file_contexts( combined_documents=combined_documents, selected_document_ids=selected_document_ids, selected_document_id=selected_document_id, document_scope=document_scope, + user_id=user_id, active_group_id=active_group_id, + active_group_ids=active_group_ids, active_public_workspace_id=active_public_workspace_id, + active_public_workspace_ids=active_public_workspace_ids, ) return {file_context['file_name'] for file_context in tabular_file_contexts} @@ -6031,22 +6267,19 @@ def result_requires_message_reload(result: Any) -> bool: active_group_id = data.get('active_group_id') active_group_ids = data.get('active_group_ids', []) - # Backwards compat: if new list not provided, wrap single ID - if not active_group_ids and active_group_id: - active_group_ids = [active_group_id] - # Permission validation: only keep groups user is a member of - validated_group_ids = [] - for gid in active_group_ids: - g_doc = find_group_by_id(gid) - if g_doc and get_user_role_in_group(g_doc, user_id): - validated_group_ids.append(gid) - active_group_ids = validated_group_ids - # Keep single ID for backwards compat in metadata/context - active_group_id = active_group_ids[0] if active_group_ids else data.get('active_group_id') active_public_workspace_id = data.get('active_public_workspace_id') # Extract active public workspace ID active_public_workspace_ids = data.get('active_public_workspace_ids', []) - if not active_public_workspace_ids and active_public_workspace_id: - active_public_workspace_ids = [active_public_workspace_id] + scope_context = _get_authorized_chat_scope_context( + user_id, + active_group_id=active_group_id, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + active_public_workspace_ids=active_public_workspace_ids, + ) + active_group_ids = scope_context['active_group_ids'] + active_group_id = scope_context['active_group_id'] + active_public_workspace_ids = scope_context['active_public_workspace_ids'] + active_public_workspace_id = scope_context['active_public_workspace_id'] frontend_gpt_model = data.get('model_deployment') top_n_results = data.get('top_n') # Extract top_n parameter from request classifications_to_send = data.get('classifications') # Extract classifications parameter from request @@ -6064,20 +6297,12 @@ def result_requires_message_reload(result: Any) -> bool: operation_type = 'Edit' if is_edit else 'Retry' debug_print(f"🔍 Chat API - {operation_type} detected! user_message_id={retry_user_message_id}, thread_id={retry_thread_id}, attempt={retry_thread_attempt}") - # Store conversation_id in Flask context for plugin logger access - g.conversation_id = conversation_id - - # Clear plugin invocations at start of message processing to ensure - # each message only shows citations for tools executed during that specific interaction - from semantic_kernel_plugins.plugin_invocation_logger import get_plugin_logger - plugin_logger = get_plugin_logger() - plugin_logger.clear_invocations_for_conversation(user_id, conversation_id) - # Validate chat_type if chat_type not in ('user', 'group'): chat_type = 'user' search_query = user_message # <--- ADD THIS LINE (Initialize search_query) + web_search_query_text = build_web_search_query_text(user_message) hybrid_citations_list = [] # <--- ADD THIS LINE (Initialize hybrid list) agent_citations_list = [] # <--- ADD THIS LINE (Initialize agent citations list) web_search_citations_list = [] @@ -6236,65 +6461,25 @@ def result_requires_message_reload(result: Any) -> bool: # --------------------------------------------------------------------- # 1) Load or create conversation # --------------------------------------------------------------------- - if not conversation_id: - conversation_id = str(uuid.uuid4()) - conversation_item = { - 'id': conversation_id, - 'user_id': user_id, - 'last_updated': datetime.utcnow().isoformat(), - 'title': 'New Conversation', - 'context': [], - 'tags': [], - 'strict': False, - 'chat_type': 'new' - } - cosmos_conversations_container.upsert_item(conversation_item) - - # Log conversation creation - log_conversation_creation( - user_id=user_id, - conversation_id=conversation_id, - title='New Conversation', - workspace_type='personal' + try: + conversation_item, conversation_id = _resolve_or_create_authorized_personal_conversation( + user_id, + conversation_id, ) - - # Mark as logged to activity logs to prevent duplicate migration - conversation_item['added_to_activity_log'] = True - cosmos_conversations_container.upsert_item(conversation_item) - else: - try: - conversation_item = cosmos_conversations_container.read_item(item=conversation_id, partition_key=conversation_id) - except CosmosResourceNotFoundError: - # If conversation ID is provided but not found, create a new one with that ID - # Or decide if you want to return an error instead - conversation_item = { - 'id': conversation_id, # Keep the provided ID if needed for linking - 'user_id': user_id, - 'last_updated': datetime.utcnow().isoformat(), - 'title': 'New Conversation', # Or maybe fetch title differently? - 'context': [], - 'tags': [], - 'strict': False, - 'chat_type': 'new' - } - # Optionally log that a conversation was expected but not found - debug_print(f"Warning: Conversation ID {conversation_id} not found, creating new.") - cosmos_conversations_container.upsert_item(conversation_item) - - # Log conversation creation - log_conversation_creation( - user_id=user_id, - conversation_id=conversation_id, - title='New Conversation', - workspace_type='personal' - ) - - # Mark as logged to activity logs to prevent duplicate migration - conversation_item['added_to_activity_log'] = True - cosmos_conversations_container.upsert_item(conversation_item) - except Exception as e: - debug_print(f"Error reading conversation {conversation_id}: {e}") - return jsonify({'error': f'Error reading conversation: {str(e)}'}), 500 + except LookupError: + return jsonify({'error': 'Conversation not found'}), 404 + except PermissionError: + return jsonify({'error': 'Forbidden'}), 403 + except Exception as e: + debug_print(f"Error reading conversation {conversation_id}: {e}") + return jsonify({'error': f'Error reading conversation: {str(e)}'}), 500 + + _set_authorized_chat_request_context(user_id, conversation_id, scope_context) + + # Clear plugin invocations at start of message processing to ensure + # each message only shows citations for tools executed during that specific interaction + plugin_logger = get_plugin_logger() + plugin_logger.clear_invocations_for_conversation(user_id, conversation_id) # Determine the actual chat context based on existing conversation or document usage # For existing conversations, use the chat_type from conversation metadata @@ -6404,21 +6589,16 @@ def result_requires_message_reload(result: Any) -> bool: # Get document details if specific document selected if selected_document_id and selected_document_id != "all": try: - # Use the appropriate documents container based on scope - if document_scope == 'group': - cosmos_container = cosmos_group_documents_container - elif document_scope == 'public': - cosmos_container = cosmos_public_documents_container - elif document_scope == 'personal': - cosmos_container = cosmos_user_documents_container - - doc_query = "SELECT c.file_name, c.title, c.document_id, c.group_id FROM c WHERE c.id = @doc_id" - doc_params = [{"name": "@doc_id", "value": selected_document_id}] - doc_results = list(cosmos_container.query_items( - query=doc_query, parameters=doc_params, enable_cross_partition_query=True - )) - if doc_results and 'workspace_search' in user_metadata: - doc_info = doc_results[0] + doc_info = _resolve_chat_selected_document_metadata( + selected_document_id, + user_id=user_id, + document_scope=document_scope, + active_group_id=active_group_id, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + active_public_workspace_ids=active_public_workspace_ids, + ) + if doc_info and 'workspace_search' in user_metadata: user_metadata['workspace_search']['document_name'] = doc_info.get('title') or doc_info.get('file_name') user_metadata['workspace_search']['document_filename'] = doc_info.get('file_name') except Exception as e: @@ -6802,7 +6982,11 @@ def result_requires_message_reload(result: Any) -> bool: fallback_search_parameters = build_prior_grounded_document_search_parameters( prior_grounded_document_refs ) - if fallback_search_parameters.get('document_ids'): + fallback_search_parameters = revalidate_prior_grounded_document_search_parameters( + user_id, + fallback_search_parameters, + ) + if fallback_search_parameters.get('document_ids') and fallback_search_parameters.get('doc_scope'): history_grounded_search_used = True effective_document_scope = fallback_search_parameters.get('doc_scope') or 'all' effective_selected_document_ids = list( @@ -6889,6 +7073,7 @@ def result_requires_message_reload(result: Any) -> bool: # Filter out inactive thread messages before summarizing message_texts_search = [] for msg in last_messages_asc: + role = msg.get('role', 'user') thread_info = msg.get('metadata', {}).get('thread_info', {}) active_thread = thread_info.get('active_thread') @@ -6896,8 +7081,15 @@ def result_requires_message_reload(result: Any) -> bool: if active_thread is False: debug_print(f"[THREAD] Skipping inactive thread message {msg.get('id')} from search summary") continue - - message_texts_search.append(f"{msg.get('role', 'user').upper()}: {msg.get('content', '')}") + + if role not in ('user', 'assistant'): + continue + + content = msg.get('content', '') + if role == 'assistant': + content = build_assistant_history_content_with_citations(msg, content) + + message_texts_search.append(f"{role.upper()}: {content}") if not message_texts_search: # No active messages to summarize @@ -7614,8 +7806,11 @@ def result_requires_message_reload(result: Any) -> bool: selected_document_ids=effective_selected_document_ids, selected_document_id=effective_selected_document_id, document_scope=effective_document_scope, + user_id=user_id, active_group_id=effective_active_group_id, + active_group_ids=effective_active_group_ids, active_public_workspace_id=effective_active_public_workspace_id, + active_public_workspace_ids=effective_active_public_workspace_ids, ) workspace_tabular_files = { file_context['file_name'] for file_context in workspace_tabular_file_contexts @@ -7689,7 +7884,7 @@ def result_requires_message_reload(result: Any) -> bool: ) if web_search_enabled: - thought_tracker.add_thought('web_search', f"Searching the web for '{(search_query or user_message)[:50]}'") + thought_tracker.add_thought('web_search', f"Searching the web for '{web_search_query_text[:50]}'") perform_web_search( settings=settings, conversation_id=conversation_id, @@ -7700,7 +7895,7 @@ def result_requires_message_reload(result: Any) -> bool: document_scope=document_scope, active_group_id=active_group_id, active_public_workspace_id=active_public_workspace_id, - search_query=search_query, + web_search_query_text=web_search_query_text, system_messages_for_augmentation=system_messages_for_augmentation, agent_citations_list=agent_citations_list, web_search_citations_list=web_search_citations_list, @@ -8906,9 +9101,32 @@ def chat_stream_api(): compatibility_mode = bool(data.get('image_generation')) or is_retry requested_conversation_id = str(data.get('conversation_id') or '').strip() or None + + if requested_conversation_id: + try: + _authorize_personal_conversation_access(user_id, requested_conversation_id) + except LookupError: + return jsonify({'error': 'Conversation not found'}), 404 + except PermissionError: + return jsonify({'error': 'Forbidden'}), 403 + except Exception as exc: + debug_print(f"[Streaming] Error authorizing conversation {requested_conversation_id}: {exc}") + return jsonify({'error': 'Failed to authorize conversation'}), 500 + + initial_scope_context = _get_authorized_chat_scope_context( + user_id, + active_group_id=data.get('active_group_id'), + active_group_ids=data.get('active_group_ids', []), + active_public_workspace_id=data.get('active_public_workspace_id'), + active_public_workspace_ids=data.get('active_public_workspace_ids', []), + ) finalized_conversation_id = requested_conversation_id or str(uuid.uuid4()) is_new_stream_conversation = requested_conversation_id is None data['conversation_id'] = finalized_conversation_id + data['active_group_ids'] = list(initial_scope_context['active_group_ids']) + data['active_group_id'] = initial_scope_context['active_group_id'] + data['active_public_workspace_ids'] = list(initial_scope_context['active_public_workspace_ids']) + data['active_public_workspace_id'] = initial_scope_context['active_public_workspace_id'] stream_session = CHAT_STREAM_REGISTRY.start_session(user_id, finalized_conversation_id) request_message = (data.get('message') or '').strip() @@ -9047,22 +9265,19 @@ def generate(publish_background_event=None): tags_filter = data.get('tags', []) # Extract tags filter active_group_id = data.get('active_group_id') active_group_ids = data.get('active_group_ids', []) - # Backwards compat: if new list not provided, wrap single ID - if not active_group_ids and active_group_id: - active_group_ids = [active_group_id] - # Permission validation: only keep groups user is a member of - validated_group_ids = [] - for gid in active_group_ids: - g_doc = find_group_by_id(gid) - if g_doc and get_user_role_in_group(g_doc, user_id): - validated_group_ids.append(gid) - active_group_ids = validated_group_ids - # Keep single ID for backwards compat in metadata/context - active_group_id = active_group_ids[0] if active_group_ids else data.get('active_group_id') active_public_workspace_id = data.get('active_public_workspace_id') # Extract active public workspace ID active_public_workspace_ids = data.get('active_public_workspace_ids', []) - if not active_public_workspace_ids and active_public_workspace_id: - active_public_workspace_ids = [active_public_workspace_id] + scope_context = _get_authorized_chat_scope_context( + user_id, + active_group_id=active_group_id, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + active_public_workspace_ids=active_public_workspace_ids, + ) + active_group_ids = scope_context['active_group_ids'] + active_group_id = scope_context['active_group_id'] + active_public_workspace_ids = scope_context['active_public_workspace_ids'] + active_public_workspace_id = scope_context['active_public_workspace_id'] frontend_gpt_model = data.get('model_deployment') frontend_model_id = data.get('model_id') frontend_model_endpoint_id = data.get('model_endpoint_id') @@ -9156,11 +9371,9 @@ def generate(publish_background_event=None): yield f"data: {json.dumps({'error': 'Image generation is not supported in streaming mode'})}\n\n" return - # Initialize Flask context - g.conversation_id = conversation_id + _set_authorized_chat_request_context(user_id, conversation_id, scope_context) # Clear plugin invocations - from semantic_kernel_plugins.plugin_invocation_logger import get_plugin_logger plugin_logger = get_plugin_logger() plugin_logger.clear_invocations_for_conversation(user_id, conversation_id) debug_print( @@ -9175,6 +9388,7 @@ def generate(publish_background_event=None): # Initialize variables search_query = user_message + web_search_query_text = build_web_search_query_text(user_message) hybrid_citations_list = [] agent_citations_list = [] web_search_citations_list = [] @@ -9323,37 +9537,18 @@ def generate(publish_background_event=None): # Load or create conversation (simplified) if is_new_stream_conversation: - conversation_item = { - 'id': conversation_id, - 'user_id': user_id, - 'last_updated': datetime.utcnow().isoformat(), - 'title': 'New Conversation', - 'context': [], - 'tags': [], - 'strict': False, - 'chat_type': 'new' - } - cosmos_conversations_container.upsert_item(conversation_item) + conversation_item = _create_personal_conversation(user_id, conversation_id=conversation_id) debug_print(f"[Streaming] Created new conversation {conversation_id}") else: try: - conversation_item = cosmos_conversations_container.read_item( - item=conversation_id, partition_key=conversation_id - ) + conversation_item = _authorize_personal_conversation_access(user_id, conversation_id) debug_print(f"[Streaming] Loaded existing conversation {conversation_id}") - except CosmosResourceNotFoundError: - conversation_item = { - 'id': conversation_id, - 'user_id': user_id, - 'last_updated': datetime.utcnow().isoformat(), - 'title': 'New Conversation', - 'context': [], - 'tags': [], - 'strict': False, - 'chat_type': 'new' - } - cosmos_conversations_container.upsert_item(conversation_item) - debug_print(f"[Streaming] Conversation {conversation_id} not found; created replacement") + except LookupError: + yield f"data: {json.dumps({'error': 'Conversation not found'})}\n\n" + return + except PermissionError: + yield f"data: {json.dumps({'error': 'Forbidden'})}\n\n" + return # Determine chat type actual_chat_type = 'personal_single_user' @@ -9402,21 +9597,16 @@ def generate(publish_background_event=None): # Get document details if specific document selected if selected_document_id and selected_document_id != "all": try: - # Use the appropriate documents container based on scope - if document_scope == 'group': - cosmos_container = cosmos_group_documents_container - elif document_scope == 'public': - cosmos_container = cosmos_public_documents_container - elif document_scope == 'personal': - cosmos_container = cosmos_user_documents_container - - doc_query = "SELECT c.file_name, c.title, c.document_id, c.group_id FROM c WHERE c.id = @doc_id" - doc_params = [{"name": "@doc_id", "value": selected_document_id}] - doc_results = list(cosmos_container.query_items( - query=doc_query, parameters=doc_params, enable_cross_partition_query=True - )) - if doc_results: - doc_info = doc_results[0] + doc_info = _resolve_chat_selected_document_metadata( + selected_document_id, + user_id=user_id, + document_scope=document_scope, + active_group_id=active_group_id, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + active_public_workspace_ids=active_public_workspace_ids, + ) + if doc_info: user_metadata['workspace_search']['document_name'] = doc_info.get('title') or doc_info.get('file_name') user_metadata['workspace_search']['document_filename'] = doc_info.get('file_name') except Exception as e: @@ -9744,7 +9934,11 @@ def publish_live_plugin_thought(thought_payload): fallback_search_parameters = build_prior_grounded_document_search_parameters( prior_grounded_document_refs ) - if fallback_search_parameters.get('document_ids'): + fallback_search_parameters = revalidate_prior_grounded_document_search_parameters( + user_id, + fallback_search_parameters, + ) + if fallback_search_parameters.get('document_ids') and fallback_search_parameters.get('doc_scope'): history_grounded_search_used = True effective_document_scope = fallback_search_parameters.get('doc_scope') or 'all' effective_selected_document_ids = list( @@ -10072,8 +10266,11 @@ def publish_live_plugin_thought(thought_payload): selected_document_ids=effective_selected_document_ids, selected_document_id=effective_selected_document_id, document_scope=effective_document_scope, + user_id=user_id, active_group_id=effective_active_group_id, + active_group_ids=effective_active_group_ids, active_public_workspace_id=effective_active_public_workspace_id, + active_public_workspace_ids=effective_active_public_workspace_ids, ) workspace_tabular_files = { file_context['file_name'] for file_context in workspace_tabular_file_contexts @@ -10160,7 +10357,7 @@ def publish_live_plugin_thought(thought_payload): debug_print( f"[Streaming] Starting web search augmentation for conversation_id={conversation_id}" ) - yield emit_thought('web_search', f"Searching the web for '{(search_query or user_message)[:50]}'") + yield emit_thought('web_search', f"Searching the web for '{web_search_query_text[:50]}'") perform_web_search( settings=settings, conversation_id=conversation_id, @@ -10171,7 +10368,7 @@ def publish_live_plugin_thought(thought_payload): document_scope=document_scope, active_group_id=active_group_id, active_public_workspace_id=active_public_workspace_id, - search_query=search_query, + web_search_query_text=web_search_query_text, system_messages_for_augmentation=system_messages_for_augmentation, agent_citations_list=agent_citations_list, web_search_citations_list=web_search_citations_list, @@ -11102,7 +11299,13 @@ def mask_message_api(message_id): # Get action: "mask_all", "mask_selection", or "unmask_all" action = data.get('action') selection = data.get('selection', {}) - user_display_name = data.get('display_name', 'Unknown User') + current_user = get_current_user_info() or {} + user_display_name = ( + current_user.get('displayName') + or current_user.get('email') + or current_user.get('userPrincipalName') + or 'Unknown User' + ) # Validate action if action not in ['mask_all', 'mask_selection', 'unmask_all']: @@ -11688,6 +11891,43 @@ def build_prior_grounded_document_search_parameters(grounded_refs): } +def revalidate_prior_grounded_document_search_parameters(user_id, search_parameters): + """Filter fallback search parameters to scopes the caller can still access.""" + normalized_parameters = dict(search_parameters or {}) + scope_types = set(normalized_parameters.get('scope_types') or []) + scope_context = _get_authorized_chat_scope_context( + user_id, + active_group_ids=normalized_parameters.get('active_group_ids') or [], + active_public_workspace_ids=normalized_parameters.get('active_public_workspace_ids') or [], + ) + allowed_group_ids = scope_context['active_group_ids'] + allowed_public_workspace_ids = scope_context['active_public_workspace_ids'] + + allowed_scope_types = [] + if 'personal' in scope_types: + allowed_scope_types.append('personal') + if allowed_group_ids: + allowed_scope_types.append('group') + if allowed_public_workspace_ids: + allowed_scope_types.append('public') + + normalized_parameters['active_group_ids'] = allowed_group_ids + normalized_parameters['active_group_id'] = scope_context['active_group_id'] + normalized_parameters['active_public_workspace_ids'] = allowed_public_workspace_ids + normalized_parameters['active_public_workspace_id'] = scope_context['active_public_workspace_id'] + normalized_parameters['scope_types'] = allowed_scope_types + + if not allowed_scope_types: + normalized_parameters['document_ids'] = [] + normalized_parameters['doc_scope'] = None + return normalized_parameters + + normalized_parameters['doc_scope'] = ( + allowed_scope_types[0] if len(allowed_scope_types) == 1 else 'all' + ) + return normalized_parameters + + def build_history_only_assessment_messages(history_segments, default_system_prompt=''): """Construct the prompt context used to decide whether history alone is sufficient.""" assessment_messages = [] @@ -12294,6 +12534,11 @@ def to_int(value: Any) -> Optional[int]: "completion_tokens": int(completion_tokens), } + +def build_web_search_query_text(user_message): + """Return the only chat content allowed to leave the app for external web search.""" + return str(user_message or "").strip() + def perform_web_search( *, settings, @@ -12305,7 +12550,7 @@ def perform_web_search( document_scope, active_group_id, active_public_workspace_id, - search_query, + web_search_query_text, system_messages_for_augmentation, agent_citations_list, web_search_citations_list, @@ -12320,7 +12565,10 @@ def perform_web_search( debug_print(f"[WebSearch] document_scope: {document_scope}") debug_print(f"[WebSearch] active_group_id: {active_group_id}") debug_print(f"[WebSearch] active_public_workspace_id: {active_public_workspace_id}") - debug_print(f"[WebSearch] search_query: {search_query[:100] if search_query else None}...") + debug_print( + "[WebSearch] web_search_query_text: " + f"{web_search_query_text[:100] if web_search_query_text else None}..." + ) enable_web_search = settings.get("enable_web_search") debug_print(f"[WebSearch] enable_web_search setting: {enable_web_search}") @@ -12328,15 +12576,13 @@ def perform_web_search( if not enable_web_search: debug_print("[WebSearch] Web search is DISABLED in settings, returning early") return True # Not an error, just disabled - - debug_print("[WebSearch] Web search is ENABLED, proceeding...") web_search_agent = settings.get("web_search_agent") or {} debug_print(f"[WebSearch] web_search_agent config present: {bool(web_search_agent)}") if web_search_agent: # Avoid logging sensitive data, just log structure debug_print(f"[WebSearch] web_search_agent keys: {list(web_search_agent.keys())}") - + other_settings = web_search_agent.get("other_settings") or {} debug_print(f"[WebSearch] other_settings keys: {list(other_settings.keys()) if other_settings else ''}") @@ -12369,16 +12615,8 @@ def perform_web_search( return False # Configuration error debug_print(f"[WebSearch] Agent ID is configured: {agent_id}") - - query_text = None - try: - query_text = search_query - debug_print(f"[WebSearch] Using search_query as query_text: {query_text[:100] if query_text else None}...") - except NameError: - query_text = None - debug_print("[WebSearch] search_query not defined, query_text is None") - query_text = (query_text or user_message or "").strip() + query_text = (web_search_query_text or user_message or "").strip() debug_print(f"[WebSearch] Final query_text after fallback: '{query_text[:100] if query_text else ''}'") if not query_text: @@ -12400,17 +12638,8 @@ def perform_web_search( debug_print(f"[WebSearch] Message history created with {len(message_history)} message(s)") try: - foundry_metadata = { - "conversation_id": conversation_id, - "user_id": user_id, - "message_id": user_message_id, - "chat_type": chat_type, - "document_scope": document_scope, - "group_id": active_group_id if chat_type == "group" else None, - "public_workspace_id": active_public_workspace_id, - "search_query": query_text, - } - debug_print(f"[WebSearch] Foundry metadata prepared: {json.dumps(foundry_metadata, default=str)}") + foundry_metadata = {} + debug_print("[WebSearch] Foundry metadata prepared: {}") debug_print("[WebSearch] Calling execute_foundry_agent...") debug_print(f"[WebSearch] foundry_settings keys: {list(foundry_settings.keys())}") diff --git a/application/single_app/route_backend_control_center.py b/application/single_app/route_backend_control_center.py index a7e5e8a0d..b74508acf 100644 --- a/application/single_app/route_backend_control_center.py +++ b/application/single_app/route_backend_control_center.py @@ -539,7 +539,7 @@ def enhance_user_with_activity(user, force_refresh=False): # Update user settings with cached metrics settings_update = {'metrics': metrics_cache} - update_success = update_user_settings(user.get('id'), settings_update) + update_success = update_user_settings(user.get('id'), settings_update, allow_cross_user=True) if update_success: debug_print(f"Successfully cached metrics for user {user.get('id')}") @@ -2315,7 +2315,7 @@ def api_update_user_access(user_id): } } - success = update_user_settings(user_id, access_settings) + success = update_user_settings(user_id, access_settings, allow_cross_user=True) if success: # Log admin action @@ -2371,7 +2371,7 @@ def api_update_user_file_uploads(user_id): } } - success = update_user_settings(user_id, file_upload_settings) + success = update_user_settings(user_id, file_upload_settings, allow_cross_user=True) if success: # Log admin action @@ -2515,7 +2515,7 @@ def api_bulk_user_action(): for user_id in user_ids: try: - success = update_user_settings(user_id, update_settings) + success = update_user_settings(user_id, update_settings, allow_cross_user=True) if success: success_count += 1 else: @@ -5940,6 +5940,22 @@ def api_admin_get_approvals(): debug_print(traceback.format_exc()) return jsonify({'error': 'Failed to fetch approvals', 'details': str(e)}), 500 + def _get_authorized_route_approval(approval_id, group_id, require_approval_rights=False): + """Resolve the current user and return an authorized approval plus user context.""" + user = session.get('user', {}) + user_id = user.get('oid') or user.get('sub') + user_roles = user.get('roles', []) + user_email = user.get('preferred_username', user.get('email', 'unknown')) + user_name = user.get('name', user_email) + approval = get_authorized_approval( + approval_id, + group_id, + user_id, + user_roles, + require_approval_rights=require_approval_rights, + ) + return approval, user_id, user_roles, user_email, user_name + @app.route('/api/admin/control-center/approvals/', methods=['GET']) @swagger_route(security=get_auth_security()) @login_required @@ -5952,23 +5968,23 @@ def api_admin_get_approval_by_id(approval_id): group_id (str): Group ID (partition key) """ try: - user = session.get('user', {}) - user_id = user.get('oid') or user.get('sub') - group_id = request.args.get('group_id') if not group_id: return jsonify({'error': 'group_id query parameter is required'}), 400 - - # Get the approval - approval = cosmos_approvals_container.read_item( - item=approval_id, - partition_key=group_id + + approval, user_id, user_roles, _user_email, _user_name = _get_authorized_route_approval( + approval_id, + group_id, ) # Add can_approve field - approval['can_approve'] = (approval.get('requester_id') != user_id) + approval['can_approve'] = _can_user_approve(approval, user_id, user_roles) return jsonify(approval), 200 + except LookupError: + return jsonify({'error': 'Approval not found'}), 404 + except PermissionError: + return jsonify({'error': 'You are not authorized to view this approval'}), 403 except Exception as e: debug_print(f"Error fetching approval {approval_id}: {e}") @@ -5989,17 +6005,18 @@ def api_admin_approve_request(approval_id): comment (str, optional): Approval comment """ try: - user = session.get('user', {}) - user_id = user.get('oid') or user.get('sub') - user_email = user.get('preferred_username', user.get('email', 'unknown')) - user_name = user.get('name', user_email) - data = request.get_json() group_id = data.get('group_id') comment = data.get('comment', '') if not group_id: return jsonify({'error': 'group_id is required'}), 400 + + approval, user_id, _user_roles, user_email, user_name = _get_authorized_route_approval( + approval_id, + group_id, + require_approval_rights=True, + ) # Approve the request approval = approve_request( @@ -6008,7 +6025,8 @@ def api_admin_approve_request(approval_id): approver_id=user_id, approver_email=user_email, approver_name=user_name, - comment=comment + comment=comment, + approval=approval, ) # Execute the approved action @@ -6020,6 +6038,10 @@ def api_admin_approve_request(approval_id): 'approval': approval, 'execution_result': execution_result }), 200 + except LookupError: + return jsonify({'error': 'Approval not found'}), 404 + except PermissionError: + return jsonify({'error': 'You are not eligible to approve this request'}), 403 except Exception as e: debug_print(f"Error approving request: {e}") @@ -6038,11 +6060,6 @@ def api_admin_deny_request(approval_id): comment (str): Reason for denial (required) """ try: - user = session.get('user', {}) - user_id = user.get('oid') or user.get('sub') - user_email = user.get('preferred_username', user.get('email', 'unknown')) - user_name = user.get('name', user_email) - data = request.get_json() group_id = data.get('group_id') comment = data.get('comment', '') @@ -6052,6 +6069,12 @@ def api_admin_deny_request(approval_id): if not comment: return jsonify({'error': 'comment is required for denial'}), 400 + + approval, user_id, _user_roles, user_email, user_name = _get_authorized_route_approval( + approval_id, + group_id, + require_approval_rights=True, + ) # Deny the request approval = deny_request( @@ -6061,7 +6084,8 @@ def api_admin_deny_request(approval_id): denier_email=user_email, denier_name=user_name, comment=comment, - auto_denied=False + auto_denied=False, + approval=approval, ) return jsonify({ @@ -6069,6 +6093,10 @@ def api_admin_deny_request(approval_id): 'message': 'Request denied', 'approval': approval }), 200 + except LookupError: + return jsonify({'error': 'Approval not found'}), 404 + except PermissionError: + return jsonify({'error': 'You are not eligible to deny this request'}), 403 except Exception as e: debug_print(f"Error denying request: {e}") @@ -6127,8 +6155,7 @@ def api_get_approvals(): approvals_with_permission = [] for approval in result.get('approvals', []): approval_copy = dict(approval) - # User can approve if they didn't create the request - approval_copy['can_approve'] = (approval.get('requester_id') != user_id) + approval_copy['can_approve'] = _can_user_approve(approval, user_id, user_roles) approvals_with_permission.append(approval_copy) return jsonify({ @@ -6157,23 +6184,23 @@ def api_get_approval_by_id(approval_id): group_id (str): Group ID (partition key) """ try: - user = session.get('user', {}) - user_id = user.get('oid') or user.get('sub') - group_id = request.args.get('group_id') if not group_id: return jsonify({'error': 'group_id query parameter is required'}), 400 - - # Get the approval - approval = cosmos_approvals_container.read_item( - item=approval_id, - partition_key=group_id + + approval, user_id, user_roles, _user_email, _user_name = _get_authorized_route_approval( + approval_id, + group_id, ) # Add can_approve field - approval['can_approve'] = (approval.get('requester_id') != user_id) + approval['can_approve'] = _can_user_approve(approval, user_id, user_roles) return jsonify(approval), 200 + except LookupError: + return jsonify({'error': 'Approval not found'}), 404 + except PermissionError: + return jsonify({'error': 'You are not authorized to view this approval'}), 403 except Exception as e: debug_print(f"Error fetching approval {approval_id}: {e}") @@ -6193,17 +6220,18 @@ def api_approve_request(approval_id): comment (str, optional): Approval comment """ try: - user = session.get('user', {}) - user_id = user.get('oid') or user.get('sub') - user_email = user.get('preferred_username', user.get('email', 'unknown')) - user_name = user.get('name', user_email) - data = request.get_json() group_id = data.get('group_id') comment = data.get('comment', '') if not group_id: return jsonify({'error': 'group_id is required'}), 400 + + approval, user_id, _user_roles, user_email, user_name = _get_authorized_route_approval( + approval_id, + group_id, + require_approval_rights=True, + ) # Approve the request approval = approve_request( @@ -6212,7 +6240,8 @@ def api_approve_request(approval_id): approver_id=user_id, approver_email=user_email, approver_name=user_name, - comment=comment + comment=comment, + approval=approval, ) # Execute the approved action @@ -6224,6 +6253,10 @@ def api_approve_request(approval_id): 'approval': approval, 'execution_result': execution_result }), 200 + except LookupError: + return jsonify({'error': 'Approval not found'}), 404 + except PermissionError: + return jsonify({'error': 'You are not eligible to approve this request'}), 403 except Exception as e: debug_print(f"Error approving request: {e}") @@ -6241,11 +6274,6 @@ def api_deny_request(approval_id): comment (str): Reason for denial (required) """ try: - user = session.get('user', {}) - user_id = user.get('oid') or user.get('sub') - user_email = user.get('preferred_username', user.get('email', 'unknown')) - user_name = user.get('name', user_email) - data = request.get_json() group_id = data.get('group_id') comment = data.get('comment', '') @@ -6255,6 +6283,12 @@ def api_deny_request(approval_id): if not comment: return jsonify({'error': 'comment is required for denial'}), 400 + + approval, user_id, _user_roles, user_email, user_name = _get_authorized_route_approval( + approval_id, + group_id, + require_approval_rights=True, + ) # Deny the request approval = deny_request( @@ -6264,7 +6298,8 @@ def api_deny_request(approval_id): denier_email=user_email, denier_name=user_name, comment=comment, - auto_denied=False + auto_denied=False, + approval=approval, ) return jsonify({ @@ -6272,6 +6307,10 @@ def api_deny_request(approval_id): 'message': 'Request denied', 'approval': approval }), 200 + except LookupError: + return jsonify({'error': 'Approval not found'}), 404 + except PermissionError: + return jsonify({'error': 'You are not eligible to deny this request'}), 403 except Exception as e: debug_print(f"Error denying request: {e}") diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py index 23f1a0714..a99e7c54b 100644 --- a/application/single_app/route_backend_conversations.py +++ b/application/single_app/route_backend_conversations.py @@ -72,6 +72,22 @@ def _collect_child_message_documents(conversation_id, root_message_ids): return child_docs + +def _authorize_personal_conversation_read(user_id, conversation_id): + """Load a personal conversation and ensure the caller owns it.""" + try: + conversation_item = cosmos_conversations_container.read_item( + item=conversation_id, + partition_key=conversation_id, + ) + except CosmosResourceNotFoundError as exc: + raise LookupError(f"Conversation {conversation_id} not found") from exc + + if conversation_item.get('user_id') != user_id: + raise PermissionError('Forbidden') + + return conversation_item + def register_route_backend_conversations(app): @app.route('/api/get_messages', methods=['GET']) @@ -86,10 +102,7 @@ def api_get_messages(): if not conversation_id: return jsonify({'error': 'No conversation_id provided'}), 400 try: - conversation_item = cosmos_conversations_container.read_item( - item=conversation_id, - partition_key=conversation_id - ) + _authorize_personal_conversation_read(user_id, conversation_id) # Query all messages in cosmos_messages_container # We'll filter for active_thread in Python since Cosmos DB boolean queries can be tricky message_query = f""" @@ -209,7 +222,9 @@ def api_get_messages(): message['vision_analysis'] = vision_analysis return jsonify({'messages': messages}) - except CosmosResourceNotFoundError: + except PermissionError: + return jsonify({'error': 'Forbidden'}), 403 + except LookupError: return jsonify({'messages': []}) except Exception as e: print(f"ERROR: Failed to get messages: {str(e)}") @@ -240,6 +255,8 @@ def api_get_image(image_id): conversation_id = '_'.join(parts[:-3]) debug_print(f"Serving image {image_id} from conversation {conversation_id}") + + _authorize_personal_conversation_read(user_id, conversation_id) # Query for the main image document and chunks message_query = f"SELECT * FROM c WHERE c.conversation_id = '{conversation_id}'" @@ -334,6 +351,11 @@ def api_get_image(image_id): ) else: return jsonify({'error': 'Invalid image format'}), 400 + + except PermissionError: + return jsonify({'error': 'Forbidden'}), 403 + except LookupError: + return jsonify({'error': 'Image not found'}), 404 except Exception as e: print(f"ERROR: Failed to serve image {image_id}: {str(e)}") @@ -456,18 +478,21 @@ def delete_conversation(conversation_id): """ Delete a conversation. If archiving is enabled, copy it to archived_conversations first. """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + settings = get_settings() archiving_enabled = settings.get('enable_conversation_archiving', False) try: - conversation_item = cosmos_conversations_container.read_item( - item=conversation_id, - partition_key=conversation_id - ) - except CosmosResourceNotFoundError: + conversation_item = _authorize_personal_conversation_read(user_id, conversation_id) + except LookupError: return jsonify({ "error": f"Conversation {conversation_id} not found." }), 404 + except PermissionError: + return jsonify({'error': 'Forbidden'}), 403 except Exception as e: return jsonify({ "error": str(e) diff --git a/application/single_app/route_backend_documents.py b/application/single_app/route_backend_documents.py index b70d99806..c74375e0e 100644 --- a/application/single_app/route_backend_documents.py +++ b/application/single_app/route_backend_documents.py @@ -135,7 +135,7 @@ def get_file_content(): return jsonify({'error': 'Missing conversation_id or id'}), 400 try: - _ = cosmos_conversations_container.read_item( + conversation_item = cosmos_conversations_container.read_item( item=conversation_id, partition_key=conversation_id ) @@ -143,6 +143,9 @@ def get_file_content(): return jsonify({'error': 'Conversation not found'}), 404 except Exception as e: return jsonify({'error': f'Error reading conversation: {str(e)}'}), 500 + + if conversation_item.get('user_id') != user_id: + return jsonify({'error': 'Forbidden'}), 403 add_file_task_to_file_processing_log(document_id=file_id, user_id=user_id, content="Conversation exists, retrieving file content") try: @@ -919,12 +922,12 @@ def api_create_tag(): data = request.get_json() tag_name = data.get('tag_name') - color = data.get('color', '#0d6efd') # Default blue color + color = data.get('color') if not tag_name: return jsonify({'error': 'tag_name is required'}), 400 - from functions_documents import normalize_tag, validate_tags + from functions_documents import normalize_tag, validate_tag_color, validate_tags from functions_settings import get_user_settings, update_user_settings from datetime import datetime, timezone @@ -935,6 +938,9 @@ def api_create_tag(): return jsonify({'error': error_msg}), 400 normalized_tag = normalized_tags[0] + is_valid_color, color_error, normalized_color = validate_tag_color(color, normalized_tag) + if not is_valid_color: + return jsonify({'error': color_error}), 400 # Get existing tag definitions from settings user_settings = get_user_settings(user_id) @@ -954,7 +960,7 @@ def api_create_tag(): # Add new tag to existing tags (don't replace) personal_tags[normalized_tag] = { - 'color': color, + 'color': normalized_color, 'created_at': datetime.now(timezone.utc).isoformat() } @@ -973,7 +979,7 @@ def api_create_tag(): 'message': f'Tag "{normalized_tag}" created successfully', 'tag': { 'name': normalized_tag, - 'color': color + 'color': normalized_color } }), 201 @@ -1157,7 +1163,7 @@ def api_update_tag(tag_name): debug_print(f"[UPDATE TAG] Request data - new_name: {new_name}, new_color: {new_color}") from functions_documents import ( - normalize_tag, validate_tags, get_documents, + normalize_tag, validate_tag_color, validate_tags, get_documents, update_document, propagate_tags_to_chunks ) from functions_settings import get_user_settings, update_user_settings @@ -1267,6 +1273,10 @@ def api_update_tag(tag_name): # Handle color change only if new_color: debug_print(f"[UPDATE TAG] Handling color change operation...") + is_valid_color, color_error, normalized_color = validate_tag_color(new_color, normalized_old_tag) + if not is_valid_color: + return jsonify({'error': color_error}), 400 + user_settings = get_user_settings(user_id) settings_dict = user_settings.get('settings', {}) tag_defs = settings_dict.get('tag_definitions', {}) @@ -1276,13 +1286,13 @@ def api_update_tag(tag_name): debug_print(f"[UPDATE TAG] Looking for tag: {normalized_old_tag}") if normalized_old_tag in personal_tags: - debug_print(f"[UPDATE TAG] Found tag, updating color to: {new_color}") - personal_tags[normalized_old_tag]['color'] = new_color + debug_print(f"[UPDATE TAG] Found tag, updating color to: {normalized_color}") + personal_tags[normalized_old_tag]['color'] = normalized_color else: - debug_print(f"[UPDATE TAG] Tag not found, creating new entry with color: {new_color}") + debug_print(f"[UPDATE TAG] Tag not found, creating new entry with color: {normalized_color}") from datetime import datetime, timezone personal_tags[normalized_old_tag] = { - 'color': new_color, + 'color': normalized_color, 'created_at': datetime.now(timezone.utc).isoformat() } @@ -1293,7 +1303,11 @@ def api_update_tag(tag_name): debug_print(f"[UPDATE TAG] Color change completed successfully") return jsonify({ - 'message': f'Tag color updated for "{normalized_old_tag}"' + 'message': f'Tag color updated for "{normalized_old_tag}"', + 'tag': { + 'name': normalized_old_tag, + 'color': normalized_color + } }), 200 debug_print(f"[UPDATE TAG] No updates specified!") diff --git a/application/single_app/route_backend_feedback.py b/application/single_app/route_backend_feedback.py index 49167cc85..8eeb31694 100644 --- a/application/single_app/route_backend_feedback.py +++ b/application/single_app/route_backend_feedback.py @@ -5,6 +5,22 @@ from functions_settings import * from swagger_wrapper import swagger_route, get_auth_security + +def _authorize_feedback_conversation(user_id, conversation_id): + """Load the target conversation and ensure the caller owns it.""" + try: + conversation_item = cosmos_conversations_container.read_item( + item=conversation_id, + partition_key=conversation_id, + ) + except CosmosResourceNotFoundError as exc: + raise LookupError(f"Conversation {conversation_id} not found") from exc + + if conversation_item.get("user_id") != user_id: + raise PermissionError("Forbidden") + + return conversation_item + def register_route_backend_feedback(app): @app.route("/feedback/submit", methods=["POST"]) @@ -18,7 +34,7 @@ def feedback_submit(): POST /feedback/submit JSON body: { messageId, conversationId, feedbackType, reason } """ - data = request.get_json() + data = request.get_json() or {} messageId = data.get("messageId") # This is the ID of the specific AI message conversationId = data.get("conversationId") # This is the ID of the conversation feedbackType = data.get("feedbackType") @@ -30,6 +46,16 @@ def feedback_submit(): if not messageId or not conversationId or not feedbackType: return jsonify({"error": "Missing required fields"}), 400 + if not user_id: + return jsonify({"error": "No user ID found in session"}), 403 + + try: + _authorize_feedback_conversation(user_id, conversationId) + except LookupError: + return jsonify({"error": "Conversation not found"}), 404 + except PermissionError: + return jsonify({"error": "Forbidden", "message": "You do not have access to this conversation"}), 403 + ai_message_text = None user_prompt_text = None all_messages = [] # Initialize an empty list for messages @@ -51,10 +77,7 @@ def feedback_submit(): # --- END CORRECTED PART --- if not message_items: - # No messages found for this conversation ID, which is unexpected if feedback is given - # You might want to log this or handle it differently - print(f"Warning: No messages found for conversationId {conversationId} during feedback submission.") - # Keep ai_message_text and user_prompt_text as None initially + return jsonify({"error": "Assistant message not found"}), 404 all_messages = message_items # Assign the query results to all_messages @@ -70,6 +93,9 @@ def feedback_submit(): ai_msg_index = i break + if ai_msg_index == -1: + return jsonify({"error": "Assistant message not found"}), 404 + # Find the user message immediately preceding the AI message if ai_msg_index > 0: # Iterate backwards from the message before the AI's message @@ -87,21 +113,12 @@ def feedback_submit(): if all_messages[i].get("role") == "user": user_prompt_text = all_messages[i].get("content") break - - - except exceptions.CosmosResourceNotFoundError: - # This specific exception might not be raised by query_items if the container exists but no items match. - # A query returning empty is more likely. Handle general exceptions. - print(f"Error querying messages for conversation {conversationId}: Resource not found (unexpected).") - # Decide how to handle - maybe proceed with default text? except Exception as e: print(f"Error querying messages for conversation {conversationId}: {e}") - # Log the error, maybe return a 500 or proceed with default text - # For now, let the default text logic below handle it. - pass # Allow execution to continue to the default text part + return jsonify({"error": "Failed to load feedback target"}), 500 # Set default text if messages weren't found - if not ai_message_text: + if ai_message_text is None: ai_message_text = "[AI response text not found in cosmos_messages_container]" if not user_prompt_text: diff --git a/application/single_app/route_backend_group_documents.py b/application/single_app/route_backend_group_documents.py index d8f00a04c..e8a72cba7 100644 --- a/application/single_app/route_backend_group_documents.py +++ b/application/single_app/route_backend_group_documents.py @@ -1046,12 +1046,12 @@ def api_create_group_tag(): data = request.get_json() tag_name = data.get('tag_name') - color = data.get('color', '#0d6efd') + color = data.get('color') if not tag_name: return jsonify({'error': 'tag_name is required'}), 400 - from functions_documents import normalize_tag, validate_tags + from functions_documents import normalize_tag, validate_tag_color, validate_tags from datetime import datetime, timezone try: @@ -1060,6 +1060,9 @@ def api_create_group_tag(): return jsonify({'error': error_msg}), 400 normalized_tag = normalized_tags[0] + is_valid_color, color_error, normalized_color = validate_tag_color(color, normalized_tag) + if not is_valid_color: + return jsonify({'error': color_error}), 400 tag_defs = group_doc.get('tag_definitions', {}) @@ -1067,7 +1070,7 @@ def api_create_group_tag(): return jsonify({'error': 'Tag already exists'}), 409 tag_defs[normalized_tag] = { - 'color': color, + 'color': normalized_color, 'created_at': datetime.now(timezone.utc).isoformat() } group_doc['tag_definitions'] = tag_defs @@ -1077,7 +1080,7 @@ def api_create_group_tag(): 'message': f'Tag "{normalized_tag}" created successfully', 'tag': { 'name': normalized_tag, - 'color': color + 'color': normalized_color } }), 201 @@ -1248,7 +1251,7 @@ def api_update_group_tag(tag_name): new_name = data.get('new_name') new_color = data.get('color') - from functions_documents import normalize_tag, validate_tags, update_document, propagate_tags_to_chunks + from functions_documents import normalize_tag, validate_tag_color, validate_tags, update_document, propagate_tags_to_chunks try: normalized_old_tag = normalize_tag(tag_name) @@ -1309,14 +1312,18 @@ def api_update_group_tag(tag_name): }), 200 if new_color: + is_valid_color, color_error, normalized_color = validate_tag_color(new_color, normalized_old_tag) + if not is_valid_color: + return jsonify({'error': color_error}), 400 + tag_defs = group_doc.get('tag_definitions', {}) if normalized_old_tag in tag_defs: - tag_defs[normalized_old_tag]['color'] = new_color + tag_defs[normalized_old_tag]['color'] = normalized_color else: from datetime import datetime, timezone tag_defs[normalized_old_tag] = { - 'color': new_color, + 'color': normalized_color, 'created_at': datetime.now(timezone.utc).isoformat() } @@ -1324,7 +1331,11 @@ def api_update_group_tag(tag_name): cosmos_groups_container.upsert_item(group_doc) return jsonify({ - 'message': f'Tag color updated for "{normalized_old_tag}"' + 'message': f'Tag color updated for "{normalized_old_tag}"', + 'tag': { + 'name': normalized_old_tag, + 'color': normalized_color + } }), 200 return jsonify({'error': 'No updates specified'}), 400 diff --git a/application/single_app/route_backend_group_prompts.py b/application/single_app/route_backend_group_prompts.py index ec87d4ec5..bd49da209 100644 --- a/application/single_app/route_backend_group_prompts.py +++ b/application/single_app/route_backend_group_prompts.py @@ -2,10 +2,26 @@ from config import * from functions_authentication import * +from functions_group import require_active_group from functions_settings import * from functions_prompts import * from swagger_wrapper import swagger_route, get_auth_security + +def _get_active_group_or_error(user_id): + try: + return require_active_group( + user_id, + allowed_roles=("Owner", "Admin", "DocumentManager", "User"), + ), None + except ValueError: + return None, (jsonify({"error": "No active group selected"}), 400) + except LookupError: + return None, (jsonify({"error": "Active group not found"}), 404) + except PermissionError: + return None, (jsonify({"error": "You are not a member of the active group"}), 403) + + def register_route_backend_group_prompts(app): @app.route('/api/group_prompts', methods=['GET']) @swagger_route(security=get_auth_security()) @@ -13,10 +29,10 @@ def register_route_backend_group_prompts(app): @user_required @enabled_required("enable_group_workspaces") def get_group_prompts(): - user_id = get_current_user_id() - active_group = get_user_settings(user_id)["settings"].get("activeGroupOid") - if not active_group: - return jsonify({"error":"No active group selected"}), 400 + user_id = get_current_user_id() + active_group, error_response = _get_active_group_or_error(user_id) + if error_response: + return error_response try: items, total, page, page_size = list_prompts( @@ -41,10 +57,10 @@ def get_group_prompts(): @user_required @enabled_required("enable_group_workspaces") def create_group_prompt(): - user_id = get_current_user_id() - active_group = get_user_settings(user_id)["settings"].get("activeGroupOid") - if not active_group: - return jsonify({"error":"No active group selected"}), 400 + user_id = get_current_user_id() + active_group, error_response = _get_active_group_or_error(user_id) + if error_response: + return error_response data = request.get_json() or {} name = data.get("name","").strip() @@ -71,10 +87,10 @@ def create_group_prompt(): @user_required @enabled_required("enable_group_workspaces") def get_group_prompt(prompt_id): - user_id = get_current_user_id() - active_group = get_user_settings(user_id)["settings"].get("activeGroupOid") - if not active_group: - return jsonify({"error":"No active group selected"}), 400 + user_id = get_current_user_id() + active_group, error_response = _get_active_group_or_error(user_id) + if error_response: + return error_response try: item = get_prompt_doc( @@ -96,10 +112,10 @@ def get_group_prompt(prompt_id): @user_required @enabled_required("enable_group_workspaces") def update_group_prompt(prompt_id): - user_id = get_current_user_id() - active_group = get_user_settings(user_id)["settings"].get("activeGroupOid") - if not active_group: - return jsonify({"error":"No active group selected"}), 400 + user_id = get_current_user_id() + active_group, error_response = _get_active_group_or_error(user_id) + if error_response: + return error_response data = request.get_json() or {} updates = {} @@ -135,10 +151,10 @@ def update_group_prompt(prompt_id): @user_required @enabled_required("enable_group_workspaces") def delete_group_prompt(prompt_id): - user_id = get_current_user_id() - active_group = get_user_settings(user_id)["settings"].get("activeGroupOid") - if not active_group: - return jsonify({"error":"No active group selected"}), 400 + user_id = get_current_user_id() + active_group, error_response = _get_active_group_or_error(user_id) + if error_response: + return error_response try: success = delete_prompt_doc( diff --git a/application/single_app/route_backend_groups.py b/application/single_app/route_backend_groups.py index 0e35d211b..9186e4ce4 100644 --- a/application/single_app/route_backend_groups.py +++ b/application/single_app/route_backend_groups.py @@ -163,10 +163,17 @@ def api_get_group_details(group_id): GET /api/groups/ Returns the full group details for that group. """ + user_info = get_current_user_info() + user_id = user_info["userId"] + group_doc = find_group_by_id(group_id) if not group_doc: return jsonify({"error": "Group not found"}), 404 + + if not get_user_role_in_group(group_doc, user_id): + return jsonify({"error": "You are not a member of this group"}), 403 + return jsonify(group_doc), 200 @app.route("/api/groups/", methods=["DELETE"]) diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index 115bf2828..b60f81894 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -28,6 +28,7 @@ validate_group_action_payload, ) from functions_keyvault import ( + resolve_secret_reference_for_context, SecretReturnType, redact_plugin_secret_values, retrieve_secret_from_key_vault_by_full_name, @@ -230,17 +231,35 @@ def _redact_plugin_for_logging(plugin): return redact_plugin_secret_values(plugin) -def _resolve_secret_value_for_sql_test(value, field_name): +def _resolve_plugin_secret_context(plugin_manifest, fallback_scope_value, fallback_scope="user"): + """Infer the expected Key Vault scope for SQL test-connection secret resolution.""" + if not isinstance(plugin_manifest, dict): + return fallback_scope_value, fallback_scope + + plugin_scope = str(plugin_manifest.get("scope") or "").strip().lower() + if plugin_scope == "group" or plugin_manifest.get("is_group"): + return plugin_manifest.get("group_id"), "group" + if plugin_scope == "global" or plugin_manifest.get("is_global"): + return plugin_manifest.get("id") or fallback_scope_value, "global" + if plugin_scope == "user" or plugin_manifest.get("user_id"): + return plugin_manifest.get("user_id") or fallback_scope_value, "user" + return fallback_scope_value, fallback_scope + + +def _resolve_secret_value_for_sql_test(value, field_name, scope_value, scope): """Resolve a Key Vault reference for SQL test-connection flows.""" if not isinstance(value, str) or not value: return value if not validate_secret_name_dynamic(value): return value - resolved_value = retrieve_secret_from_key_vault_by_full_name(value) - if validate_secret_name_dynamic(resolved_value): - raise ValueError(f"Unable to resolve stored Key Vault secret for SQL field '{field_name}'.") - return resolved_value + return resolve_secret_reference_for_context( + value, + scope_value=scope_value, + scope=scope, + allowed_sources={"action-addset"}, + context_label=f"SQL field '{field_name}'", + ) def _load_existing_plugin_for_sql_test(plugin_context, user_id): @@ -1093,9 +1112,21 @@ def test_sql_connection(): field_list = ', '.join(unresolved_fields) return jsonify({'success': False, 'error': f"Stored SQL secret could not be resolved for testing. Re-enter the {field_list}."}), 400 + plugin_scope_value, plugin_scope = _resolve_plugin_secret_context(existing_plugin, user_id) + try: - connection_string = _resolve_secret_value_for_sql_test(connection_string, 'connection_string') - password = _resolve_secret_value_for_sql_test(password, 'password') + connection_string = _resolve_secret_value_for_sql_test( + connection_string, + 'connection_string', + scope_value=plugin_scope_value, + scope=plugin_scope, + ) + password = _resolve_secret_value_for_sql_test( + password, + 'password', + scope_value=plugin_scope_value, + scope=plugin_scope, + ) except ValueError as exc: return jsonify({'success': False, 'error': str(exc)}), 400 diff --git a/application/single_app/route_backend_public_documents.py b/application/single_app/route_backend_public_documents.py index 3b7486bd2..f4396c241 100644 --- a/application/single_app/route_backend_public_documents.py +++ b/application/single_app/route_backend_public_documents.py @@ -536,12 +536,12 @@ def api_create_public_workspace_tag(): data = request.get_json() tag_name = data.get('tag_name') - color = data.get('color', '#0d6efd') + color = data.get('color') if not tag_name: return jsonify({'error': 'tag_name is required'}), 400 - from functions_documents import normalize_tag, validate_tags + from functions_documents import normalize_tag, validate_tag_color, validate_tags from datetime import datetime, timezone try: @@ -550,6 +550,9 @@ def api_create_public_workspace_tag(): return jsonify({'error': error_msg}), 400 normalized_tag = normalized_tags[0] + is_valid_color, color_error, normalized_color = validate_tag_color(color, normalized_tag) + if not is_valid_color: + return jsonify({'error': color_error}), 400 tag_defs = ws_doc.get('tag_definitions', {}) @@ -557,7 +560,7 @@ def api_create_public_workspace_tag(): return jsonify({'error': 'Tag already exists'}), 409 tag_defs[normalized_tag] = { - 'color': color, + 'color': normalized_color, 'created_at': datetime.now(timezone.utc).isoformat() } ws_doc['tag_definitions'] = tag_defs @@ -567,7 +570,7 @@ def api_create_public_workspace_tag(): 'message': f'Tag "{normalized_tag}" created successfully', 'tag': { 'name': normalized_tag, - 'color': color + 'color': normalized_color } }), 201 @@ -740,7 +743,7 @@ def api_update_public_workspace_tag(tag_name): new_name = data.get('new_name') new_color = data.get('color') - from functions_documents import normalize_tag, validate_tags, update_document, propagate_tags_to_chunks + from functions_documents import normalize_tag, validate_tag_color, validate_tags, update_document, propagate_tags_to_chunks try: normalized_old_tag = normalize_tag(tag_name) @@ -801,14 +804,18 @@ def api_update_public_workspace_tag(tag_name): }), 200 if new_color: + is_valid_color, color_error, normalized_color = validate_tag_color(new_color, normalized_old_tag) + if not is_valid_color: + return jsonify({'error': color_error}), 400 + tag_defs = ws_doc.get('tag_definitions', {}) if normalized_old_tag in tag_defs: - tag_defs[normalized_old_tag]['color'] = new_color + tag_defs[normalized_old_tag]['color'] = normalized_color else: from datetime import datetime, timezone tag_defs[normalized_old_tag] = { - 'color': new_color, + 'color': normalized_color, 'created_at': datetime.now(timezone.utc).isoformat() } @@ -816,7 +823,11 @@ def api_update_public_workspace_tag(tag_name): cosmos_public_workspaces_container.upsert_item(ws_doc) return jsonify({ - 'message': f'Tag color updated for "{normalized_old_tag}"' + 'message': f'Tag color updated for "{normalized_old_tag}"', + 'tag': { + 'name': normalized_old_tag, + 'color': normalized_color + } }), 200 return jsonify({'error': 'No updates specified'}), 400 diff --git a/application/single_app/route_backend_public_prompts.py b/application/single_app/route_backend_public_prompts.py index f125cbb32..75c3f678e 100644 --- a/application/single_app/route_backend_public_prompts.py +++ b/application/single_app/route_backend_public_prompts.py @@ -8,6 +8,20 @@ from functions_prompts import * from swagger_wrapper import swagger_route, get_auth_security + +def _get_active_public_workspace_or_error(user_id): + try: + return require_active_public_workspace( + user_id, + allowed_roles=("Owner", "Admin", "DocumentManager"), + ), None + except ValueError: + return None, (jsonify({'error': 'No active public workspace selected'}), 400) + except LookupError: + return None, (jsonify({'error': 'Workspace not found'}), 404) + except PermissionError: + return None, (jsonify({'error': 'Access denied'}), 403) + def register_route_backend_public_prompts(app): """ Backend routes for public-workspace–scoped prompts management @@ -20,15 +34,10 @@ def register_route_backend_public_prompts(app): @enabled_required('enable_public_workspaces') def api_list_public_prompts(): user_id = get_current_user_id() - settings = get_user_settings(user_id) - active_ws = settings['settings'].get('activePublicWorkspaceOid') - if not active_ws: - return jsonify({'error': 'No active public workspace selected'}), 400 - ws = find_public_workspace_by_id(active_ws) - if not ws: - return jsonify({'error': 'Workspace not found'}), 404 - if not get_user_role_in_public_workspace(ws, user_id): - return jsonify({'error': 'Access denied'}), 403 + active_workspace_context, error_response = _get_active_public_workspace_or_error(user_id) + if error_response: + return error_response + active_ws, _, _ = active_workspace_context try: items, total, page, page_size = list_prompts( @@ -54,15 +63,10 @@ def api_list_public_prompts(): @enabled_required('enable_public_workspaces') def api_create_public_prompt(): user_id = get_current_user_id() - settings = get_user_settings(user_id) - active_ws = settings['settings'].get('activePublicWorkspaceOid') - if not active_ws: - return jsonify({'error': 'No active public workspace selected'}), 400 - ws = find_public_workspace_by_id(active_ws) - if not ws: - return jsonify({'error': 'Workspace not found'}), 404 - if not get_user_role_in_public_workspace(ws, user_id): - return jsonify({'error': 'Access denied'}), 403 + active_workspace_context, error_response = _get_active_public_workspace_or_error(user_id) + if error_response: + return error_response + active_ws, _, _ = active_workspace_context data = request.get_json() or {} name = data.get('name','').strip() @@ -90,15 +94,10 @@ def api_create_public_prompt(): @enabled_required('enable_public_workspaces') def api_get_public_prompt(prompt_id): user_id = get_current_user_id() - settings = get_user_settings(user_id) - active_ws = settings['settings'].get('activePublicWorkspaceOid') - if not active_ws: - return jsonify({'error': 'No active public workspace selected'}), 400 - ws = find_public_workspace_by_id(active_ws) - if not ws: - return jsonify({'error': 'Workspace not found'}), 404 - if not get_user_role_in_public_workspace(ws, user_id): - return jsonify({'error': 'Access denied'}), 403 + active_workspace_context, error_response = _get_active_public_workspace_or_error(user_id) + if error_response: + return error_response + active_ws, _, _ = active_workspace_context try: item = get_prompt_doc( @@ -121,15 +120,10 @@ def api_get_public_prompt(prompt_id): @enabled_required('enable_public_workspaces') def api_update_public_prompt(prompt_id): user_id = get_current_user_id() - settings = get_user_settings(user_id) - active_ws = settings['settings'].get('activePublicWorkspaceOid') - if not active_ws: - return jsonify({'error': 'No active public workspace selected'}), 400 - ws = find_public_workspace_by_id(active_ws) - if not ws: - return jsonify({'error': 'Workspace not found'}), 404 - if not get_user_role_in_public_workspace(ws, user_id): - return jsonify({'error': 'Access denied'}), 403 + active_workspace_context, error_response = _get_active_public_workspace_or_error(user_id) + if error_response: + return error_response + active_ws, _, _ = active_workspace_context data = request.get_json() or {} updates = {} @@ -166,15 +160,10 @@ def api_update_public_prompt(prompt_id): @enabled_required('enable_public_workspaces') def api_delete_public_prompt(prompt_id): user_id = get_current_user_id() - settings = get_user_settings(user_id) - active_ws = settings['settings'].get('activePublicWorkspaceOid') - if not active_ws: - return jsonify({'error': 'No active public workspace selected'}), 400 - ws = find_public_workspace_by_id(active_ws) - if not ws: - return jsonify({'error': 'Workspace not found'}), 404 - if not get_user_role_in_public_workspace(ws, user_id): - return jsonify({'error': 'Access denied'}), 403 + active_workspace_context, error_response = _get_active_public_workspace_or_error(user_id) + if error_response: + return error_response + active_ws, _, _ = active_workspace_context try: success = delete_prompt_doc( diff --git a/application/single_app/route_backend_public_workspaces.py b/application/single_app/route_backend_public_workspaces.py index 9307bc3c3..8a7ade4ed 100644 --- a/application/single_app/route_backend_public_workspaces.py +++ b/application/single_app/route_backend_public_workspaces.py @@ -211,12 +211,20 @@ def api_create_public_workspace(): def api_get_public_workspace(ws_id): """ GET /api/public_workspaces/ - Returns full workspace document. + Returns a role-aware workspace payload. """ + info = get_current_user_info() + user_id = info["userId"] + ws = find_public_workspace_by_id(ws_id) if not ws: return jsonify({"error": "Workspace not found"}), 404 - return jsonify(ws), 200 + + role = get_user_role_in_public_workspace(ws, user_id) + if role: + return jsonify(build_public_workspace_member_payload(ws, user_id)), 200 + + return jsonify(build_public_workspace_public_summary(ws)), 200 @app.route("/api/public_workspaces/", methods=["PATCH", "PUT"]) @swagger_route(security=get_auth_security()) @@ -289,13 +297,11 @@ def api_set_active_public_workspace(): info = get_current_user_info() user_id = info["userId"] - ws = find_public_workspace_by_id(ws_id) - if not ws: + try: + update_active_public_workspace_for_user(user_id, ws_id) + except LookupError: return jsonify({"error": "Workspace not found"}), 404 - # Public workspaces are accessible to all authenticated users for chat. - # No membership check needed — any user can set a public workspace as active. - update_active_public_workspace_for_user(user_id, ws_id) return jsonify({"message": f"Active set to {ws_id}"}), 200 @app.route("/api/public_workspaces//requests", methods=["GET"]) diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index 459aa800c..eac97c37e 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -2,9 +2,16 @@ from config import * from functions_authentication import * +from functions_group import update_active_group_for_user +from functions_public_workspaces import update_active_public_workspace_for_user from functions_settings import * from swagger_wrapper import swagger_route, get_auth_security + +def _escape_graph_odata_literal(value): + return str(value or "").replace("'", "''") + + def register_route_backend_users(app): """ This route will expose GET /api/userSearch?query= which calls @@ -20,6 +27,8 @@ def api_user_search(): if not query: return jsonify([]), 200 + safe_query = _escape_graph_odata_literal(query) + token = get_valid_access_token() if not token: return jsonify({"error": "Could not acquire access token"}), 401 @@ -32,9 +41,9 @@ def api_user_search(): } filter_str = ( - f"startswith(displayName, '{query}') " - f"or startswith(mail, '{query}') " - f"or startswith(userPrincipalName, '{query}')" + f"startswith(displayName, '{safe_query}') " + f"or startswith(mail, '{safe_query}') " + f"or startswith(userPrincipalName, '{safe_query}')" ) params = { "$filter": filter_str, @@ -163,13 +172,54 @@ def user_settings(): invalid_keys = set(settings_to_update.keys()) - allowed_keys if invalid_keys: print(f"Warning: Received invalid settings keys: {invalid_keys}") - # Decide whether to ignore them or return an error - # To ignore: settings_to_update = {k: v for k, v in settings_to_update.items() if k in allowed_keys} - # To error: return jsonify({"error": f"Invalid settings keys provided: {', '.join(invalid_keys)}"}), 400 + settings_to_update = { + key: value + for key, value in settings_to_update.items() + if key in allowed_keys + } + if not settings_to_update: + return jsonify({"error": "No valid settings keys provided"}), 400 + + + settings_to_update = dict(settings_to_update) + active_group_updated = False + active_public_workspace_updated = False + + if "activeGroupOid" in settings_to_update: + requested_active_group = str(settings_to_update.pop("activeGroupOid") or "").strip() + if requested_active_group: + try: + update_active_group_for_user(requested_active_group, user_id=user_id) + active_group_updated = True + except LookupError: + return jsonify({"error": "Group not found"}), 404 + except PermissionError: + return jsonify({"error": "You are not a member of this group"}), 403 + else: + settings_to_update["activeGroupOid"] = requested_active_group + if "activePublicWorkspaceOid" in settings_to_update: + requested_active_public_workspace = str( + settings_to_update.pop("activePublicWorkspaceOid") or "" + ).strip() + if requested_active_public_workspace: + try: + update_active_public_workspace_for_user( + user_id, + requested_active_public_workspace, + ) + active_public_workspace_updated = True + except LookupError: + return jsonify({"error": "Workspace not found"}), 404 + else: + settings_to_update["activePublicWorkspaceOid"] = requested_active_public_workspace # Call the updated function - it handles merging and timestamp - success = update_user_settings(user_id, settings_to_update) + success = True + if settings_to_update: + success = update_user_settings(user_id, settings_to_update) + elif active_group_updated or active_public_workspace_updated: + success = True if success: return jsonify({"message": "User settings updated successfully"}), 200 diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 129dfcde6..0dc5cdd88 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -1261,7 +1261,7 @@ def is_valid_url(url): 'enable_web_search': enable_web_search, 'web_search_consent_accepted': web_search_consent_accepted, 'enable_web_search_user_notice': form_data.get('enable_web_search_user_notice') == 'on', - 'web_search_user_notice_text': form_data.get('web_search_user_notice_text', 'Your message will be sent to Microsoft Bing for web search. Only your current message is sent, not your conversation history.').strip(), + 'web_search_user_notice_text': form_data.get('web_search_user_notice_text', 'Your current message will be sent to Microsoft Bing for web search. Conversation history is not sent for web search, but any sensitive content you paste into this message may be sent.').strip(), 'web_search_agent': { 'agent_type': 'aifoundry', 'azure_openai_gpt_endpoint': form_data.get('web_search_foundry_endpoint', '').strip(), diff --git a/application/single_app/route_frontend_conversations.py b/application/single_app/route_frontend_conversations.py index d2b428fe2..b2e65be49 100644 --- a/application/single_app/route_frontend_conversations.py +++ b/application/single_app/route_frontend_conversations.py @@ -10,6 +10,22 @@ ) from swagger_wrapper import swagger_route, get_auth_security + +def _authorize_frontend_personal_conversation_access(user_id, conversation_id): + """Load a personal conversation and ensure the caller owns it.""" + try: + conversation_item = cosmos_conversations_container.read_item( + item=conversation_id, + partition_key=conversation_id, + ) + except CosmosResourceNotFoundError as exc: + raise LookupError(f"Conversation {conversation_id} not found") from exc + + if conversation_item.get('user_id') != user_id: + raise PermissionError('Forbidden') + + return conversation_item + def register_route_frontend_conversations(app): @app.route('/conversations') @swagger_route(security=get_auth_security()) @@ -41,12 +57,11 @@ def view_conversation(conversation_id): if not user_id: return redirect(url_for('login')) try: - conversation_item = cosmos_conversations_container.read_item( - item=conversation_id, - partition_key=conversation_id - ) - except Exception: + _authorize_frontend_personal_conversation_access(user_id, conversation_id) + except LookupError: return "Conversation not found", 404 + except PermissionError: + return "Forbidden", 403 message_query = f""" SELECT * FROM c @@ -70,9 +85,11 @@ def get_conversation_messages(conversation_id): return jsonify({'error': 'User not authenticated'}), 401 try: - _ = cosmos_conversations_container.read_item(conversation_id, conversation_id) - except CosmosResourceNotFoundError: + _authorize_frontend_personal_conversation_access(user_id, conversation_id) + except LookupError: return jsonify({'error': 'Conversation not found'}), 404 + except PermissionError: + return jsonify({'error': 'Forbidden'}), 403 msg_query = f""" SELECT * FROM c diff --git a/application/single_app/route_frontend_group_workspaces.py b/application/single_app/route_frontend_group_workspaces.py index e92d5a980..6e3186f62 100644 --- a/application/single_app/route_frontend_group_workspaces.py +++ b/application/single_app/route_frontend_group_workspaces.py @@ -2,7 +2,7 @@ from config import * from functions_authentication import * -from functions_group import get_group_model_endpoints +from functions_group import get_group_model_endpoints, require_active_group, update_active_group_for_user from functions_settings import * from swagger_wrapper import swagger_route, get_auth_security @@ -18,7 +18,10 @@ def group_workspaces(): settings = get_settings() user_settings = get_user_settings(user_id) public_settings = sanitize_settings_for_user(settings) - active_group_id = user_settings.get("settings", {}).get("activeGroupOid") + try: + active_group_id = require_active_group(user_id) + except (ValueError, LookupError, PermissionError): + active_group_id = None enable_document_classification = settings.get('enable_document_classification', False) enable_file_sharing = settings.get('enable_file_sharing', False) enable_extract_meta_data = settings.get('enable_extract_meta_data', False) @@ -97,7 +100,12 @@ def set_active_group(): group_id = request.form.get("group_id") if not user_id or not group_id: return "Missing user or group id", 400 - success = update_user_settings(user_id, {"activeGroupOid": group_id}) - if not success: - return "Failed to update user settings", 500 + + try: + update_active_group_for_user(group_id, user_id=user_id) + except LookupError: + return "Group not found", 404 + except PermissionError: + return "You are not a member of this group", 403 + return redirect(url_for('group_workspaces')) diff --git a/application/single_app/route_frontend_public_workspaces.py b/application/single_app/route_frontend_public_workspaces.py index 05d5b982a..fa1178d64 100644 --- a/application/single_app/route_frontend_public_workspaces.py +++ b/application/single_app/route_frontend_public_workspaces.py @@ -2,6 +2,7 @@ from config import * from functions_authentication import * +from functions_public_workspaces import update_active_public_workspace_for_user from functions_settings import * from swagger_wrapper import swagger_route, get_auth_security @@ -116,7 +117,10 @@ def set_active_public_workspace(): workspace_id = request.form.get("workspace_id") if not user_id or not workspace_id: return "Missing user or workspace id", 400 - success = update_user_settings(user_id, {"activePublicWorkspaceOid": workspace_id}) - if not success: - return "Failed to update user settings", 500 + + try: + update_active_public_workspace_for_user(user_id, workspace_id) + except LookupError: + return "Workspace not found", 404 + return redirect(url_for('public_workspaces')) \ No newline at end of file diff --git a/application/single_app/route_plugin_logging.py b/application/single_app/route_plugin_logging.py index 940d540ea..69b7ce35b 100644 --- a/application/single_app/route_plugin_logging.py +++ b/application/single_app/route_plugin_logging.py @@ -4,7 +4,7 @@ """ from flask import Blueprint, jsonify, request -from functions_authentication import login_required, get_current_user_id +from functions_authentication import admin_required, login_required, get_current_user_id from functions_appinsights import log_event from semantic_kernel_plugins.plugin_invocation_logger import get_plugin_logger from swagger_wrapper import swagger_route, get_auth_security @@ -122,10 +122,10 @@ def get_plugin_stats(): security=get_auth_security() ) @login_required +@admin_required def get_recent_invocations(): """Get the most recent plugin invocations across all users (admin only).""" try: - # Note: You might want to add admin role checking here plugin_logger = get_plugin_logger() limit = request.args.get('limit', 20, type=int) @@ -220,6 +220,7 @@ def get_plugin_specific_invocations(plugin_name): security=get_auth_security() ) @login_required +@admin_required def clear_plugin_logs(): """Clear plugin invocation logs (admin only or for testing).""" try: diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 3a2ca4b54..d2fce1c81 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -36,7 +36,16 @@ from semantic_kernel_plugins.smart_http_plugin import SmartHttpPlugin from functions_debug import debug_print from flask import g -from functions_keyvault import SecretReturnType, keyvault_model_endpoint_get_helper, retrieve_secret_from_key_vault, retrieve_secret_from_key_vault_by_full_name, validate_secret_name_dynamic +from functions_keyvault import ( + SQL_PLUGIN_SENSITIVE_ADDITIONAL_FIELDS, + SQL_PLUGIN_SENSITIVE_AUTH_FIELDS, + SecretReturnType, + keyvault_model_endpoint_get_helper, + resolve_secret_reference_for_context, + retrieve_secret_from_key_vault, + retrieve_secret_from_key_vault_by_full_name, + validate_secret_name_dynamic, +) from functions_global_actions import get_global_actions from functions_global_agents import get_global_agents from functions_group_agents import get_group_agent, get_group_agents @@ -1595,6 +1604,27 @@ def create_chat_completion_service(): log_event(f"[SK Loader] load_single_agent_for_kernel completed - returning {len(agent_objs)} agents: {list(agent_objs.keys())}", level=logging.INFO) return kernel, agent_objs +def _get_plugin_secret_context(plugin_manifest): + """Infer the expected Key Vault scope for a plugin manifest.""" + if not isinstance(plugin_manifest, dict): + return None, None + + plugin_scope = str(plugin_manifest.get("scope") or "").strip().lower() + if plugin_scope == "group" or plugin_manifest.get("is_group"): + return plugin_manifest.get("group_id"), "group" + if plugin_scope == "global" or plugin_manifest.get("is_global"): + return plugin_manifest.get("id"), "global" + if plugin_scope == "user" or plugin_manifest.get("user_id"): + return plugin_manifest.get("user_id"), "user" + return plugin_manifest.get("id"), "global" + + +def _is_sql_sensitive_plugin_field(plugin_manifest, field_name): + """Return True when an additional field should resolve as a SQL secret.""" + plugin_type = str((plugin_manifest or {}).get("type") or "").strip().lower() + return plugin_type in {"sql_query", "sql_schema"} and field_name in SQL_PLUGIN_SENSITIVE_ADDITIONAL_FIELDS + + def resolve_key_vault_secrets_in_plugins(plugin_manifest, settings): """ Resolve any Key Vault secrets in a plugin manifest. @@ -1606,26 +1636,66 @@ def resolve_key_vault_secrets_in_plugins(plugin_manifest, settings): if not kv_name: raise ValueError("Key Vault name not configured in settings") - def resolve_value(value): - if isinstance(value, str) and validate_secret_name_dynamic(value): - resolved = retrieve_secret_from_key_vault_by_full_name(value) - if resolved: - return resolved - else: - raise ValueError(f"Failed to retrieve secret '{value}' from Key Vault '{kv_name}'") - return value - - resolved_manifest = {} - for k, v in plugin_manifest.items(): - debug_print(f"[SK Loader] Resolving plugin manifest key: {k} with value type: {type(v)}") - if isinstance(v, str): - resolved_manifest[k] = resolve_value(v) - elif isinstance(v, list): - resolved_manifest[k] = [resolve_value(item) for item in v] - elif isinstance(v, dict): - resolved_manifest[k] = {sub_k: resolve_value(sub_v) for sub_k, sub_v in v.items()} - else: - resolved_manifest[k] = v # Leave other types unchanged + scope_value, scope = _get_plugin_secret_context(plugin_manifest) + resolved_manifest = dict(plugin_manifest) + + auth = plugin_manifest.get("auth", {}) + if isinstance(auth, dict): + resolved_auth = dict(auth) + for auth_field in ("key", *SQL_PLUGIN_SENSITIVE_AUTH_FIELDS): + value = auth.get(auth_field) + if not isinstance(value, str) or not validate_secret_name_dynamic(value): + continue + try: + resolved_auth[auth_field] = resolve_secret_reference_for_context( + value, + scope_value=scope_value, + scope=scope, + allowed_sources={"action"}, + context_label=f"plugin auth field '{auth_field}'", + ) + except ValueError as exc: + log_event( + f"[SK Loader] Blocked plugin auth secret resolution for field '{auth_field}': {exc}", + extra={ + "plugin_name": plugin_manifest.get("name"), + "plugin_id": plugin_manifest.get("id"), + "scope": scope, + }, + level=logging.WARNING, + ) + resolved_auth[auth_field] = "" + resolved_manifest["auth"] = resolved_auth + + additional_fields = plugin_manifest.get("additionalFields", {}) + if isinstance(additional_fields, dict): + resolved_additional_fields = dict(additional_fields) + for field_name, value in additional_fields.items(): + if not isinstance(value, str) or not validate_secret_name_dynamic(value): + continue + if not (field_name.endswith("__Secret") or _is_sql_sensitive_plugin_field(plugin_manifest, field_name)): + continue + try: + resolved_additional_fields[field_name] = resolve_secret_reference_for_context( + value, + scope_value=scope_value, + scope=scope, + allowed_sources={"action-addset"}, + context_label=f"plugin additional field '{field_name}'", + ) + except ValueError as exc: + log_event( + f"[SK Loader] Blocked plugin additionalField secret resolution for '{field_name}': {exc}", + extra={ + "plugin_name": plugin_manifest.get("name"), + "plugin_id": plugin_manifest.get("id"), + "scope": scope, + }, + level=logging.WARNING, + ) + resolved_additional_fields[field_name] = "" + resolved_manifest["additionalFields"] = resolved_additional_fields + return resolved_manifest def load_plugins_for_kernel(kernel, plugin_manifests, settings, mode_label="global"): diff --git a/application/single_app/semantic_kernel_plugins/fact_memory_plugin.py b/application/single_app/semantic_kernel_plugins/fact_memory_plugin.py index 188d16fac..f38bd37ed 100644 --- a/application/single_app/semantic_kernel_plugins/fact_memory_plugin.py +++ b/application/single_app/semantic_kernel_plugins/fact_memory_plugin.py @@ -5,8 +5,12 @@ - Exposes methods for use as a Semantic Kernel plugin (does not need to derive from BasePlugin). - Read/inject logic is handled separately by orchestration utility. """ +import logging +from flask import g, has_request_context from typing import Optional, List +from functions_appinsights import log_event +from functions_authentication import get_current_user_id from semantic_kernel.functions import kernel_function from semantic_kernel_fact_memory_store import FactMemoryStore @@ -18,6 +22,82 @@ def __init__(self, store: Optional[FactMemoryStore] = None): self.store = store or FactMemoryStore() auto_wrap_plugin_functions(self, self.__class__.__name__) + def _get_authorized_fact_memory_scope(self) -> dict: + """Return the canonical request-scoped fact-memory authorization boundary.""" + if not has_request_context(): + raise PermissionError('Fact memory requires an active request context.') + + current_user_id = str(get_current_user_id() or '').strip() + if not current_user_id: + raise PermissionError('User not authenticated.') + + authorized_context = dict(getattr(g, 'authorized_chat_context', {}) or {}) + authorized_user_id = str(authorized_context.get('user_id') or current_user_id).strip() + if authorized_user_id != current_user_id: + authorized_user_id = current_user_id + + authorized_scope_id = str( + authorized_context.get('fact_memory_scope_id') + or authorized_context.get('active_group_id') + or current_user_id + ).strip() + authorized_scope_type = str( + authorized_context.get('fact_memory_scope_type') + or ('group' if authorized_context.get('active_group_id') else 'user') + ).strip().lower() + if authorized_scope_type not in {'user', 'group'}: + authorized_scope_type = 'user' + + authorized_conversation_id = str( + authorized_context.get('conversation_id') or getattr(g, 'conversation_id', '') or '' + ).strip() or None + + return { + 'user_id': authorized_user_id, + 'scope_id': authorized_scope_id, + 'scope_type': authorized_scope_type, + 'conversation_id': authorized_conversation_id, + } + + def _resolve_authorized_fact_memory_call( + self, + scope_type: str = '', + scope_id: str = '', + conversation_id: str = '', + ) -> dict: + """Normalize tool-call scope arguments against the authorized request scope.""" + authorized_scope = self._get_authorized_fact_memory_scope() + requested_scope_type = str(scope_type or '').strip().lower() + requested_scope_id = str(scope_id or '').strip() + requested_conversation_id = str(conversation_id or '').strip() + + if ( + (requested_scope_type and requested_scope_type != authorized_scope['scope_type']) + or (requested_scope_id and requested_scope_id != authorized_scope['scope_id']) + ): + log_event( + '[FactMemoryPlugin] Overriding mismatched fact-memory scope in tool call.', + extra={ + 'requested_scope_type': requested_scope_type, + 'requested_scope_id': requested_scope_id, + 'authorized_scope_type': authorized_scope['scope_type'], + 'authorized_scope_id': authorized_scope['scope_id'], + }, + level=logging.WARNING, + ) + + if requested_conversation_id and requested_conversation_id != authorized_scope['conversation_id']: + log_event( + '[FactMemoryPlugin] Overriding mismatched fact-memory conversation_id in tool call.', + extra={ + 'requested_conversation_id': requested_conversation_id, + 'authorized_conversation_id': authorized_scope['conversation_id'], + }, + level=logging.WARNING, + ) + + return authorized_scope + @kernel_function( description=""" Store a fact for the given agent, scope, and conversation. @@ -39,11 +119,16 @@ def set_fact(self, scope_type: str, scope_id: str, value: str, conversation_id: """ Store a fact for the given agent, scope, and conversation. """ - return self.store.set_fact( + authorized_scope = self._resolve_authorized_fact_memory_call( scope_type=scope_type, scope_id=scope_id, - value=value, conversation_id=conversation_id, + ) + return self.store.set_fact( + scope_type=authorized_scope['scope_type'], + scope_id=authorized_scope['scope_id'], + value=value, + conversation_id=authorized_scope['conversation_id'], agent_id=agent_id, memory_type=memory_type, ) @@ -56,8 +141,9 @@ def update_fact(self, scope_id: str, fact_id: str, value: str, memory_type: str """ Update a fact value by its unique id and scope_id partition key. """ + authorized_scope = self._resolve_authorized_fact_memory_call(scope_id=scope_id) update_kwargs = { - 'scope_id': scope_id, + 'scope_id': authorized_scope['scope_id'], 'fact_id': fact_id, 'value': value, } @@ -77,8 +163,9 @@ def delete_fact(self, scope_id: str, fact_id: str) -> bool: """ Delete a fact by its unique id and the scope_id which is the partition key. """ + authorized_scope = self._resolve_authorized_fact_memory_call(scope_id=scope_id) return self.store.delete_fact( - scope_id=scope_id, + scope_id=authorized_scope['scope_id'], fact_id=fact_id ) @@ -100,7 +187,11 @@ def get_facts(self, scope_type: str, scope_id: str,) -> List[dict]: """ Retrieve all facts for the user. Facts are persistent values that provide important context, background knowledge, or user preferences to the AI agent. Use this to get all facts that will be injected as context for the agent. """ - return self.store.get_facts( + authorized_scope = self._resolve_authorized_fact_memory_call( scope_type=scope_type, scope_id=scope_id, ) + return self.store.get_facts( + scope_type=authorized_scope['scope_type'], + scope_id=authorized_scope['scope_id'], + ) diff --git a/application/single_app/semantic_kernel_plugins/log_analytics_plugin.py b/application/single_app/semantic_kernel_plugins/log_analytics_plugin.py index f98c0efaa..7c5a69326 100644 --- a/application/single_app/semantic_kernel_plugins/log_analytics_plugin.py +++ b/application/single_app/semantic_kernel_plugins/log_analytics_plugin.py @@ -193,7 +193,6 @@ def _generate_metadata(self) -> Dict[str, Any]: "description": "Run a KQL (Kusto Query Language) query against the Log Analytics workspace and return the results. Results are chunked for LLMs if needed. Accepts an optional timespan parameter (timedelta, tuple, or hours).", "parameters": [ {"name": "query", "type": "string", "description": "The KQL query string to execute.", "required": True}, - {"name": "user_id", "type": "string", "description": "User ID for query history tracking (optional).", "required": False}, {"name": "timespan", "type": "any", "description": "Query timespan: timedelta, (start, end) tuple, or number of hours (optional).", "required": False} ], "returns": {"type": "list[object]", "description": "A list of result rows, each as a dictionary of column values."} @@ -210,8 +209,7 @@ def _generate_metadata(self) -> Dict[str, Any]: "name": "get_query_history", "description": "Return the last N queries run by this plugin instance for the current user. Useful for re-running or editing previous queries.", "parameters": [ - {"name": "limit", "type": "integer", "description": "Number of queries to return (default 20).", "required": False}, - {"name": "user_id", "type": "string", "description": "User ID for query history tracking (optional).", "required": False} + {"name": "limit", "type": "integer", "description": "Number of queries to return (default 20).", "required": False} ], "returns": {"type": "list[string]", "description": "A list of previous KQL queries, most recent last."} }, @@ -228,6 +226,21 @@ def _generate_metadata(self) -> Dict[str, Any]: def get_functions(self) -> List[str]: return [m["name"] for m in self._metadata["methods"]] + + def _get_authenticated_history_user_id(self) -> Optional[str]: + """Return the authenticated user id for query-history persistence.""" + try: + from application.single_app.functions_authentication import get_current_user_id + except ImportError: + from functions_authentication import get_current_user_id + + try: + user_id = str(get_current_user_id() or "").strip() + except Exception as exc: + logging.warning(f"[LA] Could not resolve authenticated user for query history: {exc}") + return None + + return user_id or None @plugin_function_logger("LogAnalyticsPlugin") @kernel_function(description="Return a dictionary of all tables and their schemas (column names and types, including Properties virtual columns) in the connected Azure Log Analytics workspace. This combines list_tables and get_table_schema for efficient schema discovery.") @@ -394,14 +407,13 @@ def col_name(col): return schema @plugin_function_logger("LogAnalyticsPlugin") - @kernel_function(description="Execute a KQL (Kusto Query Language) query against a specific table in the Log Analytics workspace and return the results as a list of rows (each as a dictionary of column values). Use this function after discovering available tables and their schemas to retrieve data. Accepts an optional timespan parameter to limit the query window as a timedelta, tuple of datetimes, or number of hours. Limitations on returns should be specified in the query (ex: take N). Always provide user_id to enable saving the query to Cosmos DB for user history tracking.") + @kernel_function(description="Execute a KQL (Kusto Query Language) query against a specific table in the Log Analytics workspace and return the results as a list of rows (each as a dictionary of column values). Use this function after discovering available tables and their schemas to retrieve data. Accepts an optional timespan parameter to limit the query window as a timedelta, tuple of datetimes, or number of hours. Limitations on returns should be specified in the query (ex: take N).") def run_query( self, query: str, - user_id: Optional[str] = None, timespan: Optional[Any] = None ) -> Any: - logging.debug(f"[LA] Running query: {query} with user_id={user_id}, timespan={timespan}") + logging.debug(f"[LA] Running query: {query} with timespan={timespan}") if not self._client: raise RuntimeError("Log Analytics client not initialized.") # Determine if this is a control command (starts with '.') @@ -477,9 +489,9 @@ def col_name(col): logging.error(f"[LA] Error processing query results: {e}") return {"error": "Failed to process query results."} finally: - # Save to Cosmos query history if user_id is provided - if user_id: - self._save_query_history_to_cosmos(user_id, query) + history_user_id = self._get_authenticated_history_user_id() + if history_user_id: + self._save_query_history_to_cosmos(history_user_id, query) @plugin_function_logger("LogAnalyticsPlugin") @kernel_function(description="Summarize a result set for LLM consumption, including row count and column names.") @@ -492,7 +504,8 @@ def summarize_results(self, results: List[Dict[str, Any]]) -> str: @plugin_function_logger("LogAnalyticsPlugin") @kernel_function(description="Return the last N queries run by this plugin instance. They should be numbered for the user to allow easy selection.") - def get_query_history(self, limit: int = 20, user_id: Optional[str] = None) -> List[str]: + def get_query_history(self, limit: int = 20) -> List[str]: + user_id = self._get_authenticated_history_user_id() if not user_id: return [] return self._get_query_history_from_cosmos(user_id, limit) diff --git a/application/single_app/semantic_kernel_plugins/tabular_processing_plugin.py b/application/single_app/semantic_kernel_plugins/tabular_processing_plugin.py index 344d092a5..f76edff81 100644 --- a/application/single_app/semantic_kernel_plugins/tabular_processing_plugin.py +++ b/application/single_app/semantic_kernel_plugins/tabular_processing_plugin.py @@ -15,11 +15,15 @@ import re import warnings import pandas +from flask import g, has_request_context from typing import Annotated, Dict, List, Optional, Set from urllib.parse import urlsplit, urlunsplit from semantic_kernel.functions import kernel_function from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger from functions_appinsights import log_event +from functions_authentication import get_current_user_id +from functions_group import find_group_by_id, get_user_role_in_group +from functions_public_workspaces import get_user_visible_public_workspace_ids_from_settings from config import ( CLIENTS, TABULAR_EXTENSIONS, @@ -187,6 +191,179 @@ def _get_blob_service_client(self): raise RuntimeError("Blob storage client not available. Enhanced citations must be enabled.") return client + def _get_authorized_chat_context(self) -> dict: + """Return the canonical request-scoped authorization context for tabular access.""" + if not has_request_context(): + raise PermissionError('Tabular processing requires an active request context.') + + current_user_id = str(get_current_user_id() or '').strip() + if not current_user_id: + raise PermissionError('User not authenticated.') + + authorized_context = dict(getattr(g, 'authorized_chat_context', {}) or {}) + authorized_user_id = str(authorized_context.get('user_id') or current_user_id).strip() + if authorized_user_id != current_user_id: + authorized_user_id = current_user_id + + authorized_conversation_id = str( + authorized_context.get('conversation_id') or getattr(g, 'conversation_id', '') or '' + ).strip() + if not authorized_conversation_id: + raise PermissionError('Conversation context unavailable for tabular processing.') + + active_group_ids = [ + str(group_id or '').strip() + for group_id in (authorized_context.get('active_group_ids') or []) + if str(group_id or '').strip() + ] + active_public_workspace_ids = [ + str(workspace_id or '').strip() + for workspace_id in (authorized_context.get('active_public_workspace_ids') or []) + if str(workspace_id or '').strip() + ] + + return { + 'user_id': authorized_user_id, + 'conversation_id': authorized_conversation_id, + 'active_group_ids': active_group_ids, + 'active_group_id': str(authorized_context.get('active_group_id') or '').strip() or None, + 'active_public_workspace_ids': active_public_workspace_ids, + 'active_public_workspace_id': ( + str(authorized_context.get('active_public_workspace_id') or '').strip() or None + ), + } + + def _resolve_authorized_scope_arguments( + self, + user_id: str, + conversation_id: str, + group_id: Optional[str] = None, + public_workspace_id: Optional[str] = None, + ) -> dict: + """Normalize tool-call scope arguments against the current authorized request context.""" + authorized_context = self._get_authorized_chat_context() + requested_user_id = str(user_id or '').strip() + requested_conversation_id = str(conversation_id or '').strip() + requested_group_id = str(group_id or '').strip() + requested_public_workspace_id = str(public_workspace_id or '').strip() + + if requested_user_id and requested_user_id != authorized_context['user_id']: + log_event( + '[TabularProcessingPlugin] Ignoring mismatched user_id in tool call.', + extra={ + 'requested_user_id': requested_user_id, + 'authorized_user_id': authorized_context['user_id'], + }, + level=logging.WARNING, + ) + + if requested_conversation_id and requested_conversation_id != authorized_context['conversation_id']: + log_event( + '[TabularProcessingPlugin] Ignoring mismatched conversation_id in tool call.', + extra={ + 'requested_conversation_id': requested_conversation_id, + 'authorized_conversation_id': authorized_context['conversation_id'], + }, + level=logging.WARNING, + ) + + resolved_group_id = None + if requested_group_id: + if not self._is_authorized_group_scope( + authorized_context['user_id'], + requested_group_id, + authorized_context=authorized_context, + ): + raise PermissionError('Tabular processing cannot access that group scope.') + resolved_group_id = requested_group_id + + resolved_public_workspace_id = None + if requested_public_workspace_id: + if not self._is_authorized_public_workspace_scope( + authorized_context['user_id'], + requested_public_workspace_id, + authorized_context=authorized_context, + ): + raise PermissionError('Tabular processing cannot access that public workspace scope.') + resolved_public_workspace_id = requested_public_workspace_id + + authorized_context['user_id'] = authorized_context['user_id'] + authorized_context['conversation_id'] = authorized_context['conversation_id'] + authorized_context['group_id'] = resolved_group_id + authorized_context['public_workspace_id'] = resolved_public_workspace_id + return authorized_context + + def _is_authorized_group_scope( + self, + user_id: str, + group_id: str, + authorized_context: Optional[dict] = None, + ) -> bool: + """Return True when the current user may access the requested group scope.""" + normalized_group_id = str(group_id or '').strip() + if not normalized_group_id: + return False + + if authorized_context and normalized_group_id in set(authorized_context.get('active_group_ids') or []): + return True + + group_doc = find_group_by_id(normalized_group_id) + return bool(group_doc and get_user_role_in_group(group_doc, user_id)) + + def _is_authorized_public_workspace_scope( + self, + user_id: str, + public_workspace_id: str, + authorized_context: Optional[dict] = None, + ) -> bool: + """Return True when the current user may access the requested public workspace scope.""" + normalized_public_workspace_id = str(public_workspace_id or '').strip() + if not normalized_public_workspace_id: + return False + + if authorized_context and normalized_public_workspace_id in set( + authorized_context.get('active_public_workspace_ids') or [] + ): + return True + + visible_public_workspace_ids = set( + get_user_visible_public_workspace_ids_from_settings(user_id) or [] + ) + return normalized_public_workspace_id in visible_public_workspace_ids + + def _is_authorized_blob_location(self, container_name: str, blob_path: str, authorized_context: dict) -> bool: + """Ensure remembered blob locations still fall within the caller's authorized request scope.""" + source = self._infer_source_from_container(container_name) + blob_parts = [part for part in str(blob_path or '').split('/') if part] + if not source or not blob_parts: + return False + + if source == 'workspace': + return blob_parts[0] == authorized_context['user_id'] + + if source == 'chat': + return ( + len(blob_parts) >= 2 + and blob_parts[0] == authorized_context['user_id'] + and blob_parts[1] == authorized_context['conversation_id'] + ) + + if source == 'group': + return self._is_authorized_group_scope( + authorized_context['user_id'], + blob_parts[0], + authorized_context=authorized_context, + ) + + if source == 'public': + return self._is_authorized_public_workspace_scope( + authorized_context['user_id'], + blob_parts[0], + authorized_context=authorized_context, + ) + + return False + def _list_tabular_blobs(self, container_name: str, prefix: str) -> List[str]: """List all tabular file blobs under a given prefix.""" client = self._get_blob_service_client() @@ -2754,9 +2931,25 @@ def _resolve_blob_location(self, user_id: str, conversation_id: str, filename: s def _resolve_blob_location_with_fallback(self, user_id: str, conversation_id: str, filename: str, source: str, group_id: str = None, public_workspace_id: str = None) -> tuple: """Try primary source first, then fall back to other containers if blob not found.""" + authorized_context = self._resolve_authorized_scope_arguments( + user_id, + conversation_id, + group_id=group_id, + public_workspace_id=public_workspace_id, + ) + user_id = authorized_context['user_id'] + conversation_id = authorized_context['conversation_id'] + group_id = authorized_context['group_id'] + public_workspace_id = authorized_context['public_workspace_id'] source = source.lower().strip() + + if source == 'group' and not group_id: + group_id = authorized_context['active_group_id'] + if source == 'public' and not public_workspace_id: + public_workspace_id = authorized_context['active_public_workspace_id'] + override = self._get_resolved_blob_location_override(source, filename) - if override: + if override and self._is_authorized_blob_location(override[0], override[1], authorized_context): return override attempts = [] @@ -2811,6 +3004,29 @@ async def list_tabular_files( public_workspace_id: Annotated[Optional[str], "Public workspace ID (for public workspace documents)"] = None, ) -> Annotated[str, "JSON list of available tabular files"]: """List all tabular files available for the user across all accessible containers.""" + try: + authorized_context = self._resolve_authorized_scope_arguments( + user_id, + conversation_id, + group_id=group_id, + public_workspace_id=public_workspace_id, + ) + except PermissionError as exc: + log_event( + f"[TabularProcessingPlugin] Denied tabular file listing: {exc}", + level=logging.WARNING, + extra={ + 'requested_group_id': group_id, + 'requested_public_workspace_id': public_workspace_id, + }, + ) + return json.dumps({"error": str(exc)}) + + user_id = authorized_context['user_id'] + conversation_id = authorized_context['conversation_id'] + group_id = authorized_context['group_id'] + public_workspace_id = authorized_context['public_workspace_id'] + def _sync_work(): results = [] try: diff --git a/application/single_app/static/images/custom_logo.png b/application/single_app/static/images/custom_logo.png index ecf6e6521a737af56bcc82321caff1acefb63494..a5b440e93fcff4ab0be9dfa87b76a77de13f41e1 100644 GIT binary patch literal 149607 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¬cGi`)EdQdRwKf1LsYh6u*E3`o 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ξd#D;4?q`*@r*=fjd5U)80$= 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 literal 11877 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 F{sTGwC=37q diff --git a/application/single_app/static/images/custom_logo_dark.png b/application/single_app/static/images/custom_logo_dark.png index 4f28194576a32f4463ff13ac96521f979e739bb0..df9485a933c23364239ad33437d138140c459df6 100644 GIT binary patch literal 109738 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 z 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

ch-TY41-ct%d$ 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 literal 13468 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 aj13{1xX1-4^7hveoPvysbhV^u=>Gr|kZNTB diff --git a/application/single_app/static/js/admin/admin_agents.js b/application/single_app/static/js/admin/admin_agents.js index 5c1daed86..6f9c41458 100644 --- a/application/single_app/static/js/admin/admin_agents.js +++ b/application/single_app/static/js/admin/admin_agents.js @@ -20,6 +20,12 @@ let orchestrationSettings = {}; let agents = []; let selectedAgent = null; +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text ?? ''; + return div.innerHTML; +} + // --- Function Definitions --- async function loadAllAdminAgentData() { @@ -274,10 +280,13 @@ function renderAgentsTable() { const isSelected = selectedAgent && agent.name === selectedAgent; const tr = document.createElement('tr'); let selectedBadge = isSelected ? 'Selected' : ''; + const safeName = escapeHtml(agent.name || ''); + const safeDisplayName = escapeHtml(agent.display_name || ''); + const safeDescription = escapeHtml(agent.description || ''); tr.innerHTML = ` - ${agent.name} - ${agent.display_name} - ${agent.description || ''} + ${safeName} + ${safeDisplayName} + ${safeDescription} ${selectedBadge} diff --git a/application/single_app/static/js/agent_templates_gallery.js b/application/single_app/static/js/agent_templates_gallery.js index 428ebf702..c0d06248a 100644 --- a/application/single_app/static/js/agent_templates_gallery.js +++ b/application/single_app/static/js/agent_templates_gallery.js @@ -142,7 +142,12 @@ function renderAccordion(accordion, templates, options = {}) { if (Array.isArray(template.actions_to_load) && template.actions_to_load.length) { const actionLine = document.createElement("p"); actionLine.className = "mb-0 text-muted small"; - actionLine.innerHTML = `Recommended actions: ${template.actions_to_load.join(", ")}`; + const actionLabel = document.createElement("strong"); + actionLabel.textContent = "Recommended actions:"; + actionLine.appendChild(actionLabel); + actionLine.appendChild( + document.createTextNode(` ${template.actions_to_load.map((action) => String(action)).join(", ")}`) + ); metaList.appendChild(actionLine); } diff --git a/application/single_app/static/js/chat/chat-citations.js b/application/single_app/static/js/chat/chat-citations.js index 9d751ffd9..4fa3f60ac 100644 --- a/application/single_app/static/js/chat/chat-citations.js +++ b/application/single_app/static/js/chat/chat-citations.js @@ -195,7 +195,7 @@ export function showCitedTextPopup(citedText, fileName, pageNumber) {

`; } @@ -76,6 +77,7 @@ export async function showConversationDetails(conversationId) { */ function renderConversationMetadata(metadata, conversationId) { const { context = [], tags = [], strict = false, classification = [], last_updated, chat_type = 'personal', is_pinned = false, is_hidden = false, scope_locked, locked_contexts = [], summary = null } = metadata; + const safeConversationId = escapeHtml(conversationId); // Organize tags by category const tagsByCategory = { @@ -102,7 +104,7 @@ function renderConversationMetadata(metadata, conversationId) {
Summary
- ${summary ? `Generated ${formatDate(summary.generated_at)}${summary.model_deployment ? ` · ${summary.model_deployment}` : ''}` : ''} + ${summary ? `Generated ${formatDate(summary.generated_at)}${summary.model_deployment ? ` · ${escapeHtml(summary.model_deployment)}` : ''}` : ''}
${renderSummaryContent(summary, conversationId)} @@ -118,7 +120,7 @@ function renderConversationMetadata(metadata, conversationId) {
- Conversation ID: ${conversationId} + Conversation ID: ${safeConversationId}
Last Updated: ${formatDate(last_updated)} @@ -256,17 +258,20 @@ function renderContextSection(context) { if (primary) { const displayName = primary.name || primary.id; const isGroupChat = primary.scope === 'group'; + const safeDisplayName = escapeHtml(displayName); + const safePrimaryScope = escapeHtml(primary.scope); + const safePrimaryId = escapeHtml(primary.id); html += `
Primary Context:
- ${primary.scope} + ${safePrimaryScope} ${isGroupChat ? 'single-user' : ''} - ${displayName} + ${safeDisplayName}
- ${primary.name ? `
ID: ${primary.id}
` : ''} + ${primary.name ? `
ID: ${safePrimaryId}
` : ''}
`; @@ -281,11 +286,14 @@ function renderContextSection(context) { secondary.forEach(ctx => { const displayName = ctx.name || ctx.id; + const safeDisplayName = escapeHtml(displayName); + const safeScope = escapeHtml(ctx.scope); + const safeContextId = escapeHtml(ctx.id); html += `
- ${ctx.scope} - ${displayName} - ${ctx.name ? `
ID: ${ctx.id}
` : ''} + ${safeScope} + ${safeDisplayName} + ${ctx.name ? `
ID: ${safeContextId}
` : ''}
`; }); @@ -305,15 +313,19 @@ function renderParticipantsSection(participants) { participants.forEach(participant => { const initials = (participant.name || 'U').slice(0, 2).toUpperCase(); const avatarId = `participant-avatar-${participant.user_id}`; + const safeAvatarId = escapeHtml(avatarId); + const safeInitials = escapeHtml(initials); + const safeParticipantName = escapeHtml(participant.name || 'Unknown User'); + const safeParticipantEmail = escapeHtml(participant.email || ''); html += `
-
- ${initials} +
+ ${safeInitials}
-
${participant.name || 'Unknown User'}
- ${participant.email || ''} +
${safeParticipantName}
+ ${safeParticipantEmail}
`; @@ -337,7 +349,7 @@ async function loadParticipantProfileImage(userId) { if (!avatarElement) return; try { - const response = await fetch(`/api/user/profile-image/${userId}`); + const response = await fetch(`/api/user/profile-image/${encodeURIComponent(userId)}`); if (!response.ok) throw new Error('Failed to load user profile image'); const userData = await response.json(); @@ -380,7 +392,7 @@ function renderModelsAndAgentsSection(models, agents) { if (models.length > 0) { html += '
Models:
'; models.forEach(model => { - html += `${model.value}`; + html += `${escapeHtml(model.value)}`; }); html += '
'; } @@ -388,7 +400,7 @@ function renderModelsAndAgentsSection(models, agents) { if (agents.length > 0) { html += '
Agents:
'; agents.forEach(agent => { - html += `${agent.value}`; + html += `${escapeHtml(agent.value)}`; }); html += '
'; } @@ -407,6 +419,11 @@ function renderDocumentsSection(documents) { const chunkCount = doc.chunk_ids ? doc.chunk_ids.length : 0; const documentTitle = doc.title || doc.document_id; const scopeName = doc.scope?.name || doc.scope?.id || 'Unknown'; + const safeClassification = escapeHtml(doc.classification || 'None'); + const safeDocumentId = escapeHtml(doc.document_id || 'Unknown Document'); + const safeDocumentTitle = escapeHtml(documentTitle); + const safeScopeName = escapeHtml(scopeName); + const safeScopeType = escapeHtml(doc.scope?.type || 'Unknown'); // Format document classification with custom colors const allCategories = window.classification_categories || []; @@ -415,15 +432,15 @@ function renderDocumentsSection(documents) { if (category) { const textClass = isColorLight(category.color) ? 'text-dark' : 'text-white'; - classificationHtml = `${doc.classification}`; + classificationHtml = `${safeClassification}`; } else { - classificationHtml = `${doc.classification}`; + classificationHtml = `${safeClassification}`; } html += `
-
${documentTitle}
+
${safeDocumentTitle}
${classificationHtml}
@@ -433,12 +450,12 @@ function renderDocumentsSection(documents) {
- ${doc.scope?.type} scope: ${scopeName} + ${safeScopeType} scope: ${safeScopeName}
${doc.title && doc.title !== doc.document_id ? `
- ID: ${doc.document_id} + ID: ${safeDocumentId}
` : ''}
@@ -455,7 +472,7 @@ function renderSemanticTagsSection(semanticTags) { let html = '
'; semanticTags.forEach(tag => { - html += `${tag.value}`; + html += `${escapeHtml(tag.value)}`; }); html += '
'; @@ -469,14 +486,26 @@ function renderWebSourcesSection(webSources) { let html = ''; webSources.forEach(source => { - html += ` -
- `; + const sourceValue = typeof source.value === 'string' ? source.value : ''; + const safeSourceText = escapeHtml(sourceValue); + const safeSourceUrl = sanitizeHttpUrl(sourceValue); + + if (safeSourceUrl) { + html += ` + + `; + } else { + html += ` +
+ ${safeSourceText || 'Invalid link'} +
+ `; + } }); return html; @@ -510,7 +539,7 @@ function formatScopeLockStatus(scopeLocked, lockedContexts) { return ctx.scope; }); return 'Locked' + - (names.length > 0 ? '
' + names.join(', ') + '' : ''); + (names.length > 0 ? '
' + names.map(name => escapeHtml(name)).join(', ') + '' : ''); } // false — unlocked return 'Unlocked'; @@ -525,14 +554,15 @@ function formatClassifications(classifications) { return classifications.map(label => { const category = allCategories.find(cat => cat.label === label); + const safeLabel = escapeHtml(label); if (category) { // Found category definition, apply custom color const textClass = isColorLight(category.color) ? 'text-dark' : 'text-white'; - return `${label}`; + return `${safeLabel}`; } else { // Label exists but no definition found (maybe deleted in admin) - return `${label}`; + return `${safeLabel}`; } }).join(' '); } @@ -587,12 +617,14 @@ function extractPageNumbers(chunkIds) { * @returns {string} HTML string */ function renderSummaryContent(summary, conversationId) { + const safeConversationId = escapeHtml(conversationId); + if (summary && summary.content) { return `

${escapeHtml(summary.content)}

@@ -608,13 +640,30 @@ function renderSummaryContent(summary, conversationId) { ${modelOptions}
`; } +function sanitizeHttpUrl(value) { + if (!value || typeof value !== 'string') { + return ''; + } + + try { + const parsed = new URL(value); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return parsed.toString(); + } + } catch (error) { + return ''; + } + + return ''; +} + /** * Get available model options from the global #model-select dropdown * @returns {string} HTML option elements @@ -700,11 +749,11 @@ async function handleGenerateSummary(conversationId, modelDeployment) { * @returns {string} Escaped string */ function escapeHtml(str) { - if (!str) { + if (str === null || typeof str === 'undefined') { return ''; } const div = document.createElement('div'); - div.textContent = str; + div.textContent = String(str); return div.innerHTML; } diff --git a/application/single_app/static/js/chat/chat-documents.js b/application/single_app/static/js/chat/chat-documents.js index d0792db7e..b1fab7f08 100644 --- a/application/single_app/static/js/chat/chat-documents.js +++ b/application/single_app/static/js/chat/chat-documents.js @@ -1838,16 +1838,43 @@ document.addEventListener('DOMContentLoaded', function() { icon = 'bi-globe'; } if (name) { - workspaceItems.push(`
  • ${name}
  • `); + workspaceItems.push({ icon, name }); } } if (listEl) { + listEl.textContent = ''; if (workspaceItems.length > 0) { const listLabel = scopeLocked === true ? 'Currently locked to:' : 'Will lock to:'; - listEl.innerHTML = `

    ${listLabel}

      ${workspaceItems.join('')}
    `; + const listLabelEl = document.createElement('p'); + listLabelEl.className = 'small text-muted mb-2'; + listLabelEl.textContent = listLabel; + + const listGroupEl = document.createElement('ul'); + listGroupEl.className = 'list-group list-group-flush'; + + workspaceItems.forEach(({ icon, name }) => { + const listItemEl = document.createElement('li'); + listItemEl.className = 'list-group-item'; + + const iconEl = document.createElement('i'); + iconEl.className = `bi ${icon} me-2`; + + const nameEl = document.createElement('span'); + nameEl.textContent = name; + + listItemEl.appendChild(iconEl); + listItemEl.appendChild(nameEl); + listGroupEl.appendChild(listItemEl); + }); + + listEl.appendChild(listLabelEl); + listEl.appendChild(listGroupEl); } else { - listEl.innerHTML = '

    No specific workspaces recorded.

    '; + const emptyStateEl = document.createElement('p'); + emptyStateEl.className = 'text-muted'; + emptyStateEl.textContent = 'No specific workspaces recorded.'; + listEl.appendChild(emptyStateEl); } } diff --git a/application/single_app/static/js/chat/chat-input-actions.js b/application/single_app/static/js/chat/chat-input-actions.js index 66eaf0444..b96caf8d8 100644 --- a/application/single_app/static/js/chat/chat-input-actions.js +++ b/application/single_app/static/js/chat/chat-input-actions.js @@ -154,7 +154,7 @@ export function showFileContentPopup(fileContent, filename, isTable, fileContent ", + "${doc.scope?.type} scope: ${scopeName}", + "${tag.value}", + "", + "names.join(', ')", + ] + + for snippet in required_snippets: + assert snippet in source, f"Expected safe conversation-details snippet: {snippet}" + + for snippet in forbidden_snippets: + assert snippet not in source, f"Unexpected unsafe conversation-details snippet: {snippet}" + + +def test_fix_documentation_and_version_are_in_sync() -> None: + """Verify the version bump and fix documentation were added together.""" + version = read_version() + assert version == "0.241.022", f"Expected config.py version 0.241.022, found {version}" + assert FIX_DOC.exists(), f"Expected fix documentation file at {FIX_DOC}" + + fix_doc = read_text(FIX_DOC) + assert "Fixed in version: **0.241.019**" in fix_doc + assert "functional_tests/test_stored_xss_chat_scope_and_conversation_details_fix.py" in fix_doc + assert "ui_tests/test_chat_scope_lock_and_conversation_details_escaping.py" in fix_doc + + +if __name__ == "__main__": + tests = [ + test_scope_lock_modal_renders_workspace_names_with_text_nodes, + test_conversation_details_modal_escapes_untrusted_metadata_fields, + test_fix_documentation_and_version_are_in_sync, + ] + results = [] + + for test in tests: + print(f"\nRunning {test.__name__}...") + try: + test() + print("PASS") + results.append(True) + except Exception as error: + print(f"FAIL: {error}") + results.append(False) + + success = all(results) + print(f"\nResults: {sum(results)}/{len(results)} tests passed") + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/functional_tests/test_stored_xss_chat_workspace_rendering_fix.py b/functional_tests/test_stored_xss_chat_workspace_rendering_fix.py new file mode 100644 index 000000000..93aa10cee --- /dev/null +++ b/functional_tests/test_stored_xss_chat_workspace_rendering_fix.py @@ -0,0 +1,215 @@ +# test_stored_xss_chat_workspace_rendering_fix.py +""" +Functional test for stored XSS chat and workspace rendering hardening. +Version: 0.241.017 +Implemented in: 0.241.017 + +This test ensures chat agent display names, workspace member display names, +and Graph user search filters are safely encoded before HTML or OData +insertion. +""" + +import ast +import os +import sys + + +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CHAT_MESSAGES_JS = os.path.join( + ROOT_DIR, + "application", + "single_app", + "static", + "js", + "chat", + "chat-messages.js", +) +MANAGE_PUBLIC_WORKSPACE_JS = os.path.join( + ROOT_DIR, + "application", + "single_app", + "static", + "js", + "public", + "manage_public_workspace.js", +) +MANAGE_GROUP_JS = os.path.join( + ROOT_DIR, + "application", + "single_app", + "static", + "js", + "group", + "manage_group.js", +) +USERS_ROUTE = os.path.join( + ROOT_DIR, + "application", + "single_app", + "route_backend_users.py", +) +CONFIG_FILE = os.path.join(ROOT_DIR, "application", "single_app", "config.py") +FIX_DOC = os.path.join( + ROOT_DIR, + "docs", + "explanation", + "fixes", + "v0.241.017", + "STORED_XSS_AGENT_AND_MEMBER_RENDERING_FIX.md", +) + + +def read_file_text(file_path): + with open(file_path, "r", encoding="utf-8") as file_handle: + return file_handle.read() + + +def read_config_version(): + for line in read_file_text(CONFIG_FILE).splitlines(): + if line.startswith("VERSION = "): + return line.split("=", 1)[1].strip().strip('"') + raise AssertionError("VERSION assignment not found in config.py") + + +def load_function(file_path, function_name): + source = read_file_text(file_path) + parsed = ast.parse(source, filename=file_path) + selected_node = next( + ( + node + for node in parsed.body + if isinstance(node, ast.FunctionDef) and node.name == function_name + ), + None, + ) + assert selected_node is not None, f"Expected function {function_name} in {file_path}" + module = ast.Module(body=[selected_node], type_ignores=[]) + namespace = {} + exec(compile(module, file_path, "exec"), namespace) + return namespace[function_name] + + +def test_chat_agent_display_name_is_escaped_before_html_rendering(): + """Verify chat agent display names are escaped in both HTML sinks.""" + print("🔍 Testing chat agent display name escaping...") + + source = read_file_text(CHAT_MESSAGES_JS) + + required_snippets = [ + "senderLabel = escapeHtml(agentDisplayName);", + "${escapeHtml(metadata.agent_display_name)}", + ] + missing = [snippet for snippet in required_snippets if snippet not in source] + assert not missing, f"Missing chat escaping snippets: {missing}" + + forbidden_snippets = [ + "senderLabel = agentDisplayName;", + "${metadata.agent_display_name}", + ] + present = [snippet for snippet in forbidden_snippets if snippet in source] + assert not present, f"Unexpected unescaped chat snippets found: {present}" + + print("✅ Chat agent display name escaping passed") + + +def test_public_workspace_member_renderers_escape_untrusted_fields(): + """Verify public workspace member-management renderers escape display names and emails.""" + print("🔍 Testing public workspace member rendering escaping...") + + source = read_file_text(MANAGE_PUBLIC_WORKSPACE_JS) + + required_snippets = [ + 'const safeDisplayName = escapeHtml(m.displayName || "(no name)");', + 'const safeDisplayName = escapeHtml(req.displayName || "(no name)");', + 'const safeDisplayName = escapeHtml(u.displayName || "(no name)");', + 'data-user-name="${safeDisplayName}"', + 'membersList += `
  • • ${safeName} (${safeEmail})
  • `;', + '$(document).on("click", ".select-user-btn", function () {', + "${escapeHtml(row.displayName || '')} (${escapeHtml(row.email || '')})", + ] + missing = [snippet for snippet in required_snippets if snippet not in source] + assert not missing, f"Missing public workspace escaping snippets: {missing}" + + forbidden_snippets = [ + 'onclick="selectUserForAdd(', + '${u.displayName || "(no name)"}', + '${req.displayName}', + '
  • • ${member.name} (${member.email})
  • ', + ] + present = [snippet for snippet in forbidden_snippets if snippet in source] + assert not present, f"Unexpected unescaped public workspace snippets found: {present}" + + print("✅ Public workspace member rendering escaping passed") + + +def test_group_workspace_member_renderers_escape_untrusted_fields(): + """Verify group workspace member-management renderers escape display names and emails.""" + print("🔍 Testing group workspace member rendering escaping...") + + source = read_file_text(MANAGE_GROUP_JS) + + required_snippets = [ + 'const safeDisplayName = escapeHtml(m.displayName || "(no name)");', + 'const safeDisplayName = escapeHtml(u.displayName || "(no name)");', + 'data-user-name="${safeDisplayName}"', + 'membersList += `
  • • ${safeName} (${safeEmail})
  • `;', + "${escapeHtml(row.displayName || '')} (${escapeHtml(row.email || '')})", + ] + missing = [snippet for snippet in required_snippets if snippet not in source] + assert not missing, f"Missing group workspace escaping snippets: {missing}" + + forbidden_snippets = [ + '${u.displayName || "(no name)"}', + '${u.email || ""}', + '
  • • ${member.name} (${member.email})
  • ', + '', + ] + present = [snippet for snippet in forbidden_snippets if snippet in source] + assert not present, f"Unexpected unescaped group workspace snippets found: {present}" + + print("✅ Group workspace member rendering escaping passed") + + +def test_user_search_filter_escapes_odata_literals(): + """Verify /api/userSearch escapes apostrophes before building the Graph filter.""" + print("🔍 Testing Graph user search OData literal escaping...") + + escape_helper = load_function(USERS_ROUTE, "_escape_graph_odata_literal") + assert escape_helper("o'hare") == "o''hare" + assert escape_helper("") == "" + + source = read_file_text(USERS_ROUTE) + assert "safe_query = _escape_graph_odata_literal(query)" in source + assert "startswith(displayName, '{safe_query}')" in source + assert "startswith(mail, '{safe_query}')" in source + assert "startswith(userPrincipalName, '{safe_query}')" in source + assert "startswith(displayName, '{query}')" not in source + + print("✅ Graph user search OData literal escaping passed") + + +def test_fix_documentation_and_version_exist(): + """Verify the version bump and fix documentation landed for this change.""" + print("🔍 Testing stored XSS rendering fix documentation and version...") + + assert read_config_version() == "0.241.017" + assert os.path.exists(FIX_DOC), f"Expected fix documentation at {FIX_DOC}" + + print("✅ Stored XSS rendering fix documentation and version passed") + + +if __name__ == "__main__": + tests = [ + test_chat_agent_display_name_is_escaped_before_html_rendering, + test_public_workspace_member_renderers_escape_untrusted_fields, + test_group_workspace_member_renderers_escape_untrusted_fields, + test_user_search_filter_escapes_odata_literals, + test_fix_documentation_and_version_exist, + ] + + for test in tests: + print(f"\n🧪 Running {test.__name__}...") + test() + + print(f"\n📊 Results: {len(tests)}/{len(tests)} tests passed") + sys.exit(0) \ No newline at end of file diff --git a/functional_tests/test_stored_xss_share_activity_and_masking_fix.py b/functional_tests/test_stored_xss_share_activity_and_masking_fix.py new file mode 100644 index 000000000..41ac8fe61 --- /dev/null +++ b/functional_tests/test_stored_xss_share_activity_and_masking_fix.py @@ -0,0 +1,240 @@ +# test_stored_xss_share_activity_and_masking_fix.py +""" +Functional test for stored XSS sharing, activity, and masking hardening. +Version: 0.241.022 +Implemented in: 0.241.020 + +This test ensures document-sharing modals, group activity rendering, and chat +masking metadata render attacker-controlled values as inert text and derive +masking identity from the authenticated server-side user. +""" + +import sys +from pathlib import Path + + +ROOT_DIR = Path(__file__).resolve().parents[1] +CHAT_TOAST_JS = ROOT_DIR / "application" / "single_app" / "static" / "js" / "chat" / "chat-toast.js" +CHAT_MESSAGES_JS = ROOT_DIR / "application" / "single_app" / "static" / "js" / "chat" / "chat-messages.js" +GROUP_MANAGE_JS = ROOT_DIR / "application" / "single_app" / "static" / "js" / "group" / "manage_group.js" +GROUP_SHARE_JS = ROOT_DIR / "application" / "single_app" / "static" / "js" / "workspace" / "group-documents-sharing.js" +WORKSPACE_SHARE_JS = ROOT_DIR / "application" / "single_app" / "static" / "js" / "workspace" / "workspace-documents-sharing.js" +CHAT_ROUTE = ROOT_DIR / "application" / "single_app" / "route_backend_chats.py" +CONFIG_FILE = ROOT_DIR / "application" / "single_app" / "config.py" +FIX_DOC = ROOT_DIR / "docs" / "explanation" / "fixes" / "v0.241.020" / "STORED_XSS_SHARE_ACTIVITY_AND_MASKING_FIX.md" + + +def read_text(path: Path) -> str: + """Read a UTF-8 text file from the repository.""" + return path.read_text(encoding="utf-8") + + +def read_version() -> str: + """Extract the application version from config.py.""" + for line in read_text(CONFIG_FILE).splitlines(): + if line.strip().startswith('VERSION = '): + return line.split('"')[1] + raise AssertionError("VERSION assignment was not found in config.py") + + +def assert_required_snippets(source: str, required_snippets: list[str], description: str) -> None: + """Assert that all required snippets exist in the target source text.""" + missing = [snippet for snippet in required_snippets if snippet not in source] + assert not missing, f"Missing {description} snippets: {missing}" + + +def assert_forbidden_snippets(source: str, forbidden_snippets: list[str], description: str) -> None: + """Assert that forbidden snippets were removed from the target source text.""" + present = [snippet for snippet in forbidden_snippets if snippet in source] + assert not present, f"Unexpected {description} snippets still present: {present}" + + +def test_chat_toast_uses_text_nodes_for_messages() -> None: + """Verify the shared chat toast helper no longer interpolates raw HTML messages.""" + source = read_text(CHAT_TOAST_JS) + + assert_required_snippets( + source, + [ + 'const toastEl = document.createElement("div");', + 'bodyEl.textContent = String(message ?? "");', + 'container.appendChild(toastEl);', + ], + "chat toast hardening", + ) + assert_forbidden_snippets( + source, + [ + 'container.insertAdjacentHTML("beforeend", toastHtml);', + '${message}', + ], + "unsafe chat toast rendering", + ) + + +def test_document_share_modals_use_safe_rendering_and_delegated_clicks() -> None: + """Verify personal and group share modals no longer rehydrate attacker HTML.""" + workspace_source = read_text(WORKSPACE_SHARE_JS) + group_source = read_text(GROUP_SHARE_JS) + + assert_required_snippets( + workspace_source, + [ + "const userSearchResultsBody = document.querySelector('#userSearchResultsTable tbody');", + "const sharedUsersList = document.getElementById('sharedUsersList');", + "const addButton = e.target.closest('.user-search-add-btn');", + "const removeButton = e.target.closest('.shared-user-remove-btn');", + "displayNameCell.textContent = user.displayName || '';", + "emailCell.textContent = user.email || '';", + "toastBody.textContent = String(message ?? '');", + "tbody.replaceChildren(...userRows);", + "sharedUsersList.replaceChildren(...userRows);", + ], + "workspace share hardening", + ) + assert_forbidden_snippets( + workspace_source, + [ + 'onclick="addUserToDocument(', + 'onclick="removeUserFromDocument(', + 'toast.innerHTML = `', + ], + "unsafe workspace share rendering", + ) + + assert_required_snippets( + group_source, + [ + "const groupSearchResultsBody = document.querySelector('#groupSearchResultsTable tbody');", + "const sharedGroupsList = document.getElementById('sharedGroupsList');", + "const addButton = e.target.closest('.group-search-add-btn');", + "const removeButton = e.target.closest('.shared-group-remove-btn');", + "nameCell.textContent = group.name || '';", + "descriptionCell.textContent = group.description || '';", + "toastBody.textContent = String(message ?? '');", + "tbody.replaceChildren(...groupRows);", + "sharedGroupsList.replaceChildren(...groupRows);", + ], + "group share hardening", + ) + assert_forbidden_snippets( + group_source, + [ + 'onclick="addGroupToDocument(', + 'onclick="removeGroupFromDocument(', + 'toast.innerHTML = `', + ], + "unsafe group share rendering", + ) + + +def test_group_activity_timeline_uses_safe_text_rendering() -> None: + """Verify activity rows and the raw-activity modal no longer use unsafe HTML sinks.""" + source = read_text(GROUP_MANAGE_JS) + + assert_required_snippets( + source, + [ + 'const safeDescription = escapeHtml(description);', + "const activityTimeline = $('#activityTimeline');", + "activityTimeline.find('.activity-item').each(function(index) {", + "$(this).data('activity', activities[index]);", + '
    ', + "code.textContent = JSON.stringify(activity ?? {}, null, 2) || '{}';", + 'modalBody.replaceChildren(pre);', + ], + "group activity hardening", + ) + assert_forbidden_snippets( + source, + [ + 'data-activity=\'${activityJson.replace(/\'/g, "'")}\'', + 'onclick="showRawActivity(this)"', + 'modalBody.innerHTML = `
    ${JSON.stringify(activity, null, 2)}
    `;', + '

    ${description}

    ', + ], + "unsafe group activity rendering", + ) + + +def test_masking_renderer_and_backend_identity_are_hardened() -> None: + """Verify masked spans render safely and the backend ignores client-supplied display names.""" + chat_messages_source = read_text(CHAT_MESSAGES_JS) + chat_route_source = read_text(CHAT_ROUTE) + + assert_required_snippets( + chat_messages_source, + [ + "const fragment = document.createDocumentFragment();", + "fragment.appendChild(document.createTextNode(content.substring(lastIndex, range.start)));", + "const maskedSpan = document.createElement('span');", + "maskedSpan.setAttribute('data-display-name', String(range.display_name ?? ''));", + "maskedSpan.title = `Masked by ${String(range.display_name ?? 'Unknown User')} on ${timestamp}`;", + 'messageText.replaceChildren(fragment);', + ], + "chat masking renderer hardening", + ) + assert_forbidden_snippets( + chat_messages_source, + [ + 'messageText.innerHTML = htmlContent;', + 'data-display-name="${range.display_name}"', + 'title="Masked by ${range.display_name} on ${timestamp}"', + ], + "unsafe chat masking rendering", + ) + + assert_required_snippets( + chat_route_source, + [ + 'current_user = get_current_user_info() or {}', + "current_user.get('displayName')", + "current_user.get('email')", + "current_user.get('userPrincipalName')", + ], + "masking backend identity hardening", + ) + assert_forbidden_snippets( + chat_route_source, + [ + "user_display_name = data.get('display_name', 'Unknown User')", + ], + "client-controlled masking display name", + ) + + +def test_fix_documentation_and_version_are_in_sync() -> None: + """Verify the version bump and fix documentation landed together.""" + version = read_version() + assert version == "0.241.022", f"Expected config.py version 0.241.022, found {version}" + assert FIX_DOC.exists(), f"Expected fix documentation file at {FIX_DOC}" + + fix_doc = read_text(FIX_DOC) + assert "Fixed in version: **0.241.020**" in fix_doc + assert "functional_tests/test_stored_xss_share_activity_and_masking_fix.py" in fix_doc + assert "ui_tests/test_document_share_modal_escaping.py" in fix_doc + + +if __name__ == "__main__": + tests = [ + test_chat_toast_uses_text_nodes_for_messages, + test_document_share_modals_use_safe_rendering_and_delegated_clicks, + test_group_activity_timeline_uses_safe_text_rendering, + test_masking_renderer_and_backend_identity_are_hardened, + test_fix_documentation_and_version_are_in_sync, + ] + results = [] + + for test in tests: + print(f"\nRunning {test.__name__}...") + try: + test() + print("PASS") + results.append(True) + except Exception as error: + print(f"FAIL: {error}") + results.append(False) + + success = all(results) + print(f"\nResults: {sum(results)}/{len(results)} tests passed") + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/functional_tests/test_tabular_all_scope_group_source_context.py b/functional_tests/test_tabular_all_scope_group_source_context.py index f966e9c0c..be42e9c62 100644 --- a/functional_tests/test_tabular_all_scope_group_source_context.py +++ b/functional_tests/test_tabular_all_scope_group_source_context.py @@ -2,12 +2,13 @@ # test_tabular_all_scope_group_source_context.py """ Functional test for all-scope tabular group source context handling. -Version: 0.240.049 -Implemented in: 0.240.032; 0.240.041; 0.240.042; 0.240.043; 0.240.048; 0.240.049 +Version: 0.241.017 +Implemented in: 0.240.032; 0.240.041; 0.240.042; 0.240.043; 0.240.048; 0.240.049; 0.241.016 This test ensures mixed-scope workspace search keeps per-file group/public source metadata so tabular analysis can open group and public workbooks even -when chat document scope is set to all. +when chat document scope is set to all, while selected-document resolution only +uses documents the current chat scope is authorized to access. """ import ast @@ -19,6 +20,8 @@ ROUTE_FILE = os.path.join(ROOT_DIR, 'application', 'single_app', 'route_backend_chats.py') CONFIG_FILE = os.path.join(ROOT_DIR, 'application', 'single_app', 'config.py') TARGET_FUNCTIONS = { + '_normalize_requested_scope_ids', + '_resolve_chat_selected_document_metadata', 'is_tabular_filename', 'get_document_containers_for_scope', 'build_tabular_file_context', @@ -146,6 +149,8 @@ def test_selected_tabular_document_lookup_checks_all_scope_containers(): selected_contexts = helpers['get_selected_workspace_tabular_file_contexts']( selected_document_ids=['group-doc-123', 'public-doc-456'], document_scope='all', + active_group_ids=['93aa364a-99ee-4cfd-8e4d-f37d175f00f5'], + active_public_workspace_ids=['public-456'], ) assert selected_contexts == [ @@ -204,7 +209,7 @@ def test_route_uses_context_aware_tabular_analysis_and_version_bump(): ] missing = [snippet for snippet in required_snippets if snippet not in source] assert not missing, f'Missing route integration snippets: {missing}' - assert read_config_version() == '0.240.049' + assert read_config_version() == '0.241.017' print('✅ Route integration and version bump passed') return True diff --git a/functional_tests/test_uploaded_file_preview_xss_fix.py b/functional_tests/test_uploaded_file_preview_xss_fix.py new file mode 100644 index 000000000..f7055bde3 --- /dev/null +++ b/functional_tests/test_uploaded_file_preview_xss_fix.py @@ -0,0 +1,107 @@ +# test_uploaded_file_preview_xss_fix.py +""" +Functional test for uploaded file preview XSS hardening. +Version: 0.241.022 +Implemented in: 0.241.022 + +This test ensures uploaded file preview rendering no longer injects raw file +content into modal HTML and that current tabular previews build their DOM with +text nodes instead of untrusted HTML interpolation. +""" + +import os +import sys + + +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CHAT_INPUT_ACTIONS_JS = os.path.join( + ROOT_DIR, + "application", + "single_app", + "static", + "js", + "chat", + "chat-input-actions.js", +) +CONFIG_FILE = os.path.join(ROOT_DIR, "application", "single_app", "config.py") +FIX_DOC = os.path.join( + ROOT_DIR, + "docs", + "explanation", + "fixes", + "v0.241.022", + "UPLOADED_FILE_PREVIEW_XSS_FIX.md", +) + + +def read_file_text(file_path): + with open(file_path, "r", encoding="utf-8") as file_handle: + return file_handle.read() + + +def read_config_version(): + for line in read_file_text(CONFIG_FILE).splitlines(): + if line.strip().startswith("VERSION = "): + return line.split("=", 1)[1].strip().strip('"') + raise AssertionError("VERSION assignment not found in config.py") + + +def test_uploaded_file_preview_uses_safe_rendering_boundaries(): + """Verify the preview modal no longer feeds file content into dynamic HTML sinks.""" + print("🔍 Testing uploaded file preview rendering boundaries...") + + source = read_file_text(CHAT_INPUT_ACTIONS_JS) + + required_snippets = [ + 'downloadBtnContainer.replaceChildren();', + 'const downloadLink = document.createElement("a");', + 'const isLegacyHtmlTableContent = /^$/i.test(trimmedContent);', + 'renderPreformattedText(fileContentElement, fileContent);', + 'const tableWrapper = buildCsvTableElement(fileContent);', + 'headerCell.textContent = header;', + 'cellElement.textContent = cell;', + 'pre.textContent = String(text ?? "");', + ] + missing = [snippet for snippet in required_snippets if snippet not in source] + assert not missing, f"Missing uploaded file preview hardening snippets: {missing}" + + forbidden_snippets = [ + 'fileContentElement.innerHTML = `
    ${tableHTML}
    `;', + 'fileContentElement.innerHTML = `
    ${fileContent}
    `;', + 'fileContentElement.innerHTML = `
    ${fileContent}
    `;', + '!fileContent.trim().startsWith(\'<\')', + ] + present = [snippet for snippet in forbidden_snippets if snippet in source] + assert not present, f"Unexpected unsafe uploaded file preview snippets found: {present}" + + print("✅ Uploaded file preview rendering boundaries passed") + + +def test_fix_documentation_and_version_are_in_sync(): + """Verify the fix note and current config version landed together.""" + print("🔍 Testing uploaded file preview fix documentation and version...") + + assert read_config_version() == "0.241.022" + + assert os.path.exists(FIX_DOC), f"Expected fix documentation at {FIX_DOC}" + fix_doc = read_file_text(FIX_DOC) + assert "Fixed/Implemented in version: **0.241.022**" in fix_doc + assert "legacy html table payloads now render as inert preformatted text" in fix_doc.lower() + assert "functional_tests/test_uploaded_file_preview_xss_fix.py" in fix_doc + assert "ui_tests/test_uploaded_file_preview_escaping.py" in fix_doc + + print("✅ Uploaded file preview fix documentation and version passed") + + +if __name__ == "__main__": + tests = [ + test_uploaded_file_preview_uses_safe_rendering_boundaries, + test_fix_documentation_and_version_are_in_sync, + ] + + for test in tests: + print(f"\n🧪 Running {test.__name__}...") + test() + + print(f"\n📊 Results: {len(tests)}/{len(tests)} tests passed") + sys.exit(0) \ No newline at end of file diff --git a/functional_tests/test_web_search_current_message_only.py b/functional_tests/test_web_search_current_message_only.py new file mode 100644 index 000000000..722b37373 --- /dev/null +++ b/functional_tests/test_web_search_current_message_only.py @@ -0,0 +1,138 @@ +# test_web_search_current_message_only.py +""" +Functional test for current-message-only web search egress. +Version: 0.241.008 +Implemented in: 0.241.008 + +This test ensures external web search uses only the current user message, +keeps history-derived internal search rewrites out of the outbound web-search +boundary, and does not send the previous Foundry identifier metadata blob. +""" + +import ast +import os +import sys + + +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ROUTE_FILE = os.path.join(ROOT_DIR, 'application', 'single_app', 'route_backend_chats.py') + + +def read_file_text(file_path): + with open(file_path, 'r', encoding='utf-8') as file_handle: + return file_handle.read() + + +def extract_function_source(source_text, function_name): + parsed = ast.parse(source_text, filename=ROUTE_FILE) + for node in ast.walk(parsed): + if isinstance(node, ast.FunctionDef) and node.name == function_name: + return ast.get_source_segment(source_text, node) + raise AssertionError(f'Function {function_name} not found in route_backend_chats.py') + + +def load_helper(function_name): + source = read_file_text(ROUTE_FILE) + parsed = ast.parse(source, filename=ROUTE_FILE) + selected_nodes = [ + node for node in parsed.body + if isinstance(node, ast.FunctionDef) and node.name == function_name + ] + assert len(selected_nodes) == 1, f'Expected helper {function_name} to exist exactly once' + + module = ast.Module(body=selected_nodes, type_ignores=[]) + namespace = {} + exec(compile(module, ROUTE_FILE, 'exec'), namespace) + return namespace[function_name] + + +def test_web_search_query_helper_uses_only_current_message(): + """Verify the outbound web-search helper only normalizes the current turn.""" + print('🔍 Testing outbound web-search query helper...') + + helper = load_helper('build_web_search_query_text') + assert helper(' current turn only ') == 'current turn only' + assert helper('') == '' + assert helper(None) == '' + + print('✅ Outbound web-search query helper passed') + return True + + +def test_perform_web_search_uses_explicit_outbound_query_and_empty_metadata(): + """Verify the web-search boundary uses the explicit outbound query and no metadata blob.""" + print('🔍 Testing perform_web_search outbound boundary...') + + source = read_file_text(ROUTE_FILE) + perform_source = extract_function_source(source, 'perform_web_search') + + assert 'web_search_query_text,' in perform_source + assert 'query_text = (web_search_query_text or user_message or "").strip()' in perform_source + assert 'foundry_metadata = {}' in perform_source + + metadata_block = perform_source.split('foundry_metadata = {}', 1)[1].split( + 'debug_print("[WebSearch] Foundry metadata prepared: {}")', 1 + )[0] + + forbidden_snippets = [ + '"conversation_id": conversation_id', + '"user_id": user_id', + '"message_id": user_message_id', + '"chat_type": chat_type', + '"document_scope": document_scope', + '"group_id": active_group_id if chat_type == "group" else None', + '"public_workspace_id": active_public_workspace_id', + '"search_query": query_text', + ] + for snippet in forbidden_snippets: + assert snippet not in metadata_block, f'Unexpected outbound metadata snippet present: {snippet}' + + print('✅ perform_web_search outbound boundary passed') + return True + + +def test_chat_routes_pass_explicit_outbound_web_query(): + """Verify both chat handlers pass the dedicated outbound web-search query.""" + print('🔍 Testing chat route web-search call-site separation...') + + source = read_file_text(ROUTE_FILE) + + assert source.count('web_search_query_text = build_web_search_query_text(user_message)') >= 2 + assert source.count('web_search_query_text=web_search_query_text') >= 2 + assert 'search_query=search_query' not in source + + # Internal workspace-search rewrites still exist, but they should no longer + # be able to flow into the outbound web-search boundary. + assert 'search_query = rewritten_search_query' in source + assert "Based on the recent conversation about:" in source + + print('✅ Chat route web-search call-site separation passed') + return True + + +def test_search_summary_filters_out_system_messages(): + """Verify the optional search-summary branch excludes persisted system augmentation content.""" + print('🔍 Testing search-summary role filtering...') + + source = read_file_text(ROUTE_FILE) + assert "if role not in ('user', 'assistant'):" in source + assert "content = build_assistant_history_content_with_citations(msg, content)" in source + + print('✅ Search-summary role filtering passed') + return True + + +if __name__ == '__main__': + tests = [ + test_web_search_query_helper_uses_only_current_message, + test_perform_web_search_uses_explicit_outbound_query_and_empty_metadata, + test_chat_routes_pass_explicit_outbound_web_query, + test_search_summary_filters_out_system_messages, + ] + + for test in tests: + print(f'\n🧪 Running {test.__name__}...') + test() + + print(f'\n📊 Results: {len(tests)}/{len(tests)} tests passed') + sys.exit(0) \ No newline at end of file diff --git a/functional_tests/test_xss_guardrails_checker.py b/functional_tests/test_xss_guardrails_checker.py new file mode 100644 index 000000000..95bc930f4 --- /dev/null +++ b/functional_tests/test_xss_guardrails_checker.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +# test_xss_guardrails_checker.py +""" +Functional test for XSS PR guardrail checker. +Version: 0.241.022 +Implemented in: 0.241.021 + +This test ensures the changed-file XSS checker flags the repo's target sink +patterns, allows the approved safe rendering patterns, and stays wired into +the repo instruction and PR workflow. +""" + +import importlib.util +import os +import sys +from pathlib import Path + + +ROOT_DIR = Path(__file__).resolve().parents[1] +CHECKER_FILE = ROOT_DIR / 'scripts' / 'check_xss_sinks.py' +WORKFLOW_FILE = ROOT_DIR / '.github' / 'workflows' / 'xss-sink-check.yml' +INSTRUCTION_FILE = ROOT_DIR / '.github' / 'instructions' / 'xss-prevention.instructions.md' +FEATURE_DOC = ROOT_DIR / 'docs' / 'explanation' / 'features' / 'v0.241.021' / 'XSS_PR_GUARDRAILS.md' +CONFIG_FILE = ROOT_DIR / 'application' / 'single_app' / 'config.py' + + +def read_text(path: Path) -> str: + """Read a UTF-8 text file from the repository.""" + return path.read_text(encoding='utf-8') + + +def load_checker_module(): + """Import the checker module from disk without touching sys.path.""" + spec = importlib.util.spec_from_file_location('check_xss_sinks', CHECKER_FILE) + assert spec is not None and spec.loader is not None, 'Expected a module spec for check_xss_sinks.py' + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def read_config_version() -> str: + """Extract the current application version from config.py.""" + for line in read_text(CONFIG_FILE).splitlines(): + if line.strip().startswith('VERSION = '): + return line.split('=', 1)[1].strip().strip('"') + raise AssertionError('VERSION assignment not found in config.py') + + +def issue_messages(module, file_name: str, source_text: str) -> list[str]: + """Return the issue messages emitted for one in-memory source string.""" + issues = module.inspect_source(Path(file_name), source_text) + return [issue.message for issue in issues] + + +def test_checker_flags_dynamic_html_sinks_and_attribute_interpolation() -> None: + """Verify dynamic HTML sinks and attribute interpolation are rejected.""" + module = load_checker_module() + + js_source = """ +const row = document.createElement('tr'); +row.innerHTML = `${userName}`; +""".strip() + messages = issue_messages(module, 'sample.js', js_source) + + assert any('innerHTML/outerHTML' in message for message in messages), messages + assert any('data-* attributes' in message for message in messages), messages + + +def test_checker_flags_marked_parse_inline_handlers_and_server_side_bypasses() -> None: + """Verify the checker covers client and server bypass markers.""" + module = load_checker_module() + + js_source = """ +const html = marked.parse(markdown); +button.setAttribute('onclick', 'runDanger()'); +""".strip() + js_messages = issue_messages(module, 'sample.js', js_source) + assert any('DOMPurify.sanitize' in message for message in js_messages), js_messages + assert any('inline event-handler APIs' in message for message in js_messages), js_messages + + py_source = """ +from markupsafe import Markup +safe_markup = Markup(user_supplied_html) +""".strip() + py_messages = issue_messages(module, 'sample.py', py_source) + assert any('Markup(...)' in message for message in py_messages), py_messages + + html_source = """ +
    {{ user_bio|safe }}
    +""".strip() + html_messages = issue_messages(module, 'sample.html', html_source) + assert any("Jinja '|safe'" in message for message in html_messages), html_messages + + +def test_checker_allows_safe_dom_patterns_static_shells_and_reviewed_suppressions() -> None: + """Verify the checker allows the repo's preferred safe rendering patterns.""" + module = load_checker_module() + + safe_js_source = """ +const row = document.createElement('tr'); +const nameCell = document.createElement('td'); +nameCell.textContent = userName; +const actionButton = document.createElement('button'); +actionButton.dataset.userName = userName; +actionButton.addEventListener('click', handleClick); +modal.innerHTML = ''; +const renderedHtml = DOMPurify.sanitize(marked.parse(markdown)); +""".strip() + assert issue_messages(module, 'safe.js', safe_js_source) == [] + + suppressed_js_source = """ +// xss-check: ignore reviewed legacy shell with static allowlist +container.innerHTML = htmlFromReviewedBoundary; +""".strip() + assert issue_messages(module, 'suppressed.js', suppressed_js_source) == [] + + +def test_checker_assets_and_version_are_wired_into_repo() -> None: + """Verify the new workflow, instruction, doc, and version bump landed together.""" + assert CHECKER_FILE.exists(), f'Expected checker script at {CHECKER_FILE}' + assert WORKFLOW_FILE.exists(), f'Expected workflow file at {WORKFLOW_FILE}' + assert INSTRUCTION_FILE.exists(), f'Expected instruction file at {INSTRUCTION_FILE}' + assert FEATURE_DOC.exists(), f'Expected feature document at {FEATURE_DOC}' + assert read_config_version() == '0.241.022' + + workflow_source = read_text(WORKFLOW_FILE) + assert 'scripts/check_xss_sinks.py' in workflow_source + assert 'functional_tests/test_xss_guardrails_checker.py' in workflow_source + + instruction_source = read_text(INSTRUCTION_FILE) + assert 'xss-check: ignore' in instruction_source + assert 'innerHTML' in instruction_source + assert 'DOMPurify.sanitize' in instruction_source + + feature_doc_source = read_text(FEATURE_DOC) + assert 'Fixed/Implemented in version: **0.241.021**' in feature_doc_source + assert 'scripts/check_xss_sinks.py' in feature_doc_source + assert '.github/workflows/xss-sink-check.yml' in feature_doc_source + + +if __name__ == '__main__': + tests = [ + test_checker_flags_dynamic_html_sinks_and_attribute_interpolation, + test_checker_flags_marked_parse_inline_handlers_and_server_side_bypasses, + test_checker_allows_safe_dom_patterns_static_shells_and_reviewed_suppressions, + test_checker_assets_and_version_are_wired_into_repo, + ] + results = [] + + for test in tests: + print(f'\n🧪 Running {test.__name__}...') + try: + test() + print('✅ PASS') + results.append(True) + except Exception as exc: # pragma: no cover - standalone script reporting + print(f'❌ FAIL: {exc}') + results.append(False) + + success = all(results) + print(f'\n📊 Results: {sum(results)}/{len(results)} tests passed') + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/scripts/check_broken_access_control.py b/scripts/check_broken_access_control.py new file mode 100644 index 000000000..892a1cb90 --- /dev/null +++ b/scripts/check_broken_access_control.py @@ -0,0 +1,630 @@ +# check_broken_access_control.py + +"""Validate changed Python files for high-confidence broken access control regressions.""" + +from __future__ import annotations + +import argparse +import ast +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SUPPORTED_SUFFIXES = {'.py'} +SUPPRESSION_TOKEN = 'bac-check: ignore' +DIFF_HUNK_RE = re.compile(r'^@@ -\d+(?:,\d+)? \+(?P\d+)(?:,(?P\d+))? @@') +ACTIVE_SCOPE_KEYS = { + 'activeGroupOid': { + 'read_helper': 'require_active_group(...)', + 'write_helper': 'update_active_group_for_user(...)', + }, + 'activePublicWorkspaceOid': { + 'read_helper': 'require_active_public_workspace(...)', + 'write_helper': 'update_active_public_workspace_for_user(...)', + }, +} +ACTIVE_SCOPE_READ_ALLOWED_PATHS = { + 'application/single_app/functions_group.py', + 'application/single_app/functions_public_workspaces.py', +} +ACTIVE_SCOPE_READ_TARGET_PREFIXES = ( + 'application/single_app/route_backend_', + 'application/single_app/semantic_kernel_plugins/', +) +APPROVED_ACTIVE_SCOPE_WRITE_CONTEXTS = { + ('application/single_app/functions_group.py', 'update_active_group_for_user', 'activeGroupOid'), + ( + 'application/single_app/functions_public_workspaces.py', + 'update_active_public_workspace_for_user', + 'activePublicWorkspaceOid', + ), +} +KERNEL_SENSITIVE_PARAMS = { + 'user_id', + 'conversation_id', + 'group_id', + 'public_workspace_id', + 'scope_id', + 'scope_type', + 'active_group_id', + 'active_group_ids', + 'active_public_workspace_id', + 'active_public_workspace_ids', +} +APPROVED_KERNEL_SCOPE_HELPERS = { + '_resolve_authorized_scope_arguments', + '_resolve_authorized_fact_memory_call', + '_resolve_blob_location_with_fallback', + '_get_authenticated_history_user_id', +} +PERSONAL_CONVERSATION_ROUTE_FILES = { + 'application/single_app/route_backend_chats.py', + 'application/single_app/route_backend_conversations.py', + 'application/single_app/route_backend_documents.py', + 'application/single_app/route_backend_feedback.py', + 'application/single_app/route_frontend_conversations.py', +} +ADMIN_DECORATORS = {'admin_required', 'control_center_required'} +EXPLICIT_OWNERSHIP_SNIPPETS = ( + "conversation_item.get('user_id') != user_id", + 'conversation_item.get("user_id") != user_id', + "conversation_item['user_id'] != user_id", + 'conversation_item["user_id"] != user_id', + "conversation.get('user_id') != user_id", + 'conversation.get("user_id") != user_id', + "conversation['user_id'] != user_id", + 'conversation["user_id"] != user_id', +) + + +@dataclass(frozen=True) +class Issue: + """A single checker violation.""" + + file_path: Path + line: int + message: str + + +def get_relative_path(file_path: Path) -> str: + """Return a repository-relative path when possible.""" + try: + return file_path.relative_to(REPO_ROOT).as_posix() + except ValueError: + return file_path.as_posix() + + +def format_error_annotation(issue: Issue) -> str: + """Return a GitHub Actions error annotation for one issue.""" + return f'::error file={get_relative_path(issue.file_path)},line={issue.line}::{issue.message}' + + +def normalize_paths(paths: list[str]) -> list[Path]: + """Resolve CLI paths relative to the repository root and keep supported files.""" + normalized: list[Path] = [] + for raw_path in paths: + candidate = Path(raw_path) + if not candidate.is_absolute(): + candidate = (REPO_ROOT / candidate).resolve() + if candidate.exists() and candidate.suffix in SUPPORTED_SUFFIXES: + normalized.append(candidate) + return normalized + + +def matches_changed_lines(changed_lines: set[int] | None, start_line: int, end_line: int) -> bool: + """Return True when the issue overlaps changed lines or full-file mode is active.""" + if changed_lines is None: + return True + return any(line in changed_lines for line in range(start_line, end_line + 1)) + + +def is_suppressed(source_lines: list[str], start_line: int, end_line: int) -> bool: + """Return True when a suppression token exists near the reported lines.""" + window_start = max(1, start_line - 2) + window_end = min(len(source_lines), end_line) + for line_number in range(window_start, window_end + 1): + if SUPPRESSION_TOKEN in source_lines[line_number - 1]: + return True + return False + + +def get_changed_lines(file_path: Path, base_sha: str, head_sha: str) -> set[int] | None: + """Return added-line numbers for one file between two revisions.""" + relative_path = get_relative_path(file_path) + command = [ + 'git', + 'diff', + '--unified=0', + base_sha, + head_sha, + '--', + relative_path, + ] + + try: + result = subprocess.run( + command, + cwd=REPO_ROOT, + check=False, + capture_output=True, + text=True, + ) + except OSError: + return None + + if result.returncode not in {0, 1}: + return None + + changed_lines: set[int] = set() + for line in result.stdout.splitlines(): + match = DIFF_HUNK_RE.match(line) + if not match: + continue + + start_line = int(match.group('start')) + line_count = int(match.group('count') or '1') + if line_count == 0: + continue + + changed_lines.update(range(start_line, start_line + line_count)) + + return changed_lines + + +def call_name(call_node: ast.Call) -> str | None: + """Return the simple callable name for a Call node when available.""" + if isinstance(call_node.func, ast.Name): + return call_node.func.id + if isinstance(call_node.func, ast.Attribute): + return call_node.func.attr + return None + + +def decorator_name(decorator_node: ast.expr) -> str | None: + """Return the simple decorator name for a decorator node when available.""" + if isinstance(decorator_node, ast.Name): + return decorator_node.id + if isinstance(decorator_node, ast.Attribute): + return decorator_node.attr + if isinstance(decorator_node, ast.Call): + if isinstance(decorator_node.func, ast.Name): + return decorator_node.func.id + if isinstance(decorator_node.func, ast.Attribute): + return decorator_node.func.attr + return None + + +def has_decorator(function_node: ast.FunctionDef | ast.AsyncFunctionDef, names: set[str]) -> bool: + """Return True when the function has any decorator in the provided set.""" + return any(decorator_name(decorator) in names for decorator in function_node.decorator_list) + + +def build_parent_map(tree: ast.AST) -> dict[ast.AST, ast.AST]: + """Return a child-to-parent AST mapping.""" + parent_map: dict[ast.AST, ast.AST] = {} + for parent in ast.walk(tree): + for child in ast.iter_child_nodes(parent): + parent_map[child] = parent + return parent_map + + +def get_enclosing_function( + node: ast.AST, + parent_map: dict[ast.AST, ast.AST], +) -> ast.FunctionDef | ast.AsyncFunctionDef | None: + """Return the nearest enclosing function for a node when available.""" + current_node = node + while current_node in parent_map: + current_node = parent_map[current_node] + if isinstance(current_node, (ast.FunctionDef, ast.AsyncFunctionDef)): + return current_node + return None + + +def string_constant(node: ast.AST | None) -> str | None: + """Return a string constant value when the AST node is a string literal.""" + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + return None + + +def iter_dict_literals(call_node: ast.Call) -> list[ast.Dict]: + """Return dict literal arguments passed to a call.""" + dict_literals: list[ast.Dict] = [] + for argument in call_node.args: + if isinstance(argument, ast.Dict): + dict_literals.append(argument) + for keyword in call_node.keywords: + if isinstance(keyword.value, ast.Dict): + dict_literals.append(keyword.value) + return dict_literals + + +def collect_function_call_names(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]: + """Return the set of simple call names used inside a function.""" + call_names: set[str] = set() + for node in ast.walk(function_node): + if isinstance(node, ast.Call): + name = call_name(node) + if name: + call_names.add(name) + return call_names + + +def get_function_source(source_text: str, function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> str: + """Return the exact source segment for one function.""" + return ast.get_source_segment(source_text, function_node) or '' + + +def is_conversation_authorization_helper_name(name: str) -> bool: + """Return True when a helper name clearly represents a conversation authorization helper.""" + lowered_name = str(name or '').lower() + return lowered_name.startswith('_authorize_') and 'conversation' in lowered_name + + +def function_has_conversation_auth( + function_node: ast.FunctionDef | ast.AsyncFunctionDef, + source_text: str, +) -> bool: + """Return True when the function already uses an approved conversation auth boundary.""" + if is_conversation_authorization_helper_name(function_node.name): + return True + if has_decorator(function_node, ADMIN_DECORATORS): + return True + + function_calls = collect_function_call_names(function_node) + if any(is_conversation_authorization_helper_name(name) for name in function_calls): + return True + + function_source = get_function_source(source_text, function_node) + return any(snippet in function_source for snippet in EXPLICIT_OWNERSHIP_SNIPPETS) + + +def call_references_name_fragment(call_node: ast.Call, fragment: str, source_text: str) -> bool: + """Return True when the call source references the provided name fragment.""" + call_source = ast.get_source_segment(source_text, call_node) or '' + return fragment in call_source + + +def collect_direct_active_scope_write_issues( + *, + file_path: Path, + relative_path: str, + tree: ast.AST, + parent_map: dict[ast.AST, ast.AST], + source_lines: list[str], + changed_lines: set[int] | None, +) -> list[Issue]: + """Collect issues for direct persistence of authorization-sensitive active scope keys.""" + issues: list[Issue] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or call_name(node) != 'update_user_settings': + continue + + function_node = get_enclosing_function(node, parent_map) + function_name = function_node.name if function_node else '' + + for dict_literal in iter_dict_literals(node): + for key_node in dict_literal.keys: + key_name = string_constant(key_node) + if key_name not in ACTIVE_SCOPE_KEYS: + continue + if (relative_path, function_name, key_name) in APPROVED_ACTIVE_SCOPE_WRITE_CONTEXTS: + continue + + start_line = getattr(dict_literal, 'lineno', node.lineno) + end_line = getattr(dict_literal, 'end_lineno', start_line) + if not matches_changed_lines(changed_lines, start_line, end_line): + continue + if is_suppressed(source_lines, start_line, end_line): + continue + + helper_name = ACTIVE_SCOPE_KEYS[key_name]['write_helper'] + issues.append( + Issue( + file_path=file_path, + line=start_line, + message=( + f"Do not persist {key_name} through update_user_settings(...) outside {helper_name}. " + f"Route active-scope writes through the validator, or add '{SUPPRESSION_TOKEN}' with a justification." + ), + ) + ) + return issues + + +def collect_direct_active_scope_read_issues( + *, + file_path: Path, + relative_path: str, + tree: ast.AST, + source_lines: list[str], + changed_lines: set[int] | None, +) -> list[Issue]: + """Collect issues for raw active scope reads in backend and plugin code.""" + if relative_path in ACTIVE_SCOPE_READ_ALLOWED_PATHS: + return [] + if not relative_path.startswith(ACTIVE_SCOPE_READ_TARGET_PREFIXES): + return [] + + issues: list[Issue] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or call_name(node) != 'get' or not node.args: + continue + + key_name = string_constant(node.args[0]) + if key_name not in ACTIVE_SCOPE_KEYS: + continue + + start_line = node.lineno + end_line = getattr(node, 'end_lineno', start_line) + if not matches_changed_lines(changed_lines, start_line, end_line): + continue + if is_suppressed(source_lines, start_line, end_line): + continue + + helper_name = ACTIVE_SCOPE_KEYS[key_name]['read_helper'] + issues.append( + Issue( + file_path=file_path, + line=start_line, + message=( + f"Avoid reading {key_name} from raw settings in backend or plugin code. " + f"Use {helper_name} or a request-scoped authorization helper, " + f"or add '{SUPPRESSION_TOKEN}' with a justification." + ), + ) + ) + return issues + + +def collect_kernel_scope_param_issues( + *, + file_path: Path, + relative_path: str, + tree: ast.AST, + source_lines: list[str], + changed_lines: set[int] | None, +) -> list[Issue]: + """Collect issues for kernel functions that expose sensitive scope ids without normalization.""" + if not relative_path.startswith('application/single_app/semantic_kernel_plugins/'): + return [] + + issues: list[Issue] = [] + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if not has_decorator(node, {'kernel_function'}): + continue + + parameter_names = { + argument.arg + for argument in ( + list(node.args.posonlyargs) + + list(node.args.args) + + list(node.args.kwonlyargs) + ) + if argument.arg != 'self' + } + sensitive_params = sorted(parameter_names & KERNEL_SENSITIVE_PARAMS) + if not sensitive_params: + continue + + start_line = min([node.lineno] + [decorator.lineno for decorator in node.decorator_list]) + end_line = getattr(node, 'end_lineno', node.lineno) + if not matches_changed_lines(changed_lines, start_line, end_line): + continue + if is_suppressed(source_lines, start_line, end_line): + continue + + function_call_names = collect_function_call_names(node) + if function_call_names & APPROVED_KERNEL_SCOPE_HELPERS: + continue + + issues.append( + Issue( + file_path=file_path, + line=start_line, + message=( + f"Kernel functions that expose {', '.join(sensitive_params)} must immediately normalize those values " + f"through an approved authorization helper such as _resolve_authorized_scope_arguments(...), " + f"_resolve_blob_location_with_fallback(...), or _resolve_authorized_fact_memory_call(...), " + f"or add '{SUPPRESSION_TOKEN}' with a justification." + ), + ) + ) + return issues + + +def collect_direct_personal_conversation_read_issues( + *, + file_path: Path, + relative_path: str, + tree: ast.AST, + source_text: str, + source_lines: list[str], + changed_lines: set[int] | None, +) -> list[Issue]: + """Collect issues for direct personal conversation reads without an auth boundary.""" + if relative_path not in PERSONAL_CONVERSATION_ROUTE_FILES: + return [] + + issues: list[Issue] = [] + for function_node in ast.walk(tree): + if not isinstance(function_node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if function_has_conversation_auth(function_node, source_text): + continue + + for node in ast.walk(function_node): + if not isinstance(node, ast.Call) or call_name(node) != 'read_item': + continue + + call_source = ast.get_source_segment(source_text, node) or '' + if 'cosmos_conversations_container.read_item' not in call_source: + continue + if not call_references_name_fragment(node, 'conversation_id', source_text): + continue + + start_line = node.lineno + end_line = getattr(node, 'end_lineno', start_line) + if not matches_changed_lines(changed_lines, start_line, end_line): + continue + if is_suppressed(source_lines, start_line, end_line): + continue + + issues.append( + Issue( + file_path=file_path, + line=start_line, + message=( + "Avoid direct personal conversation reads from request-derived conversation_id values without " + "_authorize_personal_conversation_read(...), _authorize_personal_conversation_access(...), " + f"or an explicit ownership check, or add '{SUPPRESSION_TOKEN}' with a justification." + ), + ) + ) + return issues + + +def inspect_source(file_path: Path, source_text: str, changed_lines: set[int] | None = None) -> list[Issue]: + """Inspect one Python source string and return any BAC-related issues.""" + source_lines = source_text.splitlines() + + try: + tree = ast.parse(source_text, filename=str(file_path)) + except SyntaxError as exc: + return [ + Issue( + file_path=file_path, + line=exc.lineno or 1, + message=f'Unable to parse file for BAC validation: {exc.msg}', + ) + ] + + relative_path = get_relative_path(file_path) + parent_map = build_parent_map(tree) + issues: list[Issue] = [] + issues.extend( + collect_direct_active_scope_write_issues( + file_path=file_path, + relative_path=relative_path, + tree=tree, + parent_map=parent_map, + source_lines=source_lines, + changed_lines=changed_lines, + ) + ) + issues.extend( + collect_direct_active_scope_read_issues( + file_path=file_path, + relative_path=relative_path, + tree=tree, + source_lines=source_lines, + changed_lines=changed_lines, + ) + ) + issues.extend( + collect_kernel_scope_param_issues( + file_path=file_path, + relative_path=relative_path, + tree=tree, + source_lines=source_lines, + changed_lines=changed_lines, + ) + ) + issues.extend( + collect_direct_personal_conversation_read_issues( + file_path=file_path, + relative_path=relative_path, + tree=tree, + source_text=source_text, + source_lines=source_lines, + changed_lines=changed_lines, + ) + ) + + unique_issues: list[Issue] = [] + seen = set() + for issue in issues: + dedupe_key = (issue.file_path, issue.line, issue.message) + if dedupe_key in seen: + continue + seen.add(dedupe_key) + unique_issues.append(issue) + + return unique_issues + + +def inspect_file(file_path: Path, changed_lines: set[int] | None = None) -> list[Issue]: + """Load one file and return any BAC-related issues.""" + try: + source_text = file_path.read_text(encoding='utf-8') + except UnicodeDecodeError: + source_text = file_path.read_text(encoding='utf-8-sig') + except OSError as exc: + return [ + Issue( + file_path=file_path, + line=1, + message=f'Unable to read file for BAC validation: {exc}', + ) + ] + + return inspect_source(file_path, source_text, changed_lines=changed_lines) + + +def main() -> int: + """Run the Broken Access Control checker for the provided files.""" + parser = argparse.ArgumentParser( + description='Validate changed Python files for high-confidence broken access control regressions.' + ) + parser.add_argument('files', nargs='*', help='Files to validate relative to the repository root.') + parser.add_argument('--base-sha', help='Base git revision used to limit checks to added lines.') + parser.add_argument('--head-sha', help='Head git revision used to limit checks to added lines.') + parser.add_argument( + '--full-file', + action='store_true', + help='Scan the full file contents instead of only added lines.', + ) + args = parser.parse_args() + + files = normalize_paths(args.files) + if not files: + print('No supported files to validate for Broken Access Control guardrails.') + return 0 + + all_issues: list[Issue] = [] + checked_files = 0 + + for file_path in files: + changed_lines = None + if not args.full_file and args.base_sha and args.head_sha: + changed_lines = get_changed_lines(file_path, args.base_sha, args.head_sha) + if changed_lines == set(): + continue + + issues = inspect_file(file_path, changed_lines=changed_lines) + checked_files += 1 + all_issues.extend(issues) + + if all_issues: + print('Broken Access Control guardrail validation failed:') + for issue in all_issues: + print(format_error_annotation(issue)) + return 1 + + if checked_files == 0: + print('No added lines found in the provided files. Broken Access Control check skipped.') + return 0 + + print(f'Broken Access Control guardrail validation passed for {checked_files} file(s).') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file diff --git a/scripts/check_xss_sinks.py b/scripts/check_xss_sinks.py new file mode 100644 index 000000000..b3d570098 --- /dev/null +++ b/scripts/check_xss_sinks.py @@ -0,0 +1,481 @@ +# check_xss_sinks.py + +"""Validate changed files for risky XSS sink patterns.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SUPPORTED_SUFFIXES = {'.js', '.html', '.py'} +SUPPRESSION_TOKEN = 'xss-check: ignore' +INLINE_EVENT_ATTRIBUTE_RE = re.compile( + r'\bon(?:abort|auxclick|beforeinput|blur|change|click|contextmenu|dblclick|error|focus|input|keydown|keypress|keyup|load|mousedown|mouseenter|mouseleave|mousemove|mouseout|mouseover|mouseup|reset|scroll|submit|touchend|touchstart|transitionend)\s*=\s*["\']', + re.IGNORECASE, +) +INLINE_EVENT_API_RE = re.compile( + r'\.(?:onabort|onblur|onchange|onclick|ondblclick|onerror|onfocus|oninput|onkeydown|onkeyup|onload|onmousedown|onmouseenter|onmouseleave|onmousemove|onmouseout|onmouseover|onmouseup|onscroll|onsubmit)\s*=|setAttribute\(\s*["\']on', + re.IGNORECASE, +) +JAVASCRIPT_URL_RE = re.compile(r'javascript\s*:', re.IGNORECASE) +MARKUP_RE = re.compile(r'\bMarkup\s*\(') +JINJA_SAFE_RE = re.compile(r'\|\s*safe\b') +MARKED_PARSE_RE = re.compile(r'\bmarked\.parse\s*\(') +DANGEROUS_REACT_HTML_RE = re.compile(r'\bdangerouslySetInnerHTML\b') +ATTRIBUTE_INTERPOLATION_RE = re.compile( + r'\b(?:href|src|title|style|data-[\w-]+)\s*=\s*["\'][^"\'\n]*\$\{[^}]+\}', + re.IGNORECASE, +) +HTML_ASSIGNMENT_RE = re.compile( + r'\.(?PinnerHTML|outerHTML)\s*=\s*(?P.{0,500}?);', + re.DOTALL, +) +INSERT_ADJACENT_HTML_RE = re.compile( + r'\.insertAdjacentHTML\s*\(\s*[^,]+,\s*(?P.{0,500}?)\)\s*;?', + re.DOTALL, +) +JQUERY_HTML_RE = re.compile( + r'\.html\s*\(\s*(?P.{0,500}?)\)\s*;?', + re.DOTALL, +) +DIFF_HUNK_RE = re.compile(r'^@@ -\d+(?:,\d+)? \+(?P\d+)(?:,(?P\d+))? @@') + + +@dataclass(frozen=True) +class Issue: + """A single checker violation.""" + + file_path: Path + line: int + message: str + + +def get_relative_path(file_path: Path) -> str: + """Return a repository-relative path when possible.""" + try: + return file_path.relative_to(REPO_ROOT).as_posix() + except ValueError: + return file_path.as_posix() + + +def format_error_annotation(issue: Issue) -> str: + """Return a GitHub Actions annotation for one issue.""" + return f"::error file={get_relative_path(issue.file_path)},line={issue.line}::{issue.message}" + + +def normalize_paths(paths: list[str]) -> list[Path]: + """Resolve CLI paths relative to the repository root and keep supported files.""" + normalized: list[Path] = [] + for raw_path in paths: + candidate = Path(raw_path) + if not candidate.is_absolute(): + candidate = (REPO_ROOT / candidate).resolve() + if candidate.exists() and candidate.suffix in SUPPORTED_SUFFIXES: + normalized.append(candidate) + return normalized + + +def get_line_number(source_text: str, offset: int) -> int: + """Return the 1-based line number for a source offset.""" + return source_text.count('\n', 0, offset) + 1 + + +def matches_changed_lines(changed_lines: set[int] | None, start_line: int, end_line: int) -> bool: + """Return True when the issue overlaps the changed lines or full-file mode is active.""" + if changed_lines is None: + return True + return any(line in changed_lines for line in range(start_line, end_line + 1)) + + +def is_suppressed(source_lines: list[str], start_line: int, end_line: int) -> bool: + """Return True when a suppression token is present near the reported lines.""" + window_start = max(1, start_line - 2) + window_end = min(len(source_lines), end_line) + for line_number in range(window_start, window_end + 1): + if SUPPRESSION_TOKEN in source_lines[line_number - 1]: + return True + return False + + +def is_static_html_expression(expression: str) -> bool: + """Return True when an HTML expression is a static literal without interpolation.""" + stripped = expression.strip() + if not stripped: + return True + + quote_pairs = [("'", "'"), ('"', '"'), ('`', '`')] + for start_quote, end_quote in quote_pairs: + if stripped.startswith(start_quote) and stripped.endswith(end_quote): + return '${' not in stripped and '+' not in stripped + + return False + + +def is_allowed_html_expression(expression: str) -> bool: + """Return True when an HTML sink expression is explicitly allowed.""" + if 'DOMPurify.sanitize(' in expression: + return True + return is_static_html_expression(expression) + + +def get_changed_lines(file_path: Path, base_sha: str, head_sha: str) -> set[int] | None: + """Return the added-line numbers for one file between two revisions.""" + relative_path = get_relative_path(file_path) + command = [ + 'git', + 'diff', + '--unified=0', + base_sha, + head_sha, + '--', + relative_path, + ] + + try: + result = subprocess.run( + command, + cwd=REPO_ROOT, + check=False, + capture_output=True, + text=True, + ) + except OSError: + return None + + if result.returncode not in {0, 1}: + return None + + changed_lines: set[int] = set() + for line in result.stdout.splitlines(): + match = DIFF_HUNK_RE.match(line) + if not match: + continue + + start_line = int(match.group('start')) + line_count = int(match.group('count') or '1') + if line_count == 0: + continue + + changed_lines.update(range(start_line, start_line + line_count)) + + return changed_lines + + +def collect_regex_issues( + *, + file_path: Path, + source_text: str, + source_lines: list[str], + changed_lines: set[int] | None, + pattern: re.Pattern[str], + message: str, +) -> list[Issue]: + """Collect issues for a simple regex rule.""" + issues: list[Issue] = [] + for match in pattern.finditer(source_text): + start_line = get_line_number(source_text, match.start()) + end_line = get_line_number(source_text, match.end()) + if not matches_changed_lines(changed_lines, start_line, end_line): + continue + if is_suppressed(source_lines, start_line, end_line): + continue + issues.append(Issue(file_path=file_path, line=start_line, message=message)) + return issues + + +def collect_html_sink_issues( + *, + file_path: Path, + source_text: str, + source_lines: list[str], + changed_lines: set[int] | None, + pattern: re.Pattern[str], + sink_name: str, +) -> list[Issue]: + """Collect issues for dangerous HTML sinks.""" + issues: list[Issue] = [] + for match in pattern.finditer(source_text): + expression = match.group('expr') + if is_allowed_html_expression(expression): + continue + + start_line = get_line_number(source_text, match.start()) + end_line = get_line_number(source_text, match.end()) + if not matches_changed_lines(changed_lines, start_line, end_line): + continue + if is_suppressed(source_lines, start_line, end_line): + continue + + issues.append( + Issue( + file_path=file_path, + line=start_line, + message=( + f"Avoid dynamic {sink_name} sinks with untrusted data. Prefer DOM APIs, textContent, " + f"or DOMPurify.sanitize(...), or add '{SUPPRESSION_TOKEN}' with a justification." + ), + ) + ) + return issues + + +def collect_marked_parse_issues( + *, + file_path: Path, + source_text: str, + source_lines: list[str], + changed_lines: set[int] | None, +) -> list[Issue]: + """Collect issues where marked.parse is not paired with DOMPurify.sanitize.""" + issues: list[Issue] = [] + for match in MARKED_PARSE_RE.finditer(source_text): + start_line = get_line_number(source_text, match.start()) + end_line = get_line_number(source_text, match.end()) + if not matches_changed_lines(changed_lines, start_line, end_line): + continue + if is_suppressed(source_lines, start_line, end_line): + continue + + window_start = max(1, start_line - 2) + window_end = min(len(source_lines), end_line + 2) + window_text = '\n'.join(source_lines[window_start - 1:window_end]) + if 'DOMPurify.sanitize(' in window_text: + continue + + issues.append( + Issue( + file_path=file_path, + line=start_line, + message=( + "Wrap marked.parse(...) output with DOMPurify.sanitize(...) before rendering HTML, " + f"or add '{SUPPRESSION_TOKEN}' with a justification." + ), + ) + ) + return issues + + +def inspect_source(file_path: Path, source_text: str, changed_lines: set[int] | None = None) -> list[Issue]: + """Inspect one source string and return any XSS-related issues.""" + source_lines = source_text.splitlines() + issues: list[Issue] = [] + + issues.extend( + collect_regex_issues( + file_path=file_path, + source_text=source_text, + source_lines=source_lines, + changed_lines=changed_lines, + pattern=INLINE_EVENT_ATTRIBUTE_RE, + message=( + f"Avoid inline event-handler attributes in rendered HTML. Use addEventListener or data-* hooks, " + f"or add '{SUPPRESSION_TOKEN}' with a justification." + ), + ) + ) + issues.extend( + collect_regex_issues( + file_path=file_path, + source_text=source_text, + source_lines=source_lines, + changed_lines=changed_lines, + pattern=INLINE_EVENT_API_RE, + message=( + f"Avoid inline event-handler APIs such as onclick/onerror. Use addEventListener, " + f"or add '{SUPPRESSION_TOKEN}' with a justification." + ), + ) + ) + issues.extend( + collect_regex_issues( + file_path=file_path, + source_text=source_text, + source_lines=source_lines, + changed_lines=changed_lines, + pattern=JAVASCRIPT_URL_RE, + message=( + f"Avoid javascript: URLs in rendered content. Normalize dynamic URLs explicitly, " + f"or add '{SUPPRESSION_TOKEN}' with a justification." + ), + ) + ) + issues.extend( + collect_regex_issues( + file_path=file_path, + source_text=source_text, + source_lines=source_lines, + changed_lines=changed_lines, + pattern=ATTRIBUTE_INTERPOLATION_RE, + message=( + f"Avoid interpolating untrusted values directly into href/src/title/style/data-* attributes. " + f"Prefer DOM APIs and explicit URL normalization, or add '{SUPPRESSION_TOKEN}' with a justification." + ), + ) + ) + issues.extend( + collect_regex_issues( + file_path=file_path, + source_text=source_text, + source_lines=source_lines, + changed_lines=changed_lines, + pattern=DANGEROUS_REACT_HTML_RE, + message=( + f"Avoid dangerouslySetInnerHTML without a reviewed sanitizer boundary, " + f"or add '{SUPPRESSION_TOKEN}' with a justification." + ), + ) + ) + + if file_path.suffix == '.py': + issues.extend( + collect_regex_issues( + file_path=file_path, + source_text=source_text, + source_lines=source_lines, + changed_lines=changed_lines, + pattern=MARKUP_RE, + message=( + f"Avoid Markup(...) on untrusted content without a reviewed sanitizer boundary, " + f"or add '{SUPPRESSION_TOKEN}' with a justification." + ), + ) + ) + + if file_path.suffix == '.html': + issues.extend( + collect_regex_issues( + file_path=file_path, + source_text=source_text, + source_lines=source_lines, + changed_lines=changed_lines, + pattern=JINJA_SAFE_RE, + message=( + f"Avoid Jinja '|safe' on untrusted content without a reviewed sanitizer boundary, " + f"or add '{SUPPRESSION_TOKEN}' with a justification." + ), + ) + ) + + issues.extend( + collect_html_sink_issues( + file_path=file_path, + source_text=source_text, + source_lines=source_lines, + changed_lines=changed_lines, + pattern=HTML_ASSIGNMENT_RE, + sink_name='innerHTML/outerHTML', + ) + ) + issues.extend( + collect_html_sink_issues( + file_path=file_path, + source_text=source_text, + source_lines=source_lines, + changed_lines=changed_lines, + pattern=INSERT_ADJACENT_HTML_RE, + sink_name='insertAdjacentHTML', + ) + ) + issues.extend( + collect_html_sink_issues( + file_path=file_path, + source_text=source_text, + source_lines=source_lines, + changed_lines=changed_lines, + pattern=JQUERY_HTML_RE, + sink_name='jQuery .html()', + ) + ) + issues.extend( + collect_marked_parse_issues( + file_path=file_path, + source_text=source_text, + source_lines=source_lines, + changed_lines=changed_lines, + ) + ) + + unique_issues: list[Issue] = [] + seen = set() + for issue in issues: + dedupe_key = (issue.file_path, issue.line, issue.message) + if dedupe_key in seen: + continue + seen.add(dedupe_key) + unique_issues.append(issue) + + return unique_issues + + +def inspect_file(file_path: Path, changed_lines: set[int] | None = None) -> list[Issue]: + """Load one file and return any XSS-related issues.""" + try: + source_text = file_path.read_text(encoding='utf-8') + except UnicodeDecodeError: + source_text = file_path.read_text(encoding='utf-8-sig') + except OSError as exc: + return [ + Issue( + file_path=file_path, + line=1, + message=f'Unable to read file for XSS sink validation: {exc}', + ) + ] + + return inspect_source(file_path, source_text, changed_lines=changed_lines) + + +def main() -> int: + """Run the XSS sink checker for the provided files.""" + parser = argparse.ArgumentParser(description='Validate changed files for risky XSS sink patterns.') + parser.add_argument('files', nargs='*', help='Files to validate relative to the repository root.') + parser.add_argument('--base-sha', help='Base git revision used to limit checks to added lines.') + parser.add_argument('--head-sha', help='Head git revision used to limit checks to added lines.') + parser.add_argument( + '--full-file', + action='store_true', + help='Scan the full file contents instead of only added lines.', + ) + args = parser.parse_args() + + files = normalize_paths(args.files) + if not files: + print('No supported files to validate for XSS sink coverage.') + return 0 + + all_issues: list[Issue] = [] + checked_files = 0 + + for file_path in files: + changed_lines = None + if not args.full_file and args.base_sha and args.head_sha: + changed_lines = get_changed_lines(file_path, args.base_sha, args.head_sha) + if changed_lines == set(): + continue + + issues = inspect_file(file_path, changed_lines=changed_lines) + checked_files += 1 + all_issues.extend(issues) + + if all_issues: + print('XSS sink validation failed:') + for issue in all_issues: + print(format_error_annotation(issue)) + return 1 + + if checked_files == 0: + print('No added lines found in the provided files. XSS sink check skipped.') + return 0 + + print(f'XSS sink validation passed for {checked_files} file(s).') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file diff --git a/ui_tests/test_agent_template_gallery_actions_escaping.py b/ui_tests/test_agent_template_gallery_actions_escaping.py new file mode 100644 index 000000000..ecb11f810 --- /dev/null +++ b/ui_tests/test_agent_template_gallery_actions_escaping.py @@ -0,0 +1,138 @@ +# test_agent_template_gallery_actions_escaping.py +""" +UI test for agent template gallery actions escaping. +Version: 0.241.020 +Implemented in: 0.241.020 + +This test ensures malicious actions_to_load values render as inert text in the +agent template gallery instead of becoming executable DOM. +""" + +import json +import os +from pathlib import Path + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv('SIMPLECHAT_UI_BASE_URL', '').rstrip('/') +STORAGE_STATE = os.getenv('SIMPLECHAT_UI_STORAGE_STATE', '') +SKIP_RESPONSE_CODES = {401, 403, 404} + + +def _fulfill_json(route, payload, status=200): + route.fulfill( + status=status, + content_type='application/json', + body=json.dumps(payload), + ) + + +@pytest.mark.ui +def test_agent_template_gallery_escapes_actions_to_load(playwright): + """Validate gallery action labels keep attacker-controlled values inert.""" + if not BASE_URL: + pytest.skip('Set SIMPLECHAT_UI_BASE_URL to run this UI test.') + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip('Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.') + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={'width': 1440, 'height': 900}, + ) + page = context.new_page() + + first_action = 'Action' + second_action = 'Action' + + page.route( + '**/api/user/settings*', + lambda route: _fulfill_json(route, {'settings': {}, 'selected_agent': None}), + ) + page.route( + '**/api/get_conversations*', + lambda route: _fulfill_json(route, {'conversations': []}), + ) + page.route( + '**/api/agent-templates', + lambda route: _fulfill_json( + route, + { + 'templates': [ + { + 'id': 'template-1', + 'title': 'Escaping Template', + 'display_name': 'Escaping Template', + 'description': 'Regression coverage for gallery action rendering.', + 'helper_text': 'Regression coverage for gallery action rendering.', + 'instructions': 'Do not execute action labels.', + 'actions_to_load': [first_action, second_action], + 'tags': [], + } + ] + }, + ), + ) + + try: + response = page.goto(f'{BASE_URL}/chats', wait_until='domcontentloaded') + assert response is not None, 'Expected a navigation response when loading /chats.' + + if response.status in SKIP_RESPONSE_CODES: + pytest.skip(f'/chats returned HTTP {response.status} in this environment.') + + assert response.ok, f'Expected /chats to load successfully, got HTTP {response.status}.' + + page.evaluate( + """async ({ firstAction, secondAction }) => { + window.__agentTemplateActionXss = false; + window.__agentTemplateActionSvgXss = false; + window.appSettings = { + ...(window.appSettings || {}), + enable_agent_template_gallery: true, + }; + + const existing = document.getElementById('agent-template-gallery-test'); + if (existing) { + existing.remove(); + } + + const wrapper = document.createElement('div'); + wrapper.id = 'agent-template-gallery-test'; + wrapper.innerHTML = ` + + `; + document.body.appendChild(wrapper); + + await import(`/static/js/agent_templates_gallery.js?test=${Date.now()}`); + }""", + {'firstAction': first_action, 'secondAction': second_action}, + ) + + expect(page.locator('#agent-template-gallery-test .accordion-item')).to_have_count(1) + expect(page.locator('#agent-template-gallery-test')).to_contain_text('Recommended actions:') + expect(page.locator('#agent-template-gallery-test')).to_contain_text(first_action) + expect(page.locator('#agent-template-gallery-test')).to_contain_text(second_action) + expect(page.locator("#agent-template-gallery-test img[src='x']")).to_have_count(0) + expect(page.locator('#agent-template-gallery-test svg')).to_have_count(0) + + flags = page.evaluate( + """() => ({ + image: !!window.__agentTemplateActionXss, + svg: !!window.__agentTemplateActionSvgXss, + })""" + ) + assert flags == {'image': False, 'svg': False} + finally: + context.close() + browser.close() \ No newline at end of file diff --git a/ui_tests/test_chat_messages_authorization_error.py b/ui_tests/test_chat_messages_authorization_error.py new file mode 100644 index 000000000..7047b18a2 --- /dev/null +++ b/ui_tests/test_chat_messages_authorization_error.py @@ -0,0 +1,63 @@ +# test_chat_messages_authorization_error.py +""" +UI test for chat message authorization error handling. +Version: 0.241.012 +Implemented in: 0.241.012 + +This test ensures the chat message loader renders a controlled access-denied +message when the conversation messages endpoint returns 403. +""" + +import json +import os +from pathlib import Path + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv('SIMPLECHAT_UI_BASE_URL', '').rstrip('/') +STORAGE_STATE = os.getenv('SIMPLECHAT_UI_STORAGE_STATE', '') + + +@pytest.mark.ui +def test_chat_loader_shows_forbidden_message(playwright): + """Validate chat message loading handles a forbidden response cleanly.""" + if not BASE_URL: + pytest.skip('Set SIMPLECHAT_UI_BASE_URL to run this UI test.') + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip('Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.') + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={'width': 1440, 'height': 900}, + ) + page = context.new_page() + + def fulfill_forbidden_messages(route): + route.fulfill( + status=403, + content_type='application/json', + body=json.dumps({'error': 'Forbidden'}), + ) + + try: + page.route('**/conversation/blocked-conversation/messages', fulfill_forbidden_messages) + page.goto(f'{BASE_URL}/chats', wait_until='networkidle') + page.wait_for_function( + "() => window.chatMessages && typeof window.chatMessages.loadMessages === 'function'" + ) + + page.evaluate( + """async () => { + await window.chatMessages.loadMessages('blocked-conversation'); + }""" + ) + + expect(page.locator('#chatbox')).to_contain_text( + 'You do not have access to this conversation.' + ) + finally: + context.close() + browser.close() \ No newline at end of file diff --git a/ui_tests/test_chat_modal_filename_escaping.py b/ui_tests/test_chat_modal_filename_escaping.py new file mode 100644 index 000000000..e55b8cdb6 --- /dev/null +++ b/ui_tests/test_chat_modal_filename_escaping.py @@ -0,0 +1,117 @@ +# test_chat_modal_filename_escaping.py +""" +UI test for chat modal filename escaping. +Version: 0.241.018 +Implemented in: 0.241.018 + +This test ensures citation and uploaded-file modal titles render malicious +filenames as inert text on first display. +""" + +import json +import os +from pathlib import Path + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") +SKIP_RESPONSE_CODES = {401, 403, 404} + + +def _fulfill_json(route, payload, status=200): + route.fulfill( + status=status, + content_type="application/json", + body=json.dumps(payload), + ) + + +@pytest.mark.ui +def test_chat_modals_escape_malicious_filenames_on_first_render(playwright): + """Validate chat modal titles keep attacker-controlled filenames inert.""" + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip("Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.") + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + + citation_filename = '.pdf' + file_filename = '.txt' + + page.route( + "**/api/user/settings", + lambda route: _fulfill_json(route, {"selected_agent": None, "settings": {"enable_agents": False}}), + ) + page.route("**/api/get_conversations", lambda route: _fulfill_json(route, {"conversations": []})) + + try: + response = page.goto(f"{BASE_URL}/chats", wait_until="domcontentloaded") + assert response is not None, "Expected a navigation response when loading /chats." + + if response.status in SKIP_RESPONSE_CODES: + pytest.skip(f"/chats returned HTTP {response.status} in this environment.") + + assert response.ok, f"Expected /chats to load successfully, got HTTP {response.status}." + page.wait_for_selector("#chatbox") + + page.evaluate( + """ + async ({ citationFilename, fileFilename }) => { + window.__citationModalTitleXss = false; + window.__fileModalTitleXss = false; + + const citationsModule = await import('/static/js/chat/chat-citations.js'); + citationsModule.showCitedTextPopup('Citation body', citationFilename, 7); + + const fileActionsModule = await import('/static/js/chat/chat-input-actions.js'); + const citationModal = document.getElementById('citation-modal'); + const citationInstance = bootstrap.Modal.getInstance(citationModal); + if (citationInstance) { + citationInstance.hide(); + } + + fileActionsModule.showFileContentPopup( + 'Uploaded file body', + fileFilename, + false, + 'database', + null, + null, + ); + } + """, + {"citationFilename": citation_filename, "fileFilename": file_filename}, + ) + + expect(page.locator("#citation-modal .modal-title")).to_have_text( + f"Source: {citation_filename}, Page: 7" + ) + expect(page.locator("#citation-modal img[src='x']")).to_have_count(0) + expect(page.locator("#citation-modal svg")).to_have_count(0) + + expect(page.locator("#file-modal")).to_be_visible() + expect(page.locator("#file-modal .modal-title")).to_have_text( + f"Uploaded File: {file_filename}" + ) + expect(page.locator("#file-modal img[src='x']")).to_have_count(0) + expect(page.locator("#file-modal svg")).to_have_count(0) + + flags = page.evaluate( + """() => ({ + citation: !!window.__citationModalTitleXss, + file: !!window.__fileModalTitleXss, + })""" + ) + assert flags == {"citation": False, "file": False} + finally: + context.close() + browser.close() \ No newline at end of file diff --git a/ui_tests/test_chat_scope_lock_and_conversation_details_escaping.py b/ui_tests/test_chat_scope_lock_and_conversation_details_escaping.py new file mode 100644 index 000000000..e622b94dc --- /dev/null +++ b/ui_tests/test_chat_scope_lock_and_conversation_details_escaping.py @@ -0,0 +1,250 @@ +# test_chat_scope_lock_and_conversation_details_escaping.py +""" +UI test for chat scope-lock and conversation-details escaping. +Version: 0.241.019 +Implemented in: 0.241.019 + +This test ensures malicious workspace names and conversation metadata render as +inert text in the chat scope-lock modal and conversation-details modal. +""" + +import json +import os +from pathlib import Path + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") + + +def _fulfill_json(route, payload, status=200): + route.fulfill( + status=status, + content_type="application/json", + body=json.dumps(payload), + ) + + +@pytest.mark.ui +def test_chat_scope_lock_and_conversation_details_escape_malicious_metadata(playwright): + """Validate chat scope-lock and conversation-details metadata render as inert text.""" + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip("Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.") + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + + scope_lock_name = ' Locked Scope' + conversation_title = ' Conversation' + primary_context_name = ' Primary Context' + participant_name = ' Participant' + participant_email = '@example.com' + document_title = ' Document' + document_scope_name = ' Scope' + classification_label = ' Secret' + semantic_tag = 'Semantic' + model_tag = ' gpt-xss' + agent_tag = 'Agent' + web_source = 'javascript:window.__webSourceXss = true' + summary_model = ' summary-model' + + metadata_payload = { + "title": conversation_title, + "context": [ + { + "type": "primary", + "scope": "group", + "id": "group-1", + "name": primary_context_name, + }, + { + "type": "secondary", + "scope": "public", + "id": scope_lock_name, + "name": scope_lock_name, + }, + ], + "tags": [ + { + "category": "participant", + "user_id": "user-1", + "name": participant_name, + "email": participant_email, + }, + { + "category": "document", + "document_id": "doc-1", + "title": document_title, + "classification": classification_label, + "chunk_ids": ["chunk_1_p1", "chunk_1_p2"], + "scope": { + "type": "group", + "id": "group-1", + "name": document_scope_name, + }, + }, + { + "category": "semantic", + "value": semantic_tag, + }, + { + "category": "model", + "value": model_tag, + }, + { + "category": "agent", + "value": agent_tag, + }, + { + "category": "web", + "value": web_source, + }, + ], + "strict": False, + "classification": [classification_label], + "last_updated": "2026-05-05T12:00:00Z", + "chat_type": "group-single-user", + "is_pinned": False, + "is_hidden": False, + "scope_locked": True, + "locked_contexts": [ + {"scope": "group", "id": scope_lock_name}, + ], + "summary": { + "content": "Safe summary text.", + "generated_at": "2026-05-05T11:00:00Z", + "model_deployment": summary_model, + }, + } + + def fulfill_empty_docs_or_tags(route): + if "/tags" in route.request.url: + _fulfill_json(route, {"tags": []}) + return + _fulfill_json(route, {"documents": []}) + + try: + page.route("**/api/user/settings*", lambda route: _fulfill_json(route, {"settings": {}, "selected_agent": None})) + page.route("**/api/get_conversations*", lambda route: _fulfill_json(route, {"conversations": []})) + page.route("**/api/documents*", fulfill_empty_docs_or_tags) + page.route("**/api/group_documents*", fulfill_empty_docs_or_tags) + page.route("**/api/public_workspace_documents*", fulfill_empty_docs_or_tags) + page.route("**/api/user/profile-image/**", lambda route: route.fulfill(status=404, body="")) + page.route( + "**/api/conversations/conversation-xss/metadata", + lambda route: _fulfill_json(route, metadata_payload), + ) + + page.goto(f"{BASE_URL}/chats", wait_until="domcontentloaded") + page.wait_for_selector("#scopeLockModal") + + page.evaluate( + """async (scopeLockName) => { + window.__scopeLockXss = false; + window.__conversationTitleXss = false; + window.__conversationContextXss = false; + window.__participantNameXss = false; + window.__participantEmailXss = false; + window.__documentTitleXss = false; + window.__documentScopeXss = false; + window.__classificationXss = false; + window.__semanticTagXss = false; + window.__modelTagXss = false; + window.__agentTagXss = false; + window.__webSourceXss = false; + window.__summaryModelXss = false; + + const chatDocumentsModule = await import('/static/js/chat/chat-documents.js'); + chatDocumentsModule.restoreScopeLockState(true, [{ scope: 'group', id: scopeLockName }]); + + const scopeLockModal = document.getElementById('scopeLockModal'); + bootstrap.Modal.getOrCreateInstance(scopeLockModal).show(); + }""", + scope_lock_name, + ) + + locked_list = page.locator("#locked-workspaces-list") + expect(page.locator("#scopeLockModal")).to_be_visible() + expect(locked_list).to_contain_text(scope_lock_name) + expect(page.locator("#locked-workspaces-list img[src='x']")).to_have_count(0) + expect(page.locator("#locked-workspaces-list svg")).to_have_count(0) + + page.evaluate( + """() => { + const scopeLockModal = bootstrap.Modal.getInstance(document.getElementById('scopeLockModal')); + if (scopeLockModal) { + scopeLockModal.hide(); + } + }""" + ) + + page.evaluate( + """async () => { + const detailsModule = await import('/static/js/chat/chat-conversation-details.js'); + await detailsModule.showConversationDetails('conversation-xss'); + }""" + ) + + details_modal = page.locator("#conversation-details-modal") + details_content = page.locator("#conversation-details-content") + expect(details_modal).to_be_visible() + expect(page.locator("#conversation-details-modal .modal-title")).to_contain_text(conversation_title) + expect(details_content).to_contain_text(primary_context_name) + expect(details_content).to_contain_text(participant_name) + expect(details_content).to_contain_text(participant_email) + expect(details_content).to_contain_text(document_title) + expect(details_content).to_contain_text(document_scope_name) + expect(details_content).to_contain_text(classification_label) + expect(details_content).to_contain_text(semantic_tag) + expect(details_content).to_contain_text(model_tag) + expect(details_content).to_contain_text(agent_tag) + expect(details_content).to_contain_text(web_source) + expect(details_content).to_contain_text(summary_model) + expect(page.locator("#conversation-details-modal img[src='x']")).to_have_count(0) + expect(page.locator("#conversation-details-modal svg")).to_have_count(0) + expect(page.locator("#conversation-details-modal a[href^='javascript:']")).to_have_count(0) + + flags = page.evaluate( + """() => ({ + scopeLock: !!window.__scopeLockXss, + title: !!window.__conversationTitleXss, + context: !!window.__conversationContextXss, + participantName: !!window.__participantNameXss, + participantEmail: !!window.__participantEmailXss, + documentTitle: !!window.__documentTitleXss, + documentScope: !!window.__documentScopeXss, + classification: !!window.__classificationXss, + semanticTag: !!window.__semanticTagXss, + modelTag: !!window.__modelTagXss, + agentTag: !!window.__agentTagXss, + webSource: !!window.__webSourceXss, + summaryModel: !!window.__summaryModelXss, + })""" + ) + assert flags == { + "scopeLock": False, + "title": False, + "context": False, + "participantName": False, + "participantEmail": False, + "documentTitle": False, + "documentScope": False, + "classification": False, + "semanticTag": False, + "modelTag": False, + "agentTag": False, + "webSource": False, + "summaryModel": False, + } + finally: + context.close() + browser.close() \ No newline at end of file diff --git a/ui_tests/test_control_center_group_members_escaping.py b/ui_tests/test_control_center_group_members_escaping.py new file mode 100644 index 000000000..476e0f1c2 --- /dev/null +++ b/ui_tests/test_control_center_group_members_escaping.py @@ -0,0 +1,93 @@ +# test_control_center_group_members_escaping.py +""" +UI test for Control Center group member escaping. +Version: 0.241.010 +Implemented in: 0.241.010 + +This test ensures malicious group member names and emails render as inert text +in the Control Center group-members modal instead of executing as HTML. +""" + +import json +import os +from pathlib import Path + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") + + +@pytest.mark.ui +def test_control_center_group_member_metadata_is_escaped(playwright): + """Validate malicious group member metadata renders as inert text.""" + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip("Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.") + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + + member_name = '' + member_email = '@example.com' + + payload = { + "id": "group-1", + "name": "Escaping Test Group", + "owner": { + "id": "owner-1", + "displayName": "Owner", + "email": "owner@example.com", + }, + "admins": [], + "documentManagers": [], + "users": [ + { + "userId": "member-1", + "displayName": member_name, + "email": member_email, + } + ], + } + + def fulfill_group_details(route): + route.fulfill( + status=200, + content_type="application/json", + body=json.dumps(payload), + ) + + try: + page.route("**/api/admin/control-center/groups/group-1", fulfill_group_details) + + page.goto(f"{BASE_URL}/admin/control-center", wait_until="networkidle") + page.evaluate( + """async () => { + document.getElementById('groupManagementModal').setAttribute('data-group-id', 'group-1'); + await window.GroupManager.loadGroupMembers(); + }""" + ) + + table_body = page.locator("#groupMembersTableBody") + expect(table_body).to_contain_text(member_name) + expect(table_body).to_contain_text(member_email) + expect(page.locator("#groupMembersTableBody img[src='x']")).to_have_count(0) + expect(page.locator("#groupMembersTableBody svg")).to_have_count(0) + + flags = page.evaluate( + """() => ({ + name: !!window.__controlCenterMemberNameXss, + email: !!window.__controlCenterMemberEmailXss, + })""" + ) + assert flags == {"name": False, "email": False} + finally: + context.close() + browser.close() \ No newline at end of file diff --git a/ui_tests/test_control_center_public_workspace_escaping.py b/ui_tests/test_control_center_public_workspace_escaping.py new file mode 100644 index 000000000..4e3e6ce94 --- /dev/null +++ b/ui_tests/test_control_center_public_workspace_escaping.py @@ -0,0 +1,100 @@ +# test_control_center_public_workspace_escaping.py +""" +UI test for Control Center public workspace escaping. +Version: 0.241.007 +Implemented in: 0.241.007 + +This test ensures malicious public workspace metadata renders as inert text +in the Control Center public workspace table instead of executing as HTML. +""" + +import json +import os +from pathlib import Path + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") + + +@pytest.mark.ui +def test_control_center_public_workspace_metadata_is_escaped(playwright): + """Validate malicious public workspace metadata renders as inert text.""" + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip("Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.") + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + + workspace_name = '' + workspace_description = '' + owner_name = '' + + payload = { + "workspaces": [ + { + "id": "workspace-1", + "name": workspace_name, + "description": workspace_description, + "owner": { + "displayName": owner_name, + "email": "owner@example.com", + }, + "member_count": 2, + "status": "active", + "activity": { + "document_metrics": { + "total_documents": 1, + "ai_search_size": 0, + "storage_account_size": 0, + } + }, + } + ] + } + + def fulfill_public_workspaces(route): + route.fulfill( + status=200, + content_type="application/json", + body=json.dumps(payload), + ) + + try: + page.route("**/api/admin/control-center/public-workspaces?*", fulfill_public_workspaces) + + page.goto(f"{BASE_URL}/admin/control-center", wait_until="networkidle") + + with page.expect_response(lambda response: "/api/admin/control-center/public-workspaces?" in response.url): + if page.locator("#workspaces-tab").count() > 0: + page.locator("#workspaces-tab").click() + else: + page.locator('[onclick*="workspaces-tab"]').first.click() + + table_body = page.locator("#publicWorkspacesTableBody") + expect(table_body).to_contain_text(workspace_name) + expect(table_body).to_contain_text(workspace_description) + expect(table_body).to_contain_text(owner_name) + expect(page.locator("#publicWorkspacesTableBody img[src='x']")).to_have_count(0) + expect(page.locator("#publicWorkspacesTableBody svg")).to_have_count(0) + + flags = page.evaluate( + """() => ({ + name: !!window.__controlCenterNameXss, + description: !!window.__controlCenterDescriptionXss, + owner: !!window.__controlCenterOwnerXss, + })""" + ) + assert flags == {"name": False, "description": False, "owner": False} + finally: + context.close() + browser.close() \ No newline at end of file diff --git a/ui_tests/test_control_center_public_workspace_members_escaping.py b/ui_tests/test_control_center_public_workspace_members_escaping.py new file mode 100644 index 000000000..2c662b355 --- /dev/null +++ b/ui_tests/test_control_center_public_workspace_members_escaping.py @@ -0,0 +1,92 @@ +# test_control_center_public_workspace_members_escaping.py +""" +UI test for Control Center public workspace member escaping. +Version: 0.241.016 +Implemented in: 0.241.016 + +This test ensures malicious public workspace member names and emails render as +inert text in the Control Center workspace-members modal instead of executing +as HTML. +""" + +import json +import os +from pathlib import Path + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") + + +@pytest.mark.ui +def test_control_center_public_workspace_member_metadata_is_escaped(playwright): + """Validate malicious public workspace member metadata renders as inert text.""" + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip("Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.") + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + + member_name = '' + member_email = '@example.com' + + payload = { + "success": True, + "workspace_name": "Escaping Test Workspace", + "members": [ + { + "userId": "owner-1", + "displayName": member_name, + "email": member_email, + "role": "owner", + } + ], + } + + def fulfill_workspace_members(route): + route.fulfill( + status=200, + content_type="application/json", + body=json.dumps(payload), + ) + + try: + page.route( + "**/api/admin/control-center/public-workspaces/workspace-1/members", + fulfill_workspace_members, + ) + + page.goto(f"{BASE_URL}/admin/control-center", wait_until="networkidle") + page.evaluate( + """async () => { + document.getElementById('publicWorkspaceManagementModal').setAttribute('data-workspace-id', 'workspace-1'); + document.getElementById('modalWorkspaceName').textContent = 'Escaping Test Workspace'; + await window.WorkspaceManager.loadWorkspaceMembers(); + }""" + ) + + table_body = page.locator("#workspaceMembersTableBody") + expect(table_body).to_contain_text(member_name) + expect(table_body).to_contain_text(member_email) + expect(page.locator("#workspaceMembersTableBody img[src='x']")).to_have_count(0) + expect(page.locator("#workspaceMembersTableBody svg")).to_have_count(0) + + flags = page.evaluate( + """() => ({ + name: !!window.__controlCenterWorkspaceMemberNameXss, + email: !!window.__controlCenterWorkspaceMemberEmailXss, + })""" + ) + assert flags == {"name": False, "email": False} + finally: + context.close() + browser.close() \ No newline at end of file diff --git a/ui_tests/test_document_share_modal_escaping.py b/ui_tests/test_document_share_modal_escaping.py new file mode 100644 index 000000000..bb8148900 --- /dev/null +++ b/ui_tests/test_document_share_modal_escaping.py @@ -0,0 +1,253 @@ +# test_document_share_modal_escaping.py +""" +UI test for personal and group document share modal escaping. +Version: 0.241.020 +Implemented in: 0.241.020 + +This test ensures malicious names, descriptions, emails, and toast messages +render as inert text in the personal and group document sharing modals. +""" + +import json +import os +from pathlib import Path + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") +SKIP_RESPONSE_CODES = {401, 403, 404} + + +def _fulfill_json(route, payload, status=200): + route.fulfill( + status=status, + content_type="application/json", + body=json.dumps(payload), + ) + + +def _require_ui_env(): + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip("Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.") + + +def _new_page(playwright): + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + return browser, context, page + + +def _assert_ok_or_skip(response, route_path: str) -> None: + assert response is not None, f"Expected a navigation response when loading {route_path}." + if response.status in SKIP_RESPONSE_CODES: + pytest.skip(f"{route_path} returned HTTP {response.status} in this environment.") + assert response.ok, f"Expected {route_path} to load successfully, got HTTP {response.status}." + + +@pytest.mark.ui +def test_workspace_share_modal_escapes_malicious_names_and_toasts(playwright): + """Validate the personal workspace share modal renders malicious values inertly.""" + _require_ui_env() + + browser, context, page = _new_page(playwright) + + shared_name = '' + shared_email = '@example.com' + search_name = '' + search_email = '@example.com' + + try: + page.route( + "**/api/documents/doc-1/shared-users", + lambda route: _fulfill_json( + route, + { + "shared_users": [ + { + "id": "shared-user-1", + "displayName": shared_name, + "email": shared_email, + } + ] + }, + ), + ) + page.route( + "**/api/userSearch*", + lambda route: _fulfill_json( + route, + [ + { + "id": "search-user-1", + "displayName": search_name, + "email": search_email, + } + ], + ), + ) + page.route( + "**/api/documents/doc-1/share", + lambda route: _fulfill_json(route, {"success": True}), + ) + + response = page.goto(f"{BASE_URL}/workspace", wait_until="networkidle") + _assert_ok_or_skip(response, "/workspace") + page.wait_for_function("() => typeof window.shareDocument === 'function'") + + page.evaluate( + """() => { + window.__workspaceSharedNameXss = false; + window.__workspaceSharedEmailXss = false; + window.__workspaceSearchNameXss = false; + window.__workspaceSearchEmailXss = false; + window.shareDocument('doc-1', 'Escaping Test Document.txt'); + }""" + ) + + expect(page.locator("#shareDocumentModal")).to_be_visible() + expect(page.locator("#sharedUsersList")).to_contain_text(shared_name) + expect(page.locator("#sharedUsersList")).to_contain_text(shared_email) + expect(page.locator("#sharedUsersList img[src='x']")).to_have_count(0) + expect(page.locator("#sharedUsersList svg")).to_have_count(0) + + page.locator("#userSearchTerm").fill("malicious") + page.locator("#searchUsersBtn").click() + + expect(page.locator("#userSearchResultsTable tbody")).to_contain_text(search_name) + expect(page.locator("#userSearchResultsTable tbody")).to_contain_text(search_email) + expect(page.locator("#userSearchResultsTable tbody img[src='x']")).to_have_count(0) + expect(page.locator("#userSearchResultsTable tbody svg")).to_have_count(0) + + page.locator("#userSearchResultsTable tbody .user-search-add-btn").click() + + expect(page.locator("#toastContainer")).to_contain_text(f"Document shared with {search_name}") + expect(page.locator("#toastContainer img[src='x']")).to_have_count(0) + expect(page.locator("#toastContainer svg")).to_have_count(0) + + flags = page.evaluate( + """() => ({ + sharedName: !!window.__workspaceSharedNameXss, + sharedEmail: !!window.__workspaceSharedEmailXss, + searchName: !!window.__workspaceSearchNameXss, + searchEmail: !!window.__workspaceSearchEmailXss, + })""" + ) + assert flags == { + "sharedName": False, + "sharedEmail": False, + "searchName": False, + "searchEmail": False, + } + finally: + context.close() + browser.close() + + +@pytest.mark.ui +def test_group_share_modal_escapes_malicious_names_descriptions_and_toasts(playwright): + """Validate the group workspace share modal renders malicious values inertly.""" + _require_ui_env() + + browser, context, page = _new_page(playwright) + + shared_group_name = '' + shared_group_description = ' shared description' + search_group_name = '' + search_group_description = ' search description' + + try: + page.route( + "**/api/group_documents/group-doc-1/shared-groups", + lambda route: _fulfill_json( + route, + { + "shared_groups": [ + { + "id": "shared-group-1", + "name": shared_group_name, + "description": shared_group_description, + } + ] + }, + ), + ) + page.route( + "**/api/groups/discover*", + lambda route: _fulfill_json( + route, + [ + { + "id": "search-group-1", + "name": search_group_name, + "description": search_group_description, + } + ], + ), + ) + page.route( + "**/api/group_documents/group-doc-1/share-with-group", + lambda route: _fulfill_json(route, {"success": True}), + ) + + response = page.goto(f"{BASE_URL}/group_workspaces", wait_until="networkidle") + _assert_ok_or_skip(response, "/group_workspaces") + page.wait_for_function("() => typeof window.shareGroupDocument === 'function'") + + page.evaluate( + """() => { + window.__sharedGroupNameXss = false; + window.__sharedGroupDescriptionXss = false; + window.__searchGroupNameXss = false; + window.__searchGroupDescriptionXss = false; + window.shareGroupDocument('group-doc-1', 'Escaping Group Document.txt'); + }""" + ) + + expect(page.locator("#groupShareDocumentModal")).to_be_visible() + expect(page.locator("#sharedGroupsList")).to_contain_text(shared_group_name) + expect(page.locator("#sharedGroupsList")).to_contain_text(shared_group_description) + expect(page.locator("#sharedGroupsList img[src='x']")).to_have_count(0) + expect(page.locator("#sharedGroupsList svg")).to_have_count(0) + + page.locator("#groupSearchTerm").fill("malicious") + page.locator("#searchGroupsBtn").click() + + expect(page.locator("#groupSearchResultsTable tbody")).to_contain_text(search_group_name) + expect(page.locator("#groupSearchResultsTable tbody")).to_contain_text(search_group_description) + expect(page.locator("#groupSearchResultsTable tbody img[src='x']")).to_have_count(0) + expect(page.locator("#groupSearchResultsTable tbody svg")).to_have_count(0) + + page.locator("#groupSearchResultsTable tbody .group-search-add-btn").click() + + expect(page.locator("#toastContainer")).to_contain_text( + f"Document shared with group: {search_group_name}" + ) + expect(page.locator("#toastContainer img[src='x']")).to_have_count(0) + expect(page.locator("#toastContainer svg")).to_have_count(0) + + flags = page.evaluate( + """() => ({ + sharedName: !!window.__sharedGroupNameXss, + sharedDescription: !!window.__sharedGroupDescriptionXss, + searchName: !!window.__searchGroupNameXss, + searchDescription: !!window.__searchGroupDescriptionXss, + })""" + ) + assert flags == { + "sharedName": False, + "sharedDescription": False, + "searchName": False, + "searchDescription": False, + } + finally: + context.close() + browser.close() \ No newline at end of file diff --git a/ui_tests/test_group_workspace_member_rendering_escaping.py b/ui_tests/test_group_workspace_member_rendering_escaping.py new file mode 100644 index 000000000..eeff499b5 --- /dev/null +++ b/ui_tests/test_group_workspace_member_rendering_escaping.py @@ -0,0 +1,191 @@ +# test_group_workspace_member_rendering_escaping.py +""" +UI test for group workspace member rendering escaping. +Version: 0.241.017 +Implemented in: 0.241.017 + +This test ensures malicious member, request, and user-search display names and +emails render as inert text in the group workspace member-management UI. +""" + +import json +import os +from pathlib import Path + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") + + +def _fulfill_json(route, payload, status=200): + route.fulfill( + status=status, + content_type="application/json", + body=json.dumps(payload), + ) + + +def _require_ui_env(): + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip("Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.") + + +@pytest.mark.ui +def test_group_workspace_member_management_escapes_malicious_fields(playwright): + """Validate group workspace member-management views render malicious fields inertly.""" + _require_ui_env() + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + + member_name = '' + member_email = '@example.com' + request_name = '' + request_email = '@example.com' + search_name = '' + search_email = '@example.com' + + try: + page.route( + "**/api/groups?page_size=1000", + lambda route: _fulfill_json( + route, + { + "groups": [ + { + "id": "group-alpha", + "name": "Escaping Group", + "isActive": True, + "userRole": "Owner", + "status": "active", + } + ] + }, + ), + ) + page.route( + "**/api/group_documents?*", + lambda route: _fulfill_json( + route, + { + "documents": [], + "page": 1, + "page_size": 10, + "total_count": 0, + }, + ), + ) + page.route( + "**/api/group_documents/tags?*", + lambda route: _fulfill_json(route, {"tags": []}), + ) + page.route( + "**/api/groups/group-alpha/members*", + lambda route: _fulfill_json( + route, + [ + { + "userId": "member-1", + "displayName": member_name, + "email": member_email, + "role": "Admin", + } + ], + ), + ) + page.route( + "**/api/groups/group-alpha/requests*", + lambda route: _fulfill_json( + route, + [ + { + "userId": "request-1", + "displayName": request_name, + "email": request_email, + } + ], + ), + ) + page.route( + "**/api/userSearch*", + lambda route: _fulfill_json( + route, + [ + { + "id": "search-1", + "displayName": search_name, + "email": search_email, + } + ], + ), + ) + + response = page.goto(f"{BASE_URL}/group_workspaces", wait_until="networkidle") + + assert response is not None, "Expected a navigation response when loading /group_workspaces." + assert response.ok, f"Expected /group_workspaces to load successfully, got HTTP {response.status}." + + page.evaluate( + """() => { + if (typeof loadMembers === 'function') { + loadMembers(); + } + if (typeof loadPendingRequests === 'function') { + loadPendingRequests(); + } + }""" + ) + + expect(page.locator("#membersTable tbody")).to_contain_text(member_name) + expect(page.locator("#membersTable tbody")).to_contain_text(member_email) + expect(page.locator("#membersTable tbody img[src='x']")).to_have_count(0) + expect(page.locator("#membersTable tbody svg")).to_have_count(0) + + expect(page.locator("#pendingRequestsTable tbody")).to_contain_text(request_name) + expect(page.locator("#pendingRequestsTable tbody")).to_contain_text(request_email) + expect(page.locator("#pendingRequestsTable tbody img[src='x']")).to_have_count(0) + expect(page.locator("#pendingRequestsTable tbody svg")).to_have_count(0) + + page.locator("#addMemberBtn").click() + page.locator("#userSearchTerm").fill("search") + page.locator("#searchUsersBtn").click() + + expect(page.locator("#userSearchResultsTable tbody")).to_contain_text(search_name) + expect(page.locator("#userSearchResultsTable tbody")).to_contain_text(search_email) + expect(page.locator("#userSearchResultsTable tbody img[src='x']")).to_have_count(0) + expect(page.locator("#userSearchResultsTable tbody svg")).to_have_count(0) + + page.locator("#userSearchResultsTable tbody .select-user-btn").click() + expect(page.locator("#newUserDisplayName")).to_have_value(search_name) + expect(page.locator("#newUserEmail")).to_have_value(search_email) + + flags = page.evaluate( + """() => ({ + memberName: !!window.__groupMemberNameXss, + memberEmail: !!window.__groupMemberEmailXss, + requestName: !!window.__groupRequestNameXss, + requestEmail: !!window.__groupRequestEmailXss, + searchName: !!window.__groupSearchNameXss, + searchEmail: !!window.__groupSearchEmailXss, + })""" + ) + assert flags == { + "memberName": False, + "memberEmail": False, + "requestName": False, + "requestEmail": False, + "searchName": False, + "searchEmail": False, + } + finally: + context.close() + browser.close() \ No newline at end of file diff --git a/ui_tests/test_public_workspace_member_rendering_escaping.py b/ui_tests/test_public_workspace_member_rendering_escaping.py new file mode 100644 index 000000000..685692c23 --- /dev/null +++ b/ui_tests/test_public_workspace_member_rendering_escaping.py @@ -0,0 +1,182 @@ +# test_public_workspace_member_rendering_escaping.py +""" +UI test for public workspace member rendering escaping. +Version: 0.241.017 +Implemented in: 0.241.017 + +This test ensures malicious member, request, and user-search display names and +emails render as inert text in the public workspace member-management UI. +""" + +import json +import os +from pathlib import Path +from urllib.parse import urlparse + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") +SKIP_RESPONSE_CODES = {401, 403, 404} + + +def _fulfill_json(route, payload, status=200): + route.fulfill( + status=status, + content_type="application/json", + body=json.dumps(payload), + ) + + +def _require_ui_env(): + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip("Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.") + + +@pytest.mark.ui +def test_public_workspace_member_management_escapes_malicious_fields(playwright): + """Validate public workspace member-management views render malicious fields inertly.""" + _require_ui_env() + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + + member_name = '' + member_email = '@example.com' + request_name = '' + request_email = '@example.com' + search_name = '' + search_email = '@example.com' + + def handle_public_workspace_api(route): + path = urlparse(route.request.url).path + + if path == "/api/public_workspaces/public-1": + _fulfill_json( + route, + { + "id": "public-1", + "name": "Escaping Workspace", + "description": "Regression coverage", + "owner": { + "displayName": "Owner User", + "email": "owner@example.com", + }, + "status": "active", + "heroColor": "#225577", + "userRole": "Owner", + "isMember": True, + }, + ) + return + + if path == "/api/public_workspaces/public-1/members": + _fulfill_json( + route, + [ + { + "userId": "member-1", + "displayName": member_name, + "email": member_email, + "role": "Admin", + } + ], + ) + return + + if path == "/api/public_workspaces/public-1/requests": + _fulfill_json( + route, + [ + { + "userId": "request-1", + "displayName": request_name, + "email": request_email, + } + ], + ) + return + + route.continue_() + + try: + page.route("**/api/public_workspaces/public-1*", handle_public_workspace_api) + page.route( + "**/api/userSearch*", + lambda route: _fulfill_json( + route, + [ + { + "id": "search-1", + "displayName": search_name, + "email": search_email, + } + ], + ), + ) + + response = page.goto(f"{BASE_URL}/public_workspaces/public-1", wait_until="networkidle") + assert response is not None, "Expected a navigation response when loading /public_workspaces/public-1." + + if response.status in SKIP_RESPONSE_CODES: + pytest.skip( + f"/public_workspaces/public-1 returned HTTP {response.status} in this environment." + ) + + assert response.ok, ( + "Expected /public_workspaces/public-1 to load successfully, " + f"got HTTP {response.status}." + ) + + expect(page.locator("#membersTable tbody")).to_contain_text(member_name) + expect(page.locator("#membersTable tbody")).to_contain_text(member_email) + expect(page.locator("#membersTable tbody img[src='x']")).to_have_count(0) + expect(page.locator("#membersTable tbody svg")).to_have_count(0) + + expect(page.locator("#pendingRequestsTable tbody")).to_contain_text(request_name) + expect(page.locator("#pendingRequestsTable tbody")).to_contain_text(request_email) + expect(page.locator("#pendingRequestsTable tbody img[src='x']")).to_have_count(0) + expect(page.locator("#pendingRequestsTable tbody svg")).to_have_count(0) + + page.locator("#addMemberBtn").click() + page.locator("#userSearchTerm").fill("search") + page.locator("#searchUsersBtn").click() + + expect(page.locator("#userSearchResultsTable tbody")).to_contain_text(search_name) + expect(page.locator("#userSearchResultsTable tbody")).to_contain_text(search_email) + expect(page.locator("#userSearchResultsTable tbody img[src='x']")).to_have_count(0) + expect(page.locator("#userSearchResultsTable tbody svg")).to_have_count(0) + + page.locator("#userSearchResultsTable tbody .select-user-btn").click() + expect(page.locator("#newUserDisplayName")).to_have_value(search_name) + expect(page.locator("#newUserEmail")).to_have_value(search_email) + + flags = page.evaluate( + """() => ({ + memberName: !!window.__publicMemberNameXss, + memberEmail: !!window.__publicMemberEmailXss, + requestName: !!window.__publicRequestNameXss, + requestEmail: !!window.__publicRequestEmailXss, + searchName: !!window.__publicSearchNameXss, + searchEmail: !!window.__publicSearchEmailXss, + })""" + ) + assert flags == { + "memberName": False, + "memberEmail": False, + "requestName": False, + "requestEmail": False, + "searchName": False, + "searchEmail": False, + } + finally: + context.close() + browser.close() \ No newline at end of file diff --git a/ui_tests/test_public_workspace_projection_non_member_ui.py b/ui_tests/test_public_workspace_projection_non_member_ui.py new file mode 100644 index 000000000..9d35d88bd --- /dev/null +++ b/ui_tests/test_public_workspace_projection_non_member_ui.py @@ -0,0 +1,158 @@ +# test_public_workspace_projection_non_member_ui.py +""" +UI test for public workspace projection hardening. +Version: 0.241.013 +Implemented in: 0.241.013 + +This test ensures the public directory renders owner display names without +falling back to owner email addresses, and that non-members who open the +workspace details page see the public summary view without member-only tabs. +""" + +import json +import os +from pathlib import Path +from urllib.parse import urlparse + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") +SKIP_RESPONSE_CODES = {401, 403, 404} + + +def _fulfill_json(route, payload, status=200): + route.fulfill( + status=status, + content_type="application/json", + body=json.dumps(payload), + ) + + +def _handle_public_workspace_projection_api(route): + request = route.request + parsed_url = urlparse(request.url) + path = parsed_url.path + + if path == "/api/user/settings": + _fulfill_json( + route, + { + "settings": { + "publicDirectorySettings": { + "public-1": True, + } + } + }, + ) + return + + if path == "/api/public_workspaces/discover": + _fulfill_json( + route, + [ + { + "id": "public-1", + "name": "Projection Workspace", + "description": "Directory summary only", + } + ], + ) + return + + if path == "/api/public_workspaces/public-1": + _fulfill_json( + route, + { + "id": "public-1", + "name": "Projection Workspace", + "description": "Directory summary only", + "owner": { + "displayName": "Directory Owner", + }, + "status": "active", + "heroColor": "#224466", + "userRole": None, + "isMember": False, + }, + ) + return + + if path == "/api/public_workspaces/public-1/fileCount": + _fulfill_json(route, {"fileCount": 7}) + return + + if path == "/api/public_workspaces/public-1/promptCount": + _fulfill_json(route, {"promptCount": 3}) + return + + route.continue_() + + +def _require_ui_environment(): + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip( + "Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file." + ) + + +@pytest.mark.ui +def test_public_directory_owner_display_and_non_member_workspace_fallback(playwright): + """Validate the public directory and non-member workspace details view use the safe payload.""" + _require_ui_environment() + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + + page.route("**/api/user/settings", _handle_public_workspace_projection_api) + page.route("**/api/public_workspaces*", _handle_public_workspace_projection_api) + + try: + directory_response = page.goto(f"{BASE_URL}/public_directory", wait_until="networkidle") + assert directory_response is not None, "Expected a navigation response when loading /public_directory." + + if directory_response.status in SKIP_RESPONSE_CODES: + pytest.skip(f"/public_directory returned HTTP {directory_response.status} in this environment.") + + assert directory_response.ok, ( + f"Expected /public_directory to load successfully, got HTTP {directory_response.status}." + ) + + expect(page.locator("#public-directory-table tbody")).to_contain_text("Projection Workspace") + page.locator('button.expand-btn[data-id="public-1"]').click() + expect(page.locator("#owner-public-1")).to_have_text("Directory Owner") + assert "@" not in page.locator("#owner-public-1").inner_text() + + manage_response = page.goto(f"{BASE_URL}/public_workspaces/public-1", wait_until="networkidle") + assert manage_response is not None, "Expected a navigation response when loading /public_workspaces/public-1." + + if manage_response.status in SKIP_RESPONSE_CODES: + pytest.skip( + f"/public_workspaces/public-1 returned HTTP {manage_response.status} in this environment." + ) + + assert manage_response.ok, ( + "Expected /public_workspaces/public-1 to load successfully, " + f"got HTTP {manage_response.status}." + ) + + expect(page.locator("#workspaceHeroName")).to_have_text("Projection Workspace") + expect(page.locator("#workspaceOwnerName")).to_have_text("Directory Owner") + expect(page.locator("#workspace-access-alert")).to_be_visible() + expect(page.locator("#workspace-access-alert")).to_contain_text( + "Membership, statistics, and workspace settings are only available to workspace members." + ) + expect(page.locator("#membership-tab")).to_be_hidden() + expect(page.locator("#stats-tab")).to_be_hidden() + expect(page.locator("#settings-tab-item")).to_be_hidden() + finally: + context.close() + browser.close() \ No newline at end of file diff --git a/ui_tests/test_public_workspace_tag_color_rendering.py b/ui_tests/test_public_workspace_tag_color_rendering.py new file mode 100644 index 000000000..91bb295f0 --- /dev/null +++ b/ui_tests/test_public_workspace_tag_color_rendering.py @@ -0,0 +1,169 @@ +# test_public_workspace_tag_color_rendering.py +""" +UI test for public workspace tag color XSS hardening. +Version: 0.241.022 +Implemented in: 0.241.022 + +This test ensures malicious tag color payloads remain inert in the public +workspace grid, tag-management rows, tag-selection rows, and selected-tag chips. +""" + +import json +import os +from pathlib import Path + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") +SKIP_RESPONSE_CODES = {401, 403, 404} +TAG_NAME = "reviewed" +MALICIOUS_COLOR = ( + '#ff0000"; onclick="window.__publicTagColorXss = true" ' + 'onmouseover="window.__publicTagColorXss = true' +) + + +def _fulfill_json(route, payload, status=200): + route.fulfill( + status=status, + content_type="application/json", + body=json.dumps(payload), + ) + + +def _require_ui_env(): + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip( + "Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file." + ) + + +@pytest.mark.ui +def test_public_workspace_tag_color_payloads_render_inertly(playwright): + """Validate malicious tag color payloads do not become live browser attributes.""" + _require_ui_env() + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + + page.add_init_script( + """() => { + localStorage.setItem('publicWorkspaceViewPreference', 'grid'); + window.__publicTagColorXss = false; + }""" + ) + + documents_payload = { + "documents": [ + { + "id": "doc-1", + "file_name": "reviewed-spec.pdf", + "title": "Reviewed Spec", + "tags": [TAG_NAME], + "status": "Complete", + "percentage_complete": 100, + "document_classification": "Public", + "classification": "Public", + "version": "1", + "authors": "Owner User", + "number_of_pages": 3, + "enhanced_citations": False, + "publication_date": "2024-01-01", + "keywords": "reviewed", + "abstract": "Regression coverage", + } + ], + "page": 1, + "page_size": 1000, + "total_count": 1, + } + tag_payload = [{"name": TAG_NAME, "color": MALICIOUS_COLOR, "count": 1}] + + try: + page.route( + "**/api/public_documents*", + lambda route: _fulfill_json(route, documents_payload), + ) + page.route( + "**/api/public_workspace_documents/tags*", + lambda route: _fulfill_json(route, tag_payload), + ) + + response = page.goto( + f"{BASE_URL}/public_workspaces/public-1", + wait_until="networkidle", + ) + assert response is not None, ( + "Expected a navigation response when loading /public_workspaces/public-1." + ) + + if response.status in SKIP_RESPONSE_CODES: + pytest.skip( + f"/public_workspaces/public-1 returned HTTP {response.status} in this environment." + ) + + assert response.ok, ( + "Expected /public_workspaces/public-1 to load successfully, " + f"got HTTP {response.status}." + ) + + expect(page.locator("#public-tag-folders-container")).to_contain_text(TAG_NAME) + page.locator("#public-tag-folders-container .tag-folder-icon i").first.hover() + + audit = page.evaluate( + """(tagName) => { + refreshPublicTagManagementTable(); + renderPublicTagSelectionList(); + window.eval(`publicDocSelectedTags.add(${JSON.stringify(tagName)});`); + updatePublicDocTagsDisplay(); + + const selectors = [ + '#public-tag-folders-container', + '#public-existing-tags-tbody', + '#public-tag-selection-list', + '#public-doc-selected-tags-container', + ]; + + const attributes = selectors.flatMap((selector) => + Array.from(document.querySelectorAll(`${selector} *`)).flatMap((node) => + Array.from(node.attributes) + .filter((attr) => attr.name.toLowerCase().startsWith('on') || String(attr.value).includes('__publicTagColorXss')) + .map((attr) => ({ + selector, + tagName: node.tagName, + attribute: attr.name, + value: attr.value, + })) + ) + ); + + return { + attributes, + selectedTagsText: document.getElementById('public-doc-selected-tags-container')?.textContent || '', + managementText: document.getElementById('public-existing-tags-tbody')?.textContent || '', + selectionText: document.getElementById('public-tag-selection-list')?.textContent || '', + gridText: document.getElementById('public-tag-folders-container')?.textContent || '', + xssTriggered: !!window.__publicTagColorXss, + }; + }""", + TAG_NAME, + ) + + assert TAG_NAME in audit["gridText"] + assert TAG_NAME in audit["managementText"] + assert TAG_NAME in audit["selectionText"] + assert TAG_NAME in audit["selectedTagsText"] + assert audit["attributes"] == [] + assert audit["xssTriggered"] is False + finally: + context.close() + browser.close() \ No newline at end of file diff --git a/ui_tests/test_uploaded_file_preview_escaping.py b/ui_tests/test_uploaded_file_preview_escaping.py new file mode 100644 index 000000000..b5ebdea28 --- /dev/null +++ b/ui_tests/test_uploaded_file_preview_escaping.py @@ -0,0 +1,143 @@ +# test_uploaded_file_preview_escaping.py +""" +UI test for uploaded file preview body escaping. +Version: 0.241.022 +Implemented in: 0.241.022 + +This test ensures uploaded file preview content renders attacker-controlled +plain text, CSV cells, and legacy HTML table payloads as inert text. +""" + +import json +import os +from pathlib import Path + +import pytest +from playwright.sync_api import expect + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") +SKIP_RESPONSE_CODES = {401, 403, 404} + +def _fulfill_json(route, payload, status=200): + route.fulfill( + status=status, + content_type="application/json", + body=json.dumps(payload), + ) + +def _show_file_preview(page, file_content, filename, is_table, flag_name): + page.evaluate( + """ + async ({ fileContent, filename, isTable, flagName }) => { + window[flagName] = false; + + const fileActionsModule = await import('/static/js/chat/chat-input-actions.js'); + fileActionsModule.showFileContentPopup( + fileContent, + filename, + isTable, + 'database', + null, + null, + ); + } + """, + { + "fileContent": file_content, + "filename": filename, + "isTable": is_table, + "flagName": flag_name, + }, + ) + +@pytest.mark.ui +def test_uploaded_file_preview_renders_untrusted_content_as_inert_text(playwright): + """Validate uploaded file previews do not turn attacker-controlled content into DOM.""" + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip("Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.") + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + + plain_text_payload = ' plain text body' + csv_payload = 'column_a,column_b\n,safe' + legacy_table_payload = ( + '
    ' + '
    ' + ) + + page.route( + "**/api/user/settings", + lambda route: _fulfill_json(route, {"selected_agent": None, "settings": {"enable_agents": False}}), + ) + page.route("**/api/get_conversations", lambda route: _fulfill_json(route, {"conversations": []})) + + try: + response = page.goto(f"{BASE_URL}/chats", wait_until="domcontentloaded") + assert response is not None, "Expected a navigation response when loading /chats." + + if response.status in SKIP_RESPONSE_CODES: + pytest.skip(f"/chats returned HTTP {response.status} in this environment.") + + assert response.ok, f"Expected /chats to load successfully, got HTTP {response.status}." + page.wait_for_selector("#chatbox") + + _show_file_preview( + page, + plain_text_payload, + "plain-preview.txt", + False, + "__filePreviewPlainXss", + ) + expect(page.locator("#file-modal")).to_be_visible() + expect(page.locator("#file-content pre")).to_have_text(plain_text_payload) + expect(page.locator("#file-modal img[src='x']")).to_have_count(0) + expect(page.locator("#file-modal svg")).to_have_count(0) + expect(page.locator("#file-modal script")).to_have_count(0) + + _show_file_preview( + page, + csv_payload, + "table-preview.csv", + True, + "__filePreviewCsvXss", + ) + expect(page.locator("#file-content table")).to_be_visible() + expect(page.locator("#file-content")).to_contain_text( + '' + ) + expect(page.locator("#file-modal img[src='x']")).to_have_count(0) + expect(page.locator("#file-modal svg")).to_have_count(0) + expect(page.locator("#file-modal script")).to_have_count(0) + + _show_file_preview( + page, + legacy_table_payload, + "legacy-table-preview.html", + True, + "__filePreviewLegacyXss", + ) + expect(page.locator("#file-content pre")).to_contain_text("
    ({ + plain: !!window.__filePreviewPlainXss, + csv: !!window.__filePreviewCsvXss, + legacy: !!window.__filePreviewLegacyXss, + })""" + ) + assert flags == {"plain": False, "csv": False, "legacy": False} + finally: + context.close() + browser.close() \ No newline at end of file diff --git a/ui_tests/test_web_search_notice_copy.py b/ui_tests/test_web_search_notice_copy.py new file mode 100644 index 000000000..7630d418f --- /dev/null +++ b/ui_tests/test_web_search_notice_copy.py @@ -0,0 +1,56 @@ +# test_web_search_notice_copy.py +""" +UI test for web search disclosure copy. +Version: 0.241.008 +Implemented in: 0.241.008 + +This test ensures the admin settings page shows the updated current-message-only +web-search disclosure copy for the user notice placeholder and the admin +consent modal warning text. +""" + +import os +import re +from pathlib import Path + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv('SIMPLECHAT_UI_BASE_URL', '').rstrip('/') +STORAGE_STATE = os.getenv('SIMPLECHAT_UI_STORAGE_STATE', '') + + +@pytest.mark.ui +def test_admin_settings_shows_current_message_only_web_search_notice(playwright): + """Validate the admin-facing web-search disclosure copy.""" + if not BASE_URL: + pytest.skip('Set SIMPLECHAT_UI_BASE_URL to run this UI test.') + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip('Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.') + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={'width': 1440, 'height': 900}, + ) + page = context.new_page() + + try: + page.goto(f'{BASE_URL}/admin/settings', wait_until='networkidle') + + notice_textarea = page.locator('#web_search_user_notice_text') + expect(notice_textarea).to_have_count(1) + expect(notice_textarea).to_have_attribute( + 'placeholder', + re.compile(r'Your current message will be sent to Microsoft Bing for web search', re.IGNORECASE), + ) + + consent_modal = page.locator('#web-search-consent-modal') + expect(consent_modal).to_have_count(1) + consent_text = consent_modal.text_content() or '' + assert 'Only the user\'s current message is sent for web search.' in consent_text + assert 'Users should avoid including sensitive content in any message that uses web search.' in consent_text + finally: + context.close() + browser.close() \ No newline at end of file From 8f877a41c48e45736abc94751b73083b2364458f Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Tue, 5 May 2026 15:43:31 -0400 Subject: [PATCH 2/2] Update manage_group.js --- .../static/js/group/manage_group.js | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/application/single_app/static/js/group/manage_group.js b/application/single_app/static/js/group/manage_group.js index b7520e044..44174d41f 100644 --- a/application/single_app/static/js/group/manage_group.js +++ b/application/single_app/static/js/group/manage_group.js @@ -69,9 +69,6 @@ $(document).ready(function () { // Add event delegation for remove member button $(document).on("click", ".remove-member-btn", function () { - const safeName = escapeHtml(member.name || ""); - const safeEmail = escapeHtml(member.email || ""); - membersList += `
  • • ${safeName} (${safeEmail})
  • `; const userId = $(this).data("user-id"); removeMember(userId); }); @@ -79,14 +76,17 @@ $(document).ready(function () { // Add event delegation for change role button $(document).on("click", ".change-role-btn", function () { const userId = $(this).data("user-id"); - const safeUserId = escapeHtml(m.userId || ""); - const safeDisplayName = escapeHtml(m.displayName || "(no name)"); - const safeEmail = escapeHtml(m.email || ""); - options += ``; - }); + const currentRole = $(this).data("user-role"); + openChangeRoleModal(userId, currentRole); + $("#changeRoleModal").modal("show"); + }); + + $(document).on("click", ".approve-request-btn", function () { const requestId = $(this).data("request-id"); approveRequest(requestId); - }); + }); + + $(document).on("click", ".reject-request-btn", function () { const requestId = $(this).data("request-id"); rejectRequest(requestId); }); @@ -126,9 +126,7 @@ $(document).ready(function () { showRawActivity($(this).data("activity")); } }); - const safeName = escapeHtml(member.name || ""); - const safeEmail = escapeHtml(member.email || ""); - membersList += `
  • • ${safeName} (${safeEmail})
  • `; + // Retention policy settings $("#saveRetentionBtn").on("click", function () { saveGroupRetentionSettings(); @@ -209,6 +207,8 @@ $(document).ready(function () { $("#newOwnerSelect").html(options); $("#transferOwnershipModal").modal("show"); }); + }); + $("#transferOwnershipForm").on("submit", function (e) { e.preventDefault(); const newOwnerId = $("#newOwnerSelect").val();