| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "crypto/rand" |
| 6 | "encoding/hex" |
| 7 | "errors" |
| 8 | "io" |
| 9 | "mime" |
| 10 | "net/http" |
| 11 | "net/url" |
| 12 | "os" |
| 13 | "path/filepath" |
| 14 | "strings" |
| 15 | "sync" |
| 16 | "time" |
| 17 | |
| 18 | "golang.org/x/net/html" |
| 19 | ) |
| 20 | |
| 21 | type mediaDependency struct { |
| 22 | absPath string |
| 23 | identity os.FileInfo |
| 24 | filename string |
| 25 | mime string |
| 26 | modTime time.Time |
| 27 | } |
| 28 | |
| 29 | type mediaTokenEntry struct { |
| 30 | absPath string |
| 31 | identity os.FileInfo |
| 32 | filename string |
| 33 | mime string |
| 34 | kind string |
| 35 | size int64 |
| 36 | modTime time.Time |
| 37 | markdownImage bool |
| 38 | createdAt time.Time |
| 39 | expiresAt time.Time |
| 40 | dependencies map[string]mediaDependency |
| 41 | } |
| 42 | |
| 43 | type mediaTokenStore struct { |
| 44 | mu sync.Mutex |
| 45 | byTok map[string]*mediaTokenEntry |
| 46 | order []string |
| 47 | maxN int |
| 48 | ttl time.Duration |
| 49 | } |
| 50 | |
| 51 | const mediaTokenMax = 256 |
| 52 | |
| 53 | const ( |
| 54 | htmlPreviewDependencyMaxBytes = 4 << 20 |
| 55 | htmlPreviewBundleMaxBytes = 32 << 20 |
| 56 | htmlPreviewDependencyMaxCount = 64 |
| 57 | ) |
| 58 | |
| 59 | func newMediaTokenStore() *mediaTokenStore { |
| 60 | return &mediaTokenStore{ |
| 61 | byTok: map[string]*mediaTokenEntry{}, |
| 62 | maxN: mediaTokenMax, |
| 63 | ttl: 10 * time.Minute, |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | func (s *mediaTokenStore) cleanupLocked() { |
| 68 | now := time.Now() |
| 69 | active := s.order[:0] |
| 70 | for _, tok := range s.order { |
| 71 | e := s.byTok[tok] |
| 72 | if e == nil { |
| 73 | continue |
| 74 | } |
| 75 | if !now.Before(e.expiresAt) { |
| 76 | delete(s.byTok, tok) |
| 77 | continue |
| 78 | } |
| 79 | active = append(active, tok) |
| 80 | } |
| 81 | s.order = active |
| 82 | for len(s.order) > s.maxN { |
| 83 | oldest := s.order[0] |
| 84 | delete(s.byTok, oldest) |
| 85 | s.order = s.order[1:] |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func (s *mediaTokenStore) extend(token string, ttl time.Duration) bool { |
| 90 | s.mu.Lock() |
| 91 | defer s.mu.Unlock() |
| 92 | s.cleanupLocked() |
| 93 | entry := s.byTok[token] |
| 94 | if entry == nil { |
| 95 | return false |
| 96 | } |
| 97 | entry.expiresAt = time.Now().Add(ttl) |
| 98 | return true |
| 99 | } |
| 100 | |
| 101 | func (s *mediaTokenStore) revoke(token string) { |
| 102 | s.mu.Lock() |
| 103 | defer s.mu.Unlock() |
| 104 | delete(s.byTok, token) |
| 105 | } |
| 106 | |
| 107 | func (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string { |
| 108 | return s.createWithPolicy(absPath, filename, mime, kind, size, modTime, false, nil) |
| 109 | } |
| 110 | |
| 111 | func (s *mediaTokenStore) createMarkdownImage(absPath, filename, mime string, identity os.FileInfo) string { |
| 112 | return s.createWithPolicy(absPath, filename, mime, "image", identity.Size(), identity.ModTime(), true, identity) |
| 113 | } |
| 114 | |
| 115 | func (s *mediaTokenStore) createHTML(absPath, allowedRoot, filename, mimeType string, identity os.FileInfo) (string, error) { |
| 116 | dependencies, err := collectHTMLPreviewDependencies(absPath, allowedRoot, identity.Size()) |
| 117 | if err != nil { |
| 118 | return "", err |
| 119 | } |
| 120 | token := s.createWithPolicy(absPath, filename, mimeType, "html", identity.Size(), identity.ModTime(), false, identity) |
| 121 | s.mu.Lock() |
| 122 | if entry := s.byTok[token]; entry != nil { |
| 123 | entry.dependencies = dependencies |
| 124 | } |
| 125 | s.mu.Unlock() |
| 126 | return token, nil |
| 127 | } |
| 128 | |
| 129 | func collectHTMLPreviewDependencies(absPath, allowedRoot string, rootSize int64) (map[string]mediaDependency, error) { |
| 130 | if rootSize > htmlPreviewBundleMaxBytes { |
| 131 | return nil, errors.New("HTML preview exceeds the 32 MiB bundle budget") |
| 132 | } |
| 133 | f, err := os.Open(absPath) |
| 134 | if err != nil { |
| 135 | return nil, err |
| 136 | } |
| 137 | defer f.Close() |
| 138 | rootReal, err := filepath.EvalSymlinks(allowedRoot) |
| 139 | if err != nil { |
| 140 | return nil, err |
| 141 | } |
| 142 | rootReal, err = filepath.Abs(rootReal) |
| 143 | if err != nil { |
| 144 | return nil, err |
| 145 | } |
| 146 | baseDir := filepath.Dir(absPath) |
| 147 | dependencies := map[string]mediaDependency{} |
| 148 | total := rootSize |
| 149 | z := html.NewTokenizer(io.LimitReader(f, htmlPreviewBundleMaxBytes+1)) |
| 150 | for { |
| 151 | tokenType := z.Next() |
| 152 | if tokenType == html.ErrorToken { |
| 153 | if errors.Is(z.Err(), io.EOF) { |
| 154 | break |
| 155 | } |
| 156 | return nil, z.Err() |
| 157 | } |
| 158 | if tokenType != html.StartTagToken && tokenType != html.SelfClosingTagToken { |
| 159 | continue |
| 160 | } |
| 161 | tok := z.Token() |
| 162 | attrName := "" |
| 163 | switch strings.ToLower(tok.Data) { |
| 164 | case "script", "img", "source", "audio", "video": |
| 165 | attrName = "src" |
| 166 | case "link": |
| 167 | attrName = "href" |
| 168 | default: |
| 169 | continue |
| 170 | } |
| 171 | for _, attr := range tok.Attr { |
| 172 | if strings.ToLower(attr.Key) != attrName { |
| 173 | continue |
| 174 | } |
| 175 | ref, parseErr := url.Parse(strings.TrimSpace(attr.Val)) |
| 176 | if parseErr != nil { |
| 177 | return nil, errors.New("HTML preview contains an invalid local resource URL") |
| 178 | } |
| 179 | if ref.Scheme != "" || ref.Host != "" || ref.Path == "" || strings.HasPrefix(ref.Path, "//") { |
| 180 | continue |
| 181 | } |
| 182 | if strings.HasPrefix(ref.Path, "/") { |
| 183 | return nil, errors.New("HTML preview contains an unsupported root-relative local resource: " + ref.Path) |
| 184 | } |
| 185 | decoded := ref.Path |
| 186 | if strings.ContainsRune(decoded, 0) { |
| 187 | return nil, errors.New("HTML preview contains an invalid local resource path") |
| 188 | } |
| 189 | key := filepath.ToSlash(filepath.Clean(filepath.FromSlash(decoded))) |
| 190 | if key == "." || key == ".." || strings.HasPrefix(key, "../") || filepath.IsAbs(key) { |
| 191 | return nil, errors.New("HTML preview resource leaves the document directory") |
| 192 | } |
| 193 | if _, exists := dependencies[key]; exists { |
| 194 | continue |
| 195 | } |
| 196 | if len(dependencies) >= htmlPreviewDependencyMaxCount { |
| 197 | return nil, errors.New("HTML preview references more than 64 local resources") |
| 198 | } |
| 199 | candidate := filepath.Join(baseDir, filepath.FromSlash(key)) |
| 200 | finalInfo, lstatErr := os.Lstat(candidate) |
| 201 | if lstatErr != nil { |
| 202 | return nil, errors.New("HTML preview resource is missing: " + key) |
| 203 | } |
| 204 | if finalInfo.Mode()&os.ModeSymlink != 0 || !finalInfo.Mode().IsRegular() { |
| 205 | return nil, errors.New("HTML preview resource is not a regular file: " + key) |
| 206 | } |
| 207 | candidateReal, evalErr := filepath.EvalSymlinks(candidate) |
| 208 | if evalErr != nil { |
| 209 | return nil, evalErr |
| 210 | } |
| 211 | candidateReal, evalErr = filepath.Abs(candidateReal) |
| 212 | if evalErr != nil { |
| 213 | return nil, evalErr |
| 214 | } |
| 215 | relative, relErr := filepath.Rel(rootReal, candidateReal) |
| 216 | if relErr != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { |
| 217 | return nil, errors.New("HTML preview resource leaves the authorized root: " + key) |
| 218 | } |
| 219 | if finalInfo.Size() > htmlPreviewDependencyMaxBytes { |
| 220 | return nil, errors.New("HTML preview resource exceeds 4 MiB: " + key) |
| 221 | } |
| 222 | total += finalInfo.Size() |
| 223 | if total > htmlPreviewBundleMaxBytes { |
| 224 | return nil, errors.New("HTML preview bundle exceeds 32 MiB") |
| 225 | } |
| 226 | depMIME := mime.TypeByExtension(strings.ToLower(filepath.Ext(candidateReal))) |
| 227 | if depMIME == "" { |
| 228 | depMIME = "application/octet-stream" |
| 229 | } |
| 230 | dependencies[key] = mediaDependency{absPath: candidateReal, identity: finalInfo, filename: filepath.Base(candidateReal), mime: depMIME, modTime: finalInfo.ModTime()} |
| 231 | } |
| 232 | } |
| 233 | return dependencies, nil |
| 234 | } |
| 235 | |
| 236 | func (s *mediaTokenStore) createWithPolicy(absPath, filename, mime, kind string, size int64, modTime time.Time, markdownImage bool, identity os.FileInfo) string { |
| 237 | s.mu.Lock() |
| 238 | defer s.mu.Unlock() |
| 239 | s.cleanupLocked() |
| 240 | |
| 241 | tok := make([]byte, 16) |
| 242 | if _, err := rand.Read(tok); err != nil { |
| 243 | panic("crypto/rand.Read failed: " + err.Error()) |
| 244 | } |
| 245 | token := hex.EncodeToString(tok) |
| 246 | now := time.Now() |
| 247 | if identity == nil { |
| 248 | identity, _ = os.Stat(absPath) |
| 249 | } |
| 250 | s.byTok[token] = &mediaTokenEntry{ |
| 251 | absPath: absPath, identity: identity, filename: filename, mime: mime, kind: kind, |
| 252 | size: size, modTime: modTime, markdownImage: markdownImage, createdAt: now, expiresAt: now.Add(s.ttl), |
| 253 | } |
| 254 | s.order = append(s.order, token) |
| 255 | |
| 256 | for len(s.order) > s.maxN { |
| 257 | oldest := s.order[0] |
| 258 | delete(s.byTok, oldest) |
| 259 | s.order = s.order[1:] |
| 260 | } |
| 261 | return token |
| 262 | } |
| 263 | |
| 264 | func readValidatedMarkdownImageSnapshot(f *os.File, mimeType string, size int64) ([]byte, error) { |
| 265 | if size <= 0 { |
| 266 | return nil, errors.New("empty markdown image") |
| 267 | } |
| 268 | if size > remoteMarkdownImageMaxBytes { |
| 269 | return nil, errMarkdownImageTooLarge |
| 270 | } |
| 271 | body, err := io.ReadAll(io.LimitReader(f, remoteMarkdownImageMaxBytes+1)) |
| 272 | if err != nil { |
| 273 | return nil, err |
| 274 | } |
| 275 | if len(body) > remoteMarkdownImageMaxBytes { |
| 276 | return nil, errMarkdownImageTooLarge |
| 277 | } |
| 278 | snapshot, detected := safeRemoteMarkdownImage(body) |
| 279 | if detected == "" || detected != mimeType { |
| 280 | return nil, errors.New("markdown image MIME does not match the authorized file") |
| 281 | } |
| 282 | if err := validateMarkdownImageBytes(snapshot, detected); err != nil { |
| 283 | return nil, err |
| 284 | } |
| 285 | return snapshot, nil |
| 286 | } |
| 287 | |
| 288 | func (s *mediaTokenStore) get(token string) *mediaTokenEntry { |
| 289 | s.mu.Lock() |
| 290 | defer s.mu.Unlock() |
| 291 | e := s.byTok[token] |
| 292 | if e == nil { |
| 293 | return nil |
| 294 | } |
| 295 | if time.Now().After(e.expiresAt) { |
| 296 | delete(s.byTok, token) |
| 297 | return nil |
| 298 | } |
| 299 | return e |
| 300 | } |
| 301 | |
| 302 | func (a *App) ensureMediaTokenStore() *mediaTokenStore { |
| 303 | a.mu.Lock() |
| 304 | defer a.mu.Unlock() |
| 305 | if a.mediaTokens == nil { |
| 306 | a.mediaTokens = newMediaTokenStore() |
| 307 | } |
| 308 | return a.mediaTokens |
| 309 | } |
| 310 | |
| 311 | // workspaceMediaMiddleware serves only files whose identity still matches the |
| 312 | // regular file authorized when the short-lived token was minted. |
| 313 | func (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler { |
| 314 | return func(next http.Handler) http.Handler { |
| 315 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 316 | const prefix = "/__reasonix_workspace_media/" |
| 317 | if !strings.HasPrefix(r.URL.Path, prefix) { |
| 318 | next.ServeHTTP(w, r) |
| 319 | return |
| 320 | } |
| 321 | if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| 322 | w.WriteHeader(http.StatusMethodNotAllowed) |
| 323 | return |
| 324 | } |
| 325 | |
| 326 | parts := strings.SplitN(strings.TrimPrefix(r.URL.Path, prefix), "/", 2) |
| 327 | if len(parts) == 0 || parts[0] == "" { |
| 328 | http.NotFound(w, r) |
| 329 | return |
| 330 | } |
| 331 | entry := a.ensureMediaTokenStore().get(parts[0]) |
| 332 | if entry == nil { |
| 333 | http.NotFound(w, r) |
| 334 | return |
| 335 | } |
| 336 | |
| 337 | path := entry.absPath |
| 338 | identity := entry.identity |
| 339 | filename := entry.filename |
| 340 | mimeType := entry.mime |
| 341 | modTime := entry.modTime |
| 342 | isRoot := true |
| 343 | if len(parts) == 2 { |
| 344 | requested := parts[1] |
| 345 | if strings.ContainsRune(requested, 0) { |
| 346 | http.NotFound(w, r) |
| 347 | return |
| 348 | } |
| 349 | requested = filepath.ToSlash(filepath.Clean(filepath.FromSlash(requested))) |
| 350 | if requested != entry.filename { |
| 351 | dependency, found := entry.dependencies[requested] |
| 352 | if !found { |
| 353 | http.NotFound(w, r) |
| 354 | return |
| 355 | } |
| 356 | path, identity, filename, mimeType, modTime = dependency.absPath, dependency.identity, dependency.filename, dependency.mime, dependency.modTime |
| 357 | isRoot = false |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | f, err := os.Open(path) |
| 362 | if err != nil { |
| 363 | http.NotFound(w, r) |
| 364 | return |
| 365 | } |
| 366 | defer f.Close() |
| 367 | opened, err := f.Stat() |
| 368 | if err != nil || identity == nil || !opened.Mode().IsRegular() || !os.SameFile(identity, opened) { |
| 369 | http.NotFound(w, r) |
| 370 | return |
| 371 | } |
| 372 | content := io.ReadSeeker(f) |
| 373 | if entry.markdownImage { |
| 374 | snapshot, snapshotErr := readValidatedMarkdownImageSnapshot(f, entry.mime, opened.Size()) |
| 375 | if snapshotErr != nil { |
| 376 | if errors.Is(snapshotErr, errMarkdownImageTooLarge) { |
| 377 | http.Error(w, "markdown image exceeds the decode budget", http.StatusRequestEntityTooLarge) |
| 378 | } else { |
| 379 | http.Error(w, "markdown image is invalid", http.StatusUnsupportedMediaType) |
| 380 | } |
| 381 | return |
| 382 | } |
| 383 | content = bytes.NewReader(snapshot) |
| 384 | } |
| 385 | |
| 386 | w.Header().Set("Content-Type", mimeType) |
| 387 | w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": filename})) |
| 388 | w.Header().Set("X-Content-Type-Options", "nosniff") |
| 389 | w.Header().Set("Cache-Control", "private, max-age=600") |
| 390 | if entry.kind == "image" { |
| 391 | w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox") |
| 392 | w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") |
| 393 | w.Header().Set("Referrer-Policy", "no-referrer") |
| 394 | } else if entry.kind == "html" && isRoot { |
| 395 | w.Header().Set("Content-Security-Policy", "default-src 'self' https: data: blob:; script-src 'self' https: 'unsafe-inline'; style-src 'self' https: 'unsafe-inline'; img-src 'self' https: data: blob:; media-src 'self' https: data: blob:; connect-src https:; frame-src https:; object-src 'none'; base-uri 'none'; form-action 'none'; sandbox allow-scripts") |
| 396 | w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") |
| 397 | w.Header().Set("Referrer-Policy", "no-referrer") |
| 398 | } |
| 399 | http.ServeContent(w, r, filename, modTime, content) |
| 400 | }) |
| 401 | } |
| 402 | } |
| 403 |