GHSA-9V4J-7G44-QCQW
Vulnerability from github – Published: 2026-05-19 14:36 – Updated: 2026-05-19 14:36Summary
When auto-refresh is enabled, Algernon spins up an SSE handler that streams a data: line for every filesystem event under the watched directory. The handler performs no authentication of any kind — no shared token, no cookie check against the permissions2 userstate, no IP allow-list, no path-prefix permission. Any client that can complete a TCP connection to the listener address receives the stream.
This advisory covers the authentication gap in isolation. The cross-origin browser-reach (advisory #2b) and the network-reach (advisory #2c) amplify the impact, but each is independently fixable; this finding addresses the case where a same-origin or LAN-local client connects directly to the SSE port and reads the stream without proving anything about its identity.
Details
Root cause — the SSE handler does not consult permissions2 or any other auth
// vendor/github.com/xyproto/recwatch/eventserver.go:100-144 (1.17.6)
func GenFileChangeEvents(events TimeEventMap, mut *sync.Mutex, maxAge time.Duration, allowed string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream;charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", allowed)
// ... loop emits one SSE record per filename touched ...
}
}
Note the handler signature: func(w http.ResponseWriter, _ *http.Request). The request is discarded — no Cookie, Authorization, query-string, or remote-IP check is performed before the stream begins.
In 1.17.6 the listener was placed on its own http.ServeMux (recwatch/eventserver.go:200-215), wholly outside the perm.Rejected middleware chain that gates Algernon's main HTTP listener. Even an operator who had configured admin/user path prefixes via perm.AddAdminPath, set a cookieSecret, and forced authentication on every URL of the main server had no way to gate this listener — it was unreachable from the mux argument the perm middleware uses.
Why authentication matters for this listener
The stream contents are not public data. They reveal:
- Which files the developer is actively editing, with sub-second timing precision.
- The existence of files inside the watched root (including files the operator may have meant to keep private —
.env.local,secrets.lua, in-progress draft files). - By inference, the directory layout of the project.
A client that can connect to the listener obtains a low-rate continuous information disclosure for the lifetime of the connection. The handler is an infinite for {} loop — there is no natural session boundary or expiry.
Source-level evidence
$ rg -n 'GenFileChangeEvents|EventServer\(' vendor/github.com/xyproto/recwatch/
vendor/github.com/xyproto/recwatch/eventserver.go:101:func GenFileChangeEvents(events TimeEventMap, mut *sync.Mutex, maxAge time.Duration, allowed string) http.HandlerFunc {
vendor/github.com/xyproto/recwatch/eventserver.go:177:func EventServer(path, allowed, eventAddr, eventPath string, refreshDuration time.Duration) {
$ rg -n 'Cookie|Authorization|Token|state\.User' vendor/github.com/xyproto/recwatch/eventserver.go
# zero matches — no authentication primitive is referenced anywhere in the file
PoC (against 1.17.6)
# 1. Operator runs algernon with auto-refresh on a project directory:
algernon -a /path/to/project # spins up :5553 on Linux/macOS, localhost:5553 on Windows
# 2. Any client that can reach the listener connects without credentials:
curl -sN http://<server>:5553/sse
# => id: 0
# data: /path/to/project/secret-notes.md
#
# id: 1
# data: /path/to/project/.env.local
No Cookie, no Authorization, no X-Token, no preflight, no challenge. The connection succeeds and the stream is delivered for as long as the client keeps the socket open.
Impact
- Confidentiality: medium. Continuous information disclosure of filenames and edit timing to anyone who can connect.
- Integrity: none.
- Availability: low. Each connection consumes a goroutine indefinitely; many simultaneous connections can exhaust descriptors.
Suggestions to fix
Primary fix — require a shared secret on the SSE endpoint. The auto-refresh feature already injects a script into served HTML (engine/sse.go:118-165); that script knows the SSE URL. Add a per-startup token, embed it in the injected JS, and require it on the SSE request:
// engine/sse.go -- in InsertAutoRefresh
tmplData.SessionToken = ac.sseToken // generated once at startup, e.g. crypto/rand 32 bytes
// JS:
// var source = new EventSource('...?token={{.SessionToken}}');
// recwatch handler:
// if subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("token")),
// []byte(serverToken)) != 1 {
// http.Error(w, "forbidden", http.StatusForbidden); return
// }
Cookie-bearing requests work too if recwatch.EventServer is moved behind perm.Rejected (see "Defence in depth"). The token approach is the smaller change.
Defence in depth — mount the SSE handler on the main mux. Moving recwatch.EventServerHandler onto the main http.ServeMux automatically places the SSE handler behind whatever middleware the operator has configured — perm.Rejected, tollbooth, custom auth wrappers. This closes the same-origin half of the gap without a per-token implementation. Any dedicated-port path bypasses perm.Rejected because it uses its own http.ServeMux, and that path needs the token fix from "Primary fix" above.
Live verification
$ ./algernon.exe --nodb --httponly --server -a --addr 127.0.0.1:18781 --quiet poc2/site
$ ( curl -sN --max-time 4 http://127.0.0.1:5553/sse > stream.txt &
sleep 1
echo "edit-1" >> poc2/site/secret-notes.md
echo "edit-2" >> poc2/site/.env.local
wait )
$ cat stream.txt
id: 0
data: C:\Users\xbox\Desktop\VulnTesting\algernon-main\poc-test\poc2\site\secret-notes.md
id: 1
data: C:\Users\xbox\Desktop\VulnTesting\algernon-main\poc-test\poc2\site\.env.local
No Cookie, no Authorization header. Stream delivered.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.17.6"
},
"package": {
"ecosystem": "Go",
"name": "github.com/xyproto/algernon"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.17.7"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-1188",
"CWE-200",
"CWE-306",
"CWE-942"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-19T14:36:34Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "### Summary\n\nWhen auto-refresh is enabled, Algernon spins up an SSE handler that streams a `data:` line for every filesystem event under the watched directory. The handler performs **no authentication** of any kind \u2014 no shared token, no cookie check against the `permissions2` userstate, no IP allow-list, no path-prefix permission. Any client that can complete a TCP connection to the listener address receives the stream.\n\nThis advisory covers the authentication gap in isolation. The cross-origin browser-reach (advisory #2b) and the network-reach (advisory #2c) amplify the impact, but each is independently fixable; this finding addresses the case where a same-origin or LAN-local client connects directly to the SSE port and reads the stream without proving anything about its identity.\n\n### Details\n\n#### Root cause \u2014 the SSE handler does not consult `permissions2` or any other auth\n\n```go\n// vendor/github.com/xyproto/recwatch/eventserver.go:100-144 (1.17.6)\nfunc GenFileChangeEvents(events TimeEventMap, mut *sync.Mutex, maxAge time.Duration, allowed string) http.HandlerFunc {\n return func(w http.ResponseWriter, _ *http.Request) {\n w.Header().Set(\"Content-Type\", \"text/event-stream;charset=utf-8\")\n w.Header().Set(\"Cache-Control\", \"no-cache\")\n w.Header().Set(\"Connection\", \"keep-alive\")\n w.Header().Set(\"Access-Control-Allow-Origin\", allowed)\n // ... loop emits one SSE record per filename touched ...\n }\n}\n```\n\nNote the handler signature: `func(w http.ResponseWriter, _ *http.Request)`. The request is discarded \u2014 no `Cookie`, `Authorization`, query-string, or remote-IP check is performed before the stream begins.\n\nIn 1.17.6 the listener was placed on its own `http.ServeMux` ([recwatch/eventserver.go:200-215](../vendor/github.com/xyproto/recwatch/eventserver.go)), wholly outside the `perm.Rejected` middleware chain that gates Algernon\u0027s main HTTP listener. Even an operator who had configured admin/user path prefixes via `perm.AddAdminPath`, set a `cookieSecret`, and forced authentication on every URL of the main server had no way to gate this listener \u2014 it was unreachable from the `mux` argument the perm middleware uses.\n\n#### Why authentication matters for this listener\n\nThe stream contents are not public data. They reveal:\n\n- Which files the developer is actively editing, with sub-second timing precision.\n- The existence of files inside the watched root (including files the operator may have meant to keep private \u2014 `.env.local`, `secrets.lua`, in-progress draft files).\n- By inference, the directory layout of the project.\n\nA client that can connect to the listener obtains a low-rate continuous information disclosure for the lifetime of the connection. The handler is an infinite `for {}` loop \u2014 there is no natural session boundary or expiry.\n\n#### Source-level evidence\n\n```text\n$ rg -n \u0027GenFileChangeEvents|EventServer\\(\u0027 vendor/github.com/xyproto/recwatch/\nvendor/github.com/xyproto/recwatch/eventserver.go:101:func GenFileChangeEvents(events TimeEventMap, mut *sync.Mutex, maxAge time.Duration, allowed string) http.HandlerFunc {\nvendor/github.com/xyproto/recwatch/eventserver.go:177:func EventServer(path, allowed, eventAddr, eventPath string, refreshDuration time.Duration) {\n\n$ rg -n \u0027Cookie|Authorization|Token|state\\.User\u0027 vendor/github.com/xyproto/recwatch/eventserver.go\n# zero matches \u2014 no authentication primitive is referenced anywhere in the file\n```\n\n### PoC (against 1.17.6)\n\n```bash\n# 1. Operator runs algernon with auto-refresh on a project directory:\nalgernon -a /path/to/project # spins up :5553 on Linux/macOS, localhost:5553 on Windows\n\n# 2. Any client that can reach the listener connects without credentials:\ncurl -sN http://\u003cserver\u003e:5553/sse\n# =\u003e id: 0\n# data: /path/to/project/secret-notes.md\n#\n# id: 1\n# data: /path/to/project/.env.local\n```\n\nNo `Cookie`, no `Authorization`, no `X-Token`, no preflight, no challenge. The connection succeeds and the stream is delivered for as long as the client keeps the socket open.\n\n### Impact\n\n- **Confidentiality:** medium. Continuous information disclosure of filenames and edit timing to anyone who can connect.\n- **Integrity:** none.\n- **Availability:** low. Each connection consumes a goroutine indefinitely; many simultaneous connections can exhaust descriptors.\n\n### Suggestions to fix\n\n**Primary fix \u2014 require a shared secret on the SSE endpoint.** The auto-refresh feature already injects a script into served HTML ([engine/sse.go:118-165](../engine/sse.go)); that script knows the SSE URL. Add a per-startup token, embed it in the injected JS, and require it on the SSE request:\n\n```go\n// engine/sse.go -- in InsertAutoRefresh\ntmplData.SessionToken = ac.sseToken // generated once at startup, e.g. crypto/rand 32 bytes\n\n// JS:\n// var source = new EventSource(\u0027...?token={{.SessionToken}}\u0027);\n\n// recwatch handler:\n// if subtle.ConstantTimeCompare([]byte(r.URL.Query().Get(\"token\")),\n// []byte(serverToken)) != 1 {\n// http.Error(w, \"forbidden\", http.StatusForbidden); return\n// }\n```\n\nCookie-bearing requests work too if `recwatch.EventServer` is moved behind `perm.Rejected` (see \"Defence in depth\"). The token approach is the smaller change.\n\n**Defence in depth \u2014 mount the SSE handler on the main mux.** Moving `recwatch.EventServerHandler` onto the main `http.ServeMux` automatically places the SSE handler behind whatever middleware the operator has configured \u2014 `perm.Rejected`, `tollbooth`, custom auth wrappers. This closes the same-origin half of the gap without a per-token implementation. Any dedicated-port path bypasses `perm.Rejected` because it uses its own `http.ServeMux`, and that path needs the token fix from \"Primary fix\" above.\n\n### Live verification\n\n```\n$ ./algernon.exe --nodb --httponly --server -a --addr 127.0.0.1:18781 --quiet poc2/site\n$ ( curl -sN --max-time 4 http://127.0.0.1:5553/sse \u003e stream.txt \u0026\n sleep 1\n echo \"edit-1\" \u003e\u003e poc2/site/secret-notes.md\n echo \"edit-2\" \u003e\u003e poc2/site/.env.local\n wait )\n$ cat stream.txt\nid: 0\ndata: C:\\Users\\xbox\\Desktop\\VulnTesting\\algernon-main\\poc-test\\poc2\\site\\secret-notes.md\n\nid: 1\ndata: C:\\Users\\xbox\\Desktop\\VulnTesting\\algernon-main\\poc-test\\poc2\\site\\.env.local\n```\n\nNo `Cookie`, no `Authorization` header. Stream delivered.",
"id": "GHSA-9v4j-7g44-qcqw",
"modified": "2026-05-19T14:36:34Z",
"published": "2026-05-19T14:36:34Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/xyproto/algernon/security/advisories/GHSA-9v4j-7g44-qcqw"
},
{
"type": "PACKAGE",
"url": "https://github.com/xyproto/algernon"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Algernon: Auto-refresh SSE event server binds to all interfaces with Access-Control-Allow-Origin: * and no authentication"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.