VYPR
Medium severity4.3NVD Advisory· Published May 26, 2026· Updated May 26, 2026

CVE-2026-9566

CVE-2026-9566

Description

A vulnerability was identified in teableio teable up to 1.9.x. This impacts an unknown function of the file apps/nextjs-app/src/features/auth/pages/LoginPage.tsx of the component Sign-up. The manipulation of the argument redirect leads to cross site scripting. The attack is possible to be carried out remotely. The exploit is publicly available and might be used. Upgrading to version release.2026-04-21T08-57-20Z.1513 will fix this issue. The affected component should be upgraded. The vendor confirms: "The default branch of teableio/teable is develop, and the reported login redirect issue has already been fixed there. The login redirect flow now validates the redirect parameter with isValidRedirectPath() before navigation, which blocks javascript:, data:, and cross-origin redirects."

AI Insight

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

DOM XSS in Teable sign-in redirect parameter allows arbitrary JavaScript execution via crafted link; fixed in release.2026-04-21T08-57-20Z.1513.

Vulnerability

A DOM-based cross-site scripting (XSS) vulnerability exists in Teable versions up to 1.9.x in the sign-up/in flow. The file apps/nextjs-app/src/features/auth/pages/LoginPage.tsx directly uses the redirect query parameter from the URL without validation, passing it to router.push() [2]. This allows an attacker to inject a javascript: URI that executes arbitrary JavaScript in the victim's browser upon successful login. The affected versions are all prior to the fix release release.2026-04-21T08-57-20Z.1513 [1].

Exploitation

An attacker crafts a malicious link to the Teable login page with a redirect parameter containing a javascript: URI, e.g., http://test.local:3000/auth/login?redirect=javascript:alert(1) [2]. The victim must click the link and then successfully log in. No special privileges are required; the attack is remote and does not require authentication. The exploit is publicly available [2].

Impact

Successful exploitation allows the attacker to execute arbitrary JavaScript in the context of the victim's browser session. This can lead to session hijacking, credential theft, or unauthorized actions performed on behalf of the victim [2]. The attack compromises confidentiality and integrity of the user's data and session.

Mitigation

The vulnerability is fixed in version release.2026-04-21T08-57-20Z.1513 [1]. The fix validates the redirect parameter using isValidRedirectPath() before navigation, blocking javascript:, data:, and cross-origin redirects [4]. Users should upgrade to this version or later. No workaround is provided; upgrading is the recommended action.

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

Affected products

2
  • Teableio/Teablereferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: <= release.2026-04-21T08-57-20Z.1513

Patches

1
9d719e525373

Merge a976bdab6d5e41a2435d2338b1d5a148cdb5cfe1 into 0f57409015c08029f16d67fe9053902c08705c4f

