1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
|
package main
import (
"context"
"io"
"net/http"
"slices"
"strconv"
"strings"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/gomuks/pkg/hicli"
"go.mau.fi/gomuks/pkg/hicli/jsoncmd"
)
// This file implements an authenticated media proxy: the browser cannot fetch
// mxc:// URIs itself (they need the access token, and encrypted attachments need
// the per-file key), so webchat downloads and — when necessary — decrypts on the
// browser's behalf.
//
// Why this is written by hand rather than reused: hicli caches only the
// *metadata* needed to fetch media (mime type, file name, and the AES key in
// `media.enc_file`), never the bytes — the table's hash/size columns stay NULL.
// gomuks does have a full downloading+thumbnailing media cache, but it lives in
// pkg/gomuks, which is AGPL-3.0; webchat only imports the MPL-2.0 pkg/hicli (see
// the licence note in main.go). So the download path is ours.
//
// Consequence: there is no server-side byte cache. Every browser cache miss is a
// fresh homeserver download. That is fine for a single-user chat and keeps
// decrypted attachments off local disk entirely; immutable caching headers mean
// the browser won't re-request in practice.
const (
// mxc URIs are content-addressed: a given URI always resolves to the same
// bytes, so the response can be cached indefinitely.
mediaCacheControl = "public, max-age=31536000, immutable"
// Attachments are attacker-controlled content served from webchat's own
// origin, so they get the same lockdown gomuks applies: no scripts, no
// subresources, no same-origin privileges beyond playing the media itself.
mediaCSP = "sandbox; default-src 'none'; script-src 'none'; media-src 'self';"
// fallbackUploadLimit is used when the homeserver does not advertise
// m.upload.size. The field is optional in the spec, and "absent" means "no
// documented limit" rather than "zero" — treating it as zero would reject
// every upload.
fallbackUploadLimit = 50 << 20 // 50 MiB
// maxImageDimension bounds the width/height the client may claim. Purely a
// sanity check on client-supplied numbers; real images are far smaller.
maxImageDimension = 100000
)
// uploadableMimes is what paste-to-send accepts. This is deliberately narrower
// than hicli's safeMimes: webchat only supports pasting images, and an
// allowlist is the only defence against a client-supplied Content-Type, which
// gets recorded in the room for every other client to act on.
var uploadableMimes = []string{
"image/png", "image/jpeg", "image/gif", "image/webp", "image/avif",
}
// fetchUploadLimit asks the homeserver how large an upload it accepts.
//
// This endpoint requires authentication (augsburg.one answers M_MISSING_TOKEN
// without a token), so it has to go through hicli's authenticated client rather
// than being fetched from the browser.
func fetchUploadLimit(ctx context.Context, cli *hicli.HiClient, log zerolog.Logger) int64 {
cfg, err := cli.API.GetMediaConfig(ctx)
if err != nil {
log.Warn().Err(err).
Int64("fallback", fallbackUploadLimit).
Msg("could not fetch homeserver media config, assuming fallback upload limit")
return fallbackUploadLimit
}
// m.upload.size is `omitempty`: absent means the server documents no limit.
if cfg.UploadSize <= 0 {
log.Info().
Int64("fallback", fallbackUploadLimit).
Msg("homeserver advertises no upload limit, assuming fallback")
return fallbackUploadLimit
}
log.Info().Int64("upload_limit", cfg.UploadSize).Msg("homeserver upload limit")
return cfg.UploadSize
}
// handleUpload takes a pasted image, encrypts it, uploads it to the homeserver
// and sends it to the room as an m.image message.
//
// Route: POST /room/{roomID}/upload?w=<px>&h=<px>&filename=<name>
//
// The image is expected to already fit within the homeserver's limit: the
// browser downscales oversized pastes before posting, because attachment
// encryption is length-preserving (AES-CTR) and so the server cannot shrink
// anything after the fact without a decrypt/re-encode/re-encrypt round trip.
// The cap is still enforced here — the browser is not trusted, it is merely
// cooperative.
func (v *roomView) handleUpload(w http.ResponseWriter, r *http.Request) {
mimeType := r.Header.Get("Content-Type")
if !slices.Contains(uploadableMimes, mimeType) {
http.Error(w, "unsupported image type: "+mimeType, http.StatusUnsupportedMediaType)
return
}
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, v.s.uploadLimit))
if err != nil {
// MaxBytesReader signals an over-large body as a read error.
http.Error(w, "image too large", http.StatusRequestEntityTooLarge)
return
}
if len(data) == 0 {
http.Error(w, "empty body", http.StatusBadRequest)
return
}
// Dimensions come from the browser, which has already decoded the image to
// display the paste. They are advisory metadata (other clients use them to
// size a placeholder), so they only need to be plausible, not trusted.
width := parseDimension(r.URL.Query().Get("w"))
height := parseDimension(r.URL.Query().Get("h"))
fileName := r.URL.Query().Get("filename")
if fileName == "" {
fileName = "image"
}
// Encrypt before upload: the homeserver only ever sees ciphertext. The key
// travels inside the (separately encrypted) event content.
encFile := attachment.NewEncryptedFile()
size := len(data)
// In-place, so `data` is ciphertext afterwards. This also fills in the
// SHA-256 that the receiving client checks.
encFile.EncryptInPlace(data)
resp, err := v.s.cli.Client.UploadBytesWithName(r.Context(), data, "application/octet-stream", fileName)
if err != nil {
v.s.log.Err(err).Msg("upload media")
http.Error(w, "upload failed", http.StatusBadGateway)
return
}
content := &event.MessageEventContent{
MsgType: event.MsgImage,
Body: fileName,
FileName: fileName,
File: &event.EncryptedFileInfo{
EncryptedFile: *encFile,
URL: resp.ContentURI.CUString(),
},
Info: &event.FileInfo{
MimeType: mimeType,
Size: size,
Width: width,
Height: height,
},
}
// Text must stay empty: SendMessage overwrites base_content's body from it,
// and hicli would treat a leading slash as a command.
evt, err := v.s.cli.API.SendMessage(r.Context(), &jsoncmd.SendMessageParams{
RoomID: v.roomID,
BaseContent: content,
})
if err != nil {
v.s.log.Err(err).Msg("send image message")
http.Error(w, "send failed", http.StatusInternalServerError)
return
}
// As with text messages, the SSE echo is what actually renders it; hicli's
// send path also registers the MXC in the media table, so the media proxy
// can serve it straight back.
writeJSON(w, map[string]any{"ok": true, "event_id": evt.ID})
}
// parseDimension reads a pixel count, returning 0 for anything implausible so
// the field is simply omitted from the event rather than carrying nonsense.
func parseDimension(s string) int {
n, err := strconv.Atoi(s)
if err != nil || n <= 0 || n > maxImageDimension {
return 0
}
return n
}
// handleMedia serves a single Matrix attachment, decrypting it if needed.
//
// Route: GET /media/{server}/{fileID}
func (s *server) handleMedia(w http.ResponseWriter, r *http.Request) {
mxc := id.ContentURI{
Homeserver: r.PathValue("server"),
FileID: r.PathValue("fileID"),
}
if !mxc.IsValid() {
http.Error(w, "invalid mxc uri", http.StatusBadRequest)
return
}
// This lookup is the access control for the whole endpoint. hicli records a
// media entry for every attachment it sees in a room we are in, so "is this
// MXC in the media table" answers "have we legitimately seen this file".
// Without the check, anyone who can reach the port could use webchat as an
// open proxy for arbitrary media on arbitrary homeservers, authenticated
// with our access token.
cached, err := s.cli.DB.Media.Get(r.Context(), mxc)
if err != nil {
s.log.Err(err).Stringer("mxc", mxc).Msg("look up media")
http.Error(w, "media lookup failed", http.StatusInternalServerError)
return
}
if cached == nil {
http.NotFound(w, r)
return
}
// Validate key material *before* downloading or writing any header: once a
// 200 is on the wire the response can no longer be turned into an error.
if cached.EncFile != nil {
if err := cached.EncFile.PrepareForDecryption(); err != nil {
s.log.Err(err).Stringer("mxc", mxc).Msg("prepare media decryption")
http.Error(w, "decryption failed", http.StatusInternalServerError)
return
}
}
resp, err := s.cli.Client.Download(r.Context(), mxc)
if err != nil {
// A cancelled request is routine (scrolling past a lazy image, closing a
// tab), so it must not be logged as an upstream failure.
if r.Context().Err() != nil {
return
}
s.log.Err(err).Stringer("mxc", mxc).Msg("download media")
http.Error(w, "download failed", http.StatusBadGateway)
return
}
defer resp.Body.Close()
// For encrypted attachments the plaintext is streamed rather than buffered,
// so memory stays flat regardless of attachment size. The tradeoff is that
// the SHA-256 integrity check only completes on Close(), i.e. after the bytes
// have already been written to the client — see the check after io.Copy.
body := io.Reader(resp.Body)
if cached.EncFile != nil {
body = cached.EncFile.DecryptStream(resp.Body)
}
mimeType := cached.MimeType
if mimeType == "" {
mimeType = resp.Header.Get("Content-Type")
}
if mimeType == "" {
mimeType = "application/octet-stream"
}
w.Header().Set("Content-Type", mimeType)
// Never let the browser second-guess the type: sniffing a text/plain
// attachment into HTML would be an XSS vector on our own origin.
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", mediaCSP)
w.Header().Set("Cache-Control", mediaCacheControl)
disposition := cached.ContentDisposition()
if cached.FileName != "" {
disposition += "; filename=" + quoteFilename(cached.FileName)
}
w.Header().Set("Content-Disposition", disposition)
// Only forward an upstream length for unencrypted files: the ciphertext
// length does not necessarily match the plaintext length.
if cached.EncFile == nil && resp.ContentLength > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
}
if _, err := io.Copy(w, body); err != nil {
s.log.Debug().Err(err).Stringer("mxc", mxc).Msg("stream media")
return
}
// Closing the decrypting reader verifies the attachment hash from the event.
// A mismatch means the bytes just served did not match what the sender
// committed to; the response cannot be retracted at this point, so all we
// can do is say so loudly.
if closer, ok := body.(io.Closer); ok {
if err := closer.Close(); err != nil {
s.log.Error().Err(err).Stringer("mxc", mxc).
Msg("attachment hash mismatch — served bytes may be tampered with")
}
}
}
// quoteFilename renders a filename for a Content-Disposition header, escaping
// the characters that would otherwise let it break out of the quoted string.
func quoteFilename(name string) string {
var b strings.Builder
b.WriteByte('"')
for _, r := range name {
switch r {
case '"', '\\':
b.WriteByte('\\')
b.WriteRune(r)
case '\r', '\n':
// Dropped outright: a bare CRLF would allow header injection.
default:
b.WriteRune(r)
}
}
b.WriteByte('"')
return b.String()
}
// mediaURL is the browser-facing path for an mxc URI, or "" if the URI is
// unusable.
func mediaURL(uri id.ContentURIString) string {
parsed := uri.ParseOrIgnore()
if !parsed.IsValid() {
return ""
}
return "/media/" + parsed.Homeserver + "/" + parsed.FileID
}
|