設定留空或填「*」,安全檢查就悄悄關掉:gortexa 修掉的四個例子

gortexa 在 2026-07-04 合併的 PR #57 裡,四個修補都在處理防護被悄悄放寬:三項因設定留空、填 "*" 或給一份自訂清單而停掉安全檢查,另一項是程式寫死 OTLP 明文傳輸。啟動時既不報錯,也沒有專門的警告 log。

〈沒設定就當成零〉講的是金額上限和 kill switch,這篇講安全設定。

設定修前的解讀停掉的檢查修法
server.cors_origins 含 "*"MCP 也放行所有 OriginMCP 的 Origin 檢查MCP 端忽略 "*",只認明列的 Origin
auth.issuer 為空字串不比對 issJWT 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 Origin header 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 開關預設關閉。

檢查自己的設定

  1. 空字串或零值若會讓程式跳過某項檢查,就在啟動驗證拒絕,或者明確定義成「關閉」並在啟動 log 印出來。
  2. 兩段程式若用同一個設定值做不同的安全判斷,"*" 只該放寬它所屬的那一項。
  3. 寫死的放寬,例如 WithInsecure()、InsecureSkipVerify: true,改成預設關閉的設定,名稱寫出代價。
  4. 安全相關的清單(遮罩欄位、denylist)讓使用者擴充時,用聯集。
  5. 重看一次原有測試固定住的行為,確認那是想要的規格,沒有把舊的放寬一起固定下來。

最近也有使用者回報其他工具未經同意就自行動作。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;拒絕不認得的 Host header 是 DNS rebinding 的另一種伺服器端防護。

延伸閱讀

想法與技術判斷出自 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。