Skip to content

Commit 04e46e6

Browse files
committed
Logger middleware should escape string values when outputting JSON
1 parent 612967a commit 04e46e6

File tree

3 files changed

+554
-132
lines changed

3 files changed

+554
-132
lines changed

middleware/logger.go

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ type LoggerConfig struct {
197197
template *fasttemplate.Template
198198
colorer *color.Color
199199
pool *sync.Pool
200+
timeNow func() time.Time
200201
}
201202

202203
// DefaultLoggerConfig is the default Logger middleware config.
@@ -208,6 +209,7 @@ var DefaultLoggerConfig = LoggerConfig{
208209
`,"bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n",
209210
CustomTimeFormat: "2006-01-02 15:04:05.00000",
210211
colorer: color.New(),
212+
timeNow: time.Now,
211213
}
212214

213215
// Logger returns a middleware that logs HTTP requests using the default configuration.
@@ -267,9 +269,18 @@ func LoggerWithConfig(config LoggerConfig) echo.MiddlewareFunc {
267269
if config.Format == "" {
268270
config.Format = DefaultLoggerConfig.Format
269271
}
272+
writeString := func(buf *bytes.Buffer, in string) (int, error) { return buf.WriteString(in) }
273+
if config.Format[0] == '{' { // format looks like JSON, so we need to escape invalid characters
274+
writeString = writeJSONSafeString
275+
}
276+
270277
if config.Output == nil {
271278
config.Output = DefaultLoggerConfig.Output
272279
}
280+
timeNow := DefaultLoggerConfig.timeNow
281+
if config.timeNow != nil {
282+
timeNow = config.timeNow
283+
}
273284

274285
config.template = fasttemplate.New(config.Format, "${", "}")
275286
config.colorer = color.New()
@@ -305,49 +316,47 @@ func LoggerWithConfig(config LoggerConfig) echo.MiddlewareFunc {
305316
}
306317
return config.CustomTagFunc(c, buf)
307318
case "time_unix":
308-
return buf.WriteString(strconv.FormatInt(time.Now().Unix(), 10))
319+
return buf.WriteString(strconv.FormatInt(timeNow().Unix(), 10))
309320
case "time_unix_milli":
310-
// go 1.17 or later, it supports time#UnixMilli()
311-
return buf.WriteString(strconv.FormatInt(time.Now().UnixNano()/1000000, 10))
321+
return buf.WriteString(strconv.FormatInt(timeNow().UnixMilli(), 10))
312322
case "time_unix_micro":
313-
// go 1.17 or later, it supports time#UnixMicro()
314-
return buf.WriteString(strconv.FormatInt(time.Now().UnixNano()/1000, 10))
323+
return buf.WriteString(strconv.FormatInt(timeNow().UnixMicro(), 10))
315324
case "time_unix_nano":
316-
return buf.WriteString(strconv.FormatInt(time.Now().UnixNano(), 10))
325+
return buf.WriteString(strconv.FormatInt(timeNow().UnixNano(), 10))
317326
case "time_rfc3339":
318-
return buf.WriteString(time.Now().Format(time.RFC3339))
327+
return buf.WriteString(timeNow().Format(time.RFC3339))
319328
case "time_rfc3339_nano":
320-
return buf.WriteString(time.Now().Format(time.RFC3339Nano))
329+
return buf.WriteString(timeNow().Format(time.RFC3339Nano))
321330
case "time_custom":
322-
return buf.WriteString(time.Now().Format(config.CustomTimeFormat))
331+
return buf.WriteString(timeNow().Format(config.CustomTimeFormat))
323332
case "id":
324333
id := req.Header.Get(echo.HeaderXRequestID)
325334
if id == "" {
326335
id = res.Header().Get(echo.HeaderXRequestID)
327336
}
328-
return buf.WriteString(id)
337+
return writeString(buf, id)
329338
case "remote_ip":
330-
return buf.WriteString(c.RealIP())
339+
return writeString(buf, c.RealIP())
331340
case "host":
332-
return buf.WriteString(req.Host)
341+
return writeString(buf, req.Host)
333342
case "uri":
334-
return buf.WriteString(req.RequestURI)
343+
return writeString(buf, req.RequestURI)
335344
case "method":
336-
return buf.WriteString(req.Method)
345+
return writeString(buf, req.Method)
337346
case "path":
338347
p := req.URL.Path
339348
if p == "" {
340349
p = "/"
341350
}
342-
return buf.WriteString(p)
351+
return writeString(buf, p)
343352
case "route":
344-
return buf.WriteString(c.Path())
353+
return writeString(buf, c.Path())
345354
case "protocol":
346-
return buf.WriteString(req.Proto)
355+
return writeString(buf, req.Proto)
347356
case "referer":
348-
return buf.WriteString(req.Referer())
357+
return writeString(buf, req.Referer())
349358
case "user_agent":
350-
return buf.WriteString(req.UserAgent())
359+
return writeString(buf, req.UserAgent())
351360
case "status":
352361
n := res.Status
353362
s := config.colorer.Green(n)
@@ -377,17 +386,17 @@ func LoggerWithConfig(config LoggerConfig) echo.MiddlewareFunc {
377386
if cl == "" {
378387
cl = "0"
379388
}
380-
return buf.WriteString(cl)
389+
return writeString(buf, cl)
381390
case "bytes_out":
382391
return buf.WriteString(strconv.FormatInt(res.Size, 10))
383392
default:
384393
switch {
385394
case strings.HasPrefix(tag, "header:"):
386-
return buf.Write([]byte(c.Request().Header.Get(tag[7:])))
395+
return writeString(buf, c.Request().Header.Get(tag[7:]))
387396
case strings.HasPrefix(tag, "query:"):
388-
return buf.Write([]byte(c.QueryParam(tag[6:])))
397+
return writeString(buf, c.QueryParam(tag[6:]))
389398
case strings.HasPrefix(tag, "form:"):
390-
return buf.Write([]byte(c.FormValue(tag[5:])))
399+
return writeString(buf, c.FormValue(tag[5:]))
391400
case strings.HasPrefix(tag, "cookie:"):
392401
cookie, err := c.Cookie(tag[7:])
393402
if err == nil {

middleware/logger_strings.go

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
// Copyright 2010 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package middleware
6+
7+
import (
8+
"bytes"
9+
"unicode/utf8"
10+
)
11+
12+
// This function is modified copy from Go standard library encoding/json/encode.go `appendString` function
13+
// Source: https://github.com/golang/go/blob/36bca3166e18db52687a4d91ead3f98ffe6d00b8/src/encoding/json/encode.go#L999
14+
func writeJSONSafeString(buf *bytes.Buffer, src string) (int, error) {
15+
const hex = "0123456789abcdef"
16+
17+
written := 0
18+
start := 0
19+
for i := 0; i < len(src); {
20+
if b := src[i]; b < utf8.RuneSelf {
21+
if safeSet[b] {
22+
i++
23+
continue
24+
}
25+
26+
n, err := buf.Write([]byte(src[start:i]))
27+
written += n
28+
if err != nil {
29+
return written, err
30+
}
31+
switch b {
32+
case '\\', '"':
33+
n, err := buf.Write([]byte{'\\', b})
34+
written += n
35+
if err != nil {
36+
return written, err
37+
}
38+
case '\b':
39+
n, err := buf.Write([]byte{'\\', 'b'})
40+
if err != nil {
41+
return n, err
42+
}
43+
case '\f':
44+
n, err := buf.Write([]byte{'\\', 'f'})
45+
written += n
46+
if err != nil {
47+
return written, err
48+
}
49+
case '\n':
50+
n, err := buf.Write([]byte{'\\', 'f'})
51+
written += n
52+
if err != nil {
53+
return written, err
54+
}
55+
case '\r':
56+
n, err := buf.Write([]byte{'\\', 'r'})
57+
written += n
58+
if err != nil {
59+
return written, err
60+
}
61+
case '\t':
62+
n, err := buf.Write([]byte{'\\', 't'})
63+
written += n
64+
if err != nil {
65+
return written, err
66+
}
67+
default:
68+
// This encodes bytes < 0x20 except for \b, \f, \n, \r and \t.
69+
// If escapeHTML is set, it also escapes <, >, and &
70+
// because they can lead to security holes when
71+
// user-controlled strings are rendered into JSON
72+
// and served to some browsers.
73+
n, err := buf.Write([]byte{'\\', 'u', '0', '0', hex[b>>4], hex[b&0xF]})
74+
written += n
75+
if err != nil {
76+
return written, err
77+
}
78+
}
79+
i++
80+
start = i
81+
continue
82+
}
83+
// TODO(https://go.dev/issue/56948): Use generic utf8 functionality.
84+
// For now, cast only a small portion of byte slices to a string
85+
// so that it can be stack allocated. This slows down []byte slightly
86+
// due to the extra copy, but keeps string performance roughly the same.
87+
srcN := min(len(src)-i, utf8.UTFMax)
88+
c, size := utf8.DecodeRuneInString(src[i : i+srcN])
89+
if c == utf8.RuneError && size == 1 {
90+
n, err := buf.Write([]byte(src[start:i]))
91+
written += n
92+
if err != nil {
93+
return written, err
94+
}
95+
n, err = buf.Write([]byte(`\ufffd`))
96+
written += n
97+
if err != nil {
98+
return written, err
99+
}
100+
i += size
101+
start = i
102+
continue
103+
}
104+
// U+2028 is LINE SEPARATOR.
105+
// U+2029 is PARAGRAPH SEPARATOR.
106+
// They are both technically valid characters in JSON strings,
107+
// but don't work in JSONP, which has to be evaluated as JavaScript,
108+
// and can lead to security holes there. It is valid JSON to
109+
// escape them, so we do so unconditionally.
110+
// See https://en.wikipedia.org/wiki/JSON#Safety.
111+
if c == '\u2028' || c == '\u2029' {
112+
n, err := buf.Write([]byte(src[start:i]))
113+
written += n
114+
if err != nil {
115+
return written, err
116+
}
117+
n, err = buf.Write([]byte{'\\', 'u', '2', '0', '2', hex[c&0xF]})
118+
written += n
119+
if err != nil {
120+
return written, err
121+
}
122+
123+
i += size
124+
start = i
125+
continue
126+
}
127+
i += size
128+
}
129+
n, err := buf.Write([]byte(src[start:]))
130+
written += n
131+
return written, err
132+
}
133+
134+
// safeSet holds the value true if the ASCII character with the given array
135+
// position can be represented inside a JSON string without any further
136+
// escaping.
137+
//
138+
// All values are true except for the ASCII control characters (0-31), the
139+
// double quote ("), and the backslash character ("\").
140+
var safeSet = [utf8.RuneSelf]bool{
141+
' ': true,
142+
'!': true,
143+
'"': false,
144+
'#': true,
145+
'$': true,
146+
'%': true,
147+
'&': true,
148+
'\'': true,
149+
'(': true,
150+
')': true,
151+
'*': true,
152+
'+': true,
153+
',': true,
154+
'-': true,
155+
'.': true,
156+
'/': true,
157+
'0': true,
158+
'1': true,
159+
'2': true,
160+
'3': true,
161+
'4': true,
162+
'5': true,
163+
'6': true,
164+
'7': true,
165+
'8': true,
166+
'9': true,
167+
':': true,
168+
';': true,
169+
'<': true,
170+
'=': true,
171+
'>': true,
172+
'?': true,
173+
'@': true,
174+
'A': true,
175+
'B': true,
176+
'C': true,
177+
'D': true,
178+
'E': true,
179+
'F': true,
180+
'G': true,
181+
'H': true,
182+
'I': true,
183+
'J': true,
184+
'K': true,
185+
'L': true,
186+
'M': true,
187+
'N': true,
188+
'O': true,
189+
'P': true,
190+
'Q': true,
191+
'R': true,
192+
'S': true,
193+
'T': true,
194+
'U': true,
195+
'V': true,
196+
'W': true,
197+
'X': true,
198+
'Y': true,
199+
'Z': true,
200+
'[': true,
201+
'\\': false,
202+
']': true,
203+
'^': true,
204+
'_': true,
205+
'`': true,
206+
'a': true,
207+
'b': true,
208+
'c': true,
209+
'd': true,
210+
'e': true,
211+
'f': true,
212+
'g': true,
213+
'h': true,
214+
'i': true,
215+
'j': true,
216+
'k': true,
217+
'l': true,
218+
'm': true,
219+
'n': true,
220+
'o': true,
221+
'p': true,
222+
'q': true,
223+
'r': true,
224+
's': true,
225+
't': true,
226+
'u': true,
227+
'v': true,
228+
'w': true,
229+
'x': true,
230+
'y': true,
231+
'z': true,
232+
'{': true,
233+
'|': true,
234+
'}': true,
235+
'~': true,
236+
'\u007f': true,
237+
}

0 commit comments

Comments
 (0)