Skip to content

Commit b4f0a5c

Browse files
James McHughclaude
andcommitted
Harden security: per-user sort, proxy-aware rate limit, drop dev bypass
Addresses findings from a self-review of the codebase: - clientIP only trusts X-Forwarded-For when RemoteAddr is loopback, so the rate limiter buckets per real client behind Caddy without becoming spoofable on a direct-to-Go deployment. - Remove DEV_USER_EMAIL OAuth bypass entirely. Local dev now uses real Google OAuth like prod. - exercises.sort_order is no longer global. New user_exercise_sort_order table holds per-user overrides; ListExercisesForUser falls back to the seeded default. /settings/reorder writes per-user rows scoped to the caller, so one user can no longer reorder everyone else's home page. - Cap in-flight contact-email goroutines with a 2-slot semaphore so a flood can't fan out enough concurrent SMTP sends to exhaust fds or trip Gmail's per-account rate limit. - Add defence-in-depth user_id WHERE clauses to UpdateSetActualReps, UpdateSetsWeightForExercise, and UpsertWalkingSession (handlers already verify ownership; this catches future regressions at the SQL layer). - Log DeleteSession errors on logout instead of swallowing them. - Pin appleboy/scp-action and appleboy/ssh-action to commit SHAs so a retagged release can't grab the deploy SSH key. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2607fd7 commit b4f0a5c

15 files changed

Lines changed: 241 additions & 93 deletions

File tree

.env.example

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,6 @@ OAUTH_REDIRECT_URL=http://localhost:8080/auth/google/callback
1111
# Random 32 bytes hex - generate with: openssl rand -hex 32
1212
SESSION_KEY=replace_with_64_random_hex_chars
1313

14-
# Optional: shortcut to bypass Google OAuth in local dev. If set,
15-
# /auth/login auto-creates this user and signs them in. NEVER set in prod.
16-
DEV_USER_EMAIL=
17-
1814
# Gmail SMTP for the /contact form. Generate an app password at
1915
# https://myaccount.google.com/apppasswords (2-Step Verification must be on).
2016
# Leave SMTP_USER/SMTP_PASS blank in local dev to log submissions to stdout

.github/workflows/deploy.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ jobs:
4848
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o train .
4949

5050
- name: Copy files to server
51-
uses: appleboy/scp-action@v0.1.7
51+
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
5252
with:
5353
host: ${{ secrets.DEPLOY_HOST }}
5454
username: ${{ secrets.DEPLOY_USER }}
@@ -60,7 +60,7 @@ jobs:
6060

6161
- name: Stop service before deploy
6262
continue-on-error: true
63-
uses: appleboy/ssh-action@v1.0.3
63+
uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 # v1.0.3
6464
with:
6565
host: ${{ secrets.DEPLOY_HOST }}
6666
username: ${{ secrets.DEPLOY_USER }}
@@ -69,7 +69,7 @@ jobs:
6969
script: sudo systemctl stop train
7070

7171
- name: Install and restart service
72-
uses: appleboy/ssh-action@v1.0.3
72+
uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 # v1.0.3
7373
with:
7474
host: ${{ secrets.DEPLOY_HOST }}
7575
username: ${{ secrets.DEPLOY_USER }}

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ iPhone-only mobile barbell workout tracker with auto-progression. Hosted at
1515

1616
1. `cp .env.example .env`
1717
2. Set `SESSION_KEY` to 64 random hex chars (`openssl rand -hex 32`).
18-
3. Set `DEV_USER_EMAIL=james67@gmail.com` to bypass Google OAuth.
18+
3. Create a Google OAuth client ID at console.cloud.google.com (redirect URI
19+
`http://localhost:8080/auth/google/callback`) and fill in
20+
`GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / `OAUTH_REDIRECT_URL`.
1921
4. `build.bat` (Windows) — builds and runs on http://localhost:8080.
2022
5. Use Chrome DevTools iPhone emulation (Cmd+Opt+I → toggle device).
2123

auth.go

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,13 @@ type oauthConfig struct {
3333
cfg *oauth2.Config
3434
verifier *oidc.IDTokenVerifier
3535
sessionKey []byte
36-
devEmail string // if set, /auth/login bypasses Google entirely
3736
}
3837

3938
func initOAuth(ctx context.Context) error {
4039
clientID := os.Getenv("GOOGLE_CLIENT_ID")
4140
clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")
4241
redirect := os.Getenv("OAUTH_REDIRECT_URL")
4342
keyHex := os.Getenv("SESSION_KEY")
44-
devEmail := os.Getenv("DEV_USER_EMAIL")
4543

4644
if keyHex == "" {
4745
return errors.New("SESSION_KEY not set")
@@ -51,15 +49,10 @@ func initOAuth(ctx context.Context) error {
5149
return errors.New("SESSION_KEY must be hex with at least 16 bytes (32 hex chars)")
5250
}
5351
oauthCfg.sessionKey = key
54-
oauthCfg.devEmail = devEmail
5552

5653
if clientID == "" || clientSecret == "" || redirect == "" {
57-
// Allow startup so the dev shortcut still works without OAuth creds.
5854
oauthCfg.enabled = false
59-
if devEmail == "" {
60-
return errors.New("GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, OAUTH_REDIRECT_URL must be set (or DEV_USER_EMAIL for local dev)")
61-
}
62-
return nil
55+
return errors.New("GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, OAUTH_REDIRECT_URL must be set")
6356
}
6457

6558
provider, err := oidc.NewProvider(ctx, "https://accounts.google.com")
@@ -120,16 +113,6 @@ func handleTermsPage(w http.ResponseWriter, r *http.Request) {
120113
}
121114

122115
func handleAuthLogin(w http.ResponseWriter, r *http.Request) {
123-
// Local-dev shortcut: skip Google entirely, log in as DEV_USER_EMAIL.
124-
if oauthCfg.devEmail != "" {
125-
if err := loginDevUser(r.Context(), w); err != nil {
126-
serverError(w, "dev login", err)
127-
return
128-
}
129-
http.Redirect(w, r, "/", http.StatusSeeOther)
130-
return
131-
}
132-
133116
if !oauthCfg.enabled {
134117
http.Error(w, "OAuth not configured", http.StatusServiceUnavailable)
135118
return
@@ -245,7 +228,11 @@ func cleanupExpiredSessions() {
245228

246229
func handleAuthLogout(w http.ResponseWriter, r *http.Request) {
247230
if c, err := r.Cookie(sessionCookieName); err == nil {
248-
_ = queries.DeleteSession(r.Context(), c.Value)
231+
// Log delete failures so a DB outage doesn't silently leave a live
232+
// session row behind after the cookie has been cleared client-side.
233+
if err := queries.DeleteSession(r.Context(), c.Value); err != nil {
234+
slog.Error("logout: delete session", "error", err)
235+
}
249236
}
250237
http.SetCookie(w, &http.Cookie{
251238
Name: sessionCookieName,
@@ -259,16 +246,6 @@ func handleAuthLogout(w http.ResponseWriter, r *http.Request) {
259246
http.Redirect(w, r, "/login", http.StatusSeeOther)
260247
}
261248

262-
func loginDevUser(ctx context.Context, w http.ResponseWriter) error {
263-
email := oauthCfg.devEmail
264-
sub := "dev:" + email
265-
user, err := upsertUser(ctx, sub, email, "Dev User")
266-
if err != nil {
267-
return err
268-
}
269-
return issueSessionCookie(ctx, w, user.ID, false)
270-
}
271-
272249
func upsertUser(ctx context.Context, sub, email, name string) (db.User, error) {
273250
now := time.Now().UTC().Format(time.RFC3339)
274251
user, err := queries.GetUserByGoogleSub(ctx, sub)

charts.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ func formatOneDecimal(v float64) string { return strconv.FormatFloat(v, 'f', 1,
278278

279279
func handleCharts(w http.ResponseWriter, r *http.Request) {
280280
user := userFrom(r)
281-
exercises, err := queries.ListExercises(r.Context())
281+
exercises, err := queries.ListExercisesForUser(r.Context(), user.ID)
282282
if err != nil {
283283
serverError(w, "charts: list exercises", err)
284284
return

contact.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ import (
1313

1414
var contactLimiter = newRateLimiter(3, time.Hour)
1515

16+
// contactSendSem caps in-flight SMTP sends. SMTP send does TLS + auth and
17+
// can stall for tens of seconds; without a cap a flood of accepted
18+
// submissions could fan out to enough concurrent goroutines to exhaust file
19+
// descriptors or trip Gmail's per-account rate limit. The buffer is small
20+
// because the contact form is low-volume by design.
21+
var contactSendSem = make(chan struct{}, 2)
22+
1623
type contactPageData struct {
1724
Sent bool
1825
Error string
@@ -61,11 +68,20 @@ func handleContactSubmit(w http.ResponseWriter, r *http.Request) {
6168
}
6269
}
6370

64-
go func() {
65-
if err := sendContactEmail(replyEmail, message, ip); err != nil {
66-
slog.Error("contact send", "error", err, "ip", ip)
67-
}
68-
}()
71+
select {
72+
case contactSendSem <- struct{}{}:
73+
go func() {
74+
defer func() { <-contactSendSem }()
75+
if err := sendContactEmail(replyEmail, message, ip); err != nil {
76+
slog.Error("contact send", "error", err, "ip", ip)
77+
}
78+
}()
79+
default:
80+
// Send queue is full. The user still sees a success page (so we
81+
// don't reveal capacity to attackers) but the message is dropped
82+
// with a warning logged.
83+
slog.Warn("contact send dropped: queue full", "ip", ip)
84+
}
6985

7086
redirectContact(w, r, "sent=1")
7187
}
@@ -103,9 +119,10 @@ func sendContactEmail(replyEmail, message, ip string) error {
103119
displayReply = replyEmail
104120
}
105121

122+
submitted := time.Now().In(appLocation).Format("Mon 2 Jan 2006, 3:04 PM MST")
106123
body := fmt.Sprintf(
107124
"Reply email: %s\nIP: %s\nSubmitted: %s\n\n%s\n",
108-
displayReply, ip, time.Now().UTC().Format(time.RFC3339), message,
125+
displayReply, ip, submitted, message,
109126
)
110127

111128
msg := []byte(strings.Join([]string{

db/models.go

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

db/queries.sql

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,19 @@ DELETE FROM sessions WHERE expires_at <= ?;
4949
-- =============================================================================
5050

5151
-- name: ListExercises :many
52+
-- Global order. Used by progression code where order does not matter.
5253
SELECT id, slug, name, kind, default_sets, default_reps, default_weight_kg, sort_order
5354
FROM exercises ORDER BY sort_order;
5455

56+
-- name: ListExercisesForUser :many
57+
-- Per-user order: rows in user_exercise_sort_order override exercises.sort_order.
58+
-- Exercises without a per-user row fall back to the seeded default.
59+
SELECT e.id, e.slug, e.name, e.kind, e.default_sets, e.default_reps, e.default_weight_kg, e.sort_order
60+
FROM exercises e
61+
LEFT JOIN user_exercise_sort_order uso
62+
ON uso.exercise_id = e.id AND uso.user_id = ?
63+
ORDER BY COALESCE(uso.sort_order, e.sort_order), e.id;
64+
5565
-- name: GetExerciseByID :one
5666
SELECT id, slug, name, kind, default_sets, default_reps, default_weight_kg, sort_order
5767
FROM exercises WHERE id = ?;
@@ -67,8 +77,14 @@ ON CONFLICT(slug) DO UPDATE SET
6777
default_sets = excluded.default_sets,
6878
default_reps = excluded.default_reps;
6979

70-
-- name: UpdateExerciseSortOrder :exec
71-
UPDATE exercises SET sort_order = ? WHERE id = ?;
80+
-- name: ClearUserExerciseSortOrder :exec
81+
DELETE FROM user_exercise_sort_order WHERE user_id = ?;
82+
83+
-- name: UpsertUserExerciseSortOrder :exec
84+
INSERT INTO user_exercise_sort_order (user_id, exercise_id, sort_order)
85+
VALUES (?, ?, ?)
86+
ON CONFLICT(user_id, exercise_id) DO UPDATE SET
87+
sort_order = excluded.sort_order;
7288

7389
-- =============================================================================
7490
-- USER EXERCISE WEIGHT
@@ -193,10 +209,15 @@ INSERT INTO sets (workout_id, exercise_id, set_index, target_reps, actual_reps,
193209
VALUES (?, ?, ?, ?, NULL, ?);
194210

195211
-- name: UpdateSetActualReps :exec
196-
UPDATE sets SET actual_reps = ? WHERE id = ?;
212+
-- user_id check is defence in depth; handlers already verify ownership.
213+
UPDATE sets SET actual_reps = ?
214+
WHERE sets.id = ?
215+
AND sets.workout_id IN (SELECT w.id FROM workouts w WHERE w.user_id = ?);
197216

198217
-- name: UpdateSetsWeightForExercise :exec
199-
UPDATE sets SET weight_kg = ? WHERE workout_id = ? AND exercise_id = ?;
218+
UPDATE sets SET weight_kg = ?
219+
WHERE sets.workout_id = ? AND sets.exercise_id = ?
220+
AND sets.workout_id IN (SELECT w.id FROM workouts w WHERE w.user_id = ?);
200221

201222
-- =============================================================================
202223
-- WALKING SESSIONS
@@ -207,8 +228,16 @@ SELECT workout_id, duration_min, speed_x10, incline_x10
207228
FROM walking_sessions WHERE workout_id = ?;
208229

209230
-- name: UpsertWalkingSession :exec
231+
-- The SELECT yields zero rows (and therefore writes nothing) if the workout
232+
-- does not belong to user_id. Handlers already check ownership; this is
233+
-- defence in depth.
210234
INSERT INTO walking_sessions (workout_id, duration_min, speed_x10, incline_x10)
211-
VALUES (?, ?, ?, ?)
235+
SELECT w.id,
236+
sqlc.arg(duration_min),
237+
sqlc.arg(speed_x10),
238+
sqlc.arg(incline_x10)
239+
FROM workouts w
240+
WHERE w.id = sqlc.arg(workout_id) AND w.user_id = sqlc.arg(user_id)
212241
ON CONFLICT(workout_id) DO UPDATE SET
213242
duration_min = excluded.duration_min,
214243
speed_x10 = excluded.speed_x10,

0 commit comments

Comments
 (0)