# Config fields that quietly turn protection off: four fixes from gortexa

> Empty values, "*" and custom lists can silently disable security checks. Four fixes from gortexa PR #57 are reproduced in Go.

Source: https://sheng.page/en/posts/config-fields-that-quietly-turn-protection-off/ · Published: 2026-11-11 · Author: Sheng

[PR #57](https://github.com/yshengliao/gortexa/commit/8ce7e4732ace82a97081c911a14c9954a1198952), 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](/en/posts/default-hard-caps-and-kill-switches/)" 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](https://github.com/yshengliao/gortexa/commit/32426319e6c1d49c981c266aa06896a910696c1c), 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 `Origin` header 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:

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

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:

```go
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.

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

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:

```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"
```

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."

```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"}
```

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](https://github.com/yshengliao/gortexa/commit/a92f45c908c15186535f1ff1df3843cfd12c5619), 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

1. At startup, reject empty strings or zero values that skip a check, or explicitly define them as "off" and log that.
2. If two security checks share a config value, a `"*"` should relax only the check it belongs to.
3. Replace hard-coded relaxations such as `WithInsecure()` or `InsecureSkipVerify: true` with settings that default to off and name the cost.
4. Merge custom security lists (mask fields, a denylist) with the built-ins.
5. 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](https://github.com/anthropics/claude-code/issues/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](https://github.com/postmanlabs/postman-app-support/issues/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_origins` between CORS and MCP. MCP ignores `"*"`; listed Origins and requests without an Origin header pass.
- An empty `auth.issuer` retains 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.yaml` sets `otlp_insecure: true` for a local collector; change it before production.
- Secret masking merges built-ins and custom fields, with no setting to disable built-ins.
- MCP requires `Origin` validation; rejecting unexpected `Host` headers is a separate DNS rebinding defence.

## Further reading

- gortexa [PR #57 (8ce7e47)](https://github.com/yshengliao/gortexa/commit/8ce7e4732ace82a97081c911a14c9954a1198952): the four fixes and their regression tests; [PR #11 (3242631)](https://github.com/yshengliao/gortexa/commit/32426319e6c1d49c981c266aa06896a910696c1c): where `cors_origins` came from; [PR #58 (a92f45c)](https://github.com/yshengliao/gortexa/commit/a92f45c908c15186535f1ff1df3843cfd12c5619): the header allowlist and the reflection switch. The v0.27 public API change moved packages from `internal/` to the repository root.
- MCP specification, [Transports (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) and the current [Streamable HTTP (2026-07-28)](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http): 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](https://crypto.stanford.edu/dns/dns-rebinding.pdf)" (ACM CCS 2007): the original research, including the Host header check. Wikipedia's "[DNS rebinding](https://en.wikipedia.org/wiki/DNS_rebinding)" summarises the attack and the mitigations.
- MDN, "[Access-Control-Allow-Origin](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/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](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.1) and [RFC 8725 §3.8](https://www.rfc-editor.org/rfc/rfc8725#section-3.8): the definition of `iss` and the requirements for validating issuer and subject.
- golang-jwt [v5.3.1 validator.go](https://github.com/golang-jwt/jwt/blob/v5.3.1/validator.go): an empty expected issuer skips the comparison.
- OpenTelemetry Go [otlptracegrpc](https://pkg.go.dev/go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc@v1.46.0): `WithInsecure` and the TLS default; the [OTLP exporter specification](https://opentelemetry.io/docs/specs/otel/protocol/exporter/): when `OTEL_EXPORTER_OTLP_INSECURE` applies.
- 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)": capturing prompts and completions requires an explicit user opt-in; "[gen_ai.input.messages](https://github.com/open-telemetry/semantic-conventions-genai/blob/e6658f14c9d9ea0760825f029cb82e7673f48fab/docs/registry/attributes/gen-ai.md#gen-ai-input-messages)": the attribute and its sensitive-data warning.
- The two user reports: Claude Code [issue #107757](https://github.com/anthropics/claude-code/issues/107757) with the official [changelog](https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md) entry for 2.1.317; Postman [issue #14370](https://github.com/postmanlabs/postman-app-support/issues/14370) with the [member's reply](https://github.com/postmanlabs/postman-app-support/issues/14370#issuecomment-6649122696).
- "[Treat Unset as Zero: Hard Caps, Approval Thresholds and Which Way a Kill Switch Fails](/en/posts/default-hard-caps-and-kill-switches/)": 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.