Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ All fields are optional. Repos can be added in the config file or through the Se
| `github_token_env` | `"MIDDLEMAN_GITHUB_TOKEN"` | Env var holding the default GitHub token |
| `default_platform_host` | `"github.com"` | Host treated as implicit in repository UI labels |
| `host` | `"127.0.0.1"` | Listen address |
| `port` | `8091` | Listen port |
| `port` | `8091` | Listen port, from 1 to 65535 |
| `base_path` | `"/"` | URL prefix for reverse proxy deployments |
| `data_dir` | `"~/.config/middleman"` | Directory for the SQLite database |
| `activity.view_mode` | `"threaded"` | `"flat"` or `"threaded"` |
Expand Down
5 changes: 3 additions & 2 deletions cmd/e2e-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -878,8 +878,9 @@ func run(
srv := server.NewWithConfig(
database, syncer, diffRepo.Manager, assets, cfg, cfgPath,
server.ServerOptions{
Clones: diffRepo.Manager,
WorktreeDir: filepath.Join(tmpDir, "worktrees"),
Clones: diffRepo.Manager,
WorktreeDir: filepath.Join(tmpDir, "worktrees"),
HostCheckAllowLoopbackAnyPort: true,
},
)
rootHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
2 changes: 2 additions & 0 deletions cmd/middleman/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ func TestStartupFallbackKeepsPersistedGlobMatchesInAPIs(t *testing.T) {
)

reposReq := httptest.NewRequest(http.MethodGet, "/api/v1/repos", nil)
reposReq.Host = "127.0.0.1:8091"
reposRR := httptest.NewRecorder()
srv.ServeHTTP(reposRR, reposReq)
require.Equal(http.StatusOK, reposRR.Code, reposRR.Body.String())
Expand All @@ -339,6 +340,7 @@ func TestStartupFallbackKeepsPersistedGlobMatchesInAPIs(t *testing.T) {
})

