diff --git a/internal/server/artifact.go b/internal/server/artifact.go index 15b031b..23f8e18 100644 --- a/internal/server/artifact.go +++ b/internal/server/artifact.go @@ -33,17 +33,42 @@ func (s *Server) getArtifact(w http.ResponseWriter, r *http.Request) { return } + s.render(w, "artifact_wrapper", struct{ Slug string }{Slug: slug}) + + // Log view asynchronously. Failures are non-fatal. + go s.logView(r, slug) +} + +func (s *Server) getArtifactRaw(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + art, err := s.store.GetArtifact(slug) + if err != nil { + http.NotFound(w, r) + return + } + if art.ExpiresAt != nil && time.Now().After(*art.ExpiresAt) { + http.NotFound(w, r) + return + } + if art.HasPassword && !s.artifact.Verify(r, slug) { + http.NotFound(w, r) + return + } + body, err := os.ReadFile(s.store.ArtifactPath(slug)) if err != nil { http.Error(w, "artifact missing on disk", http.StatusInternalServerError) return } + // CSP: sandbox — if this URL is loaded directly (not via the wrapper + // iframe), the browser applies the same restrictions as an iframe + // sandbox, preventing artifact JS from reading cookies or making + // same-origin credentialed requests to the admin surface. + w.Header().Set("Content-Security-Policy", + "sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals") w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(body) - - // Log view asynchronously. Failures are non-fatal. - go s.logView(r, slug) } func (s *Server) postUnlock(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/server.go b/internal/server/server.go index 5d5c4ea..3df247b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -75,8 +75,12 @@ func (s *Server) Handler() http.Handler { r.Get("/login", s.getLogin) r.Post("/login", s.postLogin) - // Artifact serving (public; password gating is per-artifact) + // Artifact serving (public; password gating is per-artifact). + // /p/{slug} renders a sandbox-iframe wrapper; /p/{slug}/raw serves the + // artifact HTML itself with CSP: sandbox so cookies + same-origin XHR + // are unavailable to artifact JS whether loaded via iframe or directly. r.Get("/p/{slug}", s.getArtifact) + r.Get("/p/{slug}/raw", s.getArtifactRaw) r.Post("/p/{slug}/unlock", s.postUnlock) // Admin-only routes @@ -127,8 +131,9 @@ func (s *Server) RollupLoop(stop <-chan struct{}) { func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Note: no CSP on /p/{slug} since artifact HTML often uses inline scripts + CDNs. - if !strings.HasPrefix(r.URL.Path, "/p/") { + // The raw artifact endpoint sets its own CSP: sandbox; skip ours here. + isRaw := strings.HasPrefix(r.URL.Path, "/p/") && strings.HasSuffix(r.URL.Path, "/raw") + if !isRaw { w.Header().Set("Content-Security-Policy", "default-src 'self'; "+ "script-src 'self' https://unpkg.com https://cdn.tailwindcss.com 'unsafe-inline'; "+ diff --git a/internal/server/templates/artifact_wrapper.html b/internal/server/templates/artifact_wrapper.html new file mode 100644 index 0000000..ece9a60 --- /dev/null +++ b/internal/server/templates/artifact_wrapper.html @@ -0,0 +1,18 @@ + + +
+ + +