Improve security and logging

This commit is contained in:
Andrew-71 2024-03-20 16:18:23 +03:00
parent affcef2eda
commit f50f4f1919
10 changed files with 154 additions and 30 deletions

1
.gitignore vendored
View file

@ -24,3 +24,4 @@ hibiscus
# Because currently it's used in "prod"
data/
/config/log.txt

View file

@ -13,6 +13,7 @@ As a result of this, it is also neither secure nor idiot-proof.
* You can easily export everything in a zip file for backups
* Everything is plain(text) and simple. No databases, encryption, OAuth, or anything fancy. Even the password is plainte- *wait is this a feature?*
* Telegram notifications support
## Data format:
```

View file

@ -2,12 +2,11 @@
List of things to add to this project
## Crucial
* Handle all the w.Write errors somehow
* Add export feature
* Add missing frontend pages
* More slog.Debug?
## Long-medium term considerations
* Improve logging, log to files
* Make the CLI better
* Think about timezones
* Consider more secure auth methods

69
auth.go
View file

@ -3,9 +3,43 @@ package main
import (
"crypto/sha256"
"crypto/subtle"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"time"
)
type failedLogin struct {
Username string
Password string
Timestamp time.Time
}
var failedLogins []failedLogin
func NoteLoginFail(username string, password string, r *http.Request) {
slog.Warn("failed auth", "username", username, "password", password, "address", r.RemoteAddr)
NotifyTelegram(fmt.Sprintf("Failed auth attempt in hibiscus:\nusername=%s\npassword=%s\nremote=%s", username, password, r.RemoteAddr))
attempt := failedLogin{username, password, time.Now()}
updatedLogins := []failedLogin{attempt}
for _, attempt := range failedLogins {
if 100 > time.Now().Sub(attempt.Timestamp).Abs().Seconds() {
updatedLogins = append(updatedLogins, attempt)
}
}
failedLogins = updatedLogins
// At least 3 failed attempts in last 100 seconds -> likely bruteforce
if len(failedLogins) >= 3 {
Scram()
}
}
// BasicAuth is a middleware that handles authentication & authorization for the app.
// It uses BasicAuth because I doubt there is a need for something sophisticated in a small hobby project
// Originally taken from https://www.alexedwards.net/blog/basic-authentication-in-go (13.03.2024)
@ -26,8 +60,9 @@ func BasicAuth(next http.Handler) http.Handler {
if usernameMatch && passwordMatch {
next.ServeHTTP(w, r)
return
} else {
NoteLoginFail(username, password, r)
}
// TODO: Note failed login attempt?
}
// Unauthorized, inform client that we have auth and return 401
@ -35,3 +70,35 @@ func BasicAuth(next http.Handler) http.Handler {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
})
}
// Scram shuts down the service, useful in case of suspected attack
func Scram() {
slog.Warn("SCRAM triggered, shutting down")
NotifyTelegram("Hibiscus SCRAM triggered, shutting down")
os.Exit(0) // TODO: should this be 0 or 1?
}
// NotifyTelegram attempts to send a message to admin through telegram
func NotifyTelegram(msg string) {
if Cfg.TelegramChat == "" || Cfg.TelegramToken == "" {
slog.Warn("ignoring telegram request due to lack of credentials")
return
}
client := &http.Client{}
var data = strings.NewReader("chat_id=" + Cfg.TelegramChat + "&text=" + msg)
req, err := http.NewRequest("POST", "https://api.telegram.org/bot"+Cfg.TelegramToken+"/sendMessage", data)
if err != nil {
slog.Error("failed telegram request", "error", err)
return
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
slog.Error("failed telegram request", "error", err)
return
}
if resp.StatusCode != 200 {
slog.Error("failed telegram request", "status", resp.Status)
}
}

View file

@ -16,10 +16,16 @@ type Config struct {
Username string
Password string
Port int
TelegramToken string
TelegramChat string
}
func (c *Config) Save() error {
output := fmt.Sprintf("port=%d\nusername=%s\npassword=%s", c.Port, c.Username, c.Password)
if c.TelegramToken != "" && c.TelegramChat != "" {
output += fmt.Sprintf("\ntg_token=%s\ntg_chat=%s", c.TelegramToken, c.TelegramChat)
}
f, err := os.OpenFile(ConfigFile, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
@ -62,6 +68,10 @@ func (c *Config) Reload() error {
if err == nil {
c.Port = numVal
}
} else if key == "tg_token" {
c.TelegramToken = value
} else if key == "tg_chat" {
c.TelegramChat = value
}
}
if err := scanner.Err(); err != nil {

View file

@ -3,9 +3,9 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"github.com/go-chi/chi/v5"
"io"
"log/slog"
"net/http"
"os"
"path"
@ -14,6 +14,13 @@ import (
"time"
)
// HandleWrite checks for error in ResponseWriter.Write output
func HandleWrite(_ int, err error) {
if err != nil {
slog.Error("error writing response", "error", err)
}
}
// GetFile returns raw contents of a txt file in data directory
func GetFile(filename string, w http.ResponseWriter) {
filename = "data/" + path.Clean(filename) + ".txt" // Does this sanitize the path?
@ -25,6 +32,9 @@ func GetFile(filename string, w http.ResponseWriter) {
fileContents, err := os.ReadFile(filename)
if err != nil {
slog.Error("error reading file",
"error", err,
"file", filename)
http.Error(w, "error reading file", http.StatusInternalServerError)
return
}
@ -41,25 +51,30 @@ func PostFile(filename string, w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("error reading body"))
HandleWrite(w.Write([]byte("error reading body")))
return
}
f, err := os.OpenFile("data/"+filename+".txt", os.O_CREATE|os.O_WRONLY, 0644)
filename = "data/" + filename + ".txt"
f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
fmt.Println("error opening/making file")
w.Write([]byte("error opening or creating file"))
slog.Error("error opening/making file",
"error", err,
"file", filename)
HandleWrite(w.Write([]byte("error opening or creating file")))
w.WriteHeader(http.StatusInternalServerError)
return
}
if _, err := f.Write(body); err != nil {
fmt.Println("error writing to the file")
w.Write([]byte("error writing to file"))
slog.Error("error writing to file",
"error", err,
"file", filename)
HandleWrite(w.Write([]byte("error writing to file")))
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Write([]byte("wrote to file"))
HandleWrite(w.Write([]byte("wrote to file")))
w.WriteHeader(http.StatusOK)
}
@ -75,7 +90,7 @@ func ListFiles(directory string, w http.ResponseWriter) {
filenames[i] = file
}
filenamesJson, err := json.Marshal(filenames)
w.Write(filenamesJson)
HandleWrite(w.Write(filenamesJson))
}
// GetDay returns a day specified in URL
@ -83,14 +98,14 @@ func GetDay(w http.ResponseWriter, r *http.Request) {
dayString := chi.URLParam(r, "day")
if dayString == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("day not specified"))
HandleWrite(w.Write([]byte("day not specified")))
return
}
GetFile("day/"+dayString, w)
}
// GetToday runs GetFile with today's daily txt
func GetToday(w http.ResponseWriter) {
func GetToday(w http.ResponseWriter, _ *http.Request) {
GetFile("day/"+time.Now().Format("2006-01-02"), w)
}
@ -104,7 +119,7 @@ func GetNote(w http.ResponseWriter, r *http.Request) {
noteString := chi.URLParam(r, "note")
if noteString == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("note not specified"))
HandleWrite(w.Write([]byte("note not specified")))
return
}
GetFile("notes/"+noteString, w)
@ -115,7 +130,7 @@ func PostNote(w http.ResponseWriter, r *http.Request) {
noteString := chi.URLParam(r, "note")
if noteString == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("note not specified"))
HandleWrite(w.Write([]byte("note not specified")))
return
}
PostFile("notes/"+noteString, w, r)

17
log.go
View file

@ -1,8 +1,8 @@
package main
import (
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
@ -12,20 +12,27 @@ import (
// AppendLog adds the input string to the end of the log file with a timestamp
func appendLog(input string) error {
t := time.Now().Format("2006-01-02 15:04") // yyyy-mm-dd HH:MM
filename := "data/log.txt"
f, err := os.OpenFile("./data/log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
fmt.Println("Error opening/making file")
slog.Error("error opening/making file",
"error", err,
"file", filename)
return err
}
input = strings.Replace(input, "\n", "", -1) // Remove newlines to maintain structure
if _, err := f.Write([]byte(t + " | " + input + "\n")); err != nil {
fmt.Println("Error appending to the file")
slog.Error("error appending to file",
"error", err,
"file", filename)
return err
}
if err := f.Close(); err != nil {
fmt.Println("Error closing the file")
slog.Error("error closing file",
"error", err,
"file", filename)
return err
}
return nil

23
logger.go Normal file
View file

@ -0,0 +1,23 @@
package main
import (
"io"
"log"
"log/slog"
"os"
)
var LogFile = "config/log.txt"
// LogInit makes slog output to both stdout and a file
func LogInit() {
f, err := os.OpenFile(LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
log.Fatalf("error opening log file: %v", err)
}
// No defer f.Close() because that breaks the MultiWriter
w := io.MultiWriter(f, os.Stdout)
slog.SetDefault(slog.New(slog.NewTextHandler(w, nil)))
}

View file

@ -3,5 +3,6 @@ package main
var Cfg = ConfigInit()
func main() {
LogInit()
Serve()
}

View file

@ -1,10 +1,10 @@
package main
import (
"fmt"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"log"
"log/slog"
"net/http"
"strconv"
)
@ -29,17 +29,17 @@ func Serve() {
apiRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { GetFile("readme", w) })
apiRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostFile("readme", w, r) })
apiRouter.Get("/log", func(w http.ResponseWriter, r *http.Request) { GetFile("log", w) })
apiRouter.Post("/log", func(w http.ResponseWriter, r *http.Request) { PostLog(w, r) })
apiRouter.Post("/log", PostLog)
apiRouter.Get("/day", func(w http.ResponseWriter, r *http.Request) { ListFiles("day", w) })
apiRouter.Get("/day/{day}", func(w http.ResponseWriter, r *http.Request) { GetDay(w, r) })
apiRouter.Get("/day/{day}", GetDay)
apiRouter.Get("/notes", func(w http.ResponseWriter, r *http.Request) { ListFiles("notes", w) })
apiRouter.Get("/notes/{note}", func(w http.ResponseWriter, r *http.Request) { GetNote(w, r) })
apiRouter.Post("/notes/{note}", func(w http.ResponseWriter, r *http.Request) { PostNote(w, r) })
apiRouter.Get("/notes/{note}", GetNote)
apiRouter.Post("/notes/{note}", PostNote)
apiRouter.Get("/today", func(w http.ResponseWriter, r *http.Request) { GetToday(w) })
apiRouter.Post("/today", func(w http.ResponseWriter, r *http.Request) { PostToday(w, r) })
apiRouter.Get("/today", GetToday)
apiRouter.Post("/today", PostToday)
r.Mount("/api", apiRouter)
@ -47,6 +47,6 @@ func Serve() {
fs := http.FileServer(http.Dir("public"))
r.Handle("/public/*", http.StripPrefix("/public/", fs))
fmt.Println("Website working on port: ", Cfg.Port)
slog.Info("🌺 Website working", "port", Cfg.Port)
log.Fatal(http.ListenAndServe(":"+strconv.Itoa(Cfg.Port), r))
}