detached 31e383c

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
31e383c7b41ee429cbaf14266f32a9cac8a9893a
author
bnema · · 2026-02-01 15:04 UTC
parent
b9c783f
signature
unsigned
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