From b492e79f2b05fbdcc84c975aa0d813070abb96fc Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 6 Oct 2026 20:53:07 +0200 Subject: [PATCH] feat(cabana): add markdown preview admin route - 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 --- admin/openapi/admin.json | 124 ++++++++++++ admin/src/api/schema.d.ts | 99 ++++++++++ docs/backend/forms.md | 2 +- modules/cabana/README.md | 6 +- modules/cabana/admin_openapi.go | 18 ++ modules/cabana/field_markdown.go | 43 ++++ modules/cabana/http.go | 2 + modules/cabana/markdown_preview_route_test.go | 33 ++++ modules/cabana/markdown_preview_test.go | 183 ++++++++++++++++++ modules/cabana/openapi_conformance_test.go | 3 + modules/cabana/phase10_coverage_test.go | 3 +- modules/cabana/phase10_csrf_test.go | 6 +- modules/cabana/security_coverage_test.go | 1 + 13 files changed, 517 insertions(+), 6 deletions(-) create mode 100644 modules/cabana/markdown_preview_route_test.go create mode 100644 modules/cabana/markdown_preview_test.go diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index b51aa26..7cf22fa 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -105,6 +105,28 @@ ], "type": "object" }, + "cabana.AdminMarkdownPreviewRequest": { + "properties": { + "markdown": { + "type": "string" + } + }, + "required": [ + "markdown" + ], + "type": "object" + }, + "cabana.AdminMarkdownPreviewResult": { + "properties": { + "html": { + "type": "string" + } + }, + "required": [ + "html" + ], + "type": "object" + }, "cabana.AdminProfile": { "properties": { "email": { @@ -361,6 +383,21 @@ ], "type": "object" }, + "cabana.Envelope-cabana_AdminMarkdownPreviewResult": { + "properties": { + "data": { + "$ref": "#/components/schemas/cabana.AdminMarkdownPreviewResult" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.Envelope-cabana_AdminProfile": { "properties": { "data": { @@ -2211,6 +2248,93 @@ ] } }, + "/markdown/preview": { + "post": { + "description": "Renders the markdown source through cabana.RenderMarkdown (goldmark without unsafe HTML) and answers the sanitized HTML. Output the renderer's gate refuses is a 422 on markdown. Any backend session may call it.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.AdminMarkdownPreviewRequest" + } + } + }, + "description": "Markdown source", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.Envelope-cabana_AdminMarkdownPreviewResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Request Entity Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unprocessable Entity" + } + }, + "security": [ + { + "BackendBearer": [] + } + ], + "summary": "Render a markdown preview", + "tags": [ + "admin" + ] + } + }, "/navigation": { "get": { "responses": { diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index 30e6641..e0b6140 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -279,6 +279,95 @@ export interface paths { patch?: never; trace?: never; }; + "/markdown/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Render a markdown preview + * @description Renders the markdown source through cabana.RenderMarkdown (goldmark without unsafe HTML) and answers the sanitized HTML. Output the renderer's gate refuses is a 422 on markdown. Any backend session may call it. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Markdown source */ + requestBody: { + content: { + "application/json": components["schemas"]["cabana.AdminMarkdownPreviewRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.Envelope-cabana_AdminMarkdownPreviewResult"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/navigation": { parameters: { query?: never; @@ -4240,6 +4329,12 @@ export interface components { "cabana.AdminLogoutData": { status: string; }; + "cabana.AdminMarkdownPreviewRequest": { + markdown: string; + }; + "cabana.AdminMarkdownPreviewResult": { + html: string; + }; "cabana.AdminProfile": { email: string; first_name: string; @@ -4307,6 +4402,10 @@ export interface components { data: components["schemas"]["cabana.AdminLogoutData"]; meta: components["schemas"]["cabana.SuccessMeta"]; }; + "cabana.Envelope-cabana_AdminMarkdownPreviewResult": { + data: components["schemas"]["cabana.AdminMarkdownPreviewResult"]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; "cabana.Envelope-cabana_AdminProfile": { data: components["schemas"]["cabana.AdminProfile"]; meta: components["schemas"]["cabana.SuccessMeta"]; diff --git a/docs/backend/forms.md b/docs/backend/forms.md index f782ad2..e470691 100644 --- a/docs/backend/forms.md +++ b/docs/backend/forms.md @@ -323,7 +323,7 @@ func (MembersController) AdminSetPermissionValues(_ context.Context, field strin ## Markdown and multilingual fields -`type: markdown` edits markdown source on a host text column. The admin SPA shows a source editor and may preview HTML from `cabana.RenderMarkdown`, which uses the pinned goldmark engine without unsafe HTML. Output that still contains a script or iframe tag, an event handler, or a javascript, vbscript or data URL is refused, so translated raw HTML cannot become executable preview content. +`type: markdown` edits markdown source on a host text column. The admin SPA shows a source editor and may preview HTML from `cabana.RenderMarkdown`, which uses the pinned goldmark engine without unsafe HTML. Output that still contains a script or iframe tag, an event handler, or a javascript, vbscript or data URL is refused, so translated raw HTML cannot become executable preview content. `POST /api/v1/markdown/preview` renders a `{markdown}` source through `cabana.RenderMarkdown` for any signed-in administrator and answers `{html}`, or a 422 `validation_failed` on `markdown` when the output is refused. `type: mltext` and `type: mlmarkdown` reuse the ordinary text and markdown editors with a locale selector. `mlmarkdown` composes the markdown control rather than a second parser. Each ML field shows its own selector; changing one selector changes every ML control on the form. Selector options come from `cabana.FormMeta.EnabledLocales` on the form schema (filled from `cabana.TranslationWriter.EnabledLocales` after Lookup; `FormSchema.Localize` stays cache-only). Create seeds `{[code]: ""}` for every enabled code. A GET or save of a host scalar is merged onto that seed so sibling locales are not dropped. diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 6c7904f..ba0bee1 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -24,7 +24,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte - Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: ` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot. - Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms. - Permission editor: a `type: permissioneditor` field with `mode: radio` (allow `1`, inherit, deny `-1`) or `mode: checkbox` (allow `1`) edits a record's permission set as a JSON object of code to integer. The controller implements `cabana.PermissionEditorProvider`: it returns the offered `cabana.PermissionOption` list per request (served on the field as `permissionOptions`, with `locked` for permissions the administrator may not change) and reads and stores the record's values, so the storage shape is the plugin's. A save answers 422 on the field for a value that is not an object of integers, a code that is not offered or a value outside the mode's set, and 403 `forbidden` when a locked code's value changes; stored codes that are not offered are kept. The widget fill contract is unchanged: a widget still writes scalar fields only. -- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected. `type: mltext` and `type: mlmarkdown` take a JSON object of locale code to string. The form schema's `cabana.FormMeta.EnabledLocales` lists every enabled code when a `cabana.TranslationWriter` is published; create seeds an empty string per code, and Show/save replace the host scalar with that map via `hydrateMLRecord` and `TranslationWriter.TranslatedExact` (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. Relation-child create, update and show on `cabana.RelationService` use the same lift, apply and `hydrateMLRecord` path as controller save, still with no standalone translate-write route. An unknown field type, including an unknown `ml*` type, fails boot. +- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected; the admin form preview renders through it via POST `/markdown/preview`. `type: mltext` and `type: mlmarkdown` take a JSON object of locale code to string. The form schema's `cabana.FormMeta.EnabledLocales` lists every enabled code when a `cabana.TranslationWriter` is published; create seeds an empty string per code, and Show/save replace the host scalar with that map via `hydrateMLRecord` and `TranslationWriter.TranslatedExact` (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. Relation-child create, update and show on `cabana.RelationService` use the same lift, apply and `hydrateMLRecord` path as controller save, still with no standalone translate-write route. An unknown field type, including an unknown `ml*` type, fails boot. - Preset fields: `preset` on a `type: text` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text field of the same form on the create screen until the administrator edits it. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field. - Server-rendered partials: `headerPartial: ` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: ` in `fields.yaml` render the template `{ConfigDir}/_.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot. - Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation. @@ -48,6 +48,7 @@ All paths are relative to `/api/v1`. A controller ID `vendor.plugin.cont | POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator. | | GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open. | | GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. | +| POST `/markdown/preview` | Render `{markdown}` through `cabana.RenderMarkdown` for the form preview and answer `{html}`; a refused output is a 422 `validation_failed` on `markdown`. Needs any backend session. | | GET `/{vendor}/{plugin}/{controller}/schema/list`, `.../schema/form`, `.../schema/relation/{name}` | Localized list, form and relation schemas. | | GET and POST `/{vendor}/{plugin}/{controller}` | List records; create a record (needs a form and `create` in `toolbar.buttons`). | | GET, PUT and DELETE `/{vendor}/{plugin}/{controller}/{id}` | Show, update and delete a record (update and delete need a form). | @@ -236,6 +237,9 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | `cabana.ThumbOptions` | A fileupload field's `thumbOptions` (the preview thumbnail `mode`). | | `cabana.FileMutationResult` | Answer of the file removal route: `removed`. | | `cabana.AdminFileCaptionRequest` | Body of the file caption route: optional `title` and `description`. Unknown keys are refused. | +| `cabana.AdminMarkdownPreviewRequest` | Body of the markdown preview route: the `markdown` source. Unknown keys are refused. | +| `cabana.AdminMarkdownPreviewResult` | Answer of the markdown preview route: the `html` that `cabana.RenderMarkdown` produced. | +| `cabana.AdminMarkdownPreview` | Swag annotation of the markdown preview route. | | `cabana.AdminFileList` / `cabana.AdminFileUpload` / `cabana.AdminFileUpdate` / `cabana.AdminFileRemove` / `cabana.AdminFileReorder` / `cabana.AdminFileDownload` / `cabana.AdminFileThumb` | Swag annotations of the file routes. | | `cabana.AdminRelationChildFileList` / `cabana.AdminRelationChildFileUpload` / `cabana.AdminRelationChildFileUpdate` / `cabana.AdminRelationChildFileRemove` / `cabana.AdminRelationChildFileReorder` / `cabana.AdminRelationChildFileDownload` / `cabana.AdminRelationChildFileThumb` | Swag annotations of the relation child file routes. | | `cabana.PartialView` / `cabana.PartialNode` | A rendered partial: a list of nodes, each an allowlisted element (`tag`, `attrs`, `children`) or a text node (`text`). | diff --git a/modules/cabana/admin_openapi.go b/modules/cabana/admin_openapi.go index 6f77c8c..6edbec7 100644 --- a/modules/cabana/admin_openapi.go +++ b/modules/cabana/admin_openapi.go @@ -245,6 +245,24 @@ func AdminSettingsGet() {} // @Router /settings/{code} [put] func AdminSettingsPut() {} +// AdminMarkdownPreview documents POST /markdown/preview. +// +// @Summary Render a markdown preview +// @Description Renders the markdown source through cabana.RenderMarkdown (goldmark without unsafe HTML) and answers the sanitized HTML. Output the renderer's gate refuses is a 422 on markdown. Any backend session may call it. +// @Tags admin +// @Accept json +// @Produce json +// @Security BackendBearer +// @Param body body AdminMarkdownPreviewRequest true "Markdown source" +// @Success 200 {object} Envelope[AdminMarkdownPreviewResult] +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 413 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /markdown/preview [post] +func AdminMarkdownPreview() {} + // AdminListSchema documents the list schema route. // // @Summary Admin list schema diff --git a/modules/cabana/field_markdown.go b/modules/cabana/field_markdown.go index e8e9ff7..95ac6b7 100644 --- a/modules/cabana/field_markdown.go +++ b/modules/cabana/field_markdown.go @@ -3,8 +3,10 @@ package cabana import ( "bytes" "fmt" + "net/http" "regexp" + "git.golem15.com/golem15/summercms/modules/bouncer" "github.com/yuin/goldmark" ) @@ -44,3 +46,44 @@ func rejectUnsafeMarkdownHTML(html string) error { } 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) +} diff --git a/modules/cabana/http.go b/modules/cabana/http.go index 6d63351..bc31ba0 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -237,6 +237,8 @@ func (s *service) mount(r pact.Router) { r.GroupRaw(api, []string{"backend"}, func(g pact.Router) { g.Get("/auth/me", s.me) g.Get("/navigation", s.navigation) + // Form previews render through RenderMarkdown; nothing is stored. + g.Post("/markdown/preview", requireAjax(s.markdownPreview)) g.Get("/settings", s.settingsList) g.Get("/settings/{code}/schema", s.settingsSchema) constrainSetting(g) diff --git a/modules/cabana/markdown_preview_route_test.go b/modules/cabana/markdown_preview_route_test.go new file mode 100644 index 0000000..245b628 --- /dev/null +++ b/modules/cabana/markdown_preview_route_test.go @@ -0,0 +1,33 @@ +package cabana_test + +import ( + "net/http" + "strings" + "testing" +) + +// TestMarkdownPreviewRoute drives POST /markdown/preview through the +// assembled router: without credentials the backend guard answers 401, and a +// signed-in administrator gets the sanitized rendering with raw script tags +// stripped by the renderer. +func TestMarkdownPreviewRoute(t *testing.T) { + env := newConformEnv(t) + + anon := env.send(t, http.MethodPost, "/markdown/preview", map[string]string{"markdown": "# Hello"}, false) + if anon.Code != http.StatusUnauthorized { + t.Fatalf("anonymous status=%d body=%s", anon.Code, anon.Body.String()) + } + + env.loginAs(t, env.login) + rec := env.send(t, http.MethodPost, "/markdown/preview", map[string]string{"markdown": "# Hello\n\n"}, true) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + body := strings.ToLower(rec.Body.String()) + if strings.Contains(body, "Hello") { + t.Fatalf("html=%q", got.HTML) + } + + empty := httptest.NewRecorder() + (&service{}).markdownPreview(empty, previewRequest(`{"markdown":""}`, previewBackend())) + if empty.Code != http.StatusOK { + t.Fatalf("empty status=%d body=%s", empty.Code, empty.Body.String()) + } + if got := decodePreview(t, empty.Body.Bytes()); got.HTML != "" { + t.Fatalf("empty html=%q", got.HTML) + } +} + +func TestMarkdownPreviewStripsUnsafeHTML(t *testing.T) { + needles := []string{"alert(1)", true, "`, false, "`, false, "onerror"}, + {"javascript", "[x](javascript:alert(1))", false, "javascript:"}, + {"vbscript", "[x](vbscript:msgbox(1))", false, "vbscript:"}, + {"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)", false, "data:"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + (&service{}).markdownPreview(rec, previewRequest(previewJSON(t, tc.src), previewBackend())) + switch rec.Code { + case http.StatusOK: + html := strings.ToLower(decodePreview(t, rec.Body.Bytes()).HTML) + for _, needle := range needles { + if strings.Contains(html, needle) { + t.Fatalf("unsafe %q survived: %s", needle, html) + } + } + case http.StatusUnprocessableEntity: + if tc.must200 { + t.Fatalf("%s must be stripped and answered 200, got 422: %s", tc.name, rec.Body.String()) + } + code, details := previewDetails(t, rec.Body.Bytes()) + if code != "validation_failed" || len(details["markdown"]) == 0 { + t.Fatalf("code=%q details=%v", code, details) + } + if strings.Contains(strings.ToLower(rec.Body.String()), tc.mustMiss) { + t.Fatalf("refusal echoes %q: %s", tc.mustMiss, rec.Body.String()) + } + default: + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + }) + } +} + +func TestMarkdownPreviewRefusesGatedOutput(t *testing.T) { + rec := httptest.NewRecorder() + (&service{}).markdownPreview(rec, previewRequest(previewJSON(t, "see data: here"), previewBackend())) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + code, details := previewDetails(t, rec.Body.Bytes()) + if code != "validation_failed" || len(details["markdown"]) == 0 || details["markdown"][0] == "" { + t.Fatalf("code=%q details=%v", code, details) + } + if strings.Contains(rec.Body.String(), "cabana:") || strings.Contains(rec.Body.String(), "dangerous URL scheme") { + t.Fatalf("refusal echoes the renderer error: %s", rec.Body.String()) + } +} + +func TestMarkdownPreviewRefusesInvalidBodies(t *testing.T) { + for name, body := range map[string]string{ + "unknown key": `{"markdown":"x","extra":1}`, + "malformed": `{"markdown":`, + "trailing": `{"markdown":"x"} {}`, + } { + t.Run(name, func(t *testing.T) { + rec := httptest.NewRecorder() + (&service{}).markdownPreview(rec, previewRequest(body, previewBackend())) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + assertErrorCode(t, rec.Body.Bytes(), "validation_failed") + }) + } + + big := httptest.NewRecorder() + (&service{defaultBytes: 64}).markdownPreview(big, previewRequest(previewJSON(t, strings.Repeat("a", 200)), previewBackend())) + if big.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized status=%d body=%s", big.Code, big.Body.String()) + } + assertErrorCode(t, big.Body.Bytes(), "payload_too_large") +} diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go index b9ca926..254d2c4 100644 --- a/modules/cabana/openapi_conformance_test.go +++ b/modules/cabana/openapi_conformance_test.go @@ -160,6 +160,9 @@ func TestPhase10OpenAPIConformance(t *testing.T) { {"PUT /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodPut, "/settings/conform", map[string]any{"enabled": true}, true) }, into[cabana.Envelope[cabana.SettingsResult]](), nil}, + {"POST /markdown/preview", 200, "cabana.Envelope-cabana_AdminMarkdownPreviewResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodPost, "/markdown/preview", map[string]string{"markdown": "# Conform"}, true) + }, into[cabana.Envelope[cabana.AdminMarkdownPreviewResult]](), nil}, {"GET /{vendor}/{plugin}/{controller}/schema/list", 200, "cabana.Envelope-cabana_ListSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { rec := e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/list", nil, true) var body cabana.Envelope[cabana.ListSchema] diff --git a/modules/cabana/phase10_coverage_test.go b/modules/cabana/phase10_coverage_test.go index b235721..582f66c 100644 --- a/modules/cabana/phase10_coverage_test.go +++ b/modules/cabana/phase10_coverage_test.go @@ -94,6 +94,7 @@ func TestPhase10Coverage(t *testing.T) { "POST /auth/login", "POST /auth/logout", "POST /auth/refresh", + "POST /markdown/preview", "POST /{vendor}/{plugin}/{controller}", "POST /{vendor}/{plugin}/{controller}/bulk-delete", "POST /{vendor}/{plugin}/{controller}/bulk/{action}", @@ -116,7 +117,7 @@ func TestPhase10Coverage(t *testing.T) { "PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}", } if strings.Join(unsafe, "\n") != strings.Join(want, "\n") { - t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 25 besides login):\n%s", strings.Join(unsafe, "\n")) + t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 26 besides login):\n%s", strings.Join(unsafe, "\n")) } // The routes added in Phase 10 are safe reads: GET /lang and the shared // nested pattern serving field options, filter options and relation lists. diff --git a/modules/cabana/phase10_csrf_test.go b/modules/cabana/phase10_csrf_test.go index 200124a..825416d 100644 --- a/modules/cabana/phase10_csrf_test.go +++ b/modules/cabana/phase10_csrf_test.go @@ -66,14 +66,14 @@ func TestPhase10CSRF(t *testing.T) { } }) } - // refresh, logout, settings put, create, bulk-delete, bulk action, + // refresh, logout, markdown preview, settings put, create, bulk-delete, bulk action, // widget action, toolbar action, update, delete, record action, link, // unlink, file // upload, file reorder, file caption, file remove, relation child // create, update and delete, pivot update, child file upload, reorder, // caption and remove - if unsafe != 25 { - t.Fatalf("walked %d state-changing routes, want 25: %v", unsafe, router.order) + if unsafe != 26 { + t.Fatalf("walked %d state-changing routes, want 26: %v", unsafe, router.order) } loginHandler := router.handlers[login] diff --git a/modules/cabana/security_coverage_test.go b/modules/cabana/security_coverage_test.go index a03ce71..3d2901f 100644 --- a/modules/cabana/security_coverage_test.go +++ b/modules/cabana/security_coverage_test.go @@ -44,6 +44,7 @@ var phase09Routes = []adminRoute{ {key: "GET /settings/{code}/schema"}, {key: "GET /settings/{code}"}, {key: "PUT /settings/{code}"}, + {key: "POST /markdown/preview"}, {key: "GET /{vendor}/{plugin}/{controller}/schema/list"}, {key: "GET /{vendor}/{plugin}/{controller}/schema/form"}, {key: "GET /{vendor}/{plugin}/{controller}/schema/relation/{name}"},