Config fields that quietly turn protection off: four fixes from gortexa
PR #57, merged into gortexa on 4 July 2026, contains four fixes where protection had been relaxed silently. Three came from config handling, where an empty field, a "*" or a custom list could disable one security check. The fourth was hard-coded cleartext OTLP. None of them caused startup to fail, and none logged a warning that protection had been relaxed.
“Treat Unset as Zero” dealt with spending caps and kill switches; this one is about security settings.
| Setting | How it was read before the fix | Check that stopped | Fix |
|---|---|---|---|
server.cors_origins contains "*" | MCP also accepts every Origin | MCP Origin check | MCP ignores "*" and only accepts listed Origins |
auth.issuer is an empty string | iss is not compared | JWT issuer comparison | Validate() rejects the empty value and startup fails |
No setting; WithInsecure() hard-coded | Always cleartext | OTLP transport encryption | New observ.otlp_insecure, default false |
genai_mask_fields is set | Custom list replaces the built-in one | Masking of built-in secret fields | Built-in list plus the custom fields |
One field, two jobs: the CORS wildcard and the MCP Origin check
gortexa serves gRPC, HTTP/JSON and MCP from a single h2c port. server.cors_origins is a []string introduced in PR #11, replacing a hard-coded Access-Control-Allow-Origin: * with a configurable list. PR #56 wired the MCP Origin guard to that list. A "*" set allowAllOrigins to true, letting every Origin through.
CORS governs which pages a browser may allow to read a response. A * is common for public APIs without credentials. MDN says: “For requests without credentials, the literal value * can be specified as a wildcard.” Using a wildcard with credentials causes an error. The 2025-11-25 revision of the MCP specification already required Streamable HTTP servers to do this:
Servers MUST validate the
Originheader on all incoming connections to prevent DNS rebinding attacks.
In a rebinding attack, the victim opens the attacker’s page, whose hostname is then repointed at 127.0.0.1 or an internal address. The browser still treats it as the same origin, letting its script reach a local MCP server. A browser POST carries the attacker’s Origin, which the guard can reject. Jackson and colleagues’ 2007 paper, “Protecting Browsers from DNS Rebinding Attacks”, describes another server-side defence: “reject incoming HTTP requests with unexpected Host headers”. Previously, a "*" that allowed cross-origin reads of a public API also let any Origin past the MCP guard.
MCP still reads server.cors_origins, but SetAllowedOrigins now uses continue on "*", accepting only listed Origins. Requests without an Origin header still pass, including those from non-browser clients that omit it. The regression test TestBridgeOriginValidation now requires a list of just "*" to return 403 for https://anything.example.
This standard-library example models only the Origin check; it does no 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
The two uses still share cors_origins; "*" no longer affects MCP. A web page calling MCP needs its Origin listed explicitly.
An empty string means “don’t check”: auth.issuer
auth.issuer defaulted to "gortexa", but the environment variable GORTEXA_AUTH__ISSUER= could leave it empty. Validate() checked only the server address and JWT secret, letting an empty issuer reach the verifier:
if v.issuer != "" {
opts = append(opts, jwt.WithIssuer(v.issuer))
}
golang-jwt v5 documents this in validator.go: “Supplying an empty string will disable iss checking.” RFC 7519 makes iss OPTIONAL and leaves its handling to the application, so the library behaves as documented; the gap came from an application setting that silently disabled the check. RFC 8725 requires checking that the signing keys belong to the issuer in iss, when present. Signature verification still runs, but another issuer’s token can pass with the same signing key if it meets the remaining checks.
// 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
The verifier’s if is unchanged. Validate() uses strings.TrimSpace to reject empty or all-whitespace values with an error containing auth.issuer is required; MustBuild() panics at startup.
Hard-coded cleartext: WithInsecure() on three OTLP exporters
This one had no setting. The logs, traces and metrics exporters (otlploggrpc, otlptracegrpc, otlpmetricgrpc) all called WithInsecure(), sending cleartext to local and remote collectors alike. With content capture enabled, gortexa’s MCP bridge records tool call arguments and results on spans. Masked spans can still carry user data, and the OTel GenAI semantic conventions warn about gen_ai.input.messages: “This attribute is likely to contain sensitive information including user/PII data.”
The OTel Go exporters use TLS unless told otherwise. The WithInsecure() documentation says “by default, client security is required unless WithInsecure is used.” The program connects an exporter to a TCP listener to inspect the bytes sent. Clear the OTEL_EXPORTER_OTLP_* environment variables before running it:
// 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"
The WithInsecure() connection starts with the cleartext HTTP/2 preface. The default one starts with 16 03 01, a TLS handshake record carrying a ClientHello with fresh random bytes each run. The listener lacks the collector protocol, so this run produced two export errors on stderr without affecting the observed bytes.
The fix adds observ.otlp_insecure (bool, default false). All three exporters use one set of helpers: WithInsecure() when true, otherwise credentials.NewTLS(nil). The sample config etc/config.yaml sets it to true for the local docker-compose collector and warns against using it with remote collectors. The program defaults to TLS; copying the sample and enabling OTLP export still selects cleartext.
The regression tests check that the default is false and the three helpers return non-nil options; they do not check whether connections are encrypted. This example compares a cleartext HTTP/2 preface with a TLS ClientHello, without using gortexa’s helpers, whose connection behaviour needs separate tests.
A custom list that replaced the built-in one: secret masking
Before a tool call reaches a span, MaskSecrets replaces sensitive fields with [REDACTED]. The built-ins are password, token, secret, authorization and api_key; genai_mask_fields adds custom fields. Previously, a non-empty custom list replaced all five built-ins, leaving them unmasked unless repeated. This example only shows list merging; the real MaskSecrets also walks nested objects and arrays and preserves large integers.
PR #51 pinned that behaviour: TestMaskSecretsCustomFieldsOverrideDefaults expected password to remain "p". PR #57 renamed it TestMaskSecretsCustomFieldsExtendDefaults and requires password to be [REDACTED] for the same input. The comment explains that adding one field “cannot silently expose password/token/secret/authorization/api_key in telemetry.”
// 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"}
There is no setting to disable built-in masking, and I don’t intend to add one. Under Security > Simplicity > Correctness, exposing a raw value in telemetry requires a code change. The security-first ruling changed the old replacement test to require the union.
How these were found
Existing tests missed all four issues; the masking test even pinned the old behaviour. That round used six finder passes, with each finding handed to two verifiers to refute, which the PR #57 description calls adversarial double-verification. It then ran a six-dimension end-to-end matrix against scaffolded projects (projects generated by gortexa tooling). The squash commit message reads: “Green across make gen/build/vet/test -race/lint and a six-dimension end-to-end test matrix.” A single finder’s report needs adversarial review and execution before it counts.
PR #58, merged the same day, addressed similar problems. Outgoing headers returned metadata other than the request ID, with a Grpc-Metadata- prefix; the matcher now forwards only X-Request-Id. The new gRPC reflection switch is off by default.
Checking your own config
- At startup, reject empty strings or zero values that skip a check, or explicitly define them as “off” and log that.
- If two security checks share a config value, a
"*"should relax only the check it belongs to. - Replace hard-coded relaxations such as
WithInsecure()orInsecureSkipVerify: truewith settings that default to off and name the cost. - Merge custom security lists (mask fields, a denylist) with the built-ins.
- Check whether the behaviour your existing tests pin down is the spec you want, or an old relaxation.
Users have also reported other tools taking actions without their consent. Claude Code issue #107757 reports that the account email appears in the User-Agent of web search requests, and the official changelog for 2.1.317 records “Added a check that asks before a web or shell tool call sends your account email address to the network”. Postman issue #14370 reports that the desktop app installed Codex and Claude Code plugins on its own and tried to trust its own startup hook. A Postman member replied: “In the meantime, we are reverting this change.” Both issues were checked on 8 November 2026: the Postman one had been closed on 5 November 2026, and the Claude Code one was still open. These are user reports; neither the changelog nor the closed status proves resolution. Follow the issues for subsequent discussion.
Notes
- gortexa still shares
server.cors_originsbetween CORS and MCP. MCP ignores"*"; listed Origins and requests without an Origin header pass. - An empty
auth.issuerretains signature checks but can admit another issuer’s token using the same key if it passes the remaining checks.Validate()now rejects empty values. - OTel Go exporters default to TLS. The sample
etc/config.yamlsetsotlp_insecure: truefor a local collector; change it before production. - Secret masking merges built-ins and custom fields, with no setting to disable built-ins.
- MCP requires
Originvalidation; rejecting unexpectedHostheaders is a separate DNS rebinding defence.
Further reading
- gortexa PR #57 (8ce7e47): the four fixes and their regression tests; PR #11 (3242631): where
cors_originscame from; PR #58 (a92f45c): the header allowlist and the reflection switch. The v0.27 public API change moved packages frominternal/to the repository root. - MCP specification, Transports (2025-11-25) and the current Streamable HTTP (2026-07-28): Origin validation is a MUST; local binding to localhost and authentication are SHOULDs.
- Jackson, Barth, Bortz, Shao and Boneh, “Protecting Browsers from DNS Rebinding Attacks” (ACM CCS 2007): the original research, including the Host header check. Wikipedia’s “DNS rebinding” summarises the attack and the mitigations.
- MDN, “Access-Control-Allow-Origin”:
*works as a wildcard for requests without credentials; using it with credentials results in an error. - RFC 7519 §4.1.1 and RFC 8725 §3.8: the definition of
issand the requirements for validating issuer and subject. - golang-jwt v5.3.1 validator.go: an empty expected issuer skips the comparison.
- OpenTelemetry Go otlptracegrpc:
WithInsecureand the TLS default; the OTLP exporter specification: whenOTEL_EXPORTER_OTLP_INSECUREapplies. - OTel GenAI semantic conventions, “Content capture configuration”: capturing prompts and completions requires an explicit user opt-in; “gen_ai.input.messages”: the attribute and its sensitive-data warning.
- The two user reports: Claude Code issue #107757 with the official changelog entry for 2.1.317; Postman issue #14370 with the member’s reply.
- “Treat Unset as Zero: Hard Caps, Approval Thresholds and Which Way a Kill Switch Fails”: defaults for spending limits and switches.
Ideas and technical judgement by Sheng; drafted with Claude · Outline and coordination by Claude Fable; repo checks, review and tightening by Codex (gpt-6-astra); source checks, prose and second review by Claude Opus 5.5; source collation and tone pass by Grok (grok-4.7-build); examples run on Go 1.27.3 (darwin/arm64) with golang-jwt v5.3.1, otlptracegrpc v1.46.0 and OTel SDK v1.47.0.