VYPR
Medium severity5.1NVD Advisory· Published Jun 5, 2026· Updated Jun 5, 2026

NocoDB: Open Redirect via Hash Fragment in hashRedirect Plugin

CVE-2026-47377

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

1

Patches

2
d71a313fec12

fix: route oo exclude list to correct handler based on LTAR version

https://github.com/nocodb/nocodbPranavApr 14, 2026Fixed in 2026.04.1via ghsa-release-walk
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: {
    
1da6a13b8fc5

feat: improve plan gating for on-premise

https://github.com/nocodb/nocodbmertmitApr 15, 2026Fixed in 2026.04.1via ghsa-release-walk
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

3

News mentions

1