CVE-2026-64941: an open redirect in the guard that prevents open redirects
The open redirect I reported in Phoenix LiveView — in validate_local_url!/2, the guard whose entire job is keeping a redirect target on your own origin. It never looked at the three characters browsers discard before they parse a URL.
Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every test ran against local servers standing in for two origins. The issue was reported privately through the project’s own security advisory process, and is written up here only after the advisory and the fixed releases were public.

Phoenix LiveView published GHSA-36m4-rm57-3prf this morning — CVE-2026-64941, an open redirect (CWE-601) in Phoenix.LiveView.redirect/2. I reported it on 8 August, it was accepted the following day, and it was published today with a fix in three release branches. Severity is Low: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:A/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N, 2.1.
Upgrade to 1.0.19, 1.1.33 or 1.2.9. Everything from 0.5.0 — November 2019 — is affected.
What makes it worth a post is not the severity. It is that the vulnerable function is a security control whose single job is to prevent exactly this, and that the bug is not really in Elixir at all — it is in what a browser does to a string before anyone gets to parse it.
The guard
validate_local_url!/2 in lib/phoenix_live_view.ex had three clauses, unchanged since the day it was written:
@invalid_local_url_chars ["\\"]
defp validate_local_url!("//" <> _ = to, where) do
raise_invalid_local_url!(to, where)
end
defp validate_local_url!("/" <> _ = to, where) do
if String.contains?(to, @invalid_local_url_chars) do
raise ArgumentError, "unsafe characters detected for #{where} in URL #{inspect(to)}"
else
to
end
end
defp validate_local_url!(to, where) do
raise_invalid_local_url!(to, where)
end
Reject a leading //. Accept a leading / unless it contains a backslash. Reject everything else. git log -S "invalid_local_url_chars" returned a single commit, so that list had never been revisited.
Now consider /<TAB>/example.com. It starts with / but not //, so the first clause does not match. It contains no backslash, so the second clause returns it unchanged, and as far as the server is concerned it is a path on your application.
The browser is the entire bug
The WHATWG URL Standard instructs the basic URL parser to remove all ASCII tab and newline from its input before parsing. Not reject — remove. So the string the browser parses is not the string LiveView validated:
:to value |
resolved against https://app.example.com/login |
|---|---|
/<TAB>/example.com |
https://example.com/ |
/<LF>/example.com |
https://example.com/ |
/<CR>/example.com |
https://example.com/ |
/<TAB>example.com |
https://app.example.com/example.com — local, not an issue |
That last row is the reason this survived seven years. The second slash is mandatory: strip the tab out of /<TAB>example.com and you still have a path. A naive test of /<TAB>evil.com shows nothing at all, which is a good description of how a whole class of bugs stays alive — the obvious payload is the one that does not work.
I confirmed it in Chrome rather than deriving it from the spec, with two local servers standing in for the two origins and a page doing exactly what LiveView’s client does:
<script>window.location.href = "/\t/127.0.0.1:4031/CANARY-EXTERNAL";</script>
server 4030 (origin) GET /
server 4031 (attacker host) GET /CANARY-EXTERNAL
Why Phoenix core was not affected
Phoenix has the same guard, under the same name, with the same constant name — and is fine, for two independent reasons:
# phoenix/lib/phoenix/controller.ex
@invalid_local_url_chars ["\\", "/%09", "/\t"]
First, that list carries the tab entry, added in response to CVE-2017-1000163 — this same class of bypass, in the identically-named function, nine years ago. Second, Phoenix.Controller.redirect/2 writes its result into a Location response header, and Plug.Conn.put_resp_header/3 raises on any value containing LF or CR. Two layers, either of which alone would have been enough.
LiveView has neither, because its redirect never becomes an HTTP header. It travels over the LiveView channel as JSON and lands in assets/js/phoenix_live_view/browser.ts:
redirect(toURL, flash = null, navigate = (url) => { window.location.href = url; }) {
if (flash) { this.setCookie("__phoenix_flash__", flash, 60); }
navigate(toURL);
}
Assignment to window.location.href runs the URL parser that does the stripping. Nothing between the server-side guard and that assignment ever looks at the value again.
Running each candidate through both implementations:
:to |
Phoenix core | LiveView |
|---|---|---|
//evil.com |
raises ArgumentError |
raises ArgumentError |
/\evil.com |
raises ArgumentError |
raises ArgumentError |
/<TAB>/evil.com |
raises ArgumentError |
accepted |
/<LF>/evil.com |
raises Plug.Conn.InvalidHeaderError |
accepted |
/<CR>/evil.com |
raises Plug.Conn.InvalidHeaderError |
accepted |
How it was found
There was no fuzzing and no harness. It came out of reading someone else’s patch and asking what it did not cover.
In July 2026, LiveView published CVE-2026-58228, which fixed scheme validation in Phoenix.LiveView.Utils: a leading ASCII control character or space made uri_scheme/1 miss a javascript: scheme. Its stated root cause is precisely the browser behaviour above — those characters get discarded before parsing, so what you validate is not what resolves.
That is an insight about a browser, not about one function. So the question is simply: where else does this project validate a URL? The answer was one other place, in a different module, guarding a different sink — navigation rather than a rendered href — and it had not been looked at when the first one was fixed. It was still there in 1.2.8, released after that fix shipped.
That is the whole method, and it generalises past this codebase: when a project patches a URL, path or encoding guard, the fix tells you which primitive the maintainers now know about. Then you enumerate every other place the project makes the same kind of promise, and check whether the primitive reaches it. Fixes are usually applied at the site where the bug was reported, not across the class.
The other half of why this converted at all is a selection rule I now apply before picking a target: is the library itself the security control? A parser, a server or a transport usually needs a third party to disagree with it before a defect has any impact, and modern front-ends are strict. A sanitizer, an authorization check or a redirect guard has intrinsic impact, because the bypass is the impact. No one has to be misconfigured downstream for it to matter.
Whose bug it is
The obvious way to close this report is “validate your own inputs”. I addressed that in the report rather than waiting to be asked, because it is a fair question.
validate_local_url!/2 is not a convenience helper. It rejects // and \, and when it raises it says unsafe characters detected. Both the behaviour and the wording commit to a security property: a :to that survives validation cannot leave the origin. An application author reading redirect/2 has no reason to add a second check on top of a guard that already advertises this one, and the documentation for :to describes it as a path.
The narrow position — not that applications should be free to redirect anywhere, but that a guard which already promises same-origin should not be defeated by three characters browsers are specified to discard — is also the position Phoenix core took in 2017 on the identical class in the identically-named function.
What the maintainers changed, and why they were right
Two things in my report were narrowed, and both narrowings were correct. Recording them is more useful than recording the parts that stood.
The severity came down. I suggested CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N — 6.1, Medium, which is how open redirects are usually scored. The coordinating CNA rescored it in CVSS v4 to 2.1, Low, and the v4 vector says something 3.1 simply cannot: the vulnerable system is untouched, and the impact lands on a subsequent system — the user’s browser. In 3.1 the only way to express “the harm happens somewhere other than the thing that is broken” is S:C, which is where my 6.1 came from and which overstated it.
I had also written that OAuth authorization codes and tokens could leak. In the common return_to shape, that is wrong: the application redirects to the supplied path, it does not append a secret to it. SC:L — a referrer, at most — is the honest ceiling. Claim what you demonstrated, not what the vulnerability class is famous for.
The affected surface came down. My report named all four entry points, since push_opts!/2 shares the guard with redirect/2. The maintainers pointed out that this is true on the server and irrelevant on the client: live navigation goes through expandURL, which rewrites any value starting with / against window.location.protocol and window.location.host before navigating. push_navigate/2 and push_patch/2 therefore land on your own origin regardless of what the string contains. Only redirect/2, which assigns the raw value to window.location.href, actually leaves. (Live patch appears to have been exposed between 0.5.0 and 0.7.0, before that expansion existed.)
Sharing a code path is not the same as sharing a sink, and I had not read far enough down the client to know the difference.
The fix
I proposed two shapes: aligning the character list with Phoenix core, or stripping the characters the browser strips and validating the result:
stripped = String.replace(to, ["\t", "\n", "\r"], "")
if String.starts_with?(stripped, "//") or String.contains?(to, @invalid_local_url_chars) do
The maintainers proposed something better and shorter:
# We add \r and \n since those are checked on Phoenix at the header level
@invalid_local_url_chars ["\\", "/%09", "/\t", "\n", "\r"]
It allocates nothing per call, it converges the two guards so LiveView and Phoenix core now say the same thing, and it is strictly stronger than mine in one place I had missed — /%09 is on the list, and my version accepts /%09/example.com outright. (I could not get that one to leave the origin, since the parser does not percent-decode before stripping, but it costs nothing and Phoenix has carried it for years.) Unprefixed \n and \r also track the reasoning exactly: Phoenix rejects those anywhere via the header layer, so bare entries reproduce Phoenix’s effective behaviour rather than just the leading-// case.
I checked the fix rather than assume it. Sweeping 450 candidates of the form / + up to two of {tab, LF, CR, \, space, %09, %0A, %0D} + a host, and resolving each with a WHATWG parser against a document on another origin: 160 leave the origin, and none get past the list.
That is not an accident of the sample. For a stripped string to begin with //, the original must be /, then one or more characters the parser removes, then / — so bytes 0 and 1 are always a slash followed by a tab, LF or CR. Three slash-prefixed pairs is exhaustive, and the bare \\ entry covers the backslash-after-stripping case such as /<TAB>\example.com.
Timeline and credits
| 8 Aug 2026 | Reported via GitHub Security Advisory |
| 9 Aug 2026 | Accepted; CVE-2026-64941 assigned by the Erlang Ecosystem Foundation CNA; fix written and reviewed |
| 10 Aug 2026 | Published; 1.0.19, 1.1.33 and 1.2.9 released |
Roughly forty-eight hours from report to published fix across three maintained branches. The advisory credits me as reporter, two LiveView maintainers as remediation developer and remediation reviewer, and the EEF as coordinator.
I want to be specific about what was good here, because “the maintainers were responsive” undersells it. They engaged with the technical substance within hours, corrected two things I had gotten wrong, produced a fix better than either I proposed, and requested a CVE when I raised that a GHSA alone reaches Dependabot and OSV but misses scanners that consume neither. The BEAM ecosystem’s disclosure path — a project advisory, coordinated by the EEF as CNA, with a credits field that names the finder — works, and it works quickly.
If you run Phoenix LiveView and pass anything user-influenced to redirect/2, upgrade. Then go and look at every other place your own code promises that a URL is local.
Eurico Nicacio — @h3llh0und