Sandbox artifact rendering via iframe + CSP
Splits /p/{slug} into a trusted wrapper (HTML with a sandboxed iframe)
and /p/{slug}/raw (the artifact itself, served with Content-Security-Policy:
sandbox). Artifact JS now runs in an opaque origin and can't read admin
cookies or make same-origin credentialed requests to /a/* or /api/*.
Password gating is enforced on both routes so /raw can't be used to bypass
the unlock flow.
This commit is contained in:
@@ -33,17 +33,42 @@ func (s *Server) getArtifact(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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))
|
body, err := os.ReadFile(s.store.ArtifactPath(slug))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "artifact missing on disk", http.StatusInternalServerError)
|
http.Error(w, "artifact missing on disk", http.StatusInternalServerError)
|
||||||
return
|
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.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Write(body)
|
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) {
|
func (s *Server) postUnlock(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -75,8 +75,12 @@ func (s *Server) Handler() http.Handler {
|
|||||||
r.Get("/login", s.getLogin)
|
r.Get("/login", s.getLogin)
|
||||||
r.Post("/login", s.postLogin)
|
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}", s.getArtifact)
|
||||||
|
r.Get("/p/{slug}/raw", s.getArtifactRaw)
|
||||||
r.Post("/p/{slug}/unlock", s.postUnlock)
|
r.Post("/p/{slug}/unlock", s.postUnlock)
|
||||||
|
|
||||||
// Admin-only routes
|
// Admin-only routes
|
||||||
@@ -127,8 +131,9 @@ func (s *Server) RollupLoop(stop <-chan struct{}) {
|
|||||||
|
|
||||||
func securityHeaders(next http.Handler) http.Handler {
|
func securityHeaders(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Note: no CSP on /p/{slug} since artifact HTML often uses inline scripts + CDNs.
|
// The raw artifact endpoint sets its own CSP: sandbox; skip ours here.
|
||||||
if !strings.HasPrefix(r.URL.Path, "/p/") {
|
isRaw := strings.HasPrefix(r.URL.Path, "/p/") && strings.HasSuffix(r.URL.Path, "/raw")
|
||||||
|
if !isRaw {
|
||||||
w.Header().Set("Content-Security-Policy",
|
w.Header().Set("Content-Security-Policy",
|
||||||
"default-src 'self'; "+
|
"default-src 'self'; "+
|
||||||
"script-src 'self' https://unpkg.com https://cdn.tailwindcss.com 'unsafe-inline'; "+
|
"script-src 'self' https://unpkg.com https://cdn.tailwindcss.com 'unsafe-inline'; "+
|
||||||
|
|||||||
18
internal/server/templates/artifact_wrapper.html
Normal file
18
internal/server/templates/artifact_wrapper.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>artifact</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; padding: 0; height: 100%; background: #fff; }
|
||||||
|
iframe { display: block; width: 100%; height: 100vh; border: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<iframe sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals"
|
||||||
|
src="/p/{{.Slug}}/raw"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
title="artifact"></iframe>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user