NocoDB: Open Redirect via Hash Fragment in hashRedirect Plugin
Description
Summary
The client-side hashRedirect plugin called window.location.replace() on a path extracted from the URL hash fragment after only checking hashPath.startsWith('/'). Protocol-relative URLs (//attacker.com/…) also satisfy that check, so a crafted link such as https://nocodb.example/#//attacker.com/phishing silently redirected visitors to an attacker-controlled origin.
Details
In packages/nc-gui/plugins/hashRedirect.client.ts, the plugin extracted the hash content and normalised it into cleanUrl:
let cleanUrl = hashPath.startsWith('/') ? hashPath : `/${hashPath}`
if (hashQuery) cleanUrl += `?${hashQuery}`
window.location.replace(cleanUrl)
startsWith('/') returns true for //attacker.com/..., which browsers interpret as a protocol-relative absolute URL. No hostname check was performed before the redirect. The fix adds an early if (/^\/[/\\]/.test(hashPath)) return to reject protocol-relative paths.
Impact
- Open redirect from any NocoDB origin to an attacker-controlled domain.
- No authentication required; the attack lands the victim on an attacker-controlled page that may impersonate a NocoDB login.
Credit
This issue was reported by @fg0x0.
Affected products
1Patches
2d71a313fec12fix: route oo exclude list to correct handler based on LTAR version
4 files changed · +156 −19
packages/nocodb/src/controllers/data-alias-nested.controller.ts+0 −1 modified@@ -51,7 +51,6 @@ export class DataAliasNestedController { '/api/v1/db/data/:orgs/:baseName/:tableName/:rowId/ln/:columnName/exclude', '/api/v1/db/data/:orgs/:baseName/:tableName/:rowId/mo/:columnName/exclude', '/api/v1/db/data/:orgs/:baseName/:tableName/:rowId/om/:columnName/exclude', - '/api/v1/db/data/:orgs/:baseName/:tableName/:rowId/oo/:columnName/exclude', ]) @Acl('mmExcludedList') async mmExcludedList(
packages/nocodb/src/controllers/datas.controller.ts+17 −1 modified@@ -61,7 +61,6 @@ export class DatasController { '/data/:viewId/:rowId/mm/:colId/exclude', '/data/:viewId/:rowId/mo/:colId/exclude', '/data/:viewId/:rowId/om/:colId/exclude', - '/data/:viewId/:rowId/oo/:colId/exclude', ]) @Acl('mmExcludedList') async mmExcludedList( @@ -79,6 +78,23 @@ export class DatasController { }); } + @Get('/data/:viewId/:rowId/oo/:colId/exclude') + @Acl('ooExcludedList') + async ooExcludedList( + @TenantContext() context: NcContext, + @Req() req: NcRequest, + @Param('viewId') viewId: string, + @Param('colId') colId: string, + @Param('rowId') rowId: string, + ) { + return await this.datasService.ooExcludedList(context, { + viewId: viewId, + colId: colId, + rowId: rowId, + query: req.query, + }); + } + @Get('/data/:viewId/:rowId/hm/:colId/exclude') @Acl('hmExcludedList') async hmExcludedList(
packages/nocodb/src/services/data-alias-nested.service.ts+37 −16 modified@@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { isLinksOrLTAR } from 'nocodb-sdk'; +import { isLinksOrLTAR, isLinkV2 } from 'nocodb-sdk'; import type { PathParams } from '~/helpers/dataHelpers'; import type { NcContext } from '~/interface/config'; import { NcError } from '~/helpers/catchError'; @@ -214,21 +214,42 @@ export class DataAliasNestedService { const column = await getColumnByIdOrName(context, param.columnName, model); - const data = await baseModel.getExcludedOneToOneChildrenList( - { - colId: column.id, - cid: param.rowId, - }, - param.query, - ); - - const count = await baseModel.countExcludedOneToOneChildren( - { - colId: column.id, - cid: param.rowId, - }, - param.query, - ); + let data; + let count; + + if (isLinkV2(column)) { + data = await baseModel.getMmChildrenExcludedList( + { + colId: column.id, + pid: param.rowId, + }, + param.query, + ); + + count = await baseModel.getMmChildrenExcludedListCount( + { + colId: column.id, + pid: param.rowId, + }, + param.query, + ); + } else { + data = await baseModel.getExcludedOneToOneChildrenList( + { + colId: column.id, + cid: param.rowId, + }, + param.query, + ); + + count = await baseModel.countExcludedOneToOneChildren( + { + colId: column.id, + cid: param.rowId, + }, + param.query, + ); + } return new PagedResponseImpl(data, { count,
packages/nocodb/src/services/datas.service.ts+102 −1 modified@@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { isLinksOrLTAR, NcSDKErrorV2, ViewTypes } from 'nocodb-sdk'; +import { isLinksOrLTAR, isLinkV2, NcSDKErrorV2, ViewTypes } from 'nocodb-sdk'; import { NcApiVersion } from 'nocodb-sdk'; import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2'; import type { PathParams } from '~/helpers/dataHelpers'; @@ -898,6 +898,107 @@ export class DatasService { }); } + async ooExcludedList( + context: NcContext, + param: { + viewId: string; + colId: string; + query: any; + rowId: string; + }, + ) { + const view = await View.get(context, param.viewId); + + const model = await Model.getByIdOrName(context, { + id: view?.fk_model_id || param.viewId, + }); + + if (!model) + NcError.get(context).tableNotFound(view?.fk_model_id || param.viewId); + + const source = await Source.get(context, model.source_id); + + const baseModel = await Model.getBaseModelSQL(context, { + id: model.id, + viewId: view?.id, + dbDriver: await NcConnectionMgrv2.get(source), + source, + }); + + const column = await Column.get(context, { colId: param.colId }); + + const key = 'List'; + const requestObj: any = { + [key]: 1, + }; + + let data; + let count; + + if (isLinkV2(column)) { + data = ( + await nocoExecute( + requestObj, + { + [key]: async (args) => { + return await baseModel.getMmChildrenExcludedList( + { + colId: param.colId, + pid: param.rowId, + }, + args, + ); + }, + }, + {}, + + { nested: { [key]: param.query } }, + ) + )?.[key]; + + count = await baseModel.getMmChildrenExcludedListCount( + { + colId: param.colId, + pid: param.rowId, + }, + param.query, + ); + } else { + data = ( + await nocoExecute( + requestObj, + { + [key]: async (args) => { + return await baseModel.getExcludedOneToOneChildrenList( + { + colId: param.colId, + cid: param.rowId, + }, + args, + ); + }, + }, + {}, + + { nested: { [key]: param.query } }, + ) + )?.[key]; + + count = await baseModel.countExcludedOneToOneChildren( + { + colId: param.colId, + cid: param.rowId, + }, + param.query, + ); + } + + return new PagedResponseImpl(data, { + count, + ...param.query, + }); + } + async hmList( context: NcContext, param: {
1da6a13b8fc5feat: improve plan gating for on-premise
1 file changed · +4 −0
packages/nocodb-sdk/src/lib/payment/index.ts+4 −0 modified@@ -103,6 +103,8 @@ export enum PlanFeatureTypes { FEATURE_DATE_DEPENDENCY = 'feature_date_dependency', FEATURE_API_COMMENT_V3 = 'feature_api_comment_v3', FEATURE_API_WORKFLOW_MANAGEMENT = 'feature_api_workflow_management', + /** On-prem: core EE capability flag — true for all paid plans, false for free */ + FEATURE_EE_CORE = 'feature_ee_core', } export enum PlanTitles { @@ -113,6 +115,7 @@ export enum PlanTitles { } export enum OnPremPlanTitles { + FREE = 'Free', SELF_HOSTED_STARTER = 'Self-hosted Starter', SELF_HOSTED_SCALE = 'Self-hosted Scale', SELF_HOSTED_ENTERPRISE = 'Self-hosted Enterprise', @@ -362,6 +365,7 @@ export const PlanFeatureUpgradeMessages: Record<PlanFeatureTypes, string> = { [PlanFeatureTypes.FEATURE_DATE_DEPENDENCY]: 'to use date dependencies.', [PlanFeatureTypes.FEATURE_API_COMMENT_V3]: 'to use comment api.', [PlanFeatureTypes.FEATURE_API_WORKFLOW_MANAGEMENT]: 'to use workflow api.', + [PlanFeatureTypes.FEATURE_EE_CORE]: 'to access enterprise features.', }; export const getUpgradeMessage = (
Vulnerability mechanics
Root cause
"The client-side hashRedirect plugin incorrectly trusted protocol-relative URLs extracted from the URL hash fragment."
Attack vector
An attacker crafts a malicious link containing a URL fragment that starts with `//` followed by an attacker-controlled domain. When a victim clicks this link on a vulnerable NocoDB origin, the `hashRedirect` plugin processes the fragment. The plugin's check `hashPath.startsWith('/')` incorrectly evaluates to true for protocol-relative URLs, leading to a silent redirect to the attacker's site [ref_id=1]. No authentication is required to trigger this open redirect vulnerability [ref_id=1].
Affected code
The vulnerability resides in the `hashRedirect.client.ts` file within the `packages/nc-gui` directory. Specifically, the code responsible for extracting the hash content and normalizing it into `cleanUrl` before calling `window.location.replace()` is at fault [ref_id=1].
What the fix does
The fix introduces an early return condition within the `hashRedirect` plugin. This condition, `if (/^\[/\\]/.test(hashPath)) return`, checks if the extracted hash path begins with a forward slash followed by another slash or a backslash. This pattern specifically identifies protocol-relative URLs, preventing them from being processed further and thus closing the open redirect vulnerability [ref_id=1].
Preconditions
- inputThe victim must click on a crafted URL containing a hash fragment starting with `//`.
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