// 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 }