From f50f4f19196f44935244be235da3c886f97f96b5 Mon Sep 17 00:00:00 2001 From: Andrew-71 Date: Wed, 20 Mar 2024 16:18:23 +0300 Subject: [PATCH] Improve security and logging --- .gitignore | 3 ++- README.md | 1 + TODO.md | 3 +-- auth.go | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++- config.go | 10 ++++++++ files.go | 41 ++++++++++++++++++++++---------- log.go | 17 ++++++++++---- logger.go | 23 ++++++++++++++++++ main.go | 1 + serve.go | 16 ++++++------- 10 files changed, 154 insertions(+), 30 deletions(-) create mode 100644 logger.go diff --git a/.gitignore b/.gitignore index 26344d9..54ee4f9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ hibiscus .idea/ # Because currently it's used in "prod" -data/ \ No newline at end of file +data/ +/config/log.txt \ No newline at end of file diff --git a/README.md b/README.md index 9665ec1..6b27546 100644 --- a/README.md +++ b/README.md @@ -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: ``` diff --git a/TODO.md b/TODO.md index 92929d2..4472f93 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/auth.go b/auth.go index 881a15c..6c4a37e 100644 --- a/auth.go +++ b/auth.go @@ -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) + } +} diff --git a/config.go b/config.go index 5082b03..2ec2a1c 100644 --- a/config.go +++ b/config.go @@ -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 { diff --git a/files.go b/files.go index 6364905..d20b595 100644 --- a/files.go +++ b/files.go @@ -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) diff --git a/log.go b/log.go index 4403e71..6c1ed4d 100644 --- a/log.go +++ b/log.go @@ -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 diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..901aa4c --- /dev/null +++ b/logger.go @@ -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))) +} diff --git a/main.go b/main.go index 002b15f..0c5f4f4 100644 --- a/main.go +++ b/main.go @@ -3,5 +3,6 @@ package main var Cfg = ConfigInit() func main() { + LogInit() Serve() } diff --git a/serve.go b/serve.go index 2810ac5..7470cf2 100644 --- a/serve.go +++ b/serve.go @@ -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)) }