設定留空或填「*」,安全檢查就悄悄關掉:gortexa 修掉的四個例子
gortexa 在 2026-07-04 合併的 PR #57 裡,四個修補都在處理防護被悄悄放寬:三項因設定留空、填 "*" 或給一份自訂清單而停掉安全檢查,另一項是程式寫死 OTLP 明文傳輸。啟動時既不報錯,也沒有專門的警告 log。
〈沒設定就當成零〉講的是金額上限和 kill switch,這篇講安全設定。
| 設定 | 修前的解讀 | 停掉的檢查 | 修法 |
|---|---|---|---|
server.cors_origins 含 "*" | MCP 也放行所有 Origin | MCP 的 Origin 檢查 | MCP 端忽略 "*",只認明列的 Origin |
auth.issuer 為空字串 | 不比對 iss | JWT issuer 比對 | Validate() 拒絕空值,啟動失敗 |
沒有對應設定,程式寫死 WithInsecure() | 一律明文 | OTLP 傳輸加密 | 新增 observ.otlp_insecure,預設 false |
genai_mask_fields 有值 | 用自訂清單取代內建 | 內建 secret 欄位的遮罩 | 內建清單加上自訂欄位 |
一個欄位管兩件事:CORS 的「*」連帶放行 MCP
gortexa 用同一個 h2c port 提供 gRPC、HTTP/JSON 和 MCP。server.cors_origins 是 []string,PR #11 加入這個欄位,把原本寫死的 Access-Control-Allow-Origin: * 改成可設定。後來 PR #56 替 MCP endpoint 加了 Origin 檢查,讀的也是這份清單。清單裡有 "*" 時,就把 allowAllOrigins 設成 true,所有 Origin 都放行。
這兩個用途要的不一樣。CORS 管的是瀏覽器讓哪個網頁讀回應,沒有 credentials 的公開 API 常填 "*"。MDN 說明 * 可用於「requests without credentials」,帶 credentials 時,用 wildcard 會報錯。在修補前的 2025-11-25 版裡,MCP 規格對 Streamable HTTP 的要求就已經寫成:
Servers MUST validate the
Originheader on all incoming connections to prevent DNS rebinding attacks.
DNS rebinding 攻擊中,使用者開了攻擊者的網頁,攻擊者把同一個 hostname 的 DNS 解析改指本機或內網 IP,瀏覽器仍視為同源,script 就能打到本機的 MCP server。POST 請求帶著攻擊者網站的 Origin,這層檢查可以拒絕未獲允許的 Origin。另一種伺服器端防護是拒絕不認得或不預期的 Host header,Wikipedia 條目(unrecognized)與 Stanford 的原始論文(unexpected)都列了這一項。修前在 CORS 填 "*",公開 API 就能跨網域讀取,任何 Origin 也會通過 MCP 檢查。
修後 MCP 仍讀 server.cors_origins,SetAllowedOrigins 遇到 "*" 直接 continue,只接受明列的 Origin;沒有 Origin header 的請求照舊通過,包括省略這個 header 的非瀏覽器 client。回歸測試 TestBridgeOriginValidation 改為要求:清單只有 "*" 時,https://anything.example 要拿到 403。
下面用標準庫重現這段判斷,只模擬 Origin 檢查這一層,沒有做 DNS:
// Origin guard before and after: "*" in the shared CORS list.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
type guard struct {
allowAll bool // only the "before" version sets this
allowed map[string]bool
}
func newGuard(origins []string, honourWildcard bool) *guard {
g := &guard{allowed: map[string]bool{}}
for _, o := range origins {
if o == "*" {
g.allowAll = honourWildcard
continue
}
g.allowed[o] = true
}
return g
}
func (g *guard) ok(origin string) bool {
if origin == "" { // non-browser client, no Origin header
return true
}
return g.allowAll || g.allowed[origin]
}
func (g *guard) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !g.ok(r.Header.Get("Origin")) {
http.Error(w, "forbidden origin", http.StatusForbidden)
return
}
fmt.Fprint(w, `{"tools":["read_db"]}`)
})
}
func post(url, origin string) int {
req, err := http.NewRequest(http.MethodPost, url, nil)
if err != nil {
panic(err)
}
if origin != "" {
req.Header.Set("Origin", origin)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
resp.Body.Close()
return resp.StatusCode
}
func main() {
corsOrigins := []string{"*"} // server.cors_origins, also read by the MCP guard
for _, c := range []struct {
name string
honourWildcard bool
}{{"before", true}, {"after", false}} {
srv := httptest.NewServer(newGuard(corsOrigins, c.honourWildcard).handler())
fmt.Printf("%-6s Origin=https://attacker.example -> %d, no Origin -> %d\n",
c.name, post(srv.URL, "https://attacker.example"), post(srv.URL, ""))
srv.Close()
}
}
before Origin=https://attacker.example -> 200, no Origin -> 200
after Origin=https://attacker.example -> 403, no Origin -> 200
兩個用途仍共用 cors_origins,"*" 不再影響 MCP。網頁要呼叫 MCP,就得明列它的 Origin。
空字串等於不檢查:auth.issuer
auth.issuer 預設是 "gortexa",但環境變數可覆寫成空字串,例如 GORTEXA_AUTH__ISSUER=。當時 Validate() 只檢查 server address 和 JWT secret,空 issuer 會進到 verifier:
if v.issuer != "" {
opts = append(opts, jwt.WithIssuer(v.issuer))
}
golang-jwt v5 的 validator.go 註解寫著「Supplying an empty string will disable iss checking.」。RFC 7519 把 iss 列為 OPTIONAL,交給應用程式處理。函式庫的行為符合文件,問題出在應用程式的設定讓這個檢查默默消失。留空後仍會驗簽章,但只要通過其餘驗證,同一把金鑰簽出、issuer 屬於別的服務的 token 也會放行。
// An empty expected issuer turns the iss check off in golang-jwt v5.
package main
import (
"errors"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
var secret = []byte("shared-hs256-secret-for-demo-only-32b")
type authConfig struct{ Issuer string }
func (c authConfig) Validate() error {
if strings.TrimSpace(c.Issuer) == "" {
return errors.New("invalid config: auth.issuer is required")
}
return nil
}
func verify(raw string, cfg authConfig) error {
opts := []jwt.ParserOption{jwt.WithValidMethods([]string{"HS256"})}
if cfg.Issuer != "" { // the common pattern: only check when configured
opts = append(opts, jwt.WithIssuer(cfg.Issuer))
}
_, err := jwt.Parse(raw, func(*jwt.Token) (any, error) { return secret, nil }, opts...)
return err
}
func main() {
// Another service that happens to share the signing key mints this token.
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": "billing-service",
"sub": "user-42",
"exp": time.Now().Add(time.Hour).Unix(),
})
raw, err := tok.SignedString(secret)
if err != nil {
panic(err)
}
for _, cfg := range []authConfig{{Issuer: "gortexa"}, {Issuer: ""}} {
fmt.Printf("issuer=%-9q verify: %v\n", cfg.Issuer, verify(raw, cfg))
}
fmt.Println("startup check for empty issuer:", authConfig{Issuer: " "}.Validate())
}
issuer="gortexa" verify: token has invalid claims: token has invalid issuer
issuer="" verify: <nil>
startup check for empty issuer: invalid config: auth.issuer is required
修的是設定驗證,verifier 的 if 沒動:Validate() 用 strings.TrimSpace 拒絕空字串或全空白,回傳的錯誤含 auth.issuer is required,MustBuild() 在啟動時 panic。
程式寫死明文:三個 OTLP exporter 的 WithInsecure()
這一項沒有設定,logs、traces、metrics 三個 exporter(otlploggrpc、otlptracegrpc、otlpmetricgrpc)都直接呼叫 WithInsecure(),不論 collector 在本機或遠端,telemetry 一律明文送出。啟用內容擷取後,gortexa 的 MCP bridge 把 tool call 的 arguments 和 result 寫進 span,遮罩後仍可能含有使用者資料。OTel GenAI semantic conventions 對 gen_ai.input.messages 有警語:「This attribute is likely to contain sensitive information including user/PII data.」
OTel Go 的 exporter 預設就走 TLS,WithInsecure() 的文件寫著「by default, client security is required unless WithInsecure is used.」。下面讓 exporter 連到一個只讀 bytes 的 TCP listener,觀察傳輸內容。exporter 也讀 OTEL_EXPORTER_OTLP_* 環境變數,執行前要清掉:
// What a plain TCP listener receives from an OTLP gRPC exporter,
// with WithInsecure() and with the default (TLS) credentials.
package main
import (
"context"
"fmt"
"io"
"net"
"strconv"
"time"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
func firstBytes(ln net.Listener, out chan<- []byte) {
conn, err := ln.Accept()
if err != nil {
out <- nil
return
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
buf := make([]byte, 24)
n, _ := io.ReadFull(conn, buf)
out <- buf[:n]
}
func export(name string, extra ...otlptracegrpc.Option) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
defer ln.Close()
got := make(chan []byte, 1)
go firstBytes(ln, got)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
opts := append([]otlptracegrpc.Option{otlptracegrpc.WithEndpoint(ln.Addr().String())}, extra...)
exp, err := otlptracegrpc.New(ctx, opts...)
if err != nil {
panic(err)
}
tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exp))
_, span := tp.Tracer("demo").Start(ctx, "gen_ai.chat")
span.End() // the syncer exports here; the export error is not the point
_ = tp.Shutdown(ctx)
b := <-got
fmt.Printf("%-13s % x\n%-13s %s\n", name, b, "", strconv.QuoteToASCII(string(b)))
}
func main() {
export("WithInsecure", otlptracegrpc.WithInsecure())
export("default (TLS)")
}
stdout:
WithInsecure 50 52 49 20 2a 20 48 54 54 50 2f 32 2e 30 0d 0a 0d 0a 53 4d 0d 0a 0d 0a
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
default (TLS) 16 03 01 05 d3 01 00 05 cf 03 03 d8 8b bc 1d f9 a8 19 ac da 70 37 0d 36
"\x16\x03\x01\x05\xd3\x01\x00\x05\xcf\x03\x03\u060b\xbc\x1d\xf9\xa8\x19\xac\xdap7\r6"
WithInsecure() 連線一開頭是明文 HTTP/2 前言;預設連線以 16 03 01 開頭,是 TLS handshake record,內含 ClientHello 與每次執行都不同的隨機數。listener 沒有實作 collector 協定,這份紀錄的 stderr 有兩行 export 失敗的 log,不影響觀察連線開頭。
修後新增 observ.otlp_insecure(bool,預設 false),三個 exporter 共用一組 helper:true 才呼叫 WithInsecure(),否則傳 credentials.NewTLS(nil)。範例設定檔 etc/config.yaml 為本機 docker-compose collector 設成 true,註解提醒不要對遠端 collector 打開。「預設 TLS」指程式預設,照抄範例設定檔並啟用 OTLP 匯出,仍走明文。
回歸測試只確認設定預設是 false、三個 helper 回傳的 option 不是 nil,沒有檢查連線是否真的加密。範例只比較明文 HTTP/2 前言與 TLS ClientHello,沒有經過 gortexa 的 helper,三個 helper 的連線行為仍要另外測。
自訂清單取代內建:secret 遮罩
MCP bridge 把 tool call 寫進 span 前,用 MaskSecrets 把敏感欄位換成 [REDACTED]。內建清單有 password、token、secret、authorization、api_key,可用 genai_mask_fields 加自訂欄位。修前只有自訂清單為空才用內建清單,若只列新增欄位,內建的五個就都不再遮罩。範例只示範清單怎麼合併,實際 MaskSecrets 還會遞迴處理巢狀物件與陣列,並保留大整數精度。
PR #51 的 TestMaskSecretsCustomFieldsOverrideDefaults 固定了這個行為,斷言 password 保留原值 "p"。PR #57 改名為 TestMaskSecretsCustomFieldsExtendDefaults,同樣的輸入下,password 必須是 [REDACTED],註解說明:加一個欄位,不該悄悄讓 password、token 等欄位外露。
// A custom mask list that replaces the built-in one, and one that extends it.
package main
import (
"encoding/json"
"fmt"
"strings"
)
var builtin = []string{"password", "token", "secret", "authorization", "api_key"}
func norm(s string) string { return strings.ReplaceAll(strings.ToLower(s), "_", "") }
func set(fields []string) map[string]bool {
m := map[string]bool{}
for _, f := range fields {
m[norm(f)] = true
}
return m
}
func mask(raw string, fields map[string]bool) string {
var v map[string]any
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return "[UNPARSEABLE JSON REDACTED]"
}
for k := range v {
if fields[norm(k)] {
v[k] = "[REDACTED]"
}
}
out, err := json.Marshal(v)
if err != nil {
return "[REDACTED]"
}
return string(out)
}
func main() {
args := `{"user":"amy","password":"hunter2","national_id":"A123456789"}`
custom := []string{"national_id"} // the operator adds one field
replace := builtin
if len(custom) > 0 {
replace = custom // before: a non-empty custom list replaces the built-ins
}
extend := set(builtin) // after: start from the built-ins, then add
for k := range set(custom) {
extend[k] = true
}
fmt.Println("replace:", mask(args, set(replace)))
fmt.Println("extend: ", mask(args, extend))
}
replace: {"national_id":"[REDACTED]","password":"hunter2","user":"amy"}
extend: {"national_id":"[REDACTED]","password":"[REDACTED]","user":"amy"}
修後沒有關閉內建遮罩的設定,我也不打算加:依 Security > Simplicity > Correctness,需要在 telemetry 看到欄位原文,就改程式,不在設定檔留一個開關。依安全優先的裁定,原本把取代行為固定下來的測試,改為要求聯集。
怎麼找到的
這四個問題都逃過原本的測試,遮罩測試還固定了舊行為。那一輪用了 6 路 finder 各自讀程式,每個 finding 交給兩個 verifier 反駁。PR #57 的說明把它稱作 adversarial double-verification。之後再對 scaffolded projects(用 gortexa 工具產生的專案骨架)跑六個維度的端到端測試。squash commit message 寫「Green across make gen/build/vet/test -race/lint and a six-dimension end-to-end test matrix.」,表示產生程式碼、建置、靜態檢查、競態測試與 lint,以及六個維度的端到端測試都通過。單一 finder 的報告要經過反駁和實跑才算數。
同一天的 PR #58 也處理了同類問題:outgoing header 原本會把 request ID 以外的 metadata 加上 Grpc-Metadata- 前綴,再回給 HTTP client。修後這個 matcher 只放行 X-Request-Id;新增的 gRPC reflection 開關預設關閉。
檢查自己的設定
- 空字串或零值若會讓程式跳過某項檢查,就在啟動驗證拒絕,或者明確定義成「關閉」並在啟動 log 印出來。
- 兩段程式若用同一個設定值做不同的安全判斷,
"*"只該放寬它所屬的那一項。 - 寫死的放寬,例如
WithInsecure()、InsecureSkipVerify: true,改成預設關閉的設定,名稱寫出代價。 - 安全相關的清單(遮罩欄位、denylist)讓使用者擴充時,用聯集。
- 重看一次原有測試固定住的行為,確認那是想要的規格,沒有把舊的放寬一起固定下來。
最近也有使用者回報其他工具未經同意就自行動作。Claude Code 的 issue #107757 回報帳號 email 出現在 web search 請求的 User-Agent,官方 CHANGELOG 在 2.1.317 記了「Added a check that asks before a web or shell tool call sends your account email address to the network」。Postman 的 issue #14370 回報 Desktop 自動安裝 Codex 與 Claude Code plugin,並嘗試信任自己的 startup hook。兩則狀態在 2026-11-08 查詢時:Postman 那則已在 2026-11-05 關閉,成員回應說會撤回這項變更;Claude Code 那則仍開著。兩則敘述都來自使用者回報。CHANGELOG 與 issue 關閉狀態不能證明問題已排除,進展以 issue 為準。
補充筆記
- gortexa 仍共用
server.cors_origins,MCP 忽略"*",只接受明列的 Origin;沒有 Origin header 仍放行。 auth.issuer留空仍驗簽章,同一把金鑰、不同 issuer 的 token 通過其餘驗證後也可能被接受;修後Validate()拒絕空值。- OTel Go exporter 預設 TLS,
etc/config.yaml為了本機 collector 設otlp_insecure: true,搬到正式環境前要改掉。 - secret 遮罩改成內建清單加自訂欄位,沒有關閉內建遮罩的設定。
- MCP 規格要求驗證的是
Origin;拒絕不認得的Hostheader 是 DNS rebinding 的另一種伺服器端防護。
延伸閱讀
- gortexa PR #57(8ce7e47):本文四項修補與回歸測試;PR #11(3242631):
cors_origins的由來;PR #58(a92f45c):header allowlist 與 reflection 開關。v0.27 公開 API 調整把套件從internal/搬到 repo 根目錄。 - MCP 規格 Transports(2025-11-25) 與現行的 Streamable HTTP(2026-07-28):Origin 驗證為 MUST,本機只綁 localhost 與認證為 SHOULD。
- Wikipedia〈DNS rebinding〉:攻擊流程與 Host header、DNS pinning 等緩解方式(百科整理),原始研究見 Stanford 的〈Protecting Browsers from DNS Rebinding Attacks〉。
- MDN〈Access-Control-Allow-Origin〉:沒有 credentials 可用
*,帶 credentials 使用 wildcard 會報錯。 - RFC 7519 §4.1.1 與 RFC 8725 §3.8:
iss的定義與 JWT 的 issuer、subject 驗證要求。 - golang-jwt v5.3.1 validator.go:空的 expected issuer 不做比對。
- OpenTelemetry Go otlptracegrpc:
WithInsecure與預設 TLS;OTLP exporter 規格:OTEL_EXPORTER_OTLP_INSECURE的適用條件。 - OTel GenAI semantic conventions〈Content capture configuration〉:prompt 與 completion 的擷取要由使用者明確打開;〈gen_ai.input.messages〉:屬性定義與敏感資料警語。
- 兩則使用者回報:Claude Code issue #107757 與官方 CHANGELOG 2.1.317;Postman issue #14370 與成員回應。
- 〈沒設定就當成零:預設硬上限、four-eyes 門檻與 kill switch 故障方向〉:金額與開關的預設值。
想法與技術判斷出自 Sheng,和 Claude 一起起草 · 大綱與統籌 Claude Fable,repo 查證、審稿與精簡 Codex(gpt-6-astra),來源查證、撰稿與二審 Claude Opus 5.5,來源整理與語氣調整 Grok(grok-4.7-build);範例在 Go 1.27.3(darwin/arm64)實測,golang-jwt v5.3.1、otlptracegrpc v1.46.0、OTel SDK v1.47.0。