Skip to content

Commit d407887

Browse files
sarg3ntclaude
andcommitted
feat(#83): dev-only loopback auto-login bypass
Compiled in only when the binary is built with `-tags dev` (which the `make dev` target now passes via air's --build.cmd). When all three of these hold the request is auto-authenticated as the seeded `dev` user: 1. Build tag `dev` is set. 2. GEARBOX_DEV_AUTO_LOGIN=1 in the environment. 3. Post-RealIP RemoteAddr is a loopback IP. Production builds (`make build`) replace every entry point with a no-op stub via dev_bypass_off.go, so the bypass code, env-var check, loopback check, seed function, and banner are not present in release binaries at all. Verified: - dev binary contains GEARBOX_DEV_AUTO_LOGIN and seed/banner strings; prod binary contains only the tryDevBypass no-op stub symbol. - cookieless GET / -> 303 /haproxy (auto-login). - cookieless GET / with X-Forwarded-For: 1.2.3.4 -> 303 /login (chi.RealIP rewrites RemoteAddr, loopback check declines). The seeded `dev` user has the package-level dummyPasswordHash so the form-login path can never authenticate as it; only the loopback bypass can. The seed is itself build-tag-gated (users_dev.go) so prod doesn't even contain the seed function. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 28df1a8 commit d407887

9 files changed

Lines changed: 301 additions & 2 deletions

File tree

gearbox/.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ SESSION_SECRET=your-64-character-random-hex-string-here
1616
# Admin password for initial setup (if not set, a random password is generated and logged)
1717
# ADMIN_PASSWORD=your-admin-password
1818

19+
# Dev-only loopback auto-login (issue #83).
20+
# When the binary is built with `-tags dev` (which `make dev` does via .air.toml)
21+
# AND this var is "1" AND the request originates from a loopback IP, the request
22+
# is auto-authenticated as the seeded `dev` user — no login screen.
23+
# Production builds (`make build`) do NOT include the bypass code, so this var
24+
# is inert even if set. Never enable on shared hosts: any local process can
25+
# reach loopback.
26+
# GEARBOX_DEV_AUTO_LOGIN=1
27+
1928
# Base URL for the application (used for email links and WebAuthn)
2029
# BASE_URL=https://gearbox.example.com
2130

gearbox/Makefile

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,17 @@ run: templ-generate ## Run the application with .env file loaded
6666
exit 1; \
6767
fi
6868

69-
dev: templ-generate ## Run with hot reload and .env file loaded (requires air)
69+
# `--build.cmd` overrides whatever the developer's local .air.toml uses
70+
# (the file is gitignored), so `-tags dev` is always passed regardless of
71+
# per-developer config drift. The tag compiles in the loopback auto-login
72+
# bypass — see gearbox/docs/development.md and issue #83. `make build`
73+
# (production) deliberately omits the tag.
74+
DEV_BUILD_CMD = templ generate && CGO_ENABLED=1 go build -tags dev -o ./tmp/main ./cmd/server
75+
76+
dev: templ-generate ## Run with hot reload + .env loaded + dev auto-login bypass (requires air)
7077
@if [ -f .env ]; then \
7178
lsof -ti:3000 | xargs -r kill -9 2>/dev/null || true; \
72-
set -a && . ./.env && set +a && air; \
79+
set -a && . ./.env && set +a && air --build.cmd "$(DEV_BUILD_CMD)"; \
7380
else \
7481
echo "Error: .env file not found. Copy .env.example to .env and configure it."; \
7582
exit 1; \

gearbox/cmd/server/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,14 @@ func main() {
173173
logger.Warn("TLS not configured — session cookies will be sent over HTTP (insecure)")
174174
}
175175

176+
// Dev-only loopback auto-login (issue #83). Both calls are no-ops in
177+
// production builds (no `-tags dev`) — the bypass code is not even
178+
// linked in. See gearbox/internal/framework/auth/dev_bypass_off.go.
179+
if err := auth.SeedDevUserIfEnabled(db, logger); err != nil {
180+
log.Fatalf("Failed to seed dev user for loopback bypass: %v", err)
181+
}
182+
auth.LogDevBypassStartupBanner(logger)
183+
176184
// Initialize email service
177185
emailService := email.NewService(db, logger, cfg.BaseURL)
178186
logger.Info("email service initialized")

gearbox/docs/development.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Fast local development workflow for the Gearbox application on macOS.
1111
- [Option 1: Hot Reload with Air (Recommended)](#option-1-hot-reload-with-air-recommended)
1212
- [Option 2: Manual Rebuild](#option-2-manual-rebuild)
1313
- [Option 3: VS Code Launch Configuration](#option-3-vs-code-launch-configuration)
14+
- [Dev-Only Loopback Auto-Login](#dev-only-loopback-auto-login)
1415
- [VS Code Integration](#vs-code-integration)
1516
- [Required Extensions](#required-extensions)
1617
- [Launch Configuration](#launch-configuration)
@@ -122,6 +123,70 @@ make build
122123

123124
See [VS Code Integration](#vs-code-integration) below for debugger support.
124125

126+
## Dev-Only Loopback Auto-Login
127+
128+
`make dev` builds with `-tags dev` (see [.air.toml](../.air.toml)), which
129+
compiles in a localhost-only auto-login bypass. When all three of these
130+
conditions hold, the request is auto-authenticated as the seeded `dev`
131+
user — no login screen, no cookie management:
132+
133+
1. The binary was built with `-tags dev`.
134+
2. The environment variable `GEARBOX_DEV_AUTO_LOGIN=1` is set.
135+
3. The request's `RemoteAddr` resolves to a loopback IP (`127.0.0.0/8` or `::1`).
136+
137+
### Enabling it
138+
139+
1. Add to `.env`:
140+
141+
```text
142+
GEARBOX_DEV_AUTO_LOGIN=1
143+
```
144+
145+
2. Restart `make dev` (air doesn't reload `.env` changes on its own).
146+
147+
3. On startup you'll see a banner like:
148+
149+
```text
150+
WARN dev auto-login ACTIVE — loopback requests log in as `dev`
151+
WARN DO NOT USE IN PRODUCTION. Rebuild without `-tags dev` to remove entirely.
152+
```
153+
154+
4. Open <http://localhost:3000> in a browser (or run `curl http://localhost:3000/`)
155+
and you're in as the `dev` user (Admin role).
156+
157+
### Why this is safe
158+
159+
- **The bypass code is not in production binaries.** `make build` does not
160+
use `-tags dev`, so the entire mechanism — env var read, loopback check,
161+
user lookup, banner — is replaced by no-op stubs (see
162+
[internal/framework/auth/dev_bypass_off.go](../internal/framework/auth/dev_bypass_off.go)).
163+
Even setting `GEARBOX_DEV_AUTO_LOGIN=1` on a prod box does nothing.
164+
- **Loopback-only.** chi's `middleware.RealIP` rewrites `RemoteAddr` from
165+
`X-Forwarded-For` / `X-Real-IP`. A reverse proxy in front of gearbox
166+
surfaces the *proxy's* IP, not the browser's — so the bypass declines
167+
on any proxied request.
168+
- **The `dev` user has an unusable password hash.** Form-login can never
169+
authenticate as it; only the loopback bypass can.
170+
171+
> [!WARNING]
172+
> Loopback access on a shared host is not loopback-isolated — any user
173+
> with shell access can `curl http://localhost:3000` and inherit Admin.
174+
> This is dev-machine-only. Production builds enforce this by omitting
175+
> the build tag entirely.
176+
177+
### When it does NOT activate
178+
179+
- `make run` (no air, plain `go run`) — doesn't pass `-tags dev`. Add it
180+
manually with `go run -tags dev ./cmd/server` if you want the bypass
181+
without air's hot reload.
182+
- The seeded `dev` row is missing or inactive (e.g. you ran `make clean-data`).
183+
Restart `make dev` — startup runs `SeedDevUserIfEnabled` and re-creates it.
184+
- Requests through HAProxy / reverse proxy — `RemoteAddr` is no longer
185+
loopback after `middleware.RealIP` rewrites it.
186+
187+
See issue [#83](https://github.com/sarg3nt/gearbox/issues/83) for the
188+
design rationale and security analysis.
189+
125190
## VS Code Integration
126191

127192
### Required Extensions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package auth
2+
3+
// Constants shared between the build-tagged dev_bypass_on.go and
4+
// dev_bypass_off.go files. Declared here (no build tag) so they survive
5+
// linting even in builds where neither sibling is compiled in.
6+
const (
7+
// devBypassEnvVar gates whether the dev auto-login bypass is allowed
8+
// to fire. Even in a binary built with `-tags dev`, the bypass is
9+
// inert unless this env var is set to "1".
10+
devBypassEnvVar = "GEARBOX_DEV_AUTO_LOGIN"
11+
12+
// devBypassEmail is the email/username of the seeded dev account that
13+
// the bypass auto-authenticates as. The account must exist (and be
14+
// active) in the users table; the bypass never creates sessions or
15+
// auto-promotes a non-existent user.
16+
devBypassEmail = "dev"
17+
)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//go:build !dev
2+
3+
// Production sibling to dev_bypass_on.go. Compiled in for every build
4+
// that does NOT specify `-tags dev`. All entry points are no-ops, so the
5+
// dev auto-login bypass is not present in the resulting binary at all —
6+
// no codepath, no env-var check, no loopback check, nothing to exploit.
7+
8+
package auth
9+
10+
import (
11+
"log/slog"
12+
"net/http"
13+
14+
"github.com/sarg3nt/gearbox/internal/framework/database"
15+
"github.com/sarg3nt/gearbox/internal/framework/models"
16+
)
17+
18+
func tryDevBypass(_ *Manager, _ *http.Request) (*models.User, bool) {
19+
return nil, false
20+
}
21+
22+
func SeedDevUserIfEnabled(_ *database.DB, _ *slog.Logger) error { return nil }
23+
24+
func LogDevBypassStartupBanner(_ *slog.Logger) {}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
//go:build dev
2+
3+
// Dev-only loopback auto-login bypass. Compiled in only when the binary
4+
// is built with `-tags dev` (see gearbox/.air.toml and the production
5+
// build path which deliberately omits the tag). See issue #83.
6+
7+
package auth
8+
9+
import (
10+
"log/slog"
11+
"net"
12+
"net/http"
13+
"os"
14+
"sync"
15+
16+
"github.com/sarg3nt/gearbox/internal/framework/database"
17+
"github.com/sarg3nt/gearbox/internal/framework/models"
18+
)
19+
20+
var devBypassBannerOnce sync.Once
21+
22+
// tryDevBypass returns the seeded `dev` user when ALL of these hold:
23+
//
24+
// 1. The binary was built with `-tags dev` (this file is compiled in;
25+
// the production sibling dev_bypass_off.go is not).
26+
// 2. GEARBOX_DEV_AUTO_LOGIN=1 is set in the process environment.
27+
// 3. r.RemoteAddr is a loopback address (127.0.0.0/8 or ::1).
28+
//
29+
// In production builds the bypass never enters the binary at all — the
30+
// sibling dev_bypass_off.go provides a hard-coded `nil, false` stub.
31+
func tryDevBypass(m *Manager, r *http.Request) (*models.User, bool) {
32+
if os.Getenv(devBypassEnvVar) != "1" {
33+
return nil, false
34+
}
35+
if !requestIsLoopback(r) {
36+
return nil, false
37+
}
38+
user, err := m.db.GetUserByEmail(devBypassEmail)
39+
if err != nil || user == nil {
40+
m.logger.Warn("dev auto-login: dev user missing from database; bypass inactive",
41+
"user", devBypassEmail, "error", err)
42+
return nil, false
43+
}
44+
if user.Status != models.UserStatusActive {
45+
m.logger.Warn("dev auto-login: dev user is not active; bypass inactive",
46+
"status", user.Status)
47+
return nil, false
48+
}
49+
return user, true
50+
}
51+
52+
// requestIsLoopback reports whether r.RemoteAddr resolves to a loopback IP.
53+
// chi.middleware.RealIP rewrites RemoteAddr from X-Forwarded-For / X-Real-IP,
54+
// so a proxy between the browser and gearbox would surface the proxy's IP
55+
// here, not the browser's — that's the intended behavior: requests routed
56+
// through any proxy aren't loopback and the bypass declines.
57+
func requestIsLoopback(r *http.Request) bool {
58+
host, _, err := net.SplitHostPort(r.RemoteAddr)
59+
if err != nil {
60+
host = r.RemoteAddr
61+
}
62+
ip := net.ParseIP(host)
63+
if ip == nil {
64+
return false
65+
}
66+
return ip.IsLoopback()
67+
}
68+
69+
// SeedDevUserIfEnabled creates the `dev` user used by the loopback bypass
70+
// when GEARBOX_DEV_AUTO_LOGIN=1. The user is given an unusable bcrypt hash
71+
// (the package-level dummyPasswordHash) so the form-login path can never
72+
// authenticate as it — only the loopback bypass can.
73+
//
74+
// Production builds replace this with a no-op (dev_bypass_off.go).
75+
func SeedDevUserIfEnabled(db *database.DB, logger *slog.Logger) error {
76+
if os.Getenv(devBypassEnvVar) != "1" {
77+
return nil
78+
}
79+
created, err := db.EnsureDevUserExists(devBypassEmail, dummyPasswordHash)
80+
if err != nil {
81+
return err
82+
}
83+
if created {
84+
logger.Info("dev auto-login: seeded `dev` user for loopback bypass",
85+
"email", devBypassEmail)
86+
}
87+
return nil
88+
}
89+
90+
// LogDevBypassStartupBanner emits a loud warning at startup whenever the
91+
// dev auto-login bypass is potentially active in the running process.
92+
// Designed to be impossible to miss in logs so an operator never confuses
93+
// a dev binary for a production one.
94+
//
95+
// Production builds replace this with a no-op (dev_bypass_off.go).
96+
func LogDevBypassStartupBanner(logger *slog.Logger) {
97+
if os.Getenv(devBypassEnvVar) != "1" {
98+
logger.Info("dev auto-login: bypass compiled in (`-tags dev`) but disabled — set GEARBOX_DEV_AUTO_LOGIN=1 to enable")
99+
return
100+
}
101+
devBypassBannerOnce.Do(func() {
102+
logger.Warn("==========================================================")
103+
logger.Warn("dev auto-login ACTIVE — loopback requests log in as `dev`")
104+
logger.Warn("DO NOT USE IN PRODUCTION. Rebuild without `-tags dev` to remove entirely.")
105+
logger.Warn("==========================================================")
106+
})
107+
}

gearbox/internal/framework/auth/middleware.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ type SidebarIntegration struct {
2727
// RequireAuth is middleware that requires authentication.
2828
func (m *Manager) RequireAuth(next http.Handler) http.Handler {
2929
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
30+
// Dev-only loopback bypass — short-circuits the session check when
31+
// the binary is built with `-tags dev`, GEARBOX_DEV_AUTO_LOGIN=1,
32+
// and the request originates from a loopback IP. In production
33+
// builds tryDevBypass is a hard-coded `nil, false` stub (see
34+
// dev_bypass_off.go); no codepath exists to enable the bypass.
35+
if devUser, ok := tryDevBypass(m, r); ok {
36+
ctx := context.WithValue(r.Context(), userContextKey, devUser)
37+
next.ServeHTTP(w, r.WithContext(ctx))
38+
return
39+
}
40+
3041
user, err := m.GetUser(r)
3142
if err != nil {
3243
// Not authenticated, redirect to login with return URL
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//go:build dev
2+
3+
// Dev-only seed helper for the loopback auto-login bypass (issue #83).
4+
// Compiled in only when the binary is built with `-tags dev`. Production
5+
// binaries do not contain this function at all.
6+
7+
package database
8+
9+
import (
10+
"database/sql"
11+
"fmt"
12+
"time"
13+
14+
"github.com/sarg3nt/gearbox/internal/framework/models"
15+
)
16+
17+
// EnsureDevUserExists creates the seeded `dev` user used by the dev
18+
// auto-login bypass if it doesn't already exist. The password hash is
19+
// expected to be the package-level dummyPasswordHash from auth, so the
20+
// form-login path can never authenticate as this user — only the
21+
// loopback bypass can.
22+
//
23+
// Returns (created, error) — `created` is true only on the first call
24+
// that actually inserted a row.
25+
func (d *DB) EnsureDevUserExists(email, passwordHash string) (bool, error) {
26+
d.mu.Lock()
27+
defer d.mu.Unlock()
28+
29+
var existingID string
30+
err := d.db.QueryRow(`SELECT id FROM users WHERE email = ? LIMIT 1`, email).Scan(&existingID)
31+
if err == nil {
32+
return false, nil
33+
}
34+
if err != sql.ErrNoRows {
35+
return false, err
36+
}
37+
38+
now := time.Now()
39+
id := generateUUID()
40+
_, err = d.db.Exec(`
41+
INSERT INTO users (
42+
id, email, password_hash, first_name, last_name, phone_number,
43+
role, status, must_change_password, password_changed_at, created_at, updated_at
44+
) VALUES (?, ?, ?, 'Dev', 'User', '', ?, ?, 0, ?, ?, ?)`,
45+
id, email, passwordHash, models.RoleAdmin, models.UserStatusActive, now, now, now,
46+
)
47+
if err != nil {
48+
return false, fmt.Errorf("failed to create dev user: %w", err)
49+
}
50+
return true, nil
51+
}

0 commit comments

Comments
 (0)