fix: eliminate request handler race condition and add input validation

- Remove global d variable; declare passwordData locally per request,
eliminating a data race under concurrent requests and the bug where
NoUpper/DenyRepeat were never reset between requests
- Add parseParam helper that strictly validates integer fields: absent
fields fall back to the configured default, while invalid or
out-of-range values return HTTP 400 with a descriptive message
- Cap password length at 512 characters to prevent CPU/memory exhaustion
- Bump go.mod from 1.14 to 1.25 (minimum maintained release; required
for errors.Join used in validation)

Signed-off-by: Nagy Károly Gábriel <k@jpi.io>
This commit is contained in:
2026-04-30 13:31:51 +03:00
parent baaeaf19df
commit 2d3caf2e82
2 changed files with 54 additions and 35 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
module git.jpi.io/JPI/ranpass module git.jpi.io/JPI/ranpass
go 1.14 go 1.25
+53 -34
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"errors"
"flag" "flag"
"fmt" "fmt"
"log" "log"
@@ -8,6 +9,29 @@ import (
"strconv" "strconv"
) )
const maxPasswordLength = 512
// parseParam parses a positive integer form field. If the field is absent or
// empty, defaultVal is returned. If present but invalid or out of [1, max],
// an error is returned.
func parseParam(r *http.Request, name string, defaultVal, max int) (int, error) {
v := r.FormValue(name)
if v == "" {
return defaultVal, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("%s: must be an integer", name)
}
if n <= 0 {
return 0, fmt.Errorf("%s: must be greater than zero", name)
}
if n > max {
return 0, fmt.Errorf("%s: must be at most %d", name, max)
}
return n, nil
}
type passwordData struct { type passwordData struct {
Length int Length int
Digits int Digits int
@@ -17,11 +41,9 @@ type passwordData struct {
Password string Password string
} }
var d, defaults passwordData var defaults passwordData
func generatePassword(w http.ResponseWriter, r *http.Request) { func generatePassword(w http.ResponseWriter, r *http.Request) {
var err error
if r.URL.Path != "/" { if r.URL.Path != "/" {
http.Error(w, "404 not found.", http.StatusNotFound) http.Error(w, "404 not found.", http.StatusNotFound)
return return
@@ -29,56 +51,53 @@ func generatePassword(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case "GET": case "GET":
d.Password, err = generate(defaults.Length, defaults.Digits, defaults.Symbols, false, false) password, err := generate(defaults.Length, defaults.Digits, defaults.Symbols, false, false)
if err != nil { if err != nil {
d.Password = "Error: " + err.Error()
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(d.Password)) w.Write([]byte("Error: " + err.Error()))
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte(d.Password)) w.Write([]byte(password))
case "POST": case "POST":
if err = r.ParseForm(); err != nil { var d passwordData
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
w.Write([]byte(fmt.Sprintf("ParseForm() err: %v", err))) w.Write([]byte(fmt.Sprintf("ParseForm() err: %v", err)))
return return
} }
d.Length, err = strconv.Atoi(r.FormValue("length"))
if err != nil {
d.Length = defaults.Length
}
d.Digits, err = strconv.Atoi(r.FormValue("digits")) var err error
if err != nil { var validationErr error
d.Digits = defaults.Digits
}
d.Symbols, err = strconv.Atoi(r.FormValue("symbols")) d.Length, err = parseParam(r, "length", defaults.Length, maxPasswordLength)
if err != nil { validationErr = errors.Join(validationErr, err)
d.Symbols = defaults.Symbols
}
if r.FormValue("noupper") == "on" { d.Digits, err = parseParam(r, "digits", defaults.Digits, maxPasswordLength)
d.NoUpper = true validationErr = errors.Join(validationErr, err)
}
if r.FormValue("denyrepeat") == "on" { d.Symbols, err = parseParam(r, "symbols", defaults.Symbols, maxPasswordLength)
d.DenyRepeat = true validationErr = errors.Join(validationErr, err)
}
d.Password, err = generate(d.Length, d.Digits, d.Symbols, d.NoUpper, !d.DenyRepeat) if validationErr != nil {
if err != nil { w.WriteHeader(http.StatusBadRequest)
d.Password = "Error: " + err.Error() w.Write([]byte(validationErr.Error()))
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(d.Password))
return return
} }
if d.Length == 0 {
d.Password = "Error: password can not have zero length" d.NoUpper = r.FormValue("noupper") == "on"
d.DenyRepeat = r.FormValue("denyrepeat") == "on"
var password string
password, err = generate(d.Length, d.Digits, d.Symbols, d.NoUpper, !d.DenyRepeat)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Error: " + err.Error()))
return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte(d.Password)) w.Write([]byte(password))
default: default:
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Sorry, only GET and POST methods are supported.")) w.Write([]byte("Sorry, only GET and POST methods are supported."))