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

NocoDB: Hidden LTAR Column Exposure in Public Shared-View Relation Endpoints

CVE-2026-47279

Description

Summary

The public shared-view relation endpoints accepted a caller-supplied column ID without verifying that the column was visible in the shared view, so anyone holding a share UUID could read links from any LTAR column on the view's table — including columns the view owner had hidden.

Details

publicMmList, publicHmList, and relDataList already ensured that the requested column belonged to the view's model, but did not check the view-column entry's show flag. All three handlers now also fetch the shared view's column entries and reject the request unless the matching entry has show=true. The four public relation routes covered by the fix are:

- GET /api/v2/public/shared-view/:uuid/rows/:rowId/mm/:columnId (many-to-many) - GET /api/v2/public/shared-view/:uuid/rows/:rowId/hm/:columnId (has-many) - GET /api/v2/public/shared-view/:uuid/rows/:rowId/{ln,om}/:columnId (links / one-to-many — both share the many-to-many handler) - GET /api/v2/public/shared-view/:uuid/nested/:columnId (form/gallery picker)

Impact

Anyone holding a share UUID could enumerate the full set of linked records for any hidden LTAR column on the view's table by calling the relation endpoint directly, even when the same column was correctly omitted from the public /rows response.

Credit

This issue was reported by @leduckhuong.

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 public shared-view relation endpoints did not verify that a caller-supplied column ID was visible in the shared view."

Attack vector

An attacker with a share UUID could access relation endpoints directly. These endpoints accepted a caller-supplied column ID without verifying its visibility in the shared view. This allowed the attacker to read links from any LTAR column on the view's table, including columns hidden by the view owner. The affected routes include `GET /api/v2/public/shared-view/:uuid/rows/:rowId/mm/:columnId`, `GET /api/v2/public/shared-view/:uuid/rows/:rowId/hm/:columnId`, `GET /api/v2/public/shared-view/:uuid/rows/:rowId/{ln,om}/:columnId`, and `GET /api/v2/public/shared-view/:uuid/nested/:columnId` [ref_id=1].

Affected code

The vulnerability exists in the `publicMmList`, `publicHmList`, and `relDataList` handlers, which are invoked by the public relation routes. These handlers previously failed to check the `show` flag on view-column entries, allowing access to hidden columns. The fix involves adding a check for this `show` flag before returning data [ref_id=1].

What the fix does

The patch modifies the `publicMmList`, `publicHmList`, and `relDataList` handlers. Previously, these handlers ensured the requested column belonged to the view's model but did not check the `show` flag in the view-column entry. Now, they also fetch the shared view's column entries and reject requests if the matching entry does not have `show=true`. This prevents unauthorized access to hidden columns by ensuring only visible columns are accessible through these public endpoints [ref_id=1].

Preconditions

  • authThe attacker must possess a valid share UUID for a shared view.
  • inputThe attacker must be able to make direct requests to the relation endpoints, supplying a caller-supplied column ID.

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