问题解析 - grpc error while marshaling string field contains invalid UTF-8

2022/09/17

问题描述

url.QueryUnescape

url 解码 - go/src/net/url/url.go

在 request_parse_module.go 模块会对 model 字段进行解码操作;会根据 url decode 规则把 model 转换成新的数据

model := rdata.DspRequest.Device.Model

if model != "" {

   model, _ = utils.UrlDecode(model)

   m.rdata.DspRequest.Device.Model = model

}

utf8.ValidString

字符串校验 - go/src/unicode/utf8/utf8.go

在通过 grpc 调用 filter server 前,会对 pb 数据进行转码操作;转码时,会先校验数据的合法性

if strs.EnforceUTF8(fd) && !utf8.ValidString(v.String()) {

   return b, errors.InvalidUTF8(string(fd.FullName()))

}

这里校验时,model 字段是经过 步骤 1 转换后的数据,可能会导致校验失败

原因解析

url.QueryUnescape

  1. 在 url 生成时,可能会包含一些明文不可见或特殊字符;所以往往会对 url 进行 encode 编码操作,从而能够在 URL 中传递这些字符。比如字符 % 编码后变成 %25
  2. 所以在拿到 url 数据后,一般也需要对应的 decode 解码操作。解码时,会认为%或者+开头的字符是从其它特殊字符转换过来的,需要对其反转义;比如把上面的%25 三个字符反转成一个字符%
func unescape(s string, mode encoding) (string, error) {

    ...

    var t strings.Builder

    t.Grow(len(s) - 2*n)

    for i := 0; i < len(s); i++ {

       switch s[i] {

       case '%':

          t.WriteByte(unhex(s[i+1])<<4 | unhex(s[i+2]))

          i += 2

       case '+':

          if mode == encodeQueryComponent {

             t.WriteByte(' ')

          } else {

             t.WriteByte('+')

          }

       default:

          t.WriteByte(s[i])

       }

    }

    return t.String(), nil

}

utf8.ValidString

UTF-8 是解决如何为 Unicode 编码而设计的一种编码规则。可以说 UTF-8 是 Unicode 的实现方式之一。其特点是一种变长编码,使用 1 到 4 个字节表示一个字符,根据不同的符号而变化长度。UTF-8 的编码规则有二:

  • 对于单字节的符号,字节的第一位设为 0,后面 7 位为这个符号的 Unicode 码。因此对于 ASCII 码字符,UTF-8 编码和 ASCII 码是相同的。
  • 对于 n 字节的符号(n > 1,2 到 4),第一个字节的前 n 位都设为 1,第 n + 1 位设为 0,后面字节的前两位一律设为 10。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码。

以下是编码规则:

Unicode UTF-8
0000 0000-0000 007F 0xxxxxxx
0000 0080-0000 07FF 110xxxxx 10xxxxxx
0000 0800-0000 FFFF 1110xxxx 10xxxxxx 10xxxxxx
0001 0000-0010 FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

1

因为 utf8 编码中,一个字符可能对应 2-4 个字节;所以字符串的字节数并不一定等于其代表的 utf8 字符个数。如果字节数值小于 128,认为一个 utf8 字符等于一个字节;大于 128 时,认为一个 utf8 字符对应多个字节,然后根据首字节值计算 utf8 码的长度,取出剩余的字节,再验证是否符合上面的规则

//alidString reports whether s consists entirely of valid UTF-8-encoded runes.

func ValidString(s string) bool {

   //n表示字符串s的字节数

   n := len(s)

   //i表示s所代表的utf8编码的字符个数

   for i := 0; i < n; {

      si := s[i]

      //小于128,等于ascii码

      if si < RuneSelf {

         i++

         continue

      }

      //x表示上面编码规则中 n > 1字节的的首字节

      x := first[si]

      if x == xx {

         return false // Illegal starter byte.

      }

      //依照上面的规则,根据首字节计算utf8字符所对应的字节数

      size := int(x & 7)

      if i+size > n {

         return false // Short or invalid.

      }

      accept := acceptRanges[x>>4]

      if c := s[i+1]; c < accept.lo || accept.hi < c {

         return false

      } else if size == 2 {

      } else if c := s[i+2]; c < locb || hicb < c {

         return false

      } else if size == 3 {

      } else if c := s[i+3]; c < locb || hicb < c {

         return false

      }

      i += size

   }

   return true

}

