oficina / docs Public

app: fix round 1 -- the gate fails instead of hanging, a whitespace origin can't slip past, run-dev.sh doesn't orphan the server

Three findings from review, all real:

The gate's dev-auth regression check had no timeout: a release build
that (wrongly) accepted -dev-auth would call rastrillo.Serve and block
forever, so the check meant to catch that regression would hang the
gate itself rather than fail it -- worse than no check, since a hang
gets retried and a failure gets read. It also defaulted to docs.db in
the repo root. Added `timeout 5`, a scratch -db, and an ephemeral
-addr, and distinguish "timed out" (124, the vulnerability is
present) from a normal refusal. Verified by reverting
devauth_flag_release.go to the vulnerable version, confirming the
gate now fails loudly in ~60s with its own message, and restoring it.

internal/docs/app.go's empty-Origin refusal tested cfg.Origin == "",
so a whitespace-only $DOCS_ORIGIN slipped past it: the app would boot
looking healthy and then 403 every request forever, failing safe but
silently and late. New now trims before testing for blank, and
validates any non-blank value as an absolute http(s) origin with a
host and nothing else (no path/query/fragment/userinfo) via a new
validateOrigin, refusing at startup with an error naming what was
wrong. New tests in origin_test.go and app_test.go.

scripts/run-dev.sh ran the server as a plain subprocess, so its own
PID and the server's differed -- killing `./scripts/run-dev.sh &`
(the pattern the script itself invites) deleted the scratch dir via
the EXIT trap while orphaning a server still holding the port. Now
execs the binary so the wrapper's PID is the server's PID and a
signal reaches it directly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
Paul Campbell pushed by paul@keymail.dev e1847650e44be49e8e8ff3e2210b7afa0f099ec4 parent 0c62fda
5 files changed, +213 −7
  • .amadan/ci.d/10-gate +25 −2
  • internal/docs/app.go +60 −4
  • internal/docs/app_test.go +47 −0
  • internal/docs/origin_test.go +61 −0
  • scripts/run-dev.sh +20 −1

CI passed — run details

