NocoDB: SQL Injection via Column Title in Bulk GroupBy
Description
Authenticated users with column-create permission can inject SQL via column title in NocoDB's bulk groupBy endpoint, leading to database read access.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Authenticated users with column-create permission can inject SQL via column title in NocoDB's bulk groupBy endpoint, leading to database read access.
Vulnerability
The bulk groupBy endpoint in group-by.ts constructs SQL aggregations using knex.raw() that directly interpolate the column_name from the request without proper escaping. Column lookup in data-table.service.ts matches on both the sanitized column_name field and the free-text title, allowing a column title containing a SQL fragment to bypass the public endpoint's column allowlist and reach the query builder unescaped. This affects NocoDB versions prior to 2026.05.1 [1][3][4].
Exploitation
An attacker must have an authenticated session with permission to create or rename columns. The attacker sets a column's title to a crafted SQL fragment, then triggers the bulk groupBy endpoint with that column's name. The SQL fragment is interpolated into the database query, enabling injection [3][4].
Impact
Successful exploitation results in SQL injection against the connected database, granting read access to any expression the attacker can place in a column title. The attacker can read arbitrary data from the database but does not gain write or execute privileges beyond what the database connection allows [3][4].
Mitigation
The vulnerability is fixed in NocoDB version 2026.05.1 [1]. Users should upgrade to this version or later. No workarounds are documented; restricting column-create permissions to trusted users reduces risk [3][4].
AI Insight generated on Jun 11, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
nocodbnpm | < 2026.05.1 | 2026.05.1 |
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 application interpolates user-supplied column titles directly into SQL queries without proper sanitization."
Attack vector
An authenticated user with column-create permissions can exploit this vulnerability. By setting a column's title to a SQL fragment, the input bypasses existing column allowlists. This malicious title is then directly interpolated into database-specific `knex.raw()` aggregations within the bulk groupBy endpoint, leading to SQL injection. The vulnerability is present in the `group-by.ts` file, and the column lookup logic in `data-table.service.ts` allows the title to reach the query builder unescaped [ref_id=1].
Affected code
The vulnerability resides in the bulk groupBy path within `group-by.ts`. Specifically, three database-specific `knex.raw()` aggregations are constructed by directly interpolating the request's `column_name`. The column lookup mechanism in `data-table.service.ts` incorrectly matches on both the sanitized `column_name` and the free-text `title`, allowing the SQL fragment in the title to reach the query builder unescaped [ref_id=1].
What the fix does
The advisory does not provide details on a specific patch or remediation steps. It only announces a new release version, 2026.05.1, which may contain fixes for various issues, but does not explicitly mention this SQL injection vulnerability or its resolution [ref_id=1]. Therefore, the exact changes made to address this vulnerability are not specified.
Preconditions
- authThe attacker must be an authenticated user.
- authThe attacker must have column-create permission.
- inputThe attacker must be able to set a column's title to a SQL fragment.
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