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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 893 | 893 | if !ok { | |
| 894 | 894 | 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 | 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 | 902 | _, _ = fmt.Fprintf(w, "data: %s\n\n", escaped) | |
| 900 | 903 | flusher.Flush() | |
| 901 | 904 | case <-ctx.Done(): | |
| @@ -936,8 +939,12 @@ func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, lo | |||
| 936 | 939 | if !ok { | |
| 937 | 940 | return | |
| 938 | 941 | } | |
| 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: ") | |
| 941 | 948 | _, _ = fmt.Fprintf(w, "data: %s\n\n", escaped) | |
| 942 | 949 | flusher.Flush() | |
| 943 | 950 | 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 | return | 894 | 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 | return | 940 | 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(): | ||