https://github.com/teableio/teableBieberMar 24, 2026via nvd-ref
9 files changed · +113 23
  • apps/nestjs-backend/src/db-provider/select-query/postgres/select-query.postgres.spec.ts+5 3 modified
    @@ -153,7 +153,7 @@ describe('SelectQueryPostgres FROMNOW/TONOW', () => {
     });
     
     describe('SelectQueryPostgres workday', () => {
    -  it('uses interval multiplication for dynamic day-count expressions', () => {
    +  it('generates CTE-based workday SQL that skips weekends and holidays', () => {
         const query = new SelectQueryPostgres();
         query.setContext({ timeZone: 'Asia/Shanghai' } as unknown as never);
         query.setCallMetadata([
    @@ -162,7 +162,9 @@ describe('SelectQueryPostgres workday', () => {
         ] as unknown as never);
     
         const sql = query.workday('"t"."Date"', '"t"."Number"');
    -    expect(sql).toContain(`INTERVAL '1 day' * ("t"."Number")::double precision`);
    -    expect(sql).not.toContain(" days'");
    +    expect(sql).toContain('WITH params AS');
    +    expect(sql).toContain('generate_series');
    +    expect(sql).toContain('EXTRACT(DOW FROM c.candidate_date)');
    +    expect(sql).toContain(`("t"."Number")::double precision`);
       });
     });
    
  • apps/nestjs-backend/src/event-emitter/event-job/fallback/local-queue.provider.ts+51 9 modified
    @@ -4,24 +4,66 @@ import { getRandomString } from '@teable/core';
     import type { JobsOptions } from 'bullmq';
     import { localQueueEventEmitter } from './event-emitter';
     
    +interface ILocalJob {
    +  id: string;
    +  name: string;
    +  data: unknown;
    +  opts?: JobsOptions;
    +  queueName: string;
    +  progress: number | object;
    +  returnvalue: unknown;
    +  failedReason?: string;
    +  state: string;
    +  getState: () => Promise<string>;
    +  updateProgress: (progress: number | object) => Promise<void>;
    +}
    +
     export const createLocalQueueProvider = (queueName: string): Provider => ({
       provide: getQueueToken(queueName),
       useFactory: async () => {
    +    const jobs = new Map<string, ILocalJob>();
    +
    +    const createJob = (id: string, name: string, data: unknown, opts?: JobsOptions): ILocalJob => {
    +      const job: ILocalJob = {
    +        id,
    +        name,
    +        data,
    +        opts,
    +        queueName,
    +        progress: 0,
    +        returnvalue: undefined,
    +        failedReason: undefined,
    +        state: 'waiting',
    +        getState: async () => job.state,
    +        updateProgress: async (p: number | object) => {
    +          job.progress = p;
    +        },
    +      };
    +      return job;
    +    };
    +
         return {
           add: (name: string, data: unknown, opts?: JobsOptions) => {
    -        localQueueEventEmitter.emit(`handle-listener-${queueName}`, {
    -          id: getRandomString(10),
    -          name,
    -          data,
    -          opts,
    -          queueName,
    -        });
    +        const id = opts?.jobId ?? getRandomString(10);
    +        const job = createJob(id, name, data, opts);
    +        jobs.set(id, job);
    +        localQueueEventEmitter.emit(`handle-listener-${queueName}`, job);
    +        return job;
           },
    -      addBulk: (jobs: JobsOptions[]) => {
    -        jobs.forEach((job) => {
    +      addBulk: (bulkJobs: JobsOptions[]) => {
    +        bulkJobs.forEach((job) => {
               localQueueEventEmitter.emit(`handle-listener-${queueName}`, job);
             });
           },
    +      getJob: async (jobId: string) => {
    +        return jobs.get(jobId) ?? null;
    +      },
    +      getJobs: async () => {
    +        return Array.from(jobs.values());
    +      },
    +      getJobCountByTypes: async () => {
    +        return jobs.size;
    +      },
         };
       },
     });
    
  • apps/nestjs-backend/src/features/auth/social/controller.adapter.ts+11 1 modified
    @@ -1,6 +1,16 @@
     import type { Response } from 'express';
     import type { IOauth2State } from '../../../cache/types';
     
    +function isValidRedirectPath(path: string): boolean {
    +  try {
    +    const base = 'http://placeholder.local';
    +    const url = new URL(path, base);
    +    return url.origin === base && (url.protocol === 'http:' || url.protocol === 'https:');
    +  } catch {
    +    return false;
    +  }
    +}
    +
     export class ControllerAdapter {
       // eslint-disable-next-line @typescript-eslint/no-empty-function
       async authenticate() {}
    @@ -12,7 +22,7 @@ export class ControllerAdapter {
           req.login(user, (err) => (err ? reject(err) : resolve()));
         });
         const redirectUri = (req.authInfo as { state: IOauth2State })?.state?.redirectUri;
    -    if (redirectUri) {
    +    if (redirectUri && isValidRedirectPath(redirectUri)) {
           return res.redirect(redirectUri);
         }
         return res.redirect(defaultRedirectUri || '/');
    
  • apps/nextjs-app/src/features/app/blocks/view/search/SearchButton.tsx+13 4 modified
    @@ -10,7 +10,14 @@ import {
       DEFAULT_MAX_SEARCH_FIELD_COUNT,
     } from '@teable/openapi';
     import { LocalStorageKeys, useView } from '@teable/sdk';
    -import { useBaseId, useFields, useRowCount, useSearch, useTableId } from '@teable/sdk/hooks';
    +import {
    +  useBaseId,
    +  useFields,
    +  useRowCount,
    +  useSearch,
    +  useTableId,
    +  useTablePermission,
    +} from '@teable/sdk/hooks';
     import { Spin } from '@teable/ui-lib/base';
     import {
       cn,
    @@ -69,6 +76,8 @@ export const SearchButton = (props: ISearchButtonProps) => {
       const [noPrompt, setNoPrompt] = useState(false);
       const baseId = useBaseId();
       const queryClient = useQueryClient();
    +  const permission = useTablePermission();
    +  const hasTableUpdatePermission = Boolean(permission['table|update']);
     
       const [inputValue, setInputValue] = useState(value);
       const [isFocused, setIsFocused] = useState(false);
    @@ -373,6 +382,7 @@ export const SearchButton = (props: ISearchButtonProps) => {
               }}
               onChange={(e) => {
                 if (
    +              hasTableUpdatePermission &&
                   shouldTips &&
                   rowCount &&
                   rowCount > RecommendedIndexRow &&
    @@ -384,9 +394,8 @@ export const SearchButton = (props: ISearchButtonProps) => {
                   setAlertVisible(true);
                   return;
                 }
    -            if (searchAbnormalIndex.length) {
    +            if (hasTableUpdatePermission && searchAbnormalIndex.length) {
                   setAlertVisible(true);
    -              return;
                 }
                 setInputValue(e.target.value);
                 if (e.target.value === '') {
    @@ -424,7 +433,7 @@ export const SearchButton = (props: ISearchButtonProps) => {
             </div>
           </div>
     
    -      <AlertDialog open={alertVisible} onOpenChange={setAlertVisible}>
    +      <AlertDialog open={hasTableUpdatePermission && alertVisible} onOpenChange={setAlertVisible}>
             <AlertDialogContent>
               <AlertDialogHeader>
                 <AlertDialogTitle>{t('table:import.title.tipsTitle')}</AlertDialogTitle>
    
  • apps/nextjs-app/src/features/auth/components/SocialAuth.tsx+4 1 modified
    @@ -5,6 +5,7 @@ import { useTranslation } from 'next-i18next';
     import { useMemo } from 'react';
     import { useEnv } from '@/features/app/hooks/useEnv';
     import { authConfig } from '@/features/i18n/auth.config';
    +import { isValidRedirectPath } from '@/lib/isValidRedirectPath';
     
     export const providersAll = [
       {
    @@ -30,7 +31,9 @@ export const SocialAuth = () => {
       const { t } = useTranslation(authConfig.i18nNamespaces);
       const { socialAuthProviders, passwordLoginDisabled } = useEnv();
       const router = useRouter();
    -  const redirect = router.query.redirect as string;
    +  const rawRedirect = router.query.redirect as string;
    +  const redirect =
    +    rawRedirect && isValidRedirectPath(rawRedirect, window.location.origin) ? rawRedirect : '';
     
       const providers = useMemo(
         () => providersAll.filter((provider) => socialAuthProviders?.includes(provider.id)),
    
  • apps/nextjs-app/src/features/auth/pages/LoginPage.tsx+2 1 modified
    @@ -10,6 +10,7 @@ import { useBrand } from '@/features/app/hooks/useBrand';
     import { useEnv } from '@/features/app/hooks/useEnv';
     import { useInitializationZodI18n } from '@/features/app/hooks/useInitializationZodI18n';
     import { authConfig } from '@/features/i18n/auth.config';
    +import { isValidRedirectPath } from '@/lib/isValidRedirectPath';
     import { DescContent } from '../components/DescContent';
     import { SignForm } from '../components/SignForm';
     import { SocialAuth } from '../components/SocialAuth';
    @@ -36,7 +37,7 @@ export const LoginPage = (props: { children?: React.ReactNode | React.ReactNode[
         }
       }, [redirect]);
       const onSuccess = useCallback(() => {
    -    if (redirect) {
    +    if (redirect && isValidRedirectPath(redirect, window.location.origin)) {
           router.push(redirect);
         } else {
           router.push({
    
  • apps/nextjs-app/src/lib/ensureLogin.ts+5 2 modified
    @@ -8,6 +8,7 @@ import type {
     } from 'next';
     import { getUserMe } from '@/backend/api/rest/get-user';
     import { providersAll } from '@/features/auth/components/SocialAuth';
    +import { isValidRedirectPath } from './isValidRedirectPath';
     
     /* eslint-disable @typescript-eslint/no-explicit-any */
     type GetServerSideProps<
    @@ -30,7 +31,8 @@ export default function ensureLogin<P extends { [key: string]: any }>(
           // User is logged in, redirect to home page if on login page
           if (!isAnonymous(user?.id) && isLoginPage) {
             const redirect = context.query.redirect;
    -        let destination = typeof redirect === 'string' ? redirect : '/space';
    +        let destination =
    +          typeof redirect === 'string' && isValidRedirectPath(redirect) ? redirect : '/space';
     
             const via = context.query.via;
             if (typeof via === 'string' && via) {
    @@ -95,7 +97,8 @@ export default function ensureLogin<P extends { [key: string]: any }>(
     
     // Redirect to social auth if password login is disabled and only one provider is available
     function redirectSocialAuth(req: GetServerSidePropsContext['req']) {
    -  const redirect = new URLSearchParams(req?.url?.split('?')[1] ?? '').get('redirect');
    +  const rawRedirect = new URLSearchParams(req?.url?.split('?')[1] ?? '').get('redirect');
    +  const redirect = rawRedirect && isValidRedirectPath(rawRedirect) ? rawRedirect : null;
       const envProviders = process.env.SOCIAL_AUTH_PROVIDERS?.split(',') ?? [];
       const envPasswordLoginDisabled = process.env.PASSWORD_LOGIN_DISABLED === 'true';
       if (envPasswordLoginDisabled && envProviders.length === 1) {
    
  • apps/nextjs-app/src/lib/isValidRedirectPath.ts+18 0 added
    @@ -0,0 +1,18 @@
    +/**
    + * Validates that a redirect path is safe to navigate to.
    + * Blocks dangerous protocols (javascript:, data:, vbscript:, etc.) and cross-origin redirects.
    + *
    + * @param path - The redirect path to validate
    + * @param origin - The trusted origin to validate against.
    + *   On client: pass `window.location.origin`.
    + *   On server: omit to only allow same-origin relative paths.
    + */
    +export function isValidRedirectPath(path: string, origin?: string): boolean {
    +  try {
    +    const base = origin || 'http://placeholder.local';
    +    const url = new URL(path, base);
    +    return url.origin === base && (url.protocol === 'http:' || url.protocol === 'https:');
    +  } catch {
    +    return false;
    +  }
    +}
    
  • packages/openapi/src/admin/setting/pricing.spec.ts+4 2 modified
    @@ -140,7 +140,8 @@ describe('pricing', () => {
             outputTokens: 500,
             webSearches: 2,
           });
    -      const expectedUsd = 1000 * 0.0000001 + 500 * 0.0000004 + 2 * 35;
    +      // webSearch is USD per 1,000 searches
    +      const expectedUsd = 1000 * 0.0000001 + 500 * 0.0000004 + (2 * 35) / 1000;
           expect(credits).toBeCloseTo(expectedUsd / USD_PER_CREDIT);
         });
     
    @@ -399,7 +400,8 @@ describe('pricing', () => {
             outputTokens: 1000,
             webSearches: 1,
           });
    -      const usd = 5000 * 0.0000001 + 1000 * 0.0000004 + 1 * 35;
    +      // webSearch is USD per 1,000 searches
    +      const usd = 5000 * 0.0000001 + 1000 * 0.0000004 + (1 * 35) / 1000;
           expect(credits).toBeCloseTo(usd / USD_PER_CREDIT);
         });
       });
    

Vulnerability mechanics

Root cause

"Missing validation of the `redirect` URL parameter allows `javascript:` protocol URIs to be passed directly to `router.push`, enabling DOM-based XSS."

Attack vector

An attacker crafts a malicious URL such as `http://test.local:3000/auth/login?redirect=javascript:alert(1)` and tricks a victim into clicking it [ref_id=1]. The victim must not have an existing session — they must log in from a fresh session (e.g., private browsing). After the victim enters credentials and successfully signs in, the `onSuccess` callback calls `router.push(redirect)`, which passes the `javascript:` URI to `window.location`, executing arbitrary JavaScript in the browser context [ref_id=1].

Affected code

The vulnerable code is in `apps/nextjs-app/src/features/auth/pages/LoginPage.tsx`. The `redirect` parameter is read directly from the URL query string via `router.query.redirect`, decoded, and then passed unsanitized to `router.push(redirect)` in the `onSuccess` callback after a successful sign-in [ref_id=1].

What the fix does

The patch introduces validation of the `redirect` parameter using `isValidRedirectPath()` before navigation [patch_id=2566838]. The vendor confirms this function blocks `javascript:`, `data:`, and cross-origin redirects, ensuring only safe, same-origin paths are used [ref_id=1]. This closes the XSS by preventing dangerous URI schemes from reaching `router.push`.

Preconditions

  • authVictim must not have an existing session (must log in from a fresh session)
  • inputVictim must click a crafted link and complete the sign-in flow
  • inputAttacker must know the correct parameter name (redirect)

Reproduction

1. Open a private/incognito browser session. 2. Navigate to `http://test.local:3000/auth/login?redirect=javascript:alert(1)`. 3. Enter valid credentials and sign in. 4. Observe that `alert(1)` executes after successful login, demonstrating DOM XSS [ref_id=1].

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

References

6

News mentions

0

No linked articles in our index yet.