package cabana import ( "bytes" "fmt" "net/http" "regexp" "git.golem15.com/golem15/summercms/modules/bouncer" "github.com/yuin/goldmark" ) var ( markdownEngine = goldmark.New() markdownUnsafeTag = regexp.MustCompile(`(?i)<(?:script|iframe|object|embed)\b`) markdownEventHandler = regexp.MustCompile(`(?i)\son[a-z]+\s*=`) markdownDangerousURL = regexp.MustCompile(`(?i)(?:javascript|vbscript|data):`) ) // RenderMarkdown converts source to HTML using the pinned goldmark engine // without html.WithUnsafe. Output that still contains script/iframe tags, // event handlers, or javascript/vbscript/data URLs is rejected, matching // postcard's mail HTML gate. func RenderMarkdown(src string) (string, error) { var buf bytes.Buffer if err := markdownEngine.Convert([]byte(src), &buf); err != nil { return "", fmt.Errorf("cabana: markdown: %w", err) } html := buf.String() if err := rejectUnsafeMarkdownHTML(html); err != nil { return "", err } return html, nil } func rejectUnsafeMarkdownHTML(html string) error { if markdownUnsafeTag.MatchString(html) { return fmt.Errorf("cabana: rendered HTML contains raw unsafe tags") } if markdownEventHandler.MatchString(html) { return fmt.Errorf("cabana: rendered HTML contains event handlers") } if markdownDangerousURL.MatchString(html) { return fmt.Errorf("cabana: rendered HTML contains a dangerous URL scheme") } return nil } // msgMarkdownPreviewRefused is the fixed 422 detail of a preview whose // rendered HTML the RenderMarkdown gate refuses. The renderer's error text is // never written. const msgMarkdownPreviewRefused = "The rendered HTML contains a script or iframe tag, an event handler, or a javascript, vbscript or data URL and cannot be previewed." // AdminMarkdownPreviewRequest is the body of POST /markdown/preview: the // markdown source of a form field. type AdminMarkdownPreviewRequest struct { Markdown string `json:"markdown"` } // AdminMarkdownPreviewResult is the data of a POST /markdown/preview answer: // the HTML RenderMarkdown produced for the source. type AdminMarkdownPreviewResult struct { HTML string `json:"html"` } // markdownPreview serves POST /markdown/preview: it renders the source // through RenderMarkdown for the admin form preview. Any signed-in backend // administrator may call it; it reads and writes no records. Output the // RenderMarkdown gate refuses is a 422 on markdown with a fixed message. func (s *service) markdownPreview(w http.ResponseWriter, r *http.Request) { principal, ok := bouncer.User(r.Context()) if !ok || principal == nil || !principal.Backend { WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) return } var body AdminMarkdownPreviewRequest if err := s.decodeStrictBody(w, r, &body); err != nil { writeCRUDError(w, err) return } html, err := RenderMarkdown(body.Markdown) if err != nil { WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", map[string]any{"markdown": []string{msgMarkdownPreviewRefused}}) return } WriteData(w, http.StatusOK, AdminMarkdownPreviewResult{HTML: html}, nil) }