VYPR
Moderate severityGHSA Advisory· Published Jun 5, 2026· Updated Jun 5, 2026

NocoDB: SQL Injection via Column Title in Bulk GroupBy

CVE-2026-47384

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.

PackageAffected versionsPatched versions
nocodbnpm
< 2026.05.12026.05.1

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 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

3

News mentions

1