- POST {prefix}/api/v1/markdown/preview renders {markdown} through
cabana.RenderMarkdown in the backend-guarded group behind requireAjax
- refused output is a 422 validation_failed on markdown with a fixed message
- swag annotation, regenerated admin.json and schema.d.ts
- route inventories, CSRF walk (26) and OpenAPI conformance learn the route
- README and docs/backend/forms.md document the route
90 lines
3.1 KiB
Go
90 lines
3.1 KiB
Go
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)
|
|
}
|