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:
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user