diff --git a/.amadan/ci.d/10-gate b/.amadan/ci.d/10-gate
index 96728d9..2f8d83d 100755
--- a/.amadan/ci.d/10-gate
+++ b/.amadan/ci.d/10-gate
@@ -93,8 +93,31 @@ echo "=== go: a release build cannot start with dev auth"
gate_tmp="$(mktemp -d)"
trap 'rm -rf "$gate_tmp"' EXIT
CGO_ENABLED=0 go build -tags release -o "$gate_tmp/docs-release" ./cmd/docs
-if "$gate_tmp/docs-release" -dev-auth >"$gate_tmp/out" 2>&1; then
- echo "gate: a release build accepted -dev-auth and started — see cmd/docs/devauth_flag_release.go" >&2
+
+# A release binary that (wrongly) accepted -dev-auth would go on to call
+# rastrillo.Serve and block forever, listening — a hang, not a failure.
+# "if cmd; then" alone cannot tell "refused in 5ms" apart from "still
+# running after 5 seconds": both only reach a branch once something
+# finally kills the process, and a bare invocation with no timeout never
+# would. This check exists specifically to catch that regression, so it
+# must fail loudly the day it fires rather than hang the gate itself — a
+# hang gets retried, a failure gets read. `timeout` turns "still running"
+# into its own distinguishable exit code (124) instead. -db and -addr
+# point at a scratch path and an ephemeral port so a wrongly-accepted
+# -dev-auth run cannot litter the repo with a stray docs.db (it did,
+# once, from the default relative DBPath) or fight over a real port.
+set +e
+timeout 5 "$gate_tmp/docs-release" -dev-auth -db "$gate_tmp/scratch.db" -addr 127.0.0.1:0 \
+ >"$gate_tmp/out" 2>&1
+status=$?
+set -e
+if [ "$status" -eq 124 ]; then
+ echo "gate: a release build accepted -dev-auth and kept running until killed by timeout — see cmd/docs/devauth_flag_release.go" >&2
+ cat "$gate_tmp/out" >&2
+ exit 1
+fi
+if [ "$status" -eq 0 ]; then
+ echo "gate: a release build accepted -dev-auth and exited 0 — see cmd/docs/devauth_flag_release.go" >&2
cat "$gate_tmp/out" >&2
exit 1
fi
diff --git a/internal/docs/app.go b/internal/docs/app.go
index bf84fbf..2d84a23 100644
--- a/internal/docs/app.go
+++ b/internal/docs/app.go
@@ -16,6 +16,8 @@ import (
"fmt"
"log/slog"
"net/http"
+ "net/url"
+ "strings"
"github.com/carlosframework/rastrillo/csrf"
"github.com/carlosframework/rastrillo/db"
@@ -64,6 +66,12 @@ type Config struct {
// leaving $DOCS_ORIGIN unset must fail loudly rather than quietly
// protecting the app with the wrong origin — that is exactly what
// the Env-gated default below refuses to do.
+ //
+ // A non-empty value is trimmed and then validated (validateOrigin)
+ // rather than trusted as-is: whitespace-only is not "empty" by a
+ // bare == "" check, and would otherwise sail past the guard above
+ // and boot an app that rejects every request forever rather than
+ // refusing to start.
Origin string
// Logger defaults to slog.Default().
@@ -76,6 +84,36 @@ type Config struct {
// does not have to duplicate the literal.
const DefaultOrigin = "http://localhost:8080"
+// validateOrigin reports whether origin (already trimmed by the
+// caller — New) is something csrf.Protect can actually use: an
+// absolute http(s) URL naming a scheme and a host and nothing else.
+// "Nothing else" is checked deliberately, not just "parses as a
+// URL" — url.Parse happily accepts a bare path, a value carrying a
+// query string, or one with embedded userinfo, none of which is a
+// same-origin check has any business being handed, and none of which
+// would ever equal a real browser's Origin header, so an app that
+// accepted one would boot looking healthy and then reject every
+// request rather than refusing to start.
+func validateOrigin(origin string) error {
+ if origin == "" {
+ return errors.New("origin is blank")
+ }
+ u, err := url.Parse(origin)
+ if err != nil {
+ return fmt.Errorf("origin %q does not parse as a URL: %w", origin, err)
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return fmt.Errorf("origin %q must have an http or https scheme", origin)
+ }
+ if u.Host == "" {
+ return fmt.Errorf("origin %q has no host", origin)
+ }
+ if u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
+ return fmt.Errorf("origin %q must be scheme://host[:port] only, with no path, query, fragment or userinfo", origin)
+ }
+ return nil
+}
+
// App is the CARLOS Docs rastrillo app.
type App struct {
cfg Config
@@ -119,11 +157,29 @@ func New(cfg Config) (*App, error) {
// fail-closed shape as guardDevAuth below: exactly one string
// ("development") is ever treated as "safe to default", checked
// before anything else opens.
- if cfg.Origin == "" {
- if cfg.Env != "development" {
- return nil, fmt.Errorf("docs: Config.Origin is empty and Env is %q, not %q — refusing to start with an unset origin; set $DOCS_ORIGIN to this instance's real origin", cfg.Env, "development")
- }
+ //
+ // cfg.Origin == "" alone is not the right test: a whitespace value
+ // like $DOCS_ORIGIN=" " is non-empty, so it would sail past that
+ // check, and csrf.Protect would then compare every request's real
+ // Origin/Referer against literal whitespace — never matching,
+ // which means the app boots looking healthy and 403s all traffic,
+ // with or without a correct Origin header. That fails safe, but
+ // silently and late (at request time), not at boot where a
+ // malformed origin belongs. validateOrigin below rejects that and
+ // any other value that cannot possibly be what csrf.Protect needs
+ // (no scheme, no host, a stray path/query/fragment/userinfo)
+ // before New ever returns a listening app.
+ trimmedOrigin := strings.TrimSpace(cfg.Origin)
+ switch {
+ case trimmedOrigin == "" && cfg.Env != "development":
+ return nil, fmt.Errorf("docs: Config.Origin is empty and Env is %q, not %q — refusing to start with an unset origin; set $DOCS_ORIGIN to this instance's real origin", cfg.Env, "development")
+ case trimmedOrigin == "":
cfg.Origin = DefaultOrigin
+ default:
+ if err := validateOrigin(trimmedOrigin); err != nil {
+ return nil, fmt.Errorf("docs: Config.Origin is invalid: %w", err)
+ }
+ cfg.Origin = trimmedOrigin
}
// Checked before anything else opens — including the database —
diff --git a/internal/docs/app_test.go b/internal/docs/app_test.go
index 63946e5..9042c61 100644
--- a/internal/docs/app_test.go
+++ b/internal/docs/app_test.go
@@ -3,6 +3,8 @@ package docs_test
import (
"net/http"
"net/http/httptest"
+ "path/filepath"
+ "strings"
"testing"
"github.com/carlosframework/docs/internal/docs"
@@ -24,3 +26,48 @@ func TestRootRendersAPage(t *testing.T) {
t.Error("no Content-Type on the response")
}
}
+
+// TestNewRefusesWhitespaceOrigin is fix round 1's own regression test
+// at the New boundary (origin_test.go covers validateOrigin directly,
+// package-internal): $DOCS_ORIGIN=" " is non-empty, so a bare
+// cfg.Origin == "" check would let it through New and boot an app
+// that 403s every request forever instead of refusing to start. New
+// trims before testing for blank, so whitespace-only collapses onto
+// the same "empty origin" refusal an actually-empty Origin gets —
+// still a startup refusal either way, which is the property that
+// matters here, not which of the two error messages it is.
+func TestNewRefusesWhitespaceOrigin(t *testing.T) {
+ _, err := docs.New(docs.Config{
+ DBPath: filepath.Join(t.TempDir(), "docs.db"),
+ Env: "production",
+ Origin: " ",
+ })
+ if err == nil {
+ t.Fatal("New must refuse a whitespace-only Origin")
+ }
+ if !strings.Contains(err.Error(), "empty") {
+ t.Errorf("error should say the origin is empty: %v", err)
+ }
+}
+
+// TestNewRefusesMalformedOrigin covers the other ways a non-blank
+// Origin can still be unusable — no scheme, no host, or a URL
+// carrying more than scheme://host — at the same New boundary.
+func TestNewRefusesMalformedOrigin(t *testing.T) {
+ for _, origin := range []string{
+ "not a url at all",
+ "localhost:8080",
+ "http://example.com/path",
+ } {
+ t.Run(origin, func(t *testing.T) {
+ _, err := docs.New(docs.Config{
+ DBPath: filepath.Join(t.TempDir(), "docs.db"),
+ Env: "production",
+ Origin: origin,
+ })
+ if err == nil {
+ t.Fatalf("New must refuse malformed origin %q", origin)
+ }
+ })
+ }
+}
diff --git a/internal/docs/origin_test.go b/internal/docs/origin_test.go
new file mode 100644
index 0000000..26ca15d
--- /dev/null
+++ b/internal/docs/origin_test.go
@@ -0,0 +1,61 @@
+package docs
+
+import "testing"
+
+// TestValidateOriginRejectsWhitespace is fix round 1's own regression
+// test: DOCS_ORIGIN=" " (all whitespace) is non-empty, so a bare
+// cfg.Origin == "" check in New lets it through, and csrf.Protect then
+// compares every request's real Origin/Referer against literal
+// whitespace — never matching, so the app boots looking healthy and
+// 403s all traffic instead of refusing to start. validateOrigin must
+// catch it (New trims before calling this, but the function itself
+// stays correct against an untrimmed caller too).
+func TestValidateOriginRejectsWhitespace(t *testing.T) {
+ for _, origin := range []string{"", " ", "\t\n "} {
+ if err := validateOrigin(origin); err == nil {
+ t.Errorf("validateOrigin(%q) = nil, want an error", origin)
+ }
+ }
+}
+
+// TestValidateOriginRejectsMalformed covers the other ways a
+// non-blank string can still be unusable as csrf.Protect's
+// comparison origin: no scheme, the wrong scheme, no host, and a URL
+// carrying more than scheme://host (a path, a query, a fragment,
+// embedded userinfo) — none of which a real browser's Origin header
+// would ever equal.
+func TestValidateOriginRejectsMalformed(t *testing.T) {
+ cases := []string{
+ "not a url at all",
+ "localhost:8080", // no recognized scheme
+ "ftp://example.com", // wrong scheme
+ "http://", // no host
+ "http://example.com/path", // path
+ "http://example.com?x=1", // query
+ "http://example.com#frag", // fragment
+ "http://user:pass@example.com", // userinfo
+ }
+ for _, origin := range cases {
+ t.Run(origin, func(t *testing.T) {
+ if err := validateOrigin(origin); err == nil {
+ t.Errorf("validateOrigin(%q) = nil, want an error", origin)
+ }
+ })
+ }
+}
+
+// TestValidateOriginAcceptsGoodOrigins proves the check above is not
+// so strict it rejects the values this app actually uses:
+// DefaultOrigin, an -addr-derived origin, and a real platform origin
+// with a port.
+func TestValidateOriginAcceptsGoodOrigins(t *testing.T) {
+ for _, origin := range []string{
+ DefaultOrigin,
+ "http://localhost:8099",
+ "https://docs.bds.oncarlos.com",
+ } {
+ if err := validateOrigin(origin); err != nil {
+ t.Errorf("validateOrigin(%q) = %v, want nil", origin, err)
+ }
+ }
+}
diff --git a/scripts/run-dev.sh b/scripts/run-dev.sh
index fc446ce..b560598 100755
--- a/scripts/run-dev.sh
+++ b/scripts/run-dev.sh
@@ -20,6 +20,19 @@ addr="localhost:${port}"
# accumulates state nobody wrote down. mktemp -d rather than a fixed
# path under /tmp, so two concurrent runs (two people, or a person and
# a CI job) never collide or clobber each other's database.
+#
+# The EXIT trap below only ever fires while this is still a running
+# bash script — up through a failed build. It is deliberately gone the
+# moment exec below replaces this process with the server (see that
+# line's own comment): fix round 1 found that running the server as a
+# plain foreground subprocess instead left the wrapper's PID and the
+# server's PID different, so killing `./scripts/run-dev.sh &` — the
+# exact pattern this script invites — fired this trap and deleted the
+# scratch directory while orphaning a server still holding the port,
+# undeletable and needing `kill -9` by hand. exec fixes the orphan at
+# the cost of this cleanup after the server itself is running; a
+# scratch directory under $TMPDIR left behind by a killed dev server is
+# a smaller problem than a leaked listener nothing can reach again.
scratch="$(mktemp -d "${TMPDIR:-/tmp}/docs-dev.XXXXXX")"
db="${scratch}/docs.db"
trap 'rm -rf "$scratch"' EXIT
@@ -37,4 +50,10 @@ go build -o "${scratch}/docs" ./cmd/docs
echo "docs: dev database at ${db}"
echo "docs: http://${addr}/"
-"${scratch}/docs" -dev-auth -addr "${addr}" -db "${db}"
+# exec, not a plain invocation: this script's own PID becomes the
+# server's PID, so a signal sent to it (Ctrl-C, or `kill` on the
+# backgrounded `./scripts/run-dev.sh &`) reaches the server directly —
+# rastrillo.Serve's own SIGTERM/SIGINT drain runs instead of orphaning
+# a listener nothing can reach or clean up afterward. See the comment
+# above the trap for what this costs.
+exec "${scratch}/docs" -dev-auth -addr "${addr}" -db "${db}"