detached 5a0c51d

fix(admin): normalize CRLF to LF before escaping in SSE log streams

Replace \r\n and \r with \n before escaping newlines to prevent
SSE injection from mixed line endings in log lines.

Applies to both streamProcessLogs and streamContainerLogs.
commit
5a0c51d39b78d4865cb2ef16575822facfbc17f0
author
bnema · · 2026-02-01 15:04 UTC
parent
41b10a9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +11 -4
M internal/adapters/in/http/admin/handler.go +11 -4
@@ -893,9 +893,12 @@ func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, line
893893 if !ok {
894894 return
895895 }
896- // SECURITY: Escape newlines in log lines to prevent SSE event injection.
896+ // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
897+ // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
897898 // Per the SSE spec, multi-line data must use separate "data:" prefixes.
898- escaped := strings.ReplaceAll(line, "\n", "\ndata: ")
899+ normalized := strings.ReplaceAll(line, "\r\n", "\n")
900+ normalized = strings.ReplaceAll(normalized, "\r", "\n")
901+ escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
899902 _, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
900903 flusher.Flush()
901904 case <-ctx.Done():
@@ -936,8 +939,12 @@ func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, lo
936939 if !ok {
937940 return
938941 }
939- // SECURITY: Escape newlines in log lines to prevent SSE event injection.
940- escaped := strings.ReplaceAll(line, "\n", "\ndata: ")
942+ // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
943+ // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
944+ // Per the SSE spec, multi-line data must use separate "data:" prefixes.
945+ normalized := strings.ReplaceAll(line, "\r\n", "\n")
946+ normalized = strings.ReplaceAll(normalized, "\r", "\n")
947+ escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
941948 _, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
942949 flusher.Flush()
943950 case <-ctx.Done():
@@ -893,9 +893,12 @@ func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, line
893 if !ok {893 if !ok {
894 return894 return
895 }895 }
896- // SECURITY: Escape newlines in log lines to prevent SSE event injection.896+ // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
897+ // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
897 // Per the SSE spec, multi-line data must use separate "data:" prefixes.898 // Per the SSE spec, multi-line data must use separate "data:" prefixes.
898- escaped := strings.ReplaceAll(line, "\n", "\ndata: ")899+ normalized := strings.ReplaceAll(line, "\r\n", "\n")
900+ normalized = strings.ReplaceAll(normalized, "\r", "\n")
901+ escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
899 _, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)902 _, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
900 flusher.Flush()903 flusher.Flush()
901 case <-ctx.Done():904 case <-ctx.Done():
@@ -936,8 +939,12 @@ func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, lo
936 if !ok {939 if !ok {
937 return940 return
938 }941 }
939- // SECURITY: Escape newlines in log lines to prevent SSE event injection.942+ // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
940- escaped := strings.ReplaceAll(line, "\n", "\ndata: ")943+ // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
944+ // Per the SSE spec, multi-line data must use separate "data:" prefixes.
945+ normalized := strings.ReplaceAll(line, "\r\n", "\n")
946+ normalized = strings.ReplaceAll(normalized, "\r", "\n")
947+ escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
941 _, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)948 _, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
942 flusher.Flush()949 flusher.Flush()
943 case <-ctx.Done():950 case <-ctx.Done():

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close