package handlers import ( "errors" "net/http" "strconv" "github.com/go-chi/chi" "github.com/google/uuid" "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/database" ) // bodyChunkBytes is how much of a stored body is resident at // once while it is being written to the client. The event log // page caps what it renders at maxRenderedBodyBytes, so this // route is the only way to reach a whole body and by // construction serves the largest ones in the system. Reading // it in fixed chunks keeps the peak a property of this constant // rather than of the payload. const bodyChunkBytes = 64 * 1024 // eventBodySizeColumn measures a stored body the same way // eventLogColumns cuts one: the cast to blob makes length count // bytes rather than characters, so Content-Length matches what // substr will actually hand back. const eventBodySizeColumn = "length(cast(body as blob))" // eventBodyChunkQuery reads one byte range of a stored body. // substr over a blob is 1-indexed over bytes. The soft-delete // predicate is spelled out because Raw bypasses GORM's default // scope, and it is what stops a reaped event still being // downloadable. const eventBodyChunkQuery = "SELECT substr(cast(body as blob), ?, ?) " + "FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL" // errShortBodyRead reports that a chunk query returned nothing // while bytes were still owed, which means the row went away // mid-download. var errShortBodyRead = errors.New("stored body ended early") // HandleEventBodyDownload serves one event's stored body in // full, which the event log page cannot: it caps each rendered // body at maxRenderedBodyBytes. // // The bytes are attacker-supplied — anyone who can reach the // public receiver chooses them — and this route hands them back // inside the operator's own authenticated origin, so the // response is deliberately not renderable. Content-Disposition // makes the browser download rather than display it, and the // octet-stream type plus nosniff stop it being interpreted as // HTML or script. Without those a stored payload would execute // as the logged-in operator. The application's CSP does not // help here: script-src allows 'unsafe-inline' from 'self', so // a document served from this origin could run its own inline // script. func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { webhook, ok := h.ownedWebhook(w, r) if !ok { return } // Parsing the id before use serves two purposes: a // malformed id can never reach the SQL or the response // header, and the canonical form below is drawn from // uuid's own fixed alphabet rather than from the // request, so the Content-Disposition value cannot be // steered by a client. eventID, err := uuid.Parse(chi.URLParam(r, "eventID")) if err != nil { http.NotFound(w, r) return } h.serveEventBody(w, r, webhook, eventID.String()) } } // serveEventBody writes the named event's stored body to w. // // The event must belong to webhook, which is what keeps this // route from reading any event in the system by id alone. Two // things enforce that and they are not equally strong. The // operative one is that events live in a per-webhook SQLite // file, so a sibling webhook's event is not in the database // being queried at all. The webhook_id predicate on every query // below is the second guard, and it is currently redundant // against that isolation; it is there so the scoping survives // any future change that puts more than one webhook's events in // one file. func (h *Handlers) serveEventBody( w http.ResponseWriter, r *http.Request, webhook database.Webhook, eventID string, ) { if !h.dbMgr.DBExists(webhook.ID) { http.NotFound(w, r) return } webhookDB, err := h.dbMgr.GetDB(webhook.ID) if err != nil { h.serverError(w, "failed to get webhook database", err) return } size, found, err := eventBodySize(webhookDB, webhook.ID, eventID) if err != nil { h.serverError(w, "failed to size event body", err) return } // A miss is a 404 whether the event belongs to another // webhook or does not exist at all, so the response does // not report which. if !found { http.NotFound(w, r) return } setEventBodyHeaders(w, eventID, size) err = writeEventBody(w, webhookDB, webhook.ID, eventID, size) if err != nil { // The status and Content-Length are already committed, // so the client sees a short download. There is no way // to report a 500 from here; the log is the record. h.log.Error( "failed to write event body", "webhook_id", webhook.ID, "event_id", eventID, "error", err, ) } } // eventBodySize returns the stored size in bytes of an event's // body and whether the event exists within the webhook. The // size is read separately from the body so Content-Length can // be set before any bytes are written. func eventBodySize( webhookDB *gorm.DB, webhookID, eventID string, ) (int64, bool, error) { var size int64 result := webhookDB.Model(&database.Event{}). Select(eventBodySizeColumn). Where( "id = ? AND webhook_id = ?", eventID, webhookID, ). Limit(1). Scan(&size) if result.Error != nil { return 0, false, result.Error } if result.RowsAffected == 0 { return 0, false, nil } return size, true, nil } // setEventBodyHeaders applies the response headers that make // this route safe to hand attacker-supplied bytes through. See // HandleEventBodyDownload for why they are a security control // and not a formatting choice. // // nosniff is also set by the global SecurityHeaders middleware. // It is repeated here so the guarantee belongs to the route // that needs it rather than to a middleware someone could // reorder or scope away. func setEventBodyHeaders( w http.ResponseWriter, eventID string, size int64, ) { w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set( "Content-Disposition", `attachment; filename="webhooker-event-`+eventID+`.bin"`, ) w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) } // writeEventBody copies size bytes of the event's stored body to // w in bodyChunkBytes-sized reads. // // This is where the route earns its memory bound. database/sql // exposes no incremental handle on a SQLite BLOB, so scanning // the column would materialise the whole body regardless of the // wrapper around it; reading byte ranges instead keeps the // resident cost at one chunk. Nothing goes through // renderTemplate, which buffers a whole response before writing // it. // // There is deliberately no wrapping read transaction. These // per-webhook databases run in SQLite's default journal mode, // not WAL, so a read lock held for the length of a slow client's // download would block the receiver from recording new events. // The cost of that choice is that a body deleted mid-download // ends the response short, which is reported as an error rather // than passed off as a complete file. func writeEventBody( w http.ResponseWriter, webhookDB *gorm.DB, webhookID, eventID string, size int64, ) error { // Flushing each chunk keeps the claim above true at the // socket as well as in this loop. A ResponseWriter that // cannot flush is not an error: net/http's own output // buffer is a fixed size either way. flusher := http.NewResponseController(w) for written := int64(0); written < size; { var chunk []byte err := webhookDB.Raw( eventBodyChunkQuery, written+1, bodyChunkBytes, eventID, webhookID, ).Row().Scan(&chunk) if err != nil { return err } if len(chunk) == 0 { return errShortBodyRead } n, err := w.Write(chunk) written += int64(n) if err != nil { return err } _ = flusher.Flush() } return nil }