On November 9, 2023, Sentry published an article on their blog titled Next.js SDK Security Advisory - CVE-2023-46729. The article discusses the details of the CVE-2023-46729 vulnerability, including its cause, discovery time, and patching time.
Although the vulnerability was officially announced on 11/9, it was actually fixed in version 7.77.0 released on 10/31. Some time was given to developers to patch the vulnerability.
Now let’s briefly discuss the cause and attack method of this vulnerability.
Vulnerability Analysis
There is also a more technical explanation on GitHub: CVE-2023-46729: SSRF via Next.js SDK tunnel endpoint
You can see this paragraph:
An unsanitized input of Next.js SDK tunnel endpoint allows sending HTTP requests to arbitrary URLs and reflecting the response back to the user.
In Sentry, there is a feature called “tunnel,” and this image from the official documentation perfectly explains why tunneling is needed:
Without tunneling, requests sent to Sentry would be directly sent through the browser on the frontend. However, these requests sent directly to Sentry may be blocked by ad blockers, preventing Sentry from receiving the data. If tunneling is enabled, the request is first sent to the user’s own server and then forwarded to Sentry. This way, the request becomes a same-origin request and will not be blocked by ad blockers.
In the Sentry SDK specifically designed for Next.js, a feature called rewrite is used. Here is an example from the official documentation:
module.exports = {
async rewrites() {
return [
{
source: '/blog',
destination: 'https://example.com/blog',
},
{
source: '/blog/:slug',
destination: 'https://example.com/blog/:slug', // Matched parameters can be used in the destination
},
]
},
}
Next.js rewrite can be divided into two types: internal and external. The latter is more like a proxy, as it can directly redirect the request to an external website and display the response.
The implementation of the Next.js Sentry SDK is in sentry-javascript/packages/nextjs/src/config/withSentryConfig.ts:
/**
* Injects rewrite rules into the Next.js config provided by the user to tunnel
* requests from the `tunnelPath` to Sentry.
*
* See https://nextjs.org/docs/api-reference/next.config.js/rewrites.
*/
function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: string): void {
const originalRewrites = userNextConfig.rewrites;
// This function doesn't take any arguments at the time of writing but we future-proof
// here in case Next.js ever decides to pass some
userNextConfig.rewrites = async (...args: unknown[]) => {
const injectedRewrite = {
// Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]`
// Nextjs will automatically convert `source` into a regex for us
source: `${tunnelPath}(/?)`,
has: [
{
type: 'query',
key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers
value: '(?<orgid>.*)',
},
{
type: 'query',
key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers
value: '(?<projectid>.*)',
},
],
destination: 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0',
};
if (typeof originalRewrites !== 'function') {
return [injectedRewrite];
}
// @ts-expect-error Expected 0 arguments but got 1 - this is from the future-proofing mentioned above, so we don't care about it
const originalRewritesResult = await originalRewrites(...args);
if (Array.isArray(originalRewritesResult)) {
return [injectedRewrite, ...originalRewritesResult];
} else {
return {
...originalRewritesResult,
beforeFiles: [injectedRewrite, ...(originalRewritesResult.beforeFiles || [])],
};
}
};
}
The crucial part is this section:
const injectedRewrite = {
// Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]`
// Nextjs will automatically convert `source` into a regex for us
source: `${tunnelPath}(/?)`,
has: [
{
type: 'query',
key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers
value: '(?<orgid>.*)',
},
{
type: 'query',
key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers
value: '(?<projectid>.*)',
},
],
destination: 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0',
};
It determines the final URL to redirect to based on the o
and p
query string parameters.
The problem here is that both of these parameters use the .*
regular expression, which matches any character. In other words, for the following URL:
https://huli.tw/tunnel?o=abc&p=def
It will proxy to:
https://oabc.ingest.sentry.io/api/def/envelope/?hsts=0
It looks fine, but what if it’s like this?
https://huli.tw/tunnel?o=example.com%23&p=def
%23
is the URL-encoded result of #
. It will be proxied to:
https://oexample.com#.ingest.sentry.io/api/def/envelope/?hsts=0
We use #
to include the original hostname as part of the hash and successfully change the destination of the proxy. However, the leading o
is a bit annoying. Let’s get rid of it by adding @
at the beginning:
https://huli.tw/[email protected]%23&p=def
It becomes:
https://[email protected]#.ingest.sentry.io/api/def/envelope/?hsts=0
In this way, an attacker can use the o
parameter to change the destination of the proxy and redirect the request anywhere. As mentioned earlier, this rewrite feature directly returns the response. So when a user visits https://huli.tw/[email protected]%23&p=def
, they will see the response of example.com
.
In other words, if an attacker redirects the request to their own website, they can output <script>alert(document.cookie)</script>
, turning it into an XSS vulnerability.
If the attacker redirects the request to other internal web pages like https://localhost:3001
, it becomes an SSRF vulnerability (but the target must support HTTPS).
As for the fix, it’s simple. Just add some restrictions to the regex. Finally, Sentry adjusted it to only allow digits:
{
type: 'query',
key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers
value: '(?<orgid>\\d*)',
},
This issue has been fixed in version 7.77.0 and later.
Conclusion
This vulnerability is really simple and easy to reproduce. Just find the fix commit and take a look at the code to understand how to exploit it.
In summary, when doing URL rewriting, you really need to be cautious, as it’s easy to encounter issues (especially when you’re not just rewriting the path but the entire URL).