{"slug": "my-first-cve-the-bad-kind", "title": "My first CVE (the bad kind)", "summary": "Developer Chris Smith disclosed GitHub advisory GHSA-87xc-jgvf-hmvg, a server-side request forgery (CWE-918) in his reverse proxy Centauri that carries a CVSS score of 10.0, after Anthropic's Claude Code flagged the flaw during a security audit he ran on his own projects. The bug lets a valid request containing an absolute URL make Centauri connect out and proxy that URL instead of the intended upstream, enabling use in DDoS attacks or access to internal services and ports, and bypassing Centauri's usual header filtering such as stripping of Tailscale-User-Login. Smith said no CVE has been assigned yet because GitHub has a multi-week backlog of advisories, and noted Centauri is a small project typically deployed in a Docker container on a dedicated network with only its upstreams.", "body_md": "Let me set the scene: Friday night, after two weeks of\n[fun](https://nvd.nist.gov/vuln/detail/cve-2026-109618)\n[security](https://groups.google.com/g/golang-announce/c/PCoQcdxoScw/m/aD-MgXEvAAAJ)\n[issues](https://emudhra.we-have-a-problem.com/). A pile of leftover Claude tokens\nburning a hole in my pocket. A vague feeling that computing as we know it is\nfundamentally broken. That’s when she walked into my office. Trouble, in a red dress.\nShe had legs for miles, and an HTTP request that went somewhere it shouldn’t.\n\nUh, sorry, got a bit carried away there. So, anyway, I thought maybe I should try\nthrowing Claude Code at some security audits. I’ve had LLMs do it before, and they\nweren’t particularly impressive. On chameth.com I have a little admin mode that\nsets an `isadmin=1` cookie. If you have that cookie it shows some edit links, which\ndon’t work unless you’re on my tailnet. I find this deliberate lack of security quite\nfunny, but LLMs do not. And that’s the kind of “security finding” I expected.\nBut models are advancing stupidly fast, so maybe it was worth another go?\n\nI ran through a list of projects and picked the juiciest targets: those used by\nmore people, those with bigger attack surfaces, and so on. One of those was\n[Centauri](https://github.com/csmith/centauri), the reverse proxy I’ve mentioned\na few times on this blog. It’s one of the projects I feel most confident in:\nit has a decent architecture, extensive unit tests, and a bunch of integration\ntests. So obviously it’s the project with a massive security flaw.\n\nI almost missed it. Despite being briefed to look at security issues, Claude decided to list a bunch of minor issues as well. I was about to close the terminal when a reference to “the critical issue” caught my eye. I scrolled up. Then I got a lovely sinking feeling.\n\nFirst off, the title is a bit of a lie. There’s no CVE yet. Apparently GitHub\nhave a multi-week backlog of advisories. I understand why, but it’s still\nsomewhat worrying to think about how many people might be sitting on\ndisclosures because the only numbering authority they have access to is\nstruggling to do their numbers fast enough. But\n[GHSA-87xc-jgvf-hmvg](https://github.com/csmith/centauri/security/advisories/GHSA-87xc-jgvf-hmvg)\nit is for now. It gets a perfect 10.0 CVSS score.\n\nThe short version is that if you send Centauri a valid request that happens\nto contain an absolute URL, it’ll connect out and proxy that URL instead\nof serving the upstream it was meant to. This is a textbook example of\n[CWE-918: Server Side Request Forgery](https://cwe.mitre.org/data/definitions/918.html)\naka SSRF. I really like this little diagram they have. That’s how my face\nlooked.\n\nThere are two ways this can be bad: you can cause Centauri to connect\nout to other URLs and, for example, use it as part of a DDoS attack;\nor you can cause Centauri to connect to *internal* URLs that it\notherwise wouldn’t. If there are services that Centauri can reach\nthat shouldn’t be exposed, or alternative ports on the services\nit *is* meant to reach, an attacker could get access to them.\nCentauri’s usual header filtering isn’t applied, either, so an\nattacker could pass some headers that Centauri would usually strip,\nlike `Tailscale-User-Login`. Fortunately there’s another layer\nthat strips the more common headers like `X-Forwarded-For`.\n\nThe access to internal services is what nets this issue the big ten-point-zero: it’s remotely exploitable, and has a high confidentiality and integrity impact. Attackers could, in theory, do bad things inside your network. But as bad as that is, there are a lot of nuances that don’t come across in a CVSS score.\n\nCentauri is a very small project, not widely known or used. It’s\ngenerally deployed in a Docker container, on a dedicated network\nwith just Centauri and its upstreams. It can’t reach stuff on\nother docker networks. If there *is* an accessible private service\nthat shouldn’t be exposed, the attacker still needs to know\nthe destination host and port. That’s not insurmountable:\nyou could try all the common ports on all local IP ranges, but\nyou do have to put some effort in with an expectation that\nit’ll work.\n\nSo while it’s a terrible bug, and I feel awful about it<sup>[1](https://chameth.com/#fn:1)</sup>, the\nactual real world risk is awfully slim. The vulnerability itself\nis trivially exploitable, but finding a deployment that’s worth\nattacking is an entirely different matter. For a project\nwith a handful of users, the effort involved in a targeted attack\nseems crazily disproportionate to the reward.\n\nIt feels a bit wild that this gets a 10.0 while “my network\nstack will execute code for someone else” gets a 9.8 because it’s\na bit fiddly. CVSS is all about worst case scenarios, and\nsometimes they just don’t make sense. It *was* wonderfully\neasy to exploit, though:\n\n``` bash\n$ curl --http1.1 --request-target 'http://100.100.100.100/' https://chameth.com/\n```\n\nFun fact: Claude happily pointed out the issue, talked me through how it could be exploited, and gave me that exact curl command to try on a live server. Fast forward a few days and it keeps hitting safeguards while I’m trying to fact-check bits of this post. Exploiting vulnerable systems: no problemo. Checking statements for historical accuracy: danger, Will Robinson.\n\nA reverse proxy blindly accepting absolute URLs might be the most obvious security hole possible. How could I possibly miss it?! Well, I didn’t, initially. Early versions of Centauri weren’t exploitable, but the code that actually prevented that was a series of seemingly individually inconsequential checks in different places. With all of them working in concert, everything is fine. If you change any of them, everything becomes very not fine.\n\nDescribing it like that makes it perfectly clear this\nwas a terrible architecture from a security point of view.\nWhen I come back to the codebase after not looking at it\nfor six months, I’m not going to remember that those\nparticular if statements are a super important security\nboundary. If I have an LLM do any work on it, then it’s\nprobably not going to realise that either<sup>[2](https://chameth.com/#fn:2)</sup>.\n\nThe first bit of that house of cards was broken as\nfar back as Centauri v0.2.0 in January 2023. I\nintroduced Tailscale support, which accepted non-HTTPS\nrequests. This broke a lot of Centauri’s carefully\nheld assumptions. The key one being that by the time\nwe came to route a request, we’d know that it definitely\n*had* a route, because it was worked out from the TLS\nSNI field. Obviously with no TLS there’s no TLS SNI field,\nso invalid requests started hitting the request rewriter,\nand it panicked. My fix there was to avoid panicking by\nreturning early. This allowed the request to continue.\n\nAt that point you could SSRF via the Tailscale frontend,\nbut not via HTTPS<sup>[3](https://chameth.com/#fn:3)</sup>. Not great, but not a 10.0 CVE either.\n“The HTTP request is coming from inside the tailnet” is\nhaunting, but also a significant barrier to attack.\nAllowing the request to proceed was not a great decision,\nbut the point at which it was checked has no way to\nabort the request because of the shape of Go’s `ReverseProxy`\ninterface. If I’d realised I was fiddling with a security\nboundary, I would probably have reworked it so the check\nhappened earlier.\n\nThen along came v0.5.3 in May 2024. This release fixed\nan issue with HTTP/2 and SNI which\n[I documented right here on my blog](https://chameth.com/http2-and-sni/).\nBasically, browsers could send a request with one hostname\nin the SNI, and then send a request for a different host\non the same connection. There are lots of conditions on\nwhen that works, but the net result is Centauri could\nno longer route based on the SNI field. The fix was to use\nthat purely for picking the certificate, and use the\nHost header for routing.\n\nUnfortunately the mechanism for “use the SNI to route”\nand “use the HTTP Host header to route” is very different.\nUsing the SNI means modifying the request to explicitly\npoint at that host. But if we’re just using the normal\nHost header we don’t need to do that! We check if it’s\nvalid, if it is we adjust the URL to point at the right\nbackend; if not we just return early and leave Go’s\n`ReverseProxy` to respond with an error. Even in hindsight\nthat seems pretty reasonable. Until you realise that\n`ReverseProxy` accepts and proxies to absolute URLs.\nThen you realise your entire security boundary is\nin tatters on the floor.\n\nI’ve already hinted at what I think the most important change would have been: making the security invariants explicit. Something, somewhere should be responsible for not letting the bad thing happen. This statement:\n\nCentauri must only connect to configured upstreams. If there’s no valid route, it must not make a connection.\n\nshould have some corresponding code you can point at and\nsay “yes, that invariant is enforced right here”. It should be\nobvious what it does, and that it serves a security\nfunction. Fixing a panic in the depths of the `Rewriter`\nseems innocuous. Changing something in the\n`UpstreamConnectionPolicy` is serious business.\n\nThe other super obvious thing is testing. With the security boundary in one place it can be unit tested, of course, but even more valuable is an integration test. Centauri didn’t have them early on, because they’re pretty faffy. If it did, I may well have written a “don’t proxy absolute URLs, that’s dumb” test, and we wouldn’t be here.\n\nFinally, there’s this lovely thing called “defence\nin depth”. It’s a great idea, you just have to\nremember that you’re defending against things.\nFor Centauri’s particular case, I’m toying with\nthe idea of using a custom `net.Dialer` that\nonly permits connections to pre-designated addresses.\nCentauri knows exactly where it *should* be connecting\nto, so it’s a good fit. It should never try to Dial\nanywhere else, but the extra layer of defence would\ncatch any silly mistakes and raise a very obvious\nalarm.\n\nI’ve not got round to doing all of these for\nCentauri yet. For the immediate fix, it now blanks the\nabsolute URL (with a comment saying why!<sup>[4](https://chameth.com/#fn:4)</sup>), avoids\nsending invalid requests to the `ReverseProxy` at all,\nand has a test to make sure it doesn’t regress. The\nlonger term fixes will follow, but I want to spend a\nbit more time thinking them through.\n\nI don’t usually like formal conclusions on my posts, but\nthis felt deserving. So, what went well? The time from\ndiscovering the vulnerability to every known instance of\nCentauri being patched was ridiculously short, thanks to\nmy coordinated vulnerability disclosure process, whereby\nI notified affected stakeholders through established\ncommunication channels<sup>[5](https://chameth.com/#fn:5)</sup>. I will probably get a CVE\nto my name, eventually, even if it’s not the preferred kind\nof CVE. I now know I can use LLMs to spot big, hairy,\nsuper-obvious-in-hindsight security issues. And I have\none or two thoughts on better practices to apply generally\ngoing forward.\n\nWhat didn’t go so well? I mean, the 10.0 CVE. That’s really bad, man. I’m going to try not to do that again.\n\nI’m being pretty flippant in this post. It’s equal\nparts coping mechanism and attempting to make a dry subject\na bit more interesting. Please accept this sad SSRF face\nas evidence of my genuine remorse: :( [↩︎](https://chameth.com/#fnref:1)\n\nThis particular problem was 100% hand made, but in\nthe “we don’t look at code any more” era it feels like it’s\ngoing to become increasingly important to make security\ninvariants crystal clear. [↩︎](https://chameth.com/#fnref:2)\n\n… Probably. There might be something funky you\ncan do with TLS ticket resumption. There’s a limit to how\nfar I’m willing to take the bug archaeology, especially\nwhen there’s no treasure to loot. [↩︎](https://chameth.com/#fnref:3)\n\nI resisted the urge to add `// ⚠️⚠️ SECURITY BOUNDARY ⚠️⚠️` [↩︎](https://chameth.com/#fnref:4)\n\nI pinged my friends on IRC and told them to update\ntheir shit because I’d done bad. [↩︎](https://chameth.com/#fnref:5)", "url": "https://wpnews.pro/news/my-first-cve-the-bad-kind", "canonical_source": "https://chameth.com/my-first-cve/", "published_at": "2026-11-04 00:00:00+00:00", "updated_at": "2026-11-05 22:52:53.033282+00:00", "lang": "en", "topics": ["ai-safety", "ai-agents", "ai-tools"], "entities": ["Chris Smith", "Centauri", "Claude Code", "Anthropic", "GitHub", "GHSA-87xc-jgvf-hmvg", "CWE-918", "Tailscale-User-Login"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/my-first-cve-the-bad-kind", "markdown": "https://wpnews.pro/news/my-first-cve-the-bad-kind.md", "text": "https://wpnews.pro/news/my-first-cve-the-bad-kind.txt", "jsonld": "https://wpnews.pro/news/my-first-cve-the-bad-kind.jsonld"}}