Am Finger öffnet ein Office-Dokument jetzt das gewandelte PDF im eigenen Betrachter; "Edit" daneben führt trotzdem in den Editor. Mit Maus ändert sich nichts. Der Web-Editor ist eine vollständige Textverarbeitung – zwischen seinen Leisten bleibt auf 400 Pixeln vom Dokument ein Rechteck. Collabora und OnlyOffice verhalten sich gleich; deshalb kein besserer Bedienmodus, sondern ein anderes Werkzeug. Gewandelt wird von dem Server, der auch bearbeitet: Sonst sähe man unterwegs ein LibreOffice-Layout eines Dokuments, für das man OnlyOffice gewählt hat. Je Dokument gibt es GENAU EIN PDF, und das ist keine Frage des Aufräumens, sondern des Schlüssels: previews/<id>.pdf trägt die Fassung nicht im Namen, eine neue Fassung überschreibt. files.preview_version sagt, ob das Abgelegte noch passt. Sicherheit: Die Adresse, die OnlyOffice zurückgibt, wird von uns abgerufen – der Wähler des Transports lässt nur OFFICE_ALLOWED_HOSTS zu, zusätzlich eine ausdrückliche Prüfung mit Test. JWT-Signatur (HS256) für die eigene API des Dokumentenservers. Fremde Kennung 404, ohne Anmeldung 401. Das Ergebnis muss mit %PDF- beginnen, sonst landete eine Fehlerseite im Zwischenspeicher. Gemessen gegen einen echten OnlyOffice-Server im Docker-Netz: erster Abruf 1,05 s -> zweiter 0,009 s erneut hochgeladen -> Fassung 2, preview_version 2, weiterhin 1 Datei GC: herrenloses PDF von vorgestern weg, echtes bleibt endgültig gelöscht -> PDF weg; Papierkorb -> PDF bleibt ohne Anmeldung 401, fremde Kennung 404, Nicht-Office 415 Dazu im Admin-Bereich neu: Platzbedarf der abgeleiteten Daten (Vorschaubilder, PDFs, Profilbilder). Sie zählen gegen kein Kontingent – ein Ordner, der wächst, ohne dass ihn jemand sieht, ist eine stille Überraschung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
123 lines
3.7 KiB
Go
123 lines
3.7 KiB
Go
package office
|
||
|
||
import (
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// Die drei Stellen, an denen ein Fehler **still** wäre: eine Signatur, die
|
||
// nicht stimmt (der Server sagt nur „-8"), eine Fehlerseite, die als PDF
|
||
// abgelegt wird, und eine Ergebnisadresse, die woanders hinzeigt.
|
||
|
||
func TestSignJWTIsVerifiable(t *testing.T) {
|
||
const secret = "geheimnis-mit-mindestens-32-zeichen!!"
|
||
token := signJWT(secret, map[string]any{"outputtype": "pdf", "key": "abc"})
|
||
|
||
parts := strings.Split(token, ".")
|
||
if len(parts) != 3 {
|
||
t.Fatalf("Token hat %d Teile, erwartet 3", len(parts))
|
||
}
|
||
|
||
mac := hmac.New(sha256.New, []byte(secret))
|
||
mac.Write([]byte(parts[0] + "." + parts[1]))
|
||
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||
if parts[2] != want {
|
||
t.Errorf("Signatur stimmt nicht:\n ist: %s\n soll: %s", parts[2], want)
|
||
}
|
||
|
||
head, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||
if err != nil {
|
||
t.Fatalf("Kopf nicht dekodierbar: %v", err)
|
||
}
|
||
var h map[string]string
|
||
if err := json.Unmarshal(head, &h); err != nil {
|
||
t.Fatalf("Kopf ist kein JSON: %v", err)
|
||
}
|
||
if h["alg"] != "HS256" {
|
||
t.Errorf("alg = %q, erwartet HS256", h["alg"])
|
||
}
|
||
|
||
body, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||
var claims map[string]any
|
||
if err := json.Unmarshal(body, &claims); err != nil {
|
||
t.Fatalf("Nutzlast ist kein JSON: %v", err)
|
||
}
|
||
if claims["key"] != "abc" || claims["outputtype"] != "pdf" {
|
||
t.Errorf("Nutzlast fehlt oder ist verändert: %v", claims)
|
||
}
|
||
// Ohne Ablauf gälte ein abgefangenes Token für immer.
|
||
if _, ok := claims["exp"]; !ok {
|
||
t.Error("exp fehlt – das Token liefe nie ab")
|
||
}
|
||
// Die eigene Signatur darf nicht Teil der signierten Nutzlast sein.
|
||
if _, ok := claims["token"]; ok {
|
||
t.Error("die Signatur signiert sich selbst mit")
|
||
}
|
||
}
|
||
|
||
func TestReadPDFRejectsNonPDF(t *testing.T) {
|
||
cases := map[string]string{
|
||
"Fehlerseite": "<html><body>500 Internal Server Error</body></html>",
|
||
"leer": "",
|
||
"JSON-Fehler": `{"error":-4}`,
|
||
"fast ein PDF": "%PD",
|
||
}
|
||
for name, body := range cases {
|
||
if _, err := readPDF(strings.NewReader(body)); err == nil {
|
||
t.Errorf("%s wurde als PDF angenommen", name)
|
||
}
|
||
}
|
||
// Ein echtes PDF muss durchkommen – sonst prüft der Test nichts.
|
||
if _, err := readPDF(strings.NewReader("%PDF-1.7\nInhalt")); err != nil {
|
||
t.Errorf("echtes PDF abgelehnt: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestSameHostAsServerRefusesForeignAddresses(t *testing.T) {
|
||
c := &Client{baseURL: "http://office:80"}
|
||
|
||
// Die Ergebnisadresse kommt aus der Antwort eines anderen Servers. Genau
|
||
// hier würde aus einer Umwandlung ein Zugriff auf einen fremden Dienst.
|
||
foreign := []string{
|
||
"http://169.254.169.254/latest/meta-data/",
|
||
"http://localhost:8080/api/v1/admin/users",
|
||
"http://office.evil.example.com/x.pdf",
|
||
"file:///etc/passwd",
|
||
"://kaputt",
|
||
}
|
||
for _, raw := range foreign {
|
||
if err := c.sameHostAsServer(raw); err == nil {
|
||
t.Errorf("fremde Adresse angenommen: %s", raw)
|
||
}
|
||
}
|
||
|
||
for _, ok := range []string{
|
||
"http://office/cache/files/x.pdf",
|
||
"http://office:80/cache/files/x.pdf",
|
||
"http://OFFICE/cache/files/x.pdf", // Groß-/Kleinschreibung zählt nicht
|
||
} {
|
||
if err := c.sameHostAsServer(ok); err != nil {
|
||
t.Errorf("eigene Adresse abgelehnt: %s (%v)", ok, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestSanitizeFilename(t *testing.T) {
|
||
cases := map[string]string{
|
||
`Rechnung "2026".docx`: "Rechnung 2026",
|
||
// Zeilenumbrüche raus – sie könnten eine zweite Kopfzeile erfinden.
|
||
"kaputt\r\nInjected: x.odt": "kaputtInjected: x",
|
||
".odt": ".odt",
|
||
"": "document",
|
||
}
|
||
for in, want := range cases {
|
||
if got := sanitizeFilename(in); got != want {
|
||
t.Errorf("sanitizeFilename(%q) = %q, erwartet %q", in, got, want)
|
||
}
|
||
}
|
||
}
|