From 49296e431567080406c713aa88ca04114a330fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Fri, 3 Jul 2026 23:00:44 +0200 Subject: [PATCH] Bezbednosne i integritetne popravke iz pregleda koda MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - foreign_keys pragma po konekciji (DSN _pragma) — ranije aktivna na samo delu konekcija iz pula, rizik siročadi u bazi pri brisanju - busy_timeout + _txlock=immediate za konkurentan pristup SQLite bazi - ispravljen DnevniPrometMaloprodaje (DATE() -> substr) — dnevni pazar za KIR/KPO je bio uvek nula - rate-limit (isti IP-bruteforce brojač) dodat na TOTP/2FA verifikaciju - ispravljen nil-pointer u DodajDeloNalogu + uklonjeni debug logovi - izvuciIP prihvata X-Real-IP/X-Forwarded-For samo sa poverljive (loopback/privatne) adrese — sprečava zaobilaženje bruteforce brave - ispravljen naziv dozvole podesavanja.izmena -> podesavanja.izmeni - brisanje artikala/kategorija prebačeno sa GET na POST (CSRF zaštita) --- cmd/ntech/main.go | 6 +-- internal/db/sqlite/migracije.go | 29 ++++++----- internal/db/sqlite/prodaja.go | 4 +- internal/db/sqlite/servisni_delovi.go | 4 -- internal/handler/podesavanja.go | 2 +- internal/handler/prijava.go | 61 +++++++++++++++++++----- internal/handler/servis.go | 11 +++-- internal/handler/utils_test.go | 6 ++- web/templates/stranice/kategorije.html | 10 ++-- web/templates/stranice/magacin.html | 40 +++++++++------- web/templates/stranice/totp_provera.html | 5 ++ 11 files changed, 118 insertions(+), 60 deletions(-) diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 610d6e9..04a42d9 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -349,8 +349,8 @@ func main() { r.Get("/magacin/sledeca-sifra", h.PredlogSifre) r.Get("/magacin/izmeni/{id}", h.IzmeniArtikal) r.With(doz("artikal.izmeni")).Post("/magacin/izmeni/{id}", h.SacuvajIzmenuArtikla) - r.With(doz("artikal.obrisi")).Get("/magacin/obrisi/{id}", h.ObrisiArtikal) - r.With(doz("artikal.obrisi")).Get("/magacin/vrati/{id}", h.VratiArtikal) + r.With(doz("artikal.obrisi")).Post("/magacin/obrisi/{id}", h.ObrisiArtikal) + r.With(doz("artikal.obrisi")).Post("/magacin/vrati/{id}", h.VratiArtikal) r.With(doz("artikal.izmeni")).Post("/magacin/kartica/{id}/dobavljac/dodaj", h.DodajDobavljacaArtiklu) r.With(doz("artikal.izmeni")).Post("/magacin/kartica/{id}/dobavljac/obrisi", h.ObrisiDobavljacaArtikla) r.With(doz("artikal.premesti")).Post("/magacin/premesti/{id}", h.PremestiArtikal) @@ -359,7 +359,7 @@ func main() { r.Get("/magacin/kategorije", h.Kategorije) r.With(doz("kategorija.dodaj")).Post("/magacin/kategorije/dodaj", h.DodajKategoriju) r.With(doz("kategorija.izmeni")).Post("/magacin/kategorije/izmeni/{id}", h.IzmeniKategoriju) - r.With(doz("kategorija.obrisi")).Get("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju) + r.With(doz("kategorija.obrisi")).Post("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke", h.Nabavke) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke/nova", h.NovaNabavka) r.With(doz("nabavka.dodaj")).Post("/nabavke/nova", h.SacuvajNabavku) diff --git a/internal/db/sqlite/migracije.go b/internal/db/sqlite/migracije.go index 789bcfb..0bc41e8 100644 --- a/internal/db/sqlite/migracije.go +++ b/internal/db/sqlite/migracije.go @@ -12,23 +12,26 @@ import ( _ "modernc.org/sqlite" ) -// OtvoriDB otvara konekciju ka SQLite bazi i primenjuje performance PRAGMA podešavanja +// OtvoriDB otvara konekciju ka SQLite bazi i primenjuje performance PRAGMA podešavanja. +// +// Pragme se prosleđuju kroz DSN (_pragma=...) jer database/sql drži pul konekcija — +// PRAGMA postavljena preko db.Exec važi samo za konekciju koja je taj Exec uslužila, +// dok DSN _pragma parametri primenjuje driver na SVAKU novu konekciju pri otvaranju. func OtvoriDB(putanja string) (*sql.DB, error) { - db, err := sql.Open("sqlite", putanja) + dsn := "file:" + putanja + + "?_pragma=journal_mode(WAL)" + + "&_pragma=synchronous(NORMAL)" + + "&_pragma=cache_size(10000)" + + "&_pragma=foreign_keys(1)" + + "&_pragma=busy_timeout(5000)" + + "&_txlock=immediate" + + db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("ntech: OtvoriDB: %w", err) } - - pragme := []string{ - "PRAGMA journal_mode=WAL", - "PRAGMA synchronous=NORMAL", - "PRAGMA cache_size=10000", - "PRAGMA foreign_keys=ON", - } - for _, p := range pragme { - if _, err := db.Exec(p); err != nil { - return nil, fmt.Errorf("ntech: OtvoriDB: %s: %w", p, err) - } + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("ntech: OtvoriDB: %w", err) } return db, nil diff --git a/internal/db/sqlite/prodaja.go b/internal/db/sqlite/prodaja.go index c405dc2..ce8278a 100644 --- a/internal/db/sqlite/prodaja.go +++ b/internal/db/sqlite/prodaja.go @@ -446,7 +446,7 @@ func (r *ProdajaRepo) DnevniPrometMaloprodaje(ctx context.Context, datum string) // broj naloga tog dana err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM prodajni_nalozi - WHERE klijent_id IS NULL AND stornirano = 0 AND DATE(datum) = ?`, datum, + WHERE klijent_id IS NULL AND stornirano = 0 AND substr(datum, 1, 10) = ?`, datum, ).Scan(&p.BrojNaloga) if err != nil { return p, fmt.Errorf("ntech: ProdajaRepo.DnevniPrometMaloprodaje: count: %w", err) @@ -458,7 +458,7 @@ func (r *ProdajaRepo) DnevniPrometMaloprodaje(ctx context.Context, datum string) SELECT s.pdv_stopa, SUM(s.cena_bez_pdv * s.kolicina), SUM(s.pdv_iznos * s.kolicina) FROM stavke_prodaje s JOIN prodajni_nalozi p ON p.id = s.nalog_id - WHERE p.klijent_id IS NULL AND p.stornirano = 0 AND DATE(p.datum) = ? + WHERE p.klijent_id IS NULL AND p.stornirano = 0 AND substr(p.datum, 1, 10) = ? GROUP BY s.pdv_stopa`, datum, ) if err != nil { diff --git a/internal/db/sqlite/servisni_delovi.go b/internal/db/sqlite/servisni_delovi.go index 08487ad..e58d216 100644 --- a/internal/db/sqlite/servisni_delovi.go +++ b/internal/db/sqlite/servisni_delovi.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "log/slog" "ntech/internal/model" ) @@ -80,16 +79,13 @@ func (r *ServisniDeloviRepo) UgradiIliPotrazuj(ctx context.Context, nalogID, art func ugradiIliPotrazujTx(ctx context.Context, tx *sql.Tx, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64, predlozeno bool) (ugradjeno, nedostaje int, err error) { // Predloženi delovi: ne skidaju sa lagera, svaki predlog je poseban red (ne merge) if predlozeno { - slog.Info("PREDLOG_INSERT", "nalogID", nalogID, "artikalID", artikalID, "kolicina", kolicina) _, err = tx.ExecContext(ctx, "INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 1)", nalogID, artikalID, kolicina, cenaKomada, ) if err != nil { - slog.Error("PREDLOG_INSERT_ERR", "err", err) return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: predlozeni: %w", err) } - slog.Info("PREDLOG_INSERT_OK") return 0, kolicina, nil } diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index 77a4988..9bcbe80 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -1090,7 +1090,7 @@ func (h *Handler) BeStatus(w http.ResponseWriter, r *http.Request) { // BeResetAudit šalje reset_audit komandu kartica emulatoru i vraća HTMX fragment. func (h *Handler) BeResetAudit(w http.ResponseWriter, r *http.Request) { - if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmena"); !ok { + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok { return } bePort := os.Getenv("BE_PORT") diff --git a/internal/handler/prijava.go b/internal/handler/prijava.go index 822dcd0..719c4a1 100644 --- a/internal/handler/prijava.go +++ b/internal/handler/prijava.go @@ -186,6 +186,23 @@ func (h *Handler) VerifikujTotp(w http.ResponseWriter, r *http.Request) { return } + // isti bruteforce brojač kao za lozinku — deli IP-bazirano zaključavanje + ip := izvuciIP(r) + od := time.Now().Add(-prozorPrijave) + n, _ := h.PokusajiRepo.BrojNeuspeha(r.Context(), ip, od) + if n >= maxNeuspehaPrijave { + if preostalo, zaklj := h.preostaloBruteforce(r.Context(), ip, od); zaklj { + auth.LogZaklucano(ip, korisnik.KorisnickoIme) + _ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), &korisnik.ID, ip, r.UserAgent(), "ip_zaklucano_totp", false) + h.renderujStandalone(w, "totp_provera", map[string]any{ + "Greska": "zakljucano", + "Preostalo": preostalo, + "CsrfToken": middleware.CsrfToken(r.Context()), + }) + return + } + } + kod := r.FormValue("kod") validan := auth.VerifikujTotpKod(kod, korisnik.TotpTajna) if !validan { @@ -196,10 +213,15 @@ func (h *Handler) VerifikujTotp(w http.ResponseWriter, r *http.Request) { } } if !validan { + _ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnik.KorisnickoIme, false) + _ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), &korisnik.ID, ip, r.UserAgent(), "pogrešan_totp", false) + auth.LogNeuspehPrijave(ip, korisnik.KorisnickoIme, "wrong_totp") http.Redirect(w, r, "/prijava/totp?greska=1", http.StatusSeeOther) return } + _ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnik.KorisnickoIme, true) + novoIstice := time.Now().Add(trajanjeSeije) if err := h.SesijeRepo.PotvrdiTotp(r.Context(), kolacic.Value, novoIstice); err != nil { http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther) @@ -295,24 +317,41 @@ func (h *Handler) preostaloBruteforce(ctx context.Context, ip string, od time.Ti return fmt.Sprintf("%d sek", sek), true } -// izvuciIP čita pravi IP klijenta — najpre X-Real-IP (koji nginx postavlja), -// zatim poslednji X-Forwarded-For (dodat od strane proxy-a), pa RemoteAddr +// izvuciIP čita pravi IP klijenta iz X-Real-IP/X-Forwarded-For, ALI samo ako +// zahtev fizički stiže sa poverljive adrese (Caddy na istom hostu ili Docker +// mreža — loopback/privatni opseg). Ova zaglavlja su inače potpuno pod +// kontrolom klijenta; slepo verovanje u njih zaobilazi bruteforce zaključavanje +// prijave/TOTP-a. Ako RemoteAddr nije poverljiv, header se ignoriše. func izvuciIP(r *http.Request) string { - if ip := r.Header.Get("X-Real-IP"); ip != "" { - return ip - } - if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { - // uzimamo poslednju vrednost — nju dodaje naš proxy, ne klijent - parts := strings.Split(fwd, ",") - return strings.TrimSpace(parts[len(parts)-1]) - } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { - return r.RemoteAddr + host = r.RemoteAddr } + + if remoteAdresaJePoverljiva(host) { + if ip := r.Header.Get("X-Real-IP"); ip != "" { + return ip + } + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + // uzimamo poslednju vrednost — nju dodaje naš proxy, ne klijent + parts := strings.Split(fwd, ",") + return strings.TrimSpace(parts[len(parts)-1]) + } + } + return host } +// remoteAdresaJePoverljiva vraća true ako je adresa loopback ili iz privatnog +// (RFC 1918) opsega — tipično za reverse proxy na istom hostu ili u istoj Docker mreži. +func remoteAdresaJePoverljiva(host string) bool { + ip := net.ParseIP(host) + if ip == nil { + return false + } + return ip.IsLoopback() || ip.IsPrivate() +} + func napraviKolacic(token string, istice time.Time) *http.Cookie { return &http.Cookie{ Name: imeKolacica, diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 3c69835..93b97cd 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -1049,12 +1049,15 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) { } // predlog ako status nije „Primljeno" (forma više ne određuje — server odlučuje) - nalog, _ := h.ServisRepo.DohvatiID(r.Context(), nalogID) - predlozeno := nalog != nil && nalog.Status != model.StatusPrimljeno - slog.Info("DODAJ_DEO_IN", "status", nalog.Status, "predlozeno", predlozeno, "kol", kolicina) + nalog, err := h.ServisRepo.DohvatiID(r.Context(), nalogID) + if err != nil || nalog == nil { + middleware.SetFlash(w, r, h.DB, "greska", "Nalog nije pronađen.") + http.Redirect(w, r, "/servis", http.StatusSeeOther) + return + } + predlozeno := nalog.Status != model.StatusPrimljeno // atomično: ugradi ono što imamo (skida sa lagera, ne ide u minus), višak u potraživane ugradjeno, nedostaje, err := h.ServisniDeloviRepo.UgradiIliPotrazuj(r.Context(), nalogID, artikalID, kolicina, cena, &k.ID, predlozeno) - slog.Info("DODAJ_DEO_OUT", "ugradjeno", ugradjeno, "nedostaje", nedostaje, "err", err) if err != nil { slog.Error("greška pri dodavanju dela", "error", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.") diff --git a/internal/handler/utils_test.go b/internal/handler/utils_test.go index 550aa22..a2bb927 100644 --- a/internal/handler/utils_test.go +++ b/internal/handler/utils_test.go @@ -13,8 +13,10 @@ func TestIzvuciIP(t *testing.T) { remoteAddr string ocek string }{ - {"X-Real-IP ima prioritet", "1.2.3.4", "9.9.9.9", "5.5.5.5:1234", "1.2.3.4"}, - {"poslednji X-Forwarded-For", "", "1.1.1.1, 2.2.2.2, 3.3.3.3", "5.5.5.5:1234", "3.3.3.3"}, + {"X-Real-IP ima prioritet kad je proxy poverljiv (loopback)", "1.2.3.4", "9.9.9.9", "127.0.0.1:1234", "1.2.3.4"}, + {"X-Real-IP ima prioritet kad je proxy poverljiv (privatna Docker mreža)", "1.2.3.4", "9.9.9.9", "172.18.0.1:1234", "1.2.3.4"}, + {"poslednji X-Forwarded-For kad je proxy poverljiv", "", "1.1.1.1, 2.2.2.2, 3.3.3.3", "127.0.0.1:1234", "3.3.3.3"}, + {"zaglavlje se IGNORIŠE kad RemoteAddr nije poverljiv (javna adresa — spoofing)", "1.2.3.4", "", "5.5.5.5:1234", "5.5.5.5"}, {"RemoteAddr bez porta kad nema zaglavlja", "", "", "5.5.5.5:1234", "5.5.5.5"}, {"RemoteAddr kakav jeste ako nije host:port", "", "", "neispravan", "neispravan"}, } diff --git a/web/templates/stranice/kategorije.html b/web/templates/stranice/kategorije.html index 3da31dc..d5f1324 100644 --- a/web/templates/stranice/kategorije.html +++ b/web/templates/stranice/kategorije.html @@ -130,10 +130,12 @@ {{end}} {{if index $.Dozvole "kategorija.obrisi"}} - - Obriši - +
+ +
{{end}} {{else}} diff --git a/web/templates/stranice/magacin.html b/web/templates/stranice/magacin.html index 5ac61d9..2725337 100644 --- a/web/templates/stranice/magacin.html +++ b/web/templates/stranice/magacin.html @@ -126,15 +126,19 @@ {{end}}{{end}} {{if index $.Dozvole "artikal.obrisi"}} {{if $.PrikazArhivirani}} - - Vrati - +
+ +
{{else}} - - Obriši - +
+ +
{{end}} {{end}} @@ -176,15 +180,19 @@ {{end}}{{end}} {{if index $.Dozvole "artikal.obrisi"}} {{if $.PrikazArhivirani}} - - Vrati - +
+ +
{{else}} - - Obriši - +
+ +
{{end}} {{end}} diff --git a/web/templates/stranice/totp_provera.html b/web/templates/stranice/totp_provera.html index 3c80bee..764e374 100644 --- a/web/templates/stranice/totp_provera.html +++ b/web/templates/stranice/totp_provera.html @@ -35,6 +35,11 @@ {{if eq .Greska "1"}}
Neispravan kod. Pokušajte ponovo.
+ {{else if eq .Greska "zakljucano"}} +
+ Previše neuspelih pokušaja. IP adresa je privremeno blokirana. + {{if .Preostalo}}
Pokušajte ponovo za: {{.Preostalo}}{{end}} +
{{end}}