144 lines
4.9 KiB
Go
144 lines
4.9 KiB
Go
// Package logfield bounds the client-supplied values this service
|
|
// writes into its logs.
|
|
//
|
|
// Any log field whose content a client picks is spent against a budget
|
|
// here, in ENCODED bytes rather than in the bytes the client sent, so
|
|
// that escaping cannot multiply a field past its nominal size. One
|
|
// budget and one implementation serves the access log in
|
|
// internal/middleware and every other slog call that reaches a
|
|
// client-chosen path, header or form value; a second, ad-hoc
|
|
// truncation somewhere else in the tree is the thing this package
|
|
// exists to prevent.
|
|
package logfield
|
|
|
|
import (
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const (
|
|
// MaxBytes is the default budget for a log field whose value the
|
|
// client supplies outright: a URL, a path, a header, a form value.
|
|
// The budget is spent in ENCODED bytes (see Truncate), so 512 still
|
|
// holds a real browser's User-Agent whole — those are plain ASCII,
|
|
// which encodes one byte for one — while a value built from
|
|
// characters the encoder escapes keeps a shorter prefix. That is
|
|
// the intended trade: 500 quotation marks are not a debugging
|
|
// asset.
|
|
MaxBytes = 512
|
|
|
|
// TruncationMarker is appended to any field that was cut, so a
|
|
// short value and a truncated one cannot be confused. It is charged
|
|
// on top of the budget, not inside it.
|
|
TruncationMarker = "[truncated]"
|
|
)
|
|
|
|
// EncodedBytes is what r costs on the line once the log handler has
|
|
// escaped it, taking the worse of the two handlers internal/logger
|
|
// configures.
|
|
//
|
|
// slog's JSON handler escapes quote, backslash, newline, carriage
|
|
// return and tab to two bytes each, and every other C0 control plus
|
|
// LINE SEPARATOR and PARAGRAPH SEPARATOR to a six-byte \u escape; it
|
|
// passes every other rune through as its own UTF-8. Its text handler
|
|
// quotes with strconv.Quote, which spells a non-printable rune below
|
|
// U+10000 as \uXXXX but one at or above U+10000 as \UXXXXXXXX — ten
|
|
// bytes, not six. The text handler is therefore the worse of the two
|
|
// for every non-printable rune, and by four bytes apiece for the
|
|
// 955,086 unassigned, private-use and format code points on planes 1
|
|
// to 16.
|
|
//
|
|
// Charging ten there is what makes the stated line ceilings hold for
|
|
// the tty handler as well: U+1000C encodes as F0 90 80 8C, every byte
|
|
// >= 0x80, which httpguts.ValidHeaderFieldValue accepts and
|
|
// net/textproto does not strip, so a header can be filled with them.
|
|
//
|
|
// Both handlers pass printable runes through as their own UTF-8, so
|
|
// unicode.IsPrint separates the escaped cases from the plain ones for
|
|
// either handler.
|
|
func EncodedBytes(r rune) int {
|
|
const (
|
|
// A backslash and the character itself.
|
|
shortEscapeBytes = 2
|
|
// \uXXXX, which is also the width of \u00XX.
|
|
escapedRuneBytes = 6
|
|
// \UXXXXXXXX, strconv.Quote's spelling of a non-printable
|
|
// rune outside the basic multilingual plane.
|
|
escapedAstralRuneBytes = 10
|
|
// The first code point strconv.Quote spells with \U.
|
|
firstAstralRune = 0x10000
|
|
)
|
|
|
|
switch {
|
|
case r == '"' || r == '\\' || r == '\n' || r == '\r' || r == '\t':
|
|
return shortEscapeBytes
|
|
case !unicode.IsPrint(r) && r >= firstAstralRune:
|
|
return escapedAstralRuneBytes
|
|
case !unicode.IsPrint(r):
|
|
return escapedRuneBytes
|
|
default:
|
|
return utf8.RuneLen(r)
|
|
}
|
|
}
|
|
|
|
// Truncate caps s at maxBytes of ENCODED output, marking the value
|
|
// when it cuts.
|
|
//
|
|
// Budgeting raw bytes would not bound the line. Escaping only ever
|
|
// grows a value, so a raw budget spent on characters the encoder
|
|
// escapes buys a field several times its nominal size — and the line
|
|
// is the thing an operator is told to multiply by their request rate.
|
|
// Charging each rune what it will actually cost is what makes a stated
|
|
// ceiling true rather than merely larger. The visible consequence is
|
|
// that an escape-heavy value keeps a shorter prefix than a plain one,
|
|
// which is the correct trade.
|
|
//
|
|
// The result is always valid UTF-8. A cut on a byte boundary can split
|
|
// a multi-byte rune, and a header can carry bytes that were never
|
|
// valid UTF-8 to begin with; both are dropped rather than kept, since
|
|
// an encoder would otherwise spend six bytes replacing each one.
|
|
func Truncate(s string, maxBytes int) string {
|
|
// No rune encodes to fewer bytes than it occupies, so nothing past
|
|
// maxBytes raw can fit the budget. Slicing first bounds the scan
|
|
// below to the budget rather than to the size of the header the
|
|
// client sent.
|
|
window, cut := s, false
|
|
if len(window) > maxBytes {
|
|
window, cut = window[:maxBytes], true
|
|
}
|
|
|
|
var (
|
|
kept strings.Builder
|
|
spent int
|
|
)
|
|
|
|
for i := 0; i < len(window); {
|
|
r, size := utf8.DecodeRuneInString(window[i:])
|
|
if r == utf8.RuneError && size == 1 {
|
|
i += size
|
|
|
|
continue
|
|
}
|
|
|
|
cost := EncodedBytes(r)
|
|
if spent+cost > maxBytes {
|
|
cut = true
|
|
|
|
break
|
|
}
|
|
|
|
spent += cost
|
|
|
|
kept.WriteString(window[i : i+size])
|
|
|
|
i += size
|
|
}
|
|
|
|
if !cut {
|
|
return kept.String()
|
|
}
|
|
|
|
return kept.String() + TruncationMarker
|
|
}
|