场景重现

demo 代码

package main
import (

   "fmt"

   "net/url"

)



const (

   RuneError = '\uFFFD'     // the "error" Rune or "Unicode replacement character"

   RuneSelf  = 0x80         // characters below RuneSelf are represented as themselves in a single byte.

   MaxRune   = '\U0010FFFF' // Maximum valid Unicode code point.

   UTFMax    = 4            // maximum number of bytes of a UTF-8 encoded Unicode character.



   as = 0xF0 // ASCII: size 1

   xx = 0xF1 // invalid: size 1

   s1 = 0x02 // accept 0, size 2

   s2 = 0x13 // accept 1, size 3

   s3 = 0x03 // accept 0, size 3

   s4 = 0x23 // accept 2, size 3

   s5 = 0x34 // accept 3, size 4

   s6 = 0x04 // accept 0, size 4

   s7 = 0x44 // accept 4, size 4



   locb = 0b10000000

   hicb = 0b10111111

)



var first = [256]uint8{

   //   1   2   3   4   5   6   7   8   9   A   B   C   D   E   F

   as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, // 0x00-0x0F

   as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, // 0x10-0x1F

   as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, // 0x20-0x2F

   as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, // 0x30-0x3F

   as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, // 0x40-0x4F

   as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, // 0x50-0x5F

   as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, // 0x60-0x6F

   as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, as, // 0x70-0x7F

   //   1   2   3   4   5   6   7   8   9   A   B   C   D   E   F

   xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, // 0x80-0x8F

   xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, // 0x90-0x9F

   xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, // 0xA0-0xAF

   xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, // 0xB0-0xBF

   xx, xx, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, // 0xC0-0xCF

   s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, s1, // 0xD0-0xDF

   s2, s3, s3, s3, s3, s3, s3, s3, s3, s3, s3, s3, s3, s4, s3, s3, // 0xE0-0xEF

   s5, s6, s6, s6, s7, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, xx, // 0xF0-0xFF

}



type acceptRange struct {

   lo uint8 // lowest value for second byte.

   hi uint8 // highest value for second byte.

}



// acceptRanges has size 16 to avoid bounds checks in the code that uses it.

var acceptRanges = [16]acceptRange{

   0: {locb, hicb},

   1: {0xA0, hicb},

   2: {locb, 0x9F},

   3: {0x90, hicb},

   4: {locb, 0x8F},

}



func main() {

   data := "%dd"

   newData, _ := url.QueryUnescape(data)

   fmt.Println("data len:", len(data), " byte val:", []byte(data), " str val:", data)

   fmt.Println("newData len:", len(newData), " byte val:", []byte(newData), " str val:", newData)

   fmt.Println(ValidString(newData))

}



func ValidString(s string) bool {

   n := len(s)

   for i := 0; i < n; {

      si := s[i]

      if si < RuneSelf {

         i++

         continue

      }

      x := first[si]

      if x == xx {

         return false // Illegal starter byte.

      }

      size := int(x & 7)

      if i+size > n {

         return false // Short or invalid.

      }

      accept := acceptRanges[x>>4]

      if c := s[i+1]; c < accept.lo || accept.hi < c {

         return false

      } else if size == 2 {

      } else if c := s[i+2]; c < locb || hicb < c {

         return false

      } else if size == 3 {

      } else if c := s[i+3]; c < locb || hicb < c {

         return false

      }

      i += size

   }

   return true

}

代码解析

  1. 模拟 model 字段初始值为 %dd,其对应的[]byte 值为三个字节长度: [37 100 100]
  2. 进行 url.QueryUnescape 操作,因为%字符的存在,所以会认为%dd 是某个字符转义后的数据;解码后,数值变成 一个字节长度 [221]
  3. 针对解码后的数据 [221] 进行 utf8.ValidString 合法校验;因为首字节 221 > 128,所以认为其不是个 单字节 ascii 码;走到 83 行代码,认为其对应的 utf8 字符长度 size=2 为 2 个字节,但实际其字节数才为 1,所以 84 行判断失败,直接 return false
  4. 认为其字节缺失,不是个完整的 utf8 编码数据,报出 error

Post Directory