NocoDB: Hidden LTAR Column Exposure in Public Shared-View Relation Endpoints
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
1Patches
193adcf0cdc77Merge pull request #13857 from nocodb/nc-fix/side-panel
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
3News mentions
1- Nocodb: 14 Vulnerabilities Disclosed Together, Including XSS and SQL InjectionVypr Intelligence · Jun 5, 2026