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

> 設定留空、填「*」或給自訂清單，可能悄悄關掉安全檢查。gortexa 修掉的四例是 MCP Origin、JWT issuer、OTLP 明文與 secret 遮罩，附 Go 重現。

原文：https://sheng.page/posts/config-fields-that-quietly-turn-protection-off/ · 發布：2026-11-11 · 作者：Sheng

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

〈[沒設定就當成零](/posts/default-hard-caps-and-kill-switches/)〉講的是金額上限和 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](https://github.com/yshengliao/gortexa/commit/32426319e6c1d49c981c266aa06896a910696c1c) 加入這個欄位，把原本寫死的 `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：

```go
// 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()
	}
}
```

```text
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：

```go
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 也會放行。

```go
// 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())
}
```

```text
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_*` 環境變數，執行前要清掉：

```go
// 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：

```text
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 等欄位外露。

```go
// 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))
}
```

```text
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](https://github.com/yshengliao/gortexa/commit/a92f45c908c15186535f1ff1df3843cfd12c5619) 也處理了同類問題：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](https://github.com/anthropics/claude-code/issues/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](https://github.com/postmanlabs/postman-app-support/issues/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 的另一種伺服器端防護。

## 延伸閱讀

- gortexa [PR #57（8ce7e47）](https://github.com/yshengliao/gortexa/commit/8ce7e4732ace82a97081c911a14c9954a1198952)：本文四項修補與回歸測試；[PR #11（3242631）](https://github.com/yshengliao/gortexa/commit/32426319e6c1d49c981c266aa06896a910696c1c)：`cors_origins` 的由來；[PR #58（a92f45c）](https://github.com/yshengliao/gortexa/commit/a92f45c908c15186535f1ff1df3843cfd12c5619)：header allowlist 與 reflection 開關。v0.27 公開 API 調整把套件從 `internal/` 搬到 repo 根目錄。
- MCP 規格 [Transports（2025-11-25）](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) 與現行的 [Streamable HTTP（2026-07-28）](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http)：Origin 驗證為 MUST，本機只綁 localhost 與認證為 SHOULD。
- Wikipedia〈[DNS rebinding](https://en.wikipedia.org/wiki/DNS_rebinding)〉：攻擊流程與 Host header、DNS pinning 等緩解方式（百科整理），原始研究見 Stanford 的〈[Protecting Browsers from DNS Rebinding Attacks](https://crypto.stanford.edu/dns/dns-rebinding.pdf)〉。
- MDN〈[Access-Control-Allow-Origin](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin)〉：沒有 credentials 可用 `*`，帶 credentials 使用 wildcard 會報錯。
- [RFC 7519 §4.1.1](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.1) 與 [RFC 8725 §3.8](https://www.rfc-editor.org/rfc/rfc8725#section-3.8)：`iss` 的定義與 JWT 的 issuer、subject 驗證要求。
- golang-jwt [v5.3.1 validator.go](https://github.com/golang-jwt/jwt/blob/v5.3.1/validator.go)：空的 expected issuer 不做比對。
- OpenTelemetry Go [otlptracegrpc](https://pkg.go.dev/go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc@v1.46.0)：`WithInsecure` 與預設 TLS；[OTLP exporter 規格](https://opentelemetry.io/docs/specs/otel/protocol/exporter/)：`OTEL_EXPORTER_OTLP_INSECURE` 的適用條件。
- OTel GenAI semantic conventions〈[Content capture configuration](https://github.com/open-telemetry/semantic-conventions-genai/blob/e6658f14c9d9ea0760825f029cb82e7673f48fab/docs/gen-ai/gen-ai-spans.md#content-capture-configuration)〉：prompt 與 completion 的擷取要由使用者明確打開；〈[gen_ai.input.messages](https://github.com/open-telemetry/semantic-conventions-genai/blob/e6658f14c9d9ea0760825f029cb82e7673f48fab/docs/registry/attributes/gen-ai.md#gen-ai-input-messages)〉：屬性定義與敏感資料警語。
- 兩則使用者回報：Claude Code [issue #107757](https://github.com/anthropics/claude-code/issues/107757) 與官方 [CHANGELOG](https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md) 2.1.317；Postman [issue #14370](https://github.com/postmanlabs/postman-app-support/issues/14370) 與[成員回應](https://github.com/postmanlabs/postman-app-support/issues/14370#issuecomment-6649122696)。
- 〈[沒設定就當成零：預設硬上限、four-eyes 門檻與 kill switch 故障方向](/posts/default-hard-caps-and-kill-switches/)〉：金額與開關的預設值。

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