settingsReq := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
settingsReq.Host = "127.0.0.1:8091"
settingsRR := httptest.NewRecorder()
srv.ServeHTTP(settingsRR, settingsReq)
require.Equal(http.StatusOK, settingsRR.Code, settingsRR.Body.String())
Expand Down
2 changes: 1 addition & 1 deletion cmd/middleman/startup_token_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func TestCollectProviderTokensInvokesGHWithHostnameForEnterprise(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.toml")
require.NoError(os.WriteFile(configPath, []byte(`
host = "127.0.0.1"
port = 0
port = 8091

[[repos]]
platform = "github"
Expand Down
323 changes: 323 additions & 0 deletions docs/superpowers/plans/2026-05-19-host-origin-middleware.md

Large diffs are not rendered by default.

338 changes: 338 additions & 0 deletions docs/superpowers/specs/2026-05-19-host-origin-middleware-design.md

Large diffs are not rendered by default.

87 changes: 68 additions & 19 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -540,24 +540,44 @@ type Shell struct {
}

type Config struct {
SyncInterval string `toml:"sync_interval"`
GitHubTokenEnv string `toml:"github_token_env"`
DefaultPlatformHost string `toml:"default_platform_host"`
Host string `toml:"host"`
Port int `toml:"port"`
BasePath string `toml:"base_path"`
DataDir string `toml:"data_dir"`
SyncBudgetPerHour int `toml:"sync_budget_per_hour"`
SSEBufferSize int `toml:"sse_buffer_size"`
IssueWorkspaceBranchStyle string `toml:"issue_workspace_branch_style"`
Repos []Repo `toml:"repos"`
Platforms []PlatformConfig `toml:"platforms"`
Activity Activity `toml:"activity"`
Terminal Terminal `toml:"terminal"`
Agents []Agent `toml:"agents"`
Roborev Roborev `toml:"roborev"`
Tmux Tmux `toml:"tmux"`
Shell Shell `toml:"shell"`
SyncInterval string `toml:"sync_interval"`
GitHubTokenEnv string `toml:"github_token_env"`
DefaultPlatformHost string `toml:"default_platform_host"`
Host string `toml:"host"`
Port int `toml:"port"`
BasePath string `toml:"base_path"`
DataDir string `toml:"data_dir"`
SyncBudgetPerHour int `toml:"sync_budget_per_hour"`
SSEBufferSize int `toml:"sse_buffer_size"`
IssueWorkspaceBranchStyle string `toml:"issue_workspace_branch_style"`
// AllowedHosts is an exact-match allowlist of Host header values
// beyond the bind address that the Host validation middleware
// should accept. Loopback synonyms (127.0.0.1 / localhost /
// [::1]) at the bind port are auto-accepted and do not need to
// be listed.
AllowedHosts []string `toml:"allowed_hosts"`
// TrustReverseProxy enables honoring X-Forwarded-Host and
// Forwarded RFC 7239 host= for the Public Host validation step.
// The raw Host header must still pass the allowed_hosts gate
// before any forwarded header is read.
TrustReverseProxy bool `toml:"trust_reverse_proxy"`
Repos []Repo `toml:"repos"`
Platforms []PlatformConfig `toml:"platforms"`
Activity Activity `toml:"activity"`
Terminal Terminal `toml:"terminal"`
Agents []Agent `toml:"agents"`
Roborev Roborev `toml:"roborev"`
Tmux Tmux `toml:"tmux"`
Shell Shell `toml:"shell"`

// parsedAllowedHosts is the canonicalised form of AllowedHosts,
// populated by Validate so the server constructor does not have
// to re-parse on every request setup. Defensive copy via
// ParsedAllowedHosts.
parsedAllowedHosts []HostKey
// parsedBindKey is the canonical (Host, Port) key for the bind
// address, populated by Validate.
parsedBindKey HostKey
}

// SSEBufferSizeOrDefault returns the configured SSE replay ring size,
Expand Down Expand Up @@ -857,10 +877,25 @@ func (c *Config) Validate() error {
return fmt.Errorf("config: host %q is not loopback; only loopback addresses are supported", c.Host)
}

if c.Port < 0 || c.Port > 65535 {
if c.Port < 1 || c.Port > 65535 {
return fmt.Errorf("config: invalid port %d", c.Port)
}

bindKey, err := ParseHostKey(net.JoinHostPort(c.Host, strconv.Itoa(c.Port)))
if err != nil {
return fmt.Errorf("config: invalid host %q: %w", c.Host, err)
}
c.parsedBindKey = bindKey

c.parsedAllowedHosts = c.parsedAllowedHosts[:0]
for _, entry := range c.AllowedHosts {
key, err := ParseHostKey(entry)
if err != nil {
return fmt.Errorf("config: invalid allowed_hosts entry %q: %w", entry, err)
}
c.parsedAllowedHosts = append(c.parsedAllowedHosts, key)
}

if c.SyncBudgetPerHour != 0 && c.SyncBudgetPerHour < 50 {
return fmt.Errorf(
"config: sync_budget_per_hour must be >= 50 or omitted, got %d",
Expand Down Expand Up @@ -1314,6 +1349,20 @@ func (c *Config) ListenAddr() string {
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}

// BindHostKey returns the canonical (Host, Port) key for the bind
// address, populated by Validate. The zero HostKey is returned for
// configs that were not validated (e.g. test literals that omit
// Host/Port); callers should use HostKey.Valid() to gate behavior.
func (c *Config) BindHostKey() HostKey {
return c.parsedBindKey
}

// ParsedAllowedHosts returns the canonicalised allowlist, populated
// by Validate. The returned slice is a defensive copy.
func (c *Config) ParsedAllowedHosts() []HostKey {
return append([]HostKey(nil), c.parsedAllowedHosts...)
}

func (c *Config) DBPath() string {
return filepath.Join(c.DataDir, "middleman.db")
}
Expand Down
82 changes: 82 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2695,3 +2695,85 @@ func TestGitHubTokenInvokesGHWithGithubComHostname(t *testing.T) {
require.Len(t, argv, 1)
Assert.Equal(t, "auth token --hostname github.com", argv[0])
}

func TestLoadAllowedHostsDefault(t *testing.T) {
assert := Assert.New(t)
cfg, err := Load(writeConfig(t, `host = "127.0.0.1"
port = 8091
`))
require.NoError(t, err)
assert.Empty(cfg.AllowedHosts)
assert.Empty(cfg.ParsedAllowedHosts())
assert.False(cfg.TrustReverseProxy)
assert.Equal(
HostKey{Host: "127.0.0.1", Port: "8091"},
cfg.BindHostKey(),
)
}

func TestLoadRejectsZeroPort(t *testing.T) {
_, err := Load(writeConfig(t, `host = "127.0.0.1"
port = 0
`))
require.Error(t, err)
Assert.Contains(t, err.Error(), "invalid port 0")
}

func TestLoadAllowedHostsParsesAndCanonicalises(t *testing.T) {
assert := Assert.New(t)
cfg, err := Load(writeConfig(t, `host = "127.0.0.1"
port = 8091
allowed_hosts = ["mm.local:8091", "MM.Example.Com", "[::1]:8443"]
trust_reverse_proxy = true
`))
require.NoError(t, err)
assert.Equal(
[]HostKey{
{Host: "mm.local", Port: "8091"},
{Host: "mm.example.com", Port: ""},
{Host: "[::1]", Port: "8443"},
},
cfg.ParsedAllowedHosts(),
)
assert.True(cfg.TrustReverseProxy)
}

func TestLoadAllowedHostsRejectsBadEntry(t *testing.T) {
cases := []struct {
name string
entry string
}{
{name: "unbracketed ipv6", entry: "::1:8091"},
{name: "port only", entry: ":8091"},
{name: "empty", entry: ""},
{name: "port out of range", entry: "mm.local:99999"},
{name: "bracketed ipv4", entry: "[127.0.0.1]:8091"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
path := writeConfig(t, fmt.Sprintf(`host = "127.0.0.1"
port = 8091
allowed_hosts = [%q]
`, tc.entry))
_, err := Load(path)
require.Error(t, err)
Assert.Contains(t, err.Error(), "allowed_hosts")
})
}
}

func TestParsedAllowedHostsDefensiveCopy(t *testing.T) {
assert := Assert.New(t)
cfg, err := Load(writeConfig(t, `host = "127.0.0.1"
port = 8091
allowed_hosts = ["mm.local:8091"]
`))
require.NoError(t, err)
got := cfg.ParsedAllowedHosts()
got[0] = HostKey{Host: "tampered", Port: "1"}
again := cfg.ParsedAllowedHosts()
assert.Equal(
[]HostKey{{Host: "mm.local", Port: "8091"}},
again,
)
}
134 changes: 134 additions & 0 deletions internal/config/hostmatch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package config

import (
"fmt"
"net"
"strconv"
"strings"
)

// HostKey is the canonical representation of a host header value
// (or a configured allowed_hosts entry). Host is lowercased; for
// IPv6 literals it keeps the surrounding brackets so the textual
// form roundtrips. Port is the digits-only port string, or "" when
// the source value had no explicit port. ParseHostKey is the only
// constructor; downstream code should treat the struct as
// effectively immutable after parsing.
type HostKey struct {
Host string
Port string
}

// ParseHostKey canonicalises a host header value or
// configuration entry. Accepted shapes:
//
// - bare host: "mm.local", "LOCALHOST" (case-folded)
// - host with port: "mm.local:8091", "127.0.0.1:8091"
// - bracketed IPv6: "[::1]", "[::1]:8091"
//
// Rejected: empty / whitespace-only input, port-only ("·:8091"),
// unbracketed IPv6 literals ("::1", "::1:8091"), ports outside
// 1-65535.
func ParseHostKey(s string) (HostKey, error) {
s = strings.TrimSpace(s)
if s == "" {
return HostKey{}, fmt.Errorf("host: empty")
}

// Bracketed IPv6 path: [ipv6] or [ipv6]:port.
if strings.HasPrefix(s, "[") {
closing := strings.IndexByte(s, ']')
if closing < 0 {
return HostKey{}, fmt.Errorf("host: missing closing bracket")
}
host := s[1:closing]
// A bracketed value must parse as an IP literal and the
// textual form must contain a colon (IPv6 textual form
// always does; dotted-quad IPv4 never does). This rejects
// "[127.0.0.1]" while accepting IPv4-mapped IPv6 like
// "[::ffff:7f00:1]".
if ip := net.ParseIP(host); ip == nil || !strings.ContainsRune(host, ':') {
return HostKey{}, fmt.Errorf(
"host: bracketed value %q is not an IPv6 literal", host,
)
}
rest := s[closing+1:]
var port string
if rest != "" {
if !strings.HasPrefix(rest, ":") {
return HostKey{}, fmt.Errorf(
"host: unexpected trailing data %q after bracketed host", rest,
)
}
p, err := parsePort(rest[1:])
if err != nil {
return HostKey{}, err
}
port = p
}
return HostKey{Host: "[" + strings.ToLower(host) + "]", Port: port}, nil
}

// host:port (last colon) or bare host. Splitting on the last
// colon catches unbracketed IPv6 ("::1", "::1:8091") because
// the host part after the split still contains a `:`; we
// reject that explicitly below.
if idx := strings.LastIndexByte(s, ':'); idx >= 0 {
host := s[:idx]
portStr := s[idx+1:]
if host == "" {
return HostKey{}, fmt.Errorf("host: port-only input %q", s)
}
if strings.ContainsRune(host, ':') {
return HostKey{}, fmt.Errorf(
"host: unbracketed IPv6 literal %q (use [ipv6]:port instead)", s,
)
}
port, err := parsePort(portStr)
if err != nil {
return HostKey{}, err
}
return HostKey{Host: strings.ToLower(host), Port: port}, nil
}

// Bare host, no port.
return HostKey{Host: strings.ToLower(s), Port: ""}, nil
}

func parsePort(p string) (string, error) {
if p == "" {
return "", fmt.Errorf("host: empty port")
}
n, err := strconv.Atoi(p)
if err != nil {
return "", fmt.Errorf("host: invalid port %q: %w", p, err)
}
if n < 1 || n > 65535 {
return "", fmt.Errorf("host: port %d out of range", n)
}
return p, nil
}

// String renders the canonical form. Bracketed IPv6 hosts already
// carry their brackets in Host, so the renderer just joins.
func (k HostKey) String() string {
if k.Port == "" {
return k.Host
}
return k.Host + ":" + k.Port
}

// Equal reports whether the keys match. Hosts are already
// lowercased by ParseHostKey, so this is an exact-string compare
// on both fields.
func (k HostKey) Equal(other HostKey) bool {
return k.Host == other.Host && k.Port == other.Port
}

// Valid reports whether the key carries both host and port. The
// server constructor uses this to distinguish a populated bind
// key from the zero value when deciding between caller override,
// cfg-derived, and the cfg=nil test-friendly default.
func (k HostKey) Valid() bool {
return k.Host != "" && k.Port != ""
}
Loading
Loading