4 Commits

Author SHA1 Message Date
clawbot
73cae71171 deps: migrate from go-chi/chi v1 to go-chi/chi/v5
All checks were successful
check / check (push) Successful in 1m2s
Migrate all import paths from github.com/go-chi/chi to
github.com/go-chi/chi/v5 and github.com/go-chi/chi/middleware
to github.com/go-chi/chi/v5/middleware.

This resolves GO-2026-4316 (open redirect vulnerability in
RedirectSlashes middleware) by moving to the maintained v5
module path.

Files changed:
- go.mod: chi v1.5.5 → chi/v5 v5.2.1
- internal/server/server.go: updated import
- internal/server/routes.go: updated imports
- internal/middleware/middleware.go: updated import
- internal/handlers/api.go: updated import
- README.md: updated Required Libraries table
2026-03-10 07:27:26 -07:00
67446b36a1 feat: store auth tokens as SHA-256 hashes instead of plaintext (#69)
All checks were successful
check / check (push) Successful in 5s
## Summary

Hash client auth tokens with SHA-256 before storing in the database. When validating tokens, hash the incoming token and compare against the stored hash. This prevents token exposure if the database is compromised.

Existing plaintext tokens are implicitly invalidated since they will not match the new hashed lookups — users will need to create new sessions.

## Changes

- **`internal/db/queries.go`**: Added `hashToken()` helper using `crypto/sha256`. Updated `CreateSession` to store hashed token. Updated `GetSessionByToken` to hash the incoming token before querying.
- **`internal/db/auth.go`**: Updated `RegisterUser` and `LoginUser` to store hashed tokens.

## Migration

No schema changes needed. The `token` column remains `TEXT` but now stores 64-char hex SHA-256 digests instead of 64-char hex random tokens. Existing plaintext tokens are effectively invalidated.

closes #34

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #69
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 12:44:29 +01:00
b1fd2f1b96 Replace string-matching error detection with typed SQLite errors (closes #39) (#66)
All checks were successful
check / check (push) Successful in 4s
## Summary

Replaces fragile `strings.Contains(err.Error(), "UNIQUE")` checks with typed error detection using `errors.As` and the SQLite driver's `*sqlite.Error` type.

## Changes

- **`internal/db/errors.go`** (new): Adds `IsUniqueConstraintError(err)` helper that uses `errors.As` to unwrap the error into `*sqlite.Error` and checks for `SQLITE_CONSTRAINT_UNIQUE` (code 2067).
- **`internal/handlers/api.go`**: Replaces two `strings.Contains(err.Error(), "UNIQUE")` calls with `db.IsUniqueConstraintError(err)` — in `handleCreateSessionError` and `executeNickChange`.
- **`internal/handlers/auth.go`**: Replaces one `strings.Contains(err.Error(), "UNIQUE")` call with `db.IsUniqueConstraintError(err)` — in `handleRegisterError`.

## Why

String matching on error messages is fragile — if the SQLite driver changes its error message format, the detection silently breaks. Using `errors.As` with the driver's typed error and checking the specific SQLite error code is robust, idiomatic Go, and immune to message format changes.

closes #39

<!-- session: agent:sdlc-manager:subagent:3fb0b8e2-d635-4848-a5bd-131c5033cdb1 -->

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #66
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 11:54:27 +01:00
c07f94a432 Remove dead Auth() middleware method (#68)
All checks were successful
check / check (push) Successful in 5s
Remove the unused `Auth()` method from `internal/middleware/middleware.go`.

This method only logged "AUTH: before request" and passed through to the next handler — it performed no actual authentication. It was never referenced anywhere in the codebase; authentication is handled per-handler via `requireAuth` in the handlers package.

closes #38

<!-- session: agent:sdlc-manager:subagent:629a7621-ec4b-49af-b7e8-03141664d682 -->

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #68
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 11:41:43 +01:00
11 changed files with 56 additions and 30 deletions

View File

@@ -2336,7 +2336,7 @@ neoirc/
| Purpose | Library | | Purpose | Library |
|------------|---------| |------------|---------|
| DI | `go.uber.org/fx` | | DI | `go.uber.org/fx` |
| Router | `github.com/go-chi/chi` | | Router | `github.com/go-chi/chi/v5` |
| Logging | `log/slog` (stdlib) | | Logging | `log/slog` (stdlib) |
| Config | `github.com/spf13/viper` | | Config | `github.com/spf13/viper` |
| Env | `github.com/joho/godotenv/autoload` | | Env | `github.com/joho/godotenv/autoload` |

2
go.mod
View File

@@ -6,7 +6,7 @@ require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/gdamore/tcell/v2 v2.13.8 github.com/gdamore/tcell/v2 v2.13.8
github.com/getsentry/sentry-go v0.42.0 github.com/getsentry/sentry-go v0.42.0
github.com/go-chi/chi v1.5.5 github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1

4
go.sum
View File

@@ -18,8 +18,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8= github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=

View File

@@ -64,12 +64,14 @@ func (database *Database) RegisterUser(
sessionID, _ := res.LastInsertId() sessionID, _ := res.LastInsertId()
tokenHash := hashToken(token)
clientRes, err := transaction.ExecContext(ctx, clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients `INSERT INTO clients
(uuid, session_id, token, (uuid, session_id, token,
created_at, last_seen) created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, token, now, now) clientUUID, sessionID, tokenHash, now, now)
if err != nil { if err != nil {
_ = transaction.Rollback() _ = transaction.Rollback()
@@ -137,12 +139,14 @@ func (database *Database) LoginUser(
now := time.Now() now := time.Now()
tokenHash := hashToken(token)
res, err := database.conn.ExecContext(ctx, res, err := database.conn.ExecContext(ctx,
`INSERT INTO clients `INSERT INTO clients
(uuid, session_id, token, (uuid, session_id, token,
created_at, last_seen) created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, token, now, now) clientUUID, sessionID, tokenHash, now, now)
if err != nil { if err != nil {
return 0, 0, "", fmt.Errorf( return 0, 0, "", fmt.Errorf(
"create login client: %w", err, "create login client: %w", err,

20
internal/db/errors.go Normal file
View File

@@ -0,0 +1,20 @@
// Package db provides database access and migration management.
package db
import (
"errors"
"modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
// IsUniqueConstraintError reports whether err is a SQLite
// unique-constraint violation.
func IsUniqueConstraintError(err error) bool {
var sqliteErr *sqlite.Error
if !errors.As(err, &sqliteErr) {
return false
}
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
}

View File

@@ -3,6 +3,7 @@ package db
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@@ -31,6 +32,14 @@ func generateToken() (string, error) {
return hex.EncodeToString(buf), nil return hex.EncodeToString(buf), nil
} }
// hashToken returns the lowercase hex-encoded SHA-256
// digest of a plaintext token string.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// IRCMessage is the IRC envelope for all messages. // IRCMessage is the IRC envelope for all messages.
type IRCMessage struct { type IRCMessage struct {
ID string `json:"id"` ID string `json:"id"`
@@ -105,12 +114,14 @@ func (database *Database) CreateSession(
sessionID, _ := res.LastInsertId() sessionID, _ := res.LastInsertId()
tokenHash := hashToken(token)
clientRes, err := transaction.ExecContext(ctx, clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients `INSERT INTO clients
(uuid, session_id, token, (uuid, session_id, token,
created_at, last_seen) created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, token, now, now) clientUUID, sessionID, tokenHash, now, now)
if err != nil { if err != nil {
_ = transaction.Rollback() _ = transaction.Rollback()
@@ -143,6 +154,8 @@ func (database *Database) GetSessionByToken(
nick string nick string
) )
tokenHash := hashToken(token)
err := database.conn.QueryRowContext( err := database.conn.QueryRowContext(
ctx, ctx,
`SELECT s.id, c.id, s.nick `SELECT s.id, c.id, s.nick
@@ -150,7 +163,7 @@ func (database *Database) GetSessionByToken(
INNER JOIN sessions s INNER JOIN sessions s
ON s.id = c.session_id ON s.id = c.session_id
WHERE c.token = ?`, WHERE c.token = ?`,
token, tokenHash,
).Scan(&sessionID, &clientID, &nick) ).Scan(&sessionID, &clientID, &nick)
if err != nil { if err != nil {
return 0, 0, "", fmt.Errorf( return 0, 0, "", fmt.Errorf(

View File

@@ -10,8 +10,9 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/irc" "git.eeqj.de/sneak/neoirc/internal/irc"
"github.com/go-chi/chi" "github.com/go-chi/chi/v5"
) )
var validNickRe = regexp.MustCompile( var validNickRe = regexp.MustCompile(
@@ -199,7 +200,7 @@ func (hdlr *Handlers) handleCreateSessionError(
request *http.Request, request *http.Request,
err error, err error,
) { ) {
if strings.Contains(err.Error(), "UNIQUE") { if db.IsUniqueConstraintError(err) {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"nick already taken", "nick already taken",
@@ -1427,7 +1428,7 @@ func (hdlr *Handlers) executeNickChange(
request.Context(), sessionID, newNick, request.Context(), sessionID, newNick,
) )
if err != nil { if err != nil {
if strings.Contains(err.Error(), "UNIQUE") { if db.IsUniqueConstraintError(err) {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, clientID, sessionID, writer, request, clientID, sessionID,
irc.ErrNicknameInUse, nick, []string{newNick}, irc.ErrNicknameInUse, nick, []string{newNick},

View File

@@ -4,6 +4,8 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
"git.eeqj.de/sneak/neoirc/internal/db"
) )
const minPasswordLength = 8 const minPasswordLength = 8
@@ -94,7 +96,7 @@ func (hdlr *Handlers) handleRegisterError(
request *http.Request, request *http.Request,
err error, err error,
) { ) {
if strings.Contains(err.Error(), "UNIQUE") { if db.IsUniqueConstraintError(err) {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"nick already taken", "nick already taken",

View File

@@ -11,7 +11,7 @@ import (
"git.eeqj.de/sneak/neoirc/internal/globals" "git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
basicauth "github.com/99designs/basicauth-go" basicauth "github.com/99designs/basicauth-go"
chimw "github.com/go-chi/chi/middleware" chimw "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"
metrics "github.com/slok/go-http-metrics/metrics/prometheus" metrics "github.com/slok/go-http-metrics/metrics/prometheus"
ghmm "github.com/slok/go-http-metrics/middleware" ghmm "github.com/slok/go-http-metrics/middleware"
@@ -142,20 +142,6 @@ func (mware *Middleware) CORS() func(http.Handler) http.Handler {
}) })
} }
// Auth returns middleware that performs authentication.
func (mware *Middleware) Auth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(
writer http.ResponseWriter,
request *http.Request,
) {
mware.log.Info("AUTH: before request")
next.ServeHTTP(writer, request)
})
}
}
// Metrics returns middleware that records HTTP metrics. // Metrics returns middleware that records HTTP metrics.
func (mware *Middleware) Metrics() func(http.Handler) http.Handler { func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields

View File

@@ -8,8 +8,8 @@ import (
"git.eeqj.de/sneak/neoirc/web" "git.eeqj.de/sneak/neoirc/web"
sentryhttp "github.com/getsentry/sentry-go/http" sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/spf13/viper" "github.com/spf13/viper"
) )

View File

@@ -20,7 +20,7 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/go-chi/chi" "github.com/go-chi/chi/v5"
_ "github.com/joho/godotenv/autoload" // loads .env file _ "github.com/joho/godotenv/autoload" // loads .env file
) )