From 55b51b3993e6a18b1b6f07f2ce33908d966ac3b4 Mon Sep 17 00:00:00 2001 From: bsaurusrex <82356519+bsaurusrex@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:08:16 +0800 Subject: [PATCH] feat: add server.socketMode to set unix socket permissions In unix socket mode the socket was created with whatever the process umask allowed, with no way to restrict it from configuration. A permissive umask leaves the socket world-connectable, and any process that can connect is treated as a trusted proxy for forwarded client-IP headers. server.socketMode (e.g. 0660) now chmods the socket after net.Listen. It is opt-in: left unset the socket keeps its current umask-derived permissions, so existing deployments are unaffected. The mode is parsed and validated before any existing socket is removed, so a bad value does not delete the current socket, and it is rejected on Windows where it cannot be enforced. Refs #685 Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 + internal/bootstrap/router_bootstrap.go | 43 ++++++++++++++++ internal/bootstrap/router_bootstrap_test.go | 55 +++++++++++++++++++++ internal/model/config.go | 1 + 4 files changed, 101 insertions(+) create mode 100644 internal/bootstrap/router_bootstrap_test.go diff --git a/.env.example b/.env.example index 45d488e1..d0834774 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,8 @@ TINYAUTH_SERVER_PORT=3000 TINYAUTH_SERVER_ADDRESS="0.0.0.0" # The path to the Unix socket. TINYAUTH_SERVER_SOCKETPATH= +# Octal permission mode for the Unix socket (e.g. 0660). Left unset, the socket keeps the process umask's default. +TINYAUTH_SERVER_SOCKETMODE= # auth config diff --git a/internal/bootstrap/router_bootstrap.go b/internal/bootstrap/router_bootstrap.go index ae82c2d3..f52d3a6b 100644 --- a/internal/bootstrap/router_bootstrap.go +++ b/internal/bootstrap/router_bootstrap.go @@ -7,6 +7,8 @@ import ( "net" "net/http" "os" + "runtime" + "strconv" "time" "github.com/tinyauthapp/tinyauth/internal/controller" @@ -165,7 +167,36 @@ func (app *BootstrapApp) serveHTTP(ctx context.Context) error { return app.serve(listener, server, ctx, "http") } +// parseSocketMode parses an octal permission string such as "0660" into an os.FileMode. +func parseSocketMode(s string) (os.FileMode, error) { + v, err := strconv.ParseUint(s, 8, 32) + + if err != nil || v > 0o777 { + return 0, fmt.Errorf("expected an octal mode such as 0660") + } + + return os.FileMode(v), nil +} + func (app *BootstrapApp) serveUnix(ctx context.Context) error { + // Validate socketMode up front, before removing any existing socket, so a configuration error does + // not delete the current socket and then fail to start. + hasMode := app.config.Server.SocketMode != "" + + var mode os.FileMode + + if hasMode { + if runtime.GOOS == "windows" { + return errors.New("server.socketMode is not supported on Windows, where socket permissions cannot be enforced") + } + + var perr error + + if mode, perr = parseSocketMode(app.config.Server.SocketMode); perr != nil { + return fmt.Errorf("invalid server.socketMode %q: %w", app.config.Server.SocketMode, perr) + } + } + _, err := os.Stat(app.config.Server.SocketPath) if err == nil { @@ -185,6 +216,18 @@ func (app *BootstrapApp) serveUnix(ctx context.Context) error { return fmt.Errorf("failed to create unix socket listener: %w", err) } + if hasMode { + // net.Listen creates the socket with the process umask's mode; chmod tightens it immediately. + // serve() has not started accepting connections yet, and connecting to the socket is not itself + // an auth bypass, so this is the standard Listen+Chmod pattern for Go unix sockets. It is + // preferred over changing the process-wide umask, which would race with any other file created + // during startup. Operators wanting a hard guarantee should also restrict the socket's directory. + if err := os.Chmod(app.config.Server.SocketPath, mode); err != nil { + listener.Close() + return fmt.Errorf("failed to set unix socket mode: %w", err) + } + } + server := &http.Server{ Handler: app.router.Handler(), } diff --git a/internal/bootstrap/router_bootstrap_test.go b/internal/bootstrap/router_bootstrap_test.go new file mode 100644 index 00000000..72309083 --- /dev/null +++ b/internal/bootstrap/router_bootstrap_test.go @@ -0,0 +1,55 @@ +package bootstrap + +import ( + "net" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseSocketMode(t *testing.T) { + ok := map[string]os.FileMode{ + "0660": 0o660, + "660": 0o660, + "0600": 0o600, + "0": 0, + "0777": 0o777, + } + + for in, want := range ok { + got, err := parseSocketMode(in) + assert.NoError(t, err, "input %q", in) + assert.Equal(t, want, got, "input %q", in) + } + + for _, in := range []string{"", "abc", "0800", "999", "0x1a0", "1000"} { + _, err := parseSocketMode(in) + assert.Error(t, err, "input %q should be rejected", in) + } +} + +// TestSocketModeAppliedToListener documents that os.Chmod on the listening socket path sets +// exactly the requested permission bits, which is what serveUnix relies on. +func TestSocketModeAppliedToListener(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("os.Chmod cannot set Unix permission bits on Windows (socketMode is rejected there)") + } + + path := filepath.Join(t.TempDir(), "ta.sock") + + listener, err := net.Listen("unix", path) + require.NoError(t, err) + defer listener.Close() + + mode, err := parseSocketMode("0660") + require.NoError(t, err) + require.NoError(t, os.Chmod(path, mode)) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o660), info.Mode().Perm()) +} diff --git a/internal/model/config.go b/internal/model/config.go index 9d514390..117c4ca9 100644 --- a/internal/model/config.go +++ b/internal/model/config.go @@ -138,6 +138,7 @@ type ServerConfig struct { Port int `description:"The port on which the server listens." yaml:"port,omitempty"` Address string `description:"The address on which the server listens." yaml:"address,omitempty"` SocketPath string `description:"The path to the Unix socket." yaml:"socketPath,omitempty"` + SocketMode string `description:"Octal permission mode for the Unix socket (e.g. 0660). Left unset, the socket keeps the process umask's default." yaml:"socketMode,omitempty"` } type AuthConfig struct {