Sicherheit & Robustheit härten
- SQLite-DSN: busy_timeout(5000) + WAL + foreign_keys gegen SQLITE_BUSY - uid ist nie 0 mehr (0 = Sentinel für gelöscht/anonym), Retry bei Kollision - scanEntries gibt Scan-Fehler zurück statt Zeilen still zu überspringen - Session-timeout serverseitig auf 30 Tage gedeckelt (clientgesteuert) - Bild-Upload: Dimensionen vor Decode prüfen (Decompression-Bomb-Schutz) - Secure-Cookie via TLS-Erkennung (r.TLS / X-Forwarded-Proto) - Rate-Limit (httprate, 20/min/IP) auf Login & Registrierung - Vote nur auf existierende Beiträge (keine Waisen-Votes) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -79,7 +79,16 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
|
// timeout ist clientgesteuert -> serverseitig deckeln, damit niemand eine
|
||||||
|
// quasi-unbegrenzte Session anlegen kann. Default 1 Tag, Maximum 30 Tage.
|
||||||
timeoutSec, _ := strconv.ParseInt(r.FormValue("timeout"), 10, 64)
|
timeoutSec, _ := strconv.ParseInt(r.FormValue("timeout"), 10, 64)
|
||||||
|
const maxTimeout = 30 * 86400
|
||||||
|
if timeoutSec <= 0 {
|
||||||
|
timeoutSec = 86400
|
||||||
|
}
|
||||||
|
if timeoutSec > maxTimeout {
|
||||||
|
timeoutSec = maxTimeout
|
||||||
|
}
|
||||||
expires := now + timeoutSec
|
expires := now + timeoutSec
|
||||||
|
|
||||||
token, err := newToken()
|
token, err := newToken()
|
||||||
@@ -104,6 +113,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
Expires: time.Unix(expires, 0),
|
Expires: time.Unix(expires, 0),
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
Secure: isHTTPS(r),
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
@@ -114,11 +124,14 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
db.Exec(`DELETE FROM session WHERE value = ?`, s.Value)
|
db.Exec(`DELETE FROM session WHERE value = ?`, s.Value)
|
||||||
}
|
}
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "session",
|
Name: "session",
|
||||||
Value: "",
|
Value: "",
|
||||||
Path: "/",
|
Path: "/",
|
||||||
Expires: time.Unix(0, 0),
|
Expires: time.Unix(0, 0),
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isHTTPS(r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
@@ -184,6 +197,13 @@ func sanitizeUsername(u string) string {
|
|||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isHTTPS erkennt, ob die Anfrage über TLS kam — direkt oder über einen
|
||||||
|
// TLS-terminierenden Reverse-Proxy (X-Forwarded-Proto). Steuert das Secure-Flag,
|
||||||
|
// ohne lokale Tests über http zu brechen.
|
||||||
|
func isHTTPS(r *http.Request) bool {
|
||||||
|
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||||
|
}
|
||||||
|
|
||||||
func newToken() (string, error) {
|
func newToken() (string, error) {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
if _, err := crand.Read(b); err != nil {
|
if _, err := crand.Read(b); err != nil {
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ CREATE TABLE IF NOT EXISTS vote (
|
|||||||
|
|
||||||
func initDB(dsn string) error {
|
func initDB(dsn string) error {
|
||||||
var err error
|
var err error
|
||||||
db, err = sql.Open("sqlite", dsn)
|
// SQLite hat genau einen Schreiber: busy_timeout wartet bei Sperren, statt
|
||||||
|
// sofort mit SQLITE_BUSY zu scheitern; WAL erlaubt parallele Leser.
|
||||||
|
db, err = sql.Open("sqlite", dsn+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"image/gif"
|
"image/gif"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
|
"io"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -48,7 +49,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
|||||||
var e Entry
|
var e Entry
|
||||||
if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
|
if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
|
||||||
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.Username); err != nil {
|
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.Username); err != nil {
|
||||||
continue
|
return nil, err
|
||||||
}
|
}
|
||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
}
|
}
|
||||||
@@ -393,6 +394,12 @@ func handleVote(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes.
|
||||||
|
if _, err := entryByPID(pid); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var prev string
|
var prev string
|
||||||
readErr := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&prev)
|
readErr := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&prev)
|
||||||
if readErr != nil && readErr != sql.ErrNoRows {
|
if readErr != nil && readErr != sql.ErrNoRows {
|
||||||
@@ -428,6 +435,21 @@ func storeImage(fh *multipart.FileHeader) (string, error) {
|
|||||||
}
|
}
|
||||||
defer src.Close()
|
defer src.Close()
|
||||||
|
|
||||||
|
// Dimensionen aus dem Header prüfen, bevor wir die vollen Pixel allokieren:
|
||||||
|
// eine kleine Datei kann riesige Maße deklarieren (Decompression-Bomb) und
|
||||||
|
// sonst beim Decode den Speicher sprengen.
|
||||||
|
const maxPixels = 50_000_000 // ~50 MP
|
||||||
|
cfg, _, err := image.DecodeConfig(src)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxPixels {
|
||||||
|
return "", fmt.Errorf("bild zu groß: %dx%d", cfg.Width, cfg.Height)
|
||||||
|
}
|
||||||
|
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
img, format, err := image.Decode(src)
|
img, format, err := image.Decode(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ go 1.25.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.3.0
|
github.com/go-chi/chi/v5 v5.3.0
|
||||||
|
github.com/go-chi/httprate v0.15.0
|
||||||
golang.org/x/crypto v0.52.0
|
golang.org/x/crypto v0.52.0
|
||||||
golang.org/x/image v0.41.0
|
golang.org/x/image v0.41.0
|
||||||
modernc.org/sqlite v1.51.0
|
modernc.org/sqlite v1.51.0
|
||||||
@@ -12,9 +13,11 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
modernc.org/libc v1.72.3 // indirect
|
modernc.org/libc v1.72.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
@@ -2,18 +2,26 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
|||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
|
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||||
|
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/go-chi/httprate"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -28,9 +30,14 @@ func routes() http.Handler {
|
|||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
// --- Auth (öffentlich) ---
|
// --- Auth (öffentlich) ---
|
||||||
r.Post("/auth/login", handleLogin)
|
// Login und Registrierung pro IP drosseln (Brute-Force-Bremse). bcrypt bremst
|
||||||
|
// zusätzlich, aber das Limit kappt automatisiertes Durchprobieren früh.
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(httprate.LimitByIP(20, time.Minute))
|
||||||
|
r.Post("/auth/login", handleLogin)
|
||||||
|
r.Post("/auth/newuser", handleNewUser)
|
||||||
|
})
|
||||||
r.Get("/auth/logout", handleLogout)
|
r.Get("/auth/logout", handleLogout)
|
||||||
r.Post("/auth/newuser", handleNewUser)
|
|
||||||
r.Get("/auth/sessioninfo", handleSessionInfo)
|
r.Get("/auth/sessioninfo", handleSessionInfo)
|
||||||
r.Get("/auth/headerbar", handleHeaderbar)
|
r.Get("/auth/headerbar", handleHeaderbar)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func createUser(username, password string) error {
|
func createUser(username, password string) error {
|
||||||
uid := int64(rand.IntN(99999999))
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
@@ -20,11 +19,22 @@ func createUser(username, password string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.Exec(
|
// uid ist nie 0: 0 ist Sentinel für "gelöscht"/"anonym" (siehe handleDeleteEntry,
|
||||||
`INSERT INTO user (uid, created_at, username, password) VALUES (?, ?, ?, ?)`,
|
// uidFromContext). Bei der seltenen UNIQUE-Kollision auf uid neu würfeln.
|
||||||
uid, now, username, hash,
|
var lastErr error
|
||||||
)
|
for i := 0; i < 5; i++ {
|
||||||
return err
|
uid := int64(rand.IntN(99999999)) + 1
|
||||||
|
_, lastErr = db.Exec(
|
||||||
|
`INSERT INTO user (uid, created_at, username, password) VALUES (?, ?, ?, ?)`,
|
||||||
|
uid, now, username, hash,
|
||||||
|
)
|
||||||
|
if lastErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// username ist ebenfalls UNIQUE und wird vom Aufrufer vorab geprüft;
|
||||||
|
// eine Kollision hier kann also nur die uid sein -> erneut versuchen.
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// idFromUsername liefert die interne id oder 0, wenn der Nutzer nicht existiert.
|
// idFromUsername liefert die interne id oder 0, wenn der Nutzer nicht existiert.
|
||||||
@@ -169,11 +179,14 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "session",
|
Name: "session",
|
||||||
Value: "",
|
Value: "",
|
||||||
Path: "/",
|
Path: "/",
|
||||||
Expires: time.Unix(0, 0),
|
Expires: time.Unix(0, 0),
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isHTTPS(r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user