fix(sec): Authorization-Bypässe und Path-Traversal schließen, Xapian-Doku bereinigen

- SEC: requireMailAccess auf GET /api/threads/{threadID} — superadmin/domain_admin konnten Mail-Metadaten lesen
- SEC: requireMailAccess auf POST /api/export/ediscovery — superadmin/domain_admin konnten bis zu 10k EML exportieren
- SEC: V1-API user-role Keys müssen 'contact=' angeben — verhindert vollständige Tenant-Enumeration
- SEC: Domain-Regex-Validierung in handleCertACME vor filepath.Join und certbot-Aufruf
- docs: README und config.test.yml auf Manticore Search aktualisiert (kein Xapian mehr)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-05-28 23:55:31 +02:00
parent 8d950b48f7
commit fa9f77782c
5 changed files with 39 additions and 37 deletions
+21 -25
View File
@@ -41,7 +41,7 @@ Ein selbst gehostetes, unternehmenstaugliches Mail-Archiv-System. Empfängt E-Ma
| IMAP-Import manuell & automatischer Sync | ✅ Deployed | | IMAP-Import manuell & automatischer Sync | ✅ Deployed |
| EML/MBOX Web-Upload (Admin) | ✅ Deployed | | EML/MBOX Web-Upload (Admin) | ✅ Deployed |
| AES-256-GCM verschlüsselte Speicherung | ✅ Deployed | | AES-256-GCM verschlüsselte Speicherung | ✅ Deployed |
| Xapian Volltext-Indexierung (async) | ✅ Deployed | | Manticore Search Volltext-Indexierung (async) | ✅ Deployed |
| Volltext-Suche mit Filtern & Sortierung | ✅ Deployed | | Volltext-Suche mit Filtern & Sortierung | ✅ Deployed |
| E-Mail-Ansicht mit HTML-Sandbox | ✅ Deployed | | E-Mail-Ansicht mit HTML-Sandbox | ✅ Deployed |
| E-Mail-Export als EML / PDF / ZIP | ✅ Deployed | | E-Mail-Export als EML / PDF / ZIP | ✅ Deployed |
@@ -71,8 +71,8 @@ Ein selbst gehostetes, unternehmenstaugliches Mail-Archiv-System. Empfängt E-Ma
│ ▼ │──► /var/archivmail/store │ ▼ │──► /var/archivmail/store
│ Async Index Worker │ (AES-256-GCM) │ Async Index Worker │ (AES-256-GCM)
│ │ │ │ │ │
│ ▼ │──► /var/archivmail/xapian │ ▼ │──► Manticore Search (Port 9306)
Xapian Index │ (Volltext-Index) Manticore Index │ (Volltext-Index)
└─────────────────────────────────────┘ └─────────────────────────────────────┘
│ /api/* │ /api/*
@@ -87,10 +87,10 @@ Ein selbst gehostetes, unternehmenstaugliches Mail-Archiv-System. Empfängt E-Ma
| Komponente | Technologie | Beschreibung | | Komponente | Technologie | Beschreibung |
|------------|-------------|--------------| |------------|-------------|--------------|
| Backend | Go 1.23 | REST API, SMTP-Daemon, IMAP-Importer, Storage, Indexierung | | Backend | Go 1.24 | REST API, SMTP-Daemon, IMAP-Importer, Storage, Indexierung |
| Frontend | Next.js 16 (TypeScript) | Web-Oberfläche, Tailwind CSS, shadcn/ui | | Frontend | Next.js 16 (TypeScript) | Web-Oberfläche, Tailwind CSS, shadcn/ui |
| Datenbank | PostgreSQL | Metadaten, Benutzer, Audit-Log, Session-Blacklist | | Datenbank | PostgreSQL | Metadaten, Benutzer, Audit-Log, Session-Blacklist |
| Volltextsuche | Xapian | BM25-Ranking, Wildcards, Feldpräfixe | | Volltextsuche | Manticore Search | BM25-Ranking, Wildcards, Feldpräfixe |
| Speicherung | Dateisystem | AES-256-GCM verschlüsselt, SHA-256 Integrität | | Speicherung | Dateisystem | AES-256-GCM verschlüsselt, SHA-256 Integrität |
--- ---
@@ -99,10 +99,10 @@ Ein selbst gehostetes, unternehmenstaugliches Mail-Archiv-System. Empfängt E-Ma
**Server:** **Server:**
- Debian 12 / Ubuntu 22.04 oder neuer - Debian 12 / Ubuntu 22.04 oder neuer
- Go ≥ 1.23 (mit CGO für Xapian) - Go ≥ 1.24 (CGO_ENABLED=0, keine C-Abhängigkeiten)
- Node.js ≥ 20 - Node.js ≥ 20
- PostgreSQL ≥ 14 - PostgreSQL ≥ 14
- libxapian-dev - Manticore Search ≥ 6.x
**Ports (Standard):** **Ports (Standard):**
- `8080` HTTP API (Backend) - `8080` HTTP API (Backend)
@@ -121,9 +121,9 @@ curl -fsSL https://gitea.perlbach24.de/scripte/archivmail/raw/branch/main/update
Das Skript: Das Skript:
1. Klont/aktualisiert den Quellcode 1. Klont/aktualisiert den Quellcode
2. Baut Backend (Go + CGO/Xapian) und Frontend (Next.js) 2. Baut Backend (Go, CGO_ENABLED=0) und Frontend (Next.js)
3. Stoppt Dienste, spielt Binaries ein, startet Dienste neu 3. Stoppt Dienste, spielt Binaries ein, startet Dienste neu
4. Führt Datenbankmigrationen durch 4. Führt Datenbankmigrationen durch und reindexiert Manticore
### Manuell ### Manuell
@@ -133,7 +133,7 @@ git clone https://gitea.perlbach24.de/scripte/archivmail.git /opt/archivmail/_bu
cd /opt/archivmail/_build cd /opt/archivmail/_build
# Backend bauen # Backend bauen
CGO_ENABLED=1 go build -tags xapian -buildvcs=false \ CGO_ENABLED=0 go build -buildvcs=false \
-o /opt/archivmail/bin/archivmail ./cmd/archivmail/ -o /opt/archivmail/bin/archivmail ./cmd/archivmail/
# Frontend bauen # Frontend bauen
@@ -145,7 +145,7 @@ rsync -a --delete .next/static/ /opt/archivmail/web/.next/static/
### Konfigurationsdatei anlegen ### Konfigurationsdatei anlegen
```bash ```bash
mkdir -p /etc/archivmail /var/archivmail/{store,astore,xapian} /var/log/archivmail mkdir -p /etc/archivmail /var/archivmail/{store,astore} /var/log/archivmail
cat > /etc/archivmail/config.yml << 'EOF' cat > /etc/archivmail/config.yml << 'EOF'
server: server:
@@ -163,7 +163,6 @@ database:
storage: storage:
store_path: /var/archivmail/store store_path: /var/archivmail/store
astore_path: /var/archivmail/astore astore_path: /var/archivmail/astore
xapian_path: /var/archivmail/xapian
keyfile: /etc/archivmail/keyfile keyfile: /etc/archivmail/keyfile
audit: audit:
@@ -183,10 +182,9 @@ api:
secret: ZUFAELLIGER_JWT_SECRET_MINDESTENS_32_ZEICHEN secret: ZUFAELLIGER_JWT_SECRET_MINDESTENS_32_ZEICHEN
index: index:
path: /var/archivmail/xapian backend: manticore
backend: xapian manticore_dsn: "manticore@tcp(127.0.0.1:9306)/"
batch_size: 100 batch_size: 100
async_queue_size: 1000
EOF EOF
# AES-256 Schlüssel generieren # AES-256 Schlüssel generieren
@@ -216,7 +214,6 @@ database:
storage: storage:
store_path: /var/archivmail/store # Haupt-Mailspeicher (AES-256-GCM) store_path: /var/archivmail/store # Haupt-Mailspeicher (AES-256-GCM)
astore_path: /var/archivmail/astore # Anhang-Speicher astore_path: /var/archivmail/astore # Anhang-Speicher
xapian_path: /var/archivmail/xapian # Xapian-Datenbankpfad (auch in index.path)
keyfile: /etc/archivmail/keyfile # 32-Byte AES-Schlüsseldatei keyfile: /etc/archivmail/keyfile # 32-Byte AES-Schlüsseldatei
smtp: smtp:
@@ -234,10 +231,9 @@ api:
secret: "" # JWT-Signaturschlüssel (mind. 32 Zeichen, zufällig) secret: "" # JWT-Signaturschlüssel (mind. 32 Zeichen, zufällig)
index: index:
path: /var/archivmail/xapian backend: manticore # manticore (Standard)
backend: xapian # xapian (einziger unterstützter Backend) manticore_dsn: "manticore@tcp(127.0.0.1:9306)/" # Manticore Search DSN
batch_size: 100 # Dokumente pro Index-Batch batch_size: 100 # Dokumente pro Index-Batch
async_queue_size: 1000 # Größe der asynchronen Index-Queue
audit: audit:
log_path: /var/log/archivmail/audit.log log_path: /var/log/archivmail/audit.log
@@ -451,7 +447,7 @@ Alle E-Mails werden verschlüsselt auf dem Dateisystem gespeichert.
### Volltext-Suche ### Volltext-Suche
Suche über alle archivierten E-Mails per Xapian-Index unter `/search`. Suche über alle archivierten E-Mails per Manticore Search unter `/search`.
**Suchfelder:** **Suchfelder:**
- Freitext (durchsucht Betreff, Body, Absender, Empfänger, Anhangsnamen) - Freitext (durchsucht Betreff, Body, Absender, Empfänger, Anhangsnamen)
@@ -461,13 +457,13 @@ Suche über alle archivierten E-Mails per Xapian-Index unter `/search`.
- Nur Mails mit Anhängen (`has_attachment`) - Nur Mails mit Anhängen (`has_attachment`)
- Sortierung: `date_desc` (Standard), `date_asc`, `relevance` - Sortierung: `date_desc` (Standard), `date_asc`, `relevance`
**Xapian-Suchsyntax:** **Suchsyntax:**
``` ```
Rechnung AND 2024 # UND-Verknüpfung Rechnung 2024 # Mehrere Begriffe (AND)
"Angebot Projekt X" # Phrase "Angebot Projekt X" # Phrase
Rechn* # Wildcard Rechn* # Wildcard
from:chef@firma.de # Feldpräfix @from chef@firma.de # Feldsuche Absender
subject:Urlaubsantrag # Feldpräfix @subject Urlaubsantrag # Feldsuche Betreff
``` ```
**Paginierung:** 25 Ergebnisse pro Seite (konfigurierbar via `page_size`) **Paginierung:** 25 Ergebnisse pro Seite (konfigurierbar via `page_size`)
@@ -827,7 +823,7 @@ curl -fsSL https://gitea.perlbach24.de/scripte/archivmail/raw/branch/main/update
Das Update-Skript führt automatisch durch: Das Update-Skript führt automatisch durch:
1. Quellcode aktualisieren (git pull) 1. Quellcode aktualisieren (git pull)
2. Backend neu bauen (CGO + Xapian) 2. Backend neu bauen (CGO_ENABLED=0)
3. Frontend neu bauen (Next.js standalone) 3. Frontend neu bauen (Next.js standalone)
4. Dienste stoppen 4. Dienste stoppen
5. Binaries und Frontend einspielen 5. Binaries und Frontend einspielen
+2 -2
View File
@@ -37,8 +37,8 @@ api:
tls: false tls: false
index: index:
path: /tmp/archivmail-test/xapian backend: manticore
backend: xapian manticore_dsn: "manticore@tcp(127.0.0.1:9306)/"
batch_size: 10 batch_size: 10
logging: logging:
+7
View File
@@ -17,10 +17,13 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"time" "time"
) )
var validDomainRE = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`)
const ( const (
certDir = "/etc/ssl/archivmail" certDir = "/etc/ssl/archivmail"
certPath = "/etc/ssl/archivmail/archivmail.crt" certPath = "/etc/ssl/archivmail/archivmail.crt"
@@ -270,6 +273,10 @@ func (s *Server) handleCertACME(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "email is required") writeError(w, http.StatusBadRequest, "email is required")
return return
} }
if !validDomainRE.MatchString(req.Domain) {
writeError(w, http.StatusBadRequest, "invalid domain name")
return
}
// Verify certbot is available before attempting anything. // Verify certbot is available before attempting anything.
certbotPath, err := exec.LookPath("certbot") certbotPath, err := exec.LookPath("certbot")
+2 -2
View File
@@ -213,7 +213,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/admin/storage/stats", s.authAdmin(s.handleStorageStats)) s.mux.HandleFunc("GET /api/admin/storage/stats", s.authAdmin(s.handleStorageStats))
s.mux.HandleFunc("GET /api/mails/{id}", s.auth(s.requireMailAccess(s.handleGetMail))) s.mux.HandleFunc("GET /api/mails/{id}", s.auth(s.requireMailAccess(s.handleGetMail)))
s.mux.HandleFunc("GET /api/mails/{id}/attachments/{index}", s.auth(s.requireMailAccess(s.handleGetAttachment))) s.mux.HandleFunc("GET /api/mails/{id}/attachments/{index}", s.auth(s.requireMailAccess(s.handleGetAttachment)))
s.mux.HandleFunc("GET /api/threads/{threadID}", s.auth(s.handleGetThread)) s.mux.HandleFunc("GET /api/threads/{threadID}", s.auth(s.requireMailAccess(s.handleGetThread)))
s.mux.HandleFunc("GET /api/mails/{id}/raw", s.auth(s.requireMailAccess(s.handleGetRaw))) s.mux.HandleFunc("GET /api/mails/{id}/raw", s.auth(s.requireMailAccess(s.handleGetRaw)))
// PROJ-44: OCR-Text-Download — gleicher ACL-Pfad wie /raw. // PROJ-44: OCR-Text-Download — gleicher ACL-Pfad wie /raw.
s.mux.HandleFunc("GET /api/mails/{id}/ocr-text", s.auth(s.requireMailAccess(s.handleGetOCRText))) s.mux.HandleFunc("GET /api/mails/{id}/ocr-text", s.auth(s.requireMailAccess(s.handleGetOCRText)))
@@ -256,7 +256,7 @@ func (s *Server) routes() {
// Export routes // Export routes
s.mux.HandleFunc("GET /api/export/pdf/{id}", s.auth(s.requireMailAccess(s.handleExportPDF))) s.mux.HandleFunc("GET /api/export/pdf/{id}", s.auth(s.requireMailAccess(s.handleExportPDF)))
s.mux.HandleFunc("POST /api/export/zip", s.auth(s.requireMailAccess(s.handleExportZIP))) s.mux.HandleFunc("POST /api/export/zip", s.auth(s.requireMailAccess(s.handleExportZIP)))
s.mux.HandleFunc("POST /api/export/ediscovery", s.auth(s.handleExportEDiscovery)) s.mux.HandleFunc("POST /api/export/ediscovery", s.auth(s.requireMailAccess(s.handleExportEDiscovery)))
// Upload routes (admin only) // Upload routes (admin only)
s.mux.HandleFunc("POST /api/admin/upload", s.authAdmin(s.handleUpload)) s.mux.HandleFunc("POST /api/admin/upload", s.authAdmin(s.handleUpload))
+6 -7
View File
@@ -64,6 +64,12 @@ func (s *Server) handleV1SearchMails(w http.ResponseWriter, r *http.Request) {
Page: page, Page: page,
} }
// User-role keys must always scope their search to a specific contact address.
if akSess.Role == "user" && contactFilter == "" {
writeError(w, http.StatusBadRequest, "user-role API keys require the 'contact' parameter")
return
}
// "contact" searches both From and To fields via OwnEmail. // "contact" searches both From and To fields via OwnEmail.
if contactFilter != "" { if contactFilter != "" {
req.OwnEmail = contactFilter req.OwnEmail = contactFilter
@@ -153,13 +159,6 @@ func (s *Server) handleV1SearchMails(w http.ResponseWriter, r *http.Request) {
} }
m.HasAttachments = len(pm.Attachments) > 0 m.HasAttachments = len(pm.Attachments) > 0
// Role-based filtering: "user" role only sees mails they are involved in.
if akSess.Role == "user" {
// User keys need a contact filter or the mail must belong to the tenant.
// For user-role keys without explicit contact filter, we still return
// all tenant mails (tenant isolation is handled by the index).
}
mails = append(mails, m) mails = append(mails, m)
} }