VYPR
Medium severity6.9GHSA Advisory· Published Jun 5, 2026· Updated Jun 5, 2026

NocoDB: Cross-Workspace Integration Use in Connection Test

CVE-2026-47381

Description

Summary

A user in one workspace could exercise another workspace's integration through the testConnection endpoint by supplying its ID, because the integration was fetched in a bypass scope and the caller's permission check matched any base in any workspace.

Details

The connection-test endpoint fetched the integration in RootScopes.BYPASS scope and checked only that the integration was non-private and that the caller held an owner/creator role on any base in any workspace. The permission lookup is now scoped to the integration's workspace by joining on fk_workspace_id, and the controller rejects requests where the integration's workspace differs from the request's workspace.

Impact

Cross-tenant access to integration configuration through the connection-test endpoint, including the ability to drive the resolved database with the other workspace's credentials. Authentication with creator-or-owner role on any base in any workspace was sufficient.

Credit

This issue was reported by @DongyangLyu.

Affected products

1

Patches

1
93adcf0cdc77

Merge pull request #13857 from nocodb/nc-fix/side-panel

https://github.com/nocodb/nocodbRaju UdavaMay 18, 2026Fixed in 2026.05.1via release-tag
6 files changed · +68 9
  • packages/nc-gui/components/smartsheet/expanded-form/DiscardChangesModal.vue+15 1 modified
    @@ -14,13 +14,27 @@ const emits = defineEmits<{
       'save-and-continue': []
     }>()
     
    +// Disable mask click + Escape when the side panel is open — a silent
    +// dismiss there would leave the panel parked on the old (dirty) row while
    +// the grid is on a new row. The legacy modal expanded form keeps defaults.
    +const panelStore = useExpandedFormPanel()
    +
    +const allowDismiss = computed(() => !panelStore?.isOpen.value)
    +
     const onVisibleChange = (v: boolean) => {
       emits('update:modelValue', v)
     }
     </script>
     
     <template>
    -  <NcModal :visible="modelValue" size="xs" height="auto" @update:visible="onVisibleChange">
    +  <NcModal
    +    :visible="modelValue"
    +    size="xs"
    +    height="auto"
    +    :mask-closable="allowDismiss"
    +    :keyboard="allowDismiss"
    +    @update:visible="onVisibleChange"
    +  >
         <div>
           <div class="flex flex-row items-center gap-x-2 text-base font-bold">
             {{ $t('labels.saveChanges') }}
    
  • packages/nc-gui/components/smartsheet/expanded-form/index.vue+1 0 modified
    @@ -997,6 +997,7 @@ export default {
                 :template-mode="props.templateMode"
                 :blueprint-mode="props.blueprintMode"
                 :view="props.view"
    +            :row-id="rowId"
                 @duplicate-start="onDuplicateStart"
                 @after-delete="onAfterDelete"
                 @request-close="onClose(true)"
    
  • packages/nc-gui/components/smartsheet/expanded-form/MoreOptionsMenu.vue+12 6 modified
    @@ -10,13 +10,19 @@ interface Props {
       // Side-panel header is space-constrained — drop the standalone copy-URL
       // button and surface "Copy URL" inside the dropdown instead.
       compact?: boolean
    +  // Parent passes this only when the record is addressable via the current
    +  // route (URL has `?rowId=…`). Used as the gate for Copy URL / Send Record —
    +  // without it those actions point at a record that can't be reached by URL
    +  // (public shared views, add-new flow, linked-record modal-over-modal).
    +  rowId?: string
     }
     
     const props = withDefaults(defineProps<Props>(), {
       isLoading: false,
       templateMode: false,
       blueprintMode: false,
       compact: false,
    +  rowId: undefined,
     })
     
     const emits = defineEmits<{
    @@ -77,9 +83,9 @@ const visibleMoreOptions = computed(() => {
       }
     
       const result = {
    -    reloadRecord: !isEeUI,
    -    copyRecordUrl: !isNew.value && !!primaryKey.value,
    -    sendRecord: appInfo.value.ee && !isNew.value && !!primaryKey.value && !isPublic.value,
    +    reloadRecord: !isEeUI && !!props.rowId,
    +    copyRecordUrl: !isNew.value && !!props.rowId,
    +    sendRecord: appInfo.value.ee && !isNew.value && !!props.rowId && !isPublic.value,
         duplicateRecord: isUIAllowed('dataEdit', baseRoles.value) && !isSqlView.value && !isMobileMode.value,
         deleteRecord: !isNew.value && isUIAllowed('dataEdit', baseRoles.value) && !isSqlView.value,
       }
    @@ -102,7 +108,7 @@ const displayField = computed(() => (meta.value?.columns ?? []).find((c: ColumnT
     const copyRecordUrl = async () => {
       const url = `${dashboardUrl?.value}/${route.params.typeOrId}/${route.params.baseId}/${meta.value?.id}${
         view.value ? `/${view.value.id}` : ''
    -  }?rowId=${primaryKey.value}${route.query?.path ? `&path=${route.query?.path}` : ''}`
    +  }?rowId=${props.rowId}${route.query?.path ? `&path=${route.query?.path}` : ''}`
     
       await copy(encodeURI(url))
     
    @@ -315,10 +321,10 @@ const onConfirmDeleteRowClick = async () => {
       </GeneralDeleteModal>
     
       <DlgSendRecordEmail
    -    v-if="visibleMoreOptions.sendRecord && primaryKey"
    +    v-if="visibleMoreOptions.sendRecord"
         v-model="showSendRecordModal"
         :meta="meta"
         :view="view"
    -    :row-id="primaryKey"
    +    :row-id="props.rowId"
       />
     </template>
    
  • packages/nc-gui/components/smartsheet/grid/canvas/index.vue+24 0 modified
    @@ -2657,6 +2657,30 @@ watch(
       },
     )
     
    +// Side panel sync — when the EFP side panel is open, selecting a row in the
    +// grid swaps the panel to that row. Watches row + path (not column) so moving
    +// across cells in the same row is a no-op. expandForm handles the discard
    +// guard internally (via requestSwitch on the panel store), so unsaved edits
    +// surface the Save / Discard modal before the panel switches rows.
    +// Negative row indices are programmatic resets ("no cell selected") — skip
    +// those so clicking outside a column doesn't close the panel.
    +const expandedFormPanelStore = useExpandedFormPanel()
    +
    +watch([() => activeCell.value.row, () => activeCell.value.path], ([newRow, newPath]) => {
    +  if (!expandedFormPanelStore?.isOpen.value) return
    +  if (ncIsNullOrUndefined(newRow) || newRow! < 0) return
    +
    +  const path = newPath ?? []
    +  const sameRow = newRow === expandedFormPanelStore.activeRowIndex.value
    +  const samePath = path.join('-') === (expandedFormPanelStore.activePath.value ?? []).join('-')
    +  if (sameRow && samePath) return
    +
    +  const row = getDataCache(path)?.cachedRows.value.get(newRow!)
    +  if (!row) return
    +
    +  expandForm(row, undefined, false, path)
    +})
    +
     function selectCell() {
       editEnabled.value = null
       selection.value.startRange({ row: activeCell.value.row, col: activeCell.value.column })
    
  • packages/nc-gui/components/smartsheet/grid/index.vue+13 2 modified
    @@ -281,8 +281,19 @@ function expandForm(row: Row, state?: Record<string, any>, fromToolbar = false,
         rowId &&
         isCanvasRendering.value
       ) {
    -    expandedFormPanelStore.openPanel(row, row.rowMeta?.rowIndex, state, rowId, path)
    -    updateRowIdRoute(rowId, path)
    +    const doExpand = () => {
    +      expandedFormPanelStore.openPanel(row, row.rowMeta?.rowIndex, state, rowId, path)
    +      updateRowIdRoute(rowId, path)
    +    }
    +    // When panel is already open on a different row, route through the
    +    // discard guard so unsaved edits trigger the Save / Discard modal before
    +    // we swap rows. Covers every expand surface (cell click, comment icon,
    +    // Space key, deep-link) since they all funnel through expandForm.
    +    if (expandedFormPanelStore?.isOpen.value && expandedFormPanelStore.activeRowId.value !== rowId) {
    +      expandedFormPanelStore.requestSwitch.value?.(doExpand)
    +    } else {
    +      doExpand()
    +    }
         return
       }
     
    
  • packages/nc-gui/composables/useExpandedFormPanel.ts+3 0 modified
    @@ -22,6 +22,8 @@ const [useProvideExpandedFormPanel, useExpandedFormPanel] = useInjectionState(()
     
       const rowNavigator = ref(null)
     
    +  const requestSwitch = ref<(_perform: () => void) => void>((_perform) => {})
    +
       const openPanel = (_row: Row, _rowIndex?: number, _state?: Record<string, any>, _rowId?: string, _path?: number[]) => {}
       const closePanel = () => {}
       const setFullscreen = (_val: boolean) => {}
    @@ -46,6 +48,7 @@ const [useProvideExpandedFormPanel, useExpandedFormPanel] = useInjectionState(()
         hasPrev,
         hasNext,
         rowNavigator,
    +    requestSwitch,
         openPanel,
         closePanel,
         setFullscreen,
    

Vulnerability mechanics

Root cause

"The integration was fetched in a bypass scope, and the permission check was not scoped to the integration's workspace."

Attack vector

An attacker could exploit this vulnerability by sending a request to the `testConnection` endpoint with the ID of an integration belonging to a different workspace. The endpoint would fetch the integration in a bypass scope and perform a permission check that only required the caller to have an owner or creator role on any base in any workspace. This allowed cross-tenant access to integration configurations and the ability to use another workspace's credentials. [ref_id=1]

Affected code

The vulnerability exists in the `testConnection` endpoint, which fetched the integration in `RootScopes.BYPASS` scope. The permission check was insufficient as it did not properly scope the access to the integration's workspace. The fix involves scoping the permission lookup to the integration's workspace by joining on `fk_workspace_id`.

What the fix does

The patch modifies the permission lookup to be scoped to the integration's workspace by joining on `fk_workspace_id`. The controller now rejects requests where the integration's workspace does not match the request's workspace, preventing cross-tenant access. This ensures that permission checks are correctly applied within the context of the integration's own workspace. [ref_id=1]

Preconditions

  • authThe caller must possess an owner or creator role on any base in any workspace.
  • inputThe request must include the ID of an integration from a different workspace.

Generated on Jun 5, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

1