Files
Artifacto/internal/server/artifact.go
dtoro 9e3443079f 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.
2026-04-23 13:59:50 +02:00

123 lines
3.0 KiB
Go

package server
import (
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/go-chi/chi/v5"
"git.hubris.network/dtoro/artifacto/internal/auth"
)
type unlockData struct {
Slug string
Error string
}
func (s *Server) getArtifact(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) {
s.render(w, "unlock", unlockData{Slug: slug})
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)
}
func (s *Server) postUnlock(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
art, err := s.store.GetArtifact(slug)
if err != nil || !art.HasPassword {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
vid := s.visitorID(r)
key := slug + "|" + vid
if !s.rateLimiter.Allow(key) {
w.Header().Set("Retry-After", "600")
http.Error(w, "too many attempts, try again in ~10 minutes", http.StatusTooManyRequests)
return
}
ok := auth.VerifyPassword(art.PasswordHash, r.FormValue("password"))
_ = s.store.LogUnlockAttempt(slug, vid, ok, time.Now())
if !ok {
s.render(w, "unlock", unlockData{Slug: slug, Error: "wrong password"})
return
}
s.artifact.SetCookie(w, slug)
http.Redirect(w, r, "/p/"+slug, http.StatusSeeOther)
}
func (s *Server) logView(r *http.Request, slug string) {
now := time.Now()
vid := s.visitorID(r)
ref := refererHost(r.Referer())
_ = s.store.LogView(slug, vid, ref, now)
_ = s.store.TouchView(slug, now)
}
func refererHost(raw string) string {
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return ""
}
return strings.ToLower(u.Host)
}