Skip to content

Commit 2dbed9b

Browse files
sarg3ntclaude
andauthored
feat(#83): dev-only loopback auto-login bypass (#84)
* 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> * fix(#83): address Copilot review on #84 - Lint: golangci-lint's `unused` check runs without the `dev` build tag, so the shared constants in dev_bypass.go were flagged. Delete the shared file and inline the two constants into dev_bypass_on.go where they are actually referenced. - Doc accuracy (Copilot #1, #2): the `-tags dev` flag is set by the `dev:` target in gearbox/Makefile via `air --build.cmd "$(DEV_BUILD_CMD)"`, not by `.air.toml` (which is gitignored per-developer). Update the package doc and four function/header comments in dev_bypass_on.go, and the corresponding section in gearbox/docs/development.md, to reference the Makefile and drop the broken `../.air.toml` link. - Security (Copilot #3): EnsureDevUserExists previously returned early if a `dev` row already existed, leaving its password_hash untouched. A developer who manually set a password on that row could then form- login as a `dev` admin, contradicting the "form-login can never authenticate as this user" claim. Always rewrite password_hash to the caller-supplied dummyPasswordHash, plus reset status and must_change_password back to the safe defaults, on every call. Other fields (role, names) are still preserved across runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 28df1a8 commit 2dbed9b

8 files changed

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

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

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: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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 ensures the seeded `dev` user exists for the dev
18+
// auto-login bypass AND that its password hash is the caller-supplied
19+
// `passwordHash` (the package-level dummyPasswordHash from auth). The
20+
// hash is rewritten unconditionally on every call so a row left over
21+
// from a prior manual setup — where a developer may have set a real
22+
// bcrypt-able password for testing — cannot be authenticated via the
23+
// form-login path. Only the loopback bypass can authenticate as `dev`.
24+
//
25+
// Returns (created, error) — `created` is true only on the call that
26+
// inserted the row; subsequent calls return false but still rewrite the
27+
// hash, status, and must_change_password fields back to the safe
28+
// defaults. Other fields (role, names) are not overwritten so a
29+
// developer can still adjust the dev user's display info without losing
30+
// it on next startup.
31+
func (d *DB) EnsureDevUserExists(email, passwordHash string) (bool, error) {
32+
d.mu.Lock()
33+
defer d.mu.Unlock()
34+
35+
var existingID string
36+
err := d.db.QueryRow(`SELECT id FROM users WHERE email = ? LIMIT 1`, email).Scan(&existingID)
37+
switch {
38+
case err == nil:
39+
// Row exists. Force the hash + status + must_change_password back to
40+
// the safe defaults so the form-login path remains unable to
41+
// authenticate as this user even if the row was tampered with.
42+
_, err := d.db.Exec(`
43+
UPDATE users
44+
SET password_hash = ?,
45+
status = ?,
46+
must_change_password = 0,
47+
updated_at = ?
48+
WHERE id = ?`,
49+
passwordHash, models.UserStatusActive, time.Now(), existingID,
50+
)
51+
if err != nil {
52+
return false, fmt.Errorf("failed to reset dev user safety fields: %w", err)
53+
}
54+
return false, nil
55+
case err != sql.ErrNoRows:
56+
return false, err
57+
}
58+
59+
now := time.Now()
60+
id := generateUUID()
61+
_, err = d.db.Exec(`
62+
INSERT INTO users (
63+
id, email, password_hash, first_name, last_name, phone_number,
64+
role, status, must_change_password, password_changed_at, created_at, updated_at
65+
) VALUES (?, ?, ?, 'Dev', 'User', '', ?, ?, 0, ?, ?, ?)`,
66+
id, email, passwordHash, models.RoleAdmin, models.UserStatusActive, now, now, now,
67+
)
68+
if err != nil {
69+
return false, fmt.Errorf("failed to create dev user: %w", err)
70+
}
71+
return true, nil
72+
}

0 commit comments

Comments
 (0)