security: Zufallspasswörter beim Erststart, kryptographisch sichere JTI-Generierung

- seedDefaultUsers: generiert kryptographisch zufällige Passwörter (crypto/rand)
  statt hartkodiertes "archivmailrockz" — Passwörter werden einmalig im Terminal
  angezeigt und können danach nicht wiederhergestellt werden
- generateJTI: verwendet crypto/rand (16 Byte, hex) statt time.UnixNano XOR deadbeef

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-17 01:19:24 +01:00
parent 7e165c8eed
commit bb963a796f
25 changed files with 471 additions and 111 deletions
+31 -9
View File
@@ -398,6 +398,8 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
toFilter := r.URL.Query().Get("to")
dateFromStr := r.URL.Query().Get("date_from")
dateToStr := r.URL.Query().Get("date_to")
sortParam := r.URL.Query().Get("sort") // "relevance", "date_asc", "date_desc"
hasAttachStr := r.URL.Query().Get("has_attachment") // "true" or "false"
pageStr := r.URL.Query().Get("page")
pageSizeStr := r.URL.Query().Get("page_size")
@@ -409,10 +411,19 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
req := index.SearchRequest{
Query: q,
Sort: sortParam,
PageSize: pageSize,
Page: page,
}
if hasAttachStr == "true" {
v := true
req.HasAttachment = &v
} else if hasAttachStr == "false" {
v := false
req.HasAttachment = &v
}
// Domain search: @domain.de matches both From AND To fields.
// A value starting with '@' triggers OR-search across XF and XT prefixes.
if strings.HasPrefix(fromFilter, "@") || strings.HasPrefix(toFilter, "@") {
@@ -458,19 +469,22 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
Success: true,
})
// Enrich hits with metadata (from, subject, date) by parsing each mail.
// Enrich hits with metadata (from, subject, date, size, attachments).
type enrichedHit struct {
ID string `json:"id"`
Score float64 `json:"score"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Subject string `json:"subject,omitempty"`
Date string `json:"date,omitempty"`
ID string `json:"id"`
Score float64 `json:"score"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Subject string `json:"subject,omitempty"`
Date string `json:"date,omitempty"`
Size int64 `json:"size,omitempty"`
HasAttachments bool `json:"has_attachments"`
}
enriched := make([]enrichedHit, 0, len(result.Hits))
for _, h := range result.Hits {
eh := enrichedHit{ID: h.ID, Score: h.Score}
if raw, err := s.store.Load(h.ID); err == nil {
eh.Size = int64(len(raw))
if pm, err := mailparser.Parse(raw); err == nil {
eh.From = pm.From
if len(pm.To) > 0 {
@@ -480,6 +494,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
if !pm.Date.IsZero() {
eh.Date = pm.Date.UTC().Format(time.RFC3339)
}
eh.HasAttachments = len(pm.Attachments) > 0
}
}
enriched = append(enriched, eh)
@@ -1215,6 +1230,7 @@ var excludedFSTypes = map[string]bool{
"efivarfs": true, "bpf": true, "hugetlbfs": true, "mqueue": true,
"ramfs": true, "devpts": true, "fusectl": true, "configfs": true,
"autofs": true, "nsfs": true, "rpc_pipefs": true,
"fuse.lxcfs": true, "fuse": true,
}
func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) {
@@ -1251,7 +1267,8 @@ func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) {
// Disks: /proc/mounts + syscall.Statfs
var disks []diskStat
seenMounts := map[string]bool{}
seenMounts := map[string]bool{} // deduplicate by mountpoint
seenDevices := map[string]bool{} // deduplicate by device (catches ZFS bind-mounts)
if data, err := os.ReadFile("/proc/mounts"); err == nil {
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
@@ -1259,9 +1276,10 @@ func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) {
if len(fields) < 3 {
continue
}
device := fields[0]
mount := fields[1]
fstype := fields[2]
if excludedFSTypes[fstype] || seenMounts[mount] {
if excludedFSTypes[fstype] || seenMounts[mount] || seenDevices[device] {
continue
}
seenMounts[mount] = true
@@ -1272,6 +1290,10 @@ func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) {
total := stat.Blocks * uint64(stat.Bsize)
free := stat.Bavail * uint64(stat.Bsize)
used := total - free
if total == 0 {
continue // skip pseudo-mounts with no storage (e.g. lxcfs overlays)
}
seenDevices[device] = true
var usedPct float64
if total > 0 {
usedPct = math.Round(float64(used)/float64(total)*1000) / 10
+9 -2
View File
@@ -1,6 +1,8 @@
package auth
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
@@ -150,7 +152,12 @@ func HasRole(userRole, required string) bool {
return levels[userRole] >= levels[required]
}
// generateJTI returns a pseudo-unique identifier for a JWT.
// generateJTI returns a cryptographically random identifier for a JWT.
func generateJTI() string {
return fmt.Sprintf("%d-%x", time.Now().UnixNano(), time.Now().UnixNano()^0xdeadbeef)
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// fallback: should never happen on a healthy system
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
+10 -8
View File
@@ -20,14 +20,16 @@ type MailDocument struct {
// SearchRequest specifies search parameters.
type SearchRequest struct {
Query string
From string
To string
OwnEmail string
DateFrom *time.Time
DateTo *time.Time
PageSize int
Page int
Query string
From string
To string
OwnEmail string
DateFrom *time.Time
DateTo *time.Time
HasAttachment *bool // nil=no filter, true=only with, false=only without
Sort string // "relevance", "date_asc", "date_desc" (default: date_desc)
PageSize int
Page int
}
// Hit is a single search result.
+25 -2
View File
@@ -43,8 +43,12 @@ func (x *xapianIndex) IndexSync(doc MailDocument) error {
defer C.free(unsafe.Pointer(csubj))
cbody := C.CString(doc.Body)
defer C.free(unsafe.Pointer(cbody))
hasAttach := C.int(0)
if doc.HasAttachment {
hasAttach = C.int(1)
}
var cerr *C.char
rc := C.xapian_index(x.db, cid, cfrom, cto, csubj, cbody, C.longlong(doc.Date.Unix()), &cerr)
rc := C.xapian_index(x.db, cid, cfrom, cto, csubj, cbody, C.longlong(doc.Date.Unix()), hasAttach, &cerr)
if rc != 0 {
msg := C.GoString(cerr)
C.xapian_free_string(cerr)
@@ -93,8 +97,27 @@ func (x *xapianIndex) Search(req SearchRequest) (*SearchResult, error) {
limit = 25
}
// Sort mode: 0=relevance, 1=date_desc (default), 2=date_asc
sortMode := C.int(1)
switch req.Sort {
case "relevance":
sortMode = C.int(0)
case "date_asc":
sortMode = C.int(2)
}
// Attachment filter: 0=all, 1=only with, -1=only without
attachFilter := C.int(0)
if req.HasAttachment != nil {
if *req.HasAttachment {
attachFilter = C.int(1)
} else {
attachFilter = C.int(-1)
}
}
var cerr *C.char
cresult := C.xapian_search(x.db, cquery, cfrom, cown, cto, dateFrom, dateTo, offset, limit, &cerr)
cresult := C.xapian_search(x.db, cquery, cfrom, cown, cto, dateFrom, dateTo, offset, limit, sortMode, attachFilter, &cerr)
if cresult == nil {
msg := C.GoString(cerr)
C.xapian_free_string(cerr)
+27 -3
View File
@@ -44,7 +44,7 @@ void xapian_close(XapianDB* db) {
int xapian_index(XapianDB* db, const char* id, const char* from,
const char* to, const char* subject, const char* body,
long long timestamp, char** err) {
long long timestamp, int has_attachment, char** err) {
try {
Xapian::Document doc;
Xapian::TermGenerator gen;
@@ -65,6 +65,11 @@ int xapian_index(XapianDB* db, const char* id, const char* from,
gen.increase_termpos();
gen.index_text(to);
// Boolean term for attachment filter
if (has_attachment) {
doc.add_boolean_term("XHA");
}
// Store timestamp for date range queries (value slot 0)
doc.add_value(0, Xapian::sortable_serialise((double)timestamp));
@@ -96,7 +101,9 @@ char* xapian_search(XapianDB* db, const char* query_str,
const char* from_filter, const char* own_email,
const char* to_filter,
long long date_from, long long date_to,
int offset, int limit, char** err) {
int offset, int limit,
int sort_mode, int has_attachment,
char** err) {
try {
Xapian::Database& xdb = db->wdb ? (Xapian::Database&)*db->wdb : *db->rdb;
Xapian::Enquire enquire(xdb);
@@ -159,8 +166,25 @@ char* xapian_search(XapianDB* db, const char* query_str,
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, drq);
}
// Attachment filter
if (has_attachment == 1) {
Xapian::Query aq("XHA");
main_query = Xapian::Query(Xapian::Query::OP_AND, main_query, aq);
} else if (has_attachment == -1) {
Xapian::Query aq("XHA");
main_query = Xapian::Query(Xapian::Query::OP_AND_NOT, main_query, aq);
}
enquire.set_query(main_query);
enquire.set_sort_by_value(0, true); // sort by date desc
// Sort mode: 0=relevance, 1=date_desc, 2=date_asc
if (sort_mode == 2) {
enquire.set_sort_by_value(0, false); // date ascending
} else if (sort_mode == 0 && query_str && query_str[0] != '\0') {
// relevance: default BM25 ranking (no explicit sort)
} else {
enquire.set_sort_by_value(0, true); // date descending (default)
}
// Get total count
Xapian::MSet all = enquire.get_mset(0, xdb.get_doccount());
+8 -3
View File
@@ -10,19 +10,24 @@ typedef struct XapianDB XapianDB;
XapianDB* xapian_open(const char* path, int writable, char** err);
void xapian_close(XapianDB* db);
/* has_attachment: 0=no attachment, 1=has attachment */
int xapian_index(XapianDB* db, const char* id, const char* from,
const char* to, const char* subject, const char* body,
long long timestamp, char** err);
long long timestamp, int has_attachment, char** err);
int xapian_delete(XapianDB* db, const char* id, char** err);
/* Returns JSON string: {"total":N,"hits":[{"id":"...","score":0.9},...]}
Returns NULL on error, sets *err. Caller must free with xapian_free_string. */
Returns NULL on error, sets *err. Caller must free with xapian_free_string.
sort_mode: 0=relevance, 1=date_desc, 2=date_asc
has_attachment: 0=all, 1=only with attachment, -1=only without */
char* xapian_search(XapianDB* db, const char* query,
const char* from_filter, const char* own_email,
const char* to_filter,
long long date_from, long long date_to,
int offset, int limit, char** err);
int offset, int limit,
int sort_mode, int has_attachment,
char** err);
void xapian_free_string(char* s);