Nic Lin's Blog

喜歡在地上滾的工程師

API 設計時必須注意的 HTTP header 底線問題

這幾天在處理公司的第三方 API 介接,其中有一個部分是將 token 放在 HTTP header 裡面當作彼此驗證的方式,雖然這不是什麼特別的方式,但卻在我使用 Ruby 處理時掉進了時空裂縫。

底線的魔法

主要規格是在 HTTP header 裡面放置 api_key: 'token' 進行驗證請求。

這裡我使用了幾種常見的 http request 封裝 Library

  1. ruby 本身的 net/http
  2. rest-client
  3. httparty
  4. faraday

全部都會發生驗證失敗的情況,一致性的提到 API KEY 沒有夾帶,但在我開啟 Log 模式時,非常確定有發送。

突然覺得是不是學 Ruby 錯了 XD

於是乎很納悶的我打算換個程式語言來做交叉測試,就嘗試用 node.js 來發送請求至 API server,結果如預期,是可以的。

為了找到問題,我又花了一些時間起了一個 server 在 local 端,將請求往本機發送時發現,無論使用上述哪一個套件,其最後都會將 HTTP header 中夾帶的 api_key 轉為 api-key

然後我就開始看這些套件的 source code,發現在處理的過程中會把 header 做字串的替換,其真正原因是因為 HTTP header 其實在 RFC 中有明確的協議規範。

以 RestClient 這個套件來說,在處理 header 上方就有詳細的註解

# Convert headers hash into canonical form.
#
# Header names will be converted to lowercase symbols with underscores
# instead of hyphens.
#
# Headers specified multiple times will be joined by comma and space,
# except for Set-Cookie, which will always be an array.
#
# Per RFC 2616, if a server sends multiple headers with the same key, they
# MUST be able to be joined into a single header by a comma. However,
# Set-Cookie (RFC 6265) cannot because commas are valid within cookie
# definitions. The newer RFC 7230 notes (3.2.2) that Set-Cookie should be
# handled as a special case.
#
# http://tools.ietf.org/html/rfc2616#section-4.2
# http://tools.ietf.org/html/rfc7230#section-3.2.2
# http://tools.ietf.org/html/rfc6265

在 http 這個封裝請求的套件也有看到

# Matches HTTP header names when in "Canonical-Http-Format"
CANONICAL_NAME_RE = /\A[A-Z][a-z]*(?:-[A-Z][a-z]*)*\z/.freeze

# Matches valid header field name according to RFC.
# @see http://tools.ietf.org/html/rfc7230#section-3.2
COMPLIANT_NAME_RE = /\A[A-Za-z0-9!#\$%&'*+\-.^_`|~]+\z/.freeze

文件裡都有說

接著我去看了 RFC 2616 4.2 的章節部分,提到

Request (section 5) and Response (section 6) messages use the generic message format of RFC 822 [9] for transferring entities (the payload of the message)

請求和回應的部分採用 RFC 822 中的通用格式來進行傳輸。

接著看 RFC 822 3.1.2 的部分,對於格式說明有提到

The field-name must be composed of printable ASCII characters (i.e., characters that have values between 33. and 126., decimal, except colon).

也就是說,關於 HTTP 中的 Header 可以由可輸出的 ASCII 字符來組成,括號中提到可以使用 10 進制在 33 至 126 的字符,不包含冒號。

這裡不含冒號可以理解,因為通常 field name 和 value 會以冒號作為分割。

當我們查詢 ASCII 表可以發現,其實底線 _ (underscore) 的 10 進制值為 95,其實是在規範的區間內,也就是說在 HTTP header 使用底線是「合法」的。

那為什麼大多數的 Library 亦或是 Web Server 如 nginx / apache 都是默認不支援的呢?

是因為這是一個關於 CGI 的歷史遺留問題,無論是 underscore _ 還是 dash - 都會轉為 CGI 系統變數的底線 _,這樣可能會有混淆的問題,詳細可以查看 RFC 3875 4.1.18

所以為了避免這樣的問題,通用的法則就是不要在 HTTP header 中設計帶有底線 _ 的 Field name

為什麼你應該避免這樣的設計

基本上常用的 web server 例如 nginx / apache 都是預設將 http header 中帶有底線 _ 的參數給丟掉的。

當 API 設計時使用 X_API_TOKEN 時,可能會遇到從 application 發送到 web server 時卻不見的情況。

雖然有可以 workaround 的解法,但都要特別去處理

從前面兩個小節可以看到,如果不按照規範來做設計,可能會遇到 Application 在實作時,遇到大多數 HTTP request 封裝的套件無法支援 http header 夾帶 underscore 的參數,必須自己做 monkey patch(到底誰才是 monkey?)

然後等你好不容易解決了這段,卻又遇到往 web server 丟消失的情況,最後再去爬文件設定,會不會有那麼一點累?

小結

如果遇到介接的廠商,開這個規格給你,請他改。

如果對方不改,可以丟 RFC 協議或是本篇文章給他看 XD

除非對方真的有什麼非這樣設計不可的理由,不然會建議設計時遵循規範比較不會被雷到。

後記

在 Ruby 使用的部分,多數的 HTTP request 封裝 gem 都會是繼承原本的 net/http library。

所以你會發現,無論你在這個 gem 如何 patch header,他到下面一層 dependence 都還是會再次處理。

我自己的解法是使用 http 這個 gem 來做修改,因為這套 gem 在處理 request 的部分沒有去依賴 net/http

以下解法供參考處理 HTTP header 中處理底線請求

先安裝 gem

gem "http"

解法是在啟動時先打一層 monkey patch

module DontNormalizeUnderscoreHeaders
  def normalize_header(name)
    return name if name.include?('_')

    super
  end
end

module HTTP
  class Headers
    prepend DontNormalizeUnderscoreHeaders
  end
end

參考資源

comments powered by Disqus