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

NocoDB: OAuth Tokens Persist Through Security Events

CVE-2026-53926

Description

OAuth tokens were not revoked on password change, reset, or recovery, letting attacker-issued grants persist and allowing continued unauthorized access.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

OAuth tokens were not revoked on password change, reset, or recovery, letting attacker-issued grants persist and allowing continued unauthorized access.

Vulnerability

The revokeAllOAuthTokensByUser function in the NocoDB users service was an empty stub, failing to revoke OAuth access and refresh tokens when passwordChange, passwordForgot, or passwordReset were called [2]. This allowed previously issued OAuth tokens to remain valid after a user changed, reset, or recovered their password. The affected versions are prior to the fix released in 2026.05.1 [1].

Exploitation

An attacker who had obtained a legitimate OAuth token (e.g., through a third-party app integration) could continue using that token even after the victim performed a security-critical password event. No additional authentication or user interaction is required after the token is obtained; the token remains valid until explicitly revoked. The attacker can replay the token against NocoDB APIs to maintain access.

Impact

Successful exploitation allows persistent unauthorized access to the victim’s NocoDB account and associated data. This bypasses the security expectation that changing, resetting, or recovering a password would invalidate all existing sessions and tokens, leading to potential data disclosure, modification, or further compromise of the account. The OAuth grants retained their validity despite the user’s intent to lock out an attacker [2].

Mitigation

NocoDB released version 2026.05.1 which implements proper revocation: revokeAllOAuthTokensByUser now delegates to OAuthToken.revokeAllByUser(userId), deleting rows and invalidating auth caches [1]. All three password flows now consistently revoke OAuth tokens and rotate token_version. Users should upgrade to 2026.05.1 or later immediately. No workaround is documented for earlier versions.

AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

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 revokeAllOAuthTokensByUser function was an empty stub, so OAuth tokens were never revoked when a user changed, reset, or recovered their password."

Attack vector

An attacker who has obtained a valid OAuth access or refresh token (e.g., through a compromised OAuth client or session) can retain access to the victim's account even after the victim changes, resets, or recovers their password. Because the tokens were never revoked, the attacker's grant remains valid indefinitely after the security event [ref_id=1]. The attack requires the attacker to have already acquired an OAuth token before the password change.

Affected code

The `revokeAllOAuthTokensByUser` function in the users service was an empty stub. It is called from `passwordChange`, `passwordForgot`, and `passwordReset` flows. The fix replaces the stub with a call to `OAuthToken.revokeAllByUser(userId)`, which deletes the rows and invalidates related auth caches.

What the fix does

The patch replaces the empty `revokeAllOAuthTokensByUser` stub with a real implementation that calls `OAuthToken.revokeAllByUser(userId)`. This deletes the OAuth token rows from the database and invalidates the associated auth caches. All three password-change flows (passwordChange, passwordForgot, passwordReset) now consistently revoke refresh tokens, OAuth tokens, and rotate `token_version`, closing the window of persistent unauthorized access [ref_id=1].

Preconditions

  • authAttacker must have already obtained a valid OAuth access or refresh token for the victim's account
  • inputVictim must subsequently change, reset, or recover their password

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

References

3

News mentions

0

No linked articles in our index yet.