tinyrelaycommit

Commit 4cfc8b0000c7

browse files at this commit | history

1diff --git a/docs/extensions/custom-view-transforms.md b/docs/extensions/custom-view-transforms.md
2index e546019..c761045 100644
3--- a/docs/extensions/custom-view-transforms.md
4+++ b/docs/extensions/custom-view-transforms.md
5@@ -71,18 +71,18 @@ A successful endpoint returns a 2xx status and JSON such as:
6 {"artifacts":[{"block":2,"type":"image/svg+xml","body":"<svg ...></svg>","engine":"mermaid"}],"errors":[{"block":5,"error":"unknown shape"}]}
7 ```
8
9-`artifacts` and `errors` are arrays of the shown objects. No extra response fields are required; unknown object fields are ignored. The body read is limited to `4 * (4 MiB) + 1 MiB`; truncated/malformed JSON is a run failure. `block` must identify a block actually sent. Unsent-block responses and invalid artifacts are refused individually, not treated as transport failures. Missing artifacts leave code (or a prior artifact on refresh) available. `errors` is counted for diagnostics, not a signed decision or automatic retry directive.
10+`artifacts` and `errors` are arrays of the shown objects. No extra response fields are required; unknown object fields are ignored. The body read is limited to `4 * (4 MiB) + 1 MiB`; truncated/malformed JSON is a run failure. `block` must identify a block actually sent. Unsent-block responses, duplicate successful blocks and invalid artifacts are refused individually. A response with no valid artifacts is a run failure. Missing artifacts leave code (or a prior artifact on refresh) available. `errors` is counted for diagnostics, not a signed decision or automatic retry directive.
11
12 ### Artifact validation
13
14-[`checkArtifact`, `checkSVG` and `storeArtifact`](../../internal/daemon/custom_views_transform.go) enforce:
15+[`checkArtifact` and `checkSVG`](../../internal/daemon/custom_views_validation.go) validate the media before [`storeArtifact`](../../internal/daemon/custom_views_transform.go) saves it:
16
17-- Exact type `image/svg+xml` or `image/png`.
18-- SVG body length at most `max_bytes`. It must contain `<svg` case-insensitively. Regex checks reject script elements, `on...=` handlers, foreign objects and `href`/`xlink:href` targets other than empty strings or internal `#` fragments. The original body is rejected or retained, not sanitized.
19-- PNG body is trimmed Base64 (standard alphabet, padded or unpadded). Decoded length must fit `max_bytes` and begin with the PNG signature. Storage normalizes it to padded Base64. This is a signature-prefix check, not complete PNG decoding or validation.
20+- The type must be exactly `image/svg+xml` or `image/png`.
21+- SVG body length must fit `max_bytes`. XML must contain one SVG root. Scripts, event handlers, embedded documents, animation, document type declarations and stylesheet instructions, external link targets and external CSS `url()` references or imports are rejected. Links may target internal `#` fragments. CSS escapes and nested elements within styles are refused. The original body is retained without rewriting.
22+- PNG uses trimmed Base64 with the standard alphabet, padded or unpadded. Decoded bytes must fit `max_bytes`, the image must decode successfully, and dimensions must total at most 16,777,216 pixels. Storage normalizes Base64 padding.
23 - `engine` is optional, trimmed and truncated to 64 bytes before recording.
24
25-The SVG checks are not an XML/CSS parser or proof that every external-load syntax is absent. Consumers must retain the sandbox boundary below. No guarantee of a valid render, safe arbitrary SVG execution or faithful interpretation of source code follows from acceptance. A valid JSON/2xx run resets failures and records `ok` even if every returned artifact is refused or `errors` is nonempty; check actual artifact availability rather than status alone.
26+SVG is served under the sandbox policy below. Acceptance does not authorize arbitrary SVG execution or attest to the transform's interpretation of source code. A run records `ok` when all requested blocks produce valid artifacts, `partial` when some do, or `no valid artifacts` when none do. Partial success clears the failure count; no valid artifacts follows the normal retry and pause rules.
27
28 ## Signed records and media routes
29
30@@ -109,7 +109,7 @@ Write views capture registrations and queue optional work after source acceptanc
31
32 Hourly/manual runs consider at most the newest 500 events of the selected kinds; an hourly pass applies a last-run time window with overlap. This is not an unbounded backfill or guarantee to process every event. Transforms serialize per view. Before a POST, the worker checks enabled state, registration generation and whether the source is still current. Those checks do not recall an in-flight request.
33
34-Non-2xx, timeout, connection/read failure or invalid JSON produces a failed run. Two retries follow, after one minute and five minutes, for three attempts total. Twenty consecutive failures pause the view; success/resume clears the counter. Reasons include `HTTP <status>`, `timeout`, `connection failed`, `read failed` and `invalid response`. Management validation uses `invalid:`, authority uses `restricted: only the owner may ...`, and missing definitions use `not found: view`. See the [transport rules](README.md#shared-transport-boundary).
35+Non-2xx, timeout, connection/read failure, invalid JSON or no valid artifacts produces a failed run. Two retries follow, after one minute and five minutes, for three attempts total. Twenty consecutive failures pause the view; success/resume clears the counter. Reasons include `HTTP <status>`, `timeout`, `connection failed`, `read failed` and `invalid response`. Management validation uses `invalid:`, authority uses `restricted: only the owner may ...`, and missing definitions use `not found: view`. See the [transport rules](README.md#shared-transport-boundary).
36
37 Discover management names with `supportedmethods`, MCP schemas with `tools/list`, and definitions with the owner-only list method/tool. The frontend receives only enabled view names/languages through `CustomViews`; that is presentation data, not endpoint/secret disclosure or authority to configure the view. There is no transform version capability or handshake.
38
39@@ -119,4 +119,4 @@ Discover management names with `supportedmethods`, MCP schemas with `tools/list`
40 - [`custom_views_test.go`](../../internal/daemon/custom_views_test.go): `TestCustomViewManagementIsOwnerOnlyAndValidated`, `TestCustomViewTransformSendsBlocksAndKeepsSignedArtifacts`, replacement/reuse/rebuild, retries, artifact validation, members-only storage, hourly runs, repository README and MCP tools.
41 - [`custom_views_targets_test.go`](../../internal/daemon/custom_views_targets_test.go) tests recreated registration rejection and stable generation backfill; [`custom_views_media_test.go`](../../internal/daemon/custom_views_media_test.go) tests extensionless media routes.
42
43-Repeated identical blocks in one source share a `(view,hash,source)` record with one block index, not one entry per occurrence. There is no explicit duplicate-artifact response rejection, complete PNG/SVG parser, per-source audience inheritance or full remote-renderer conformance suite. These limits, the kind 30618 source-address disclosure and the `ok`/artifact-success distinction are intentional audit disclosures, not newly introduced wire behavior.
44+Repeated identical blocks in one source share a `(view,hash,source)` record with one block index. Tests cover response validation and status accounting in [`custom_views_validation_test.go`](../../internal/daemon/custom_views_validation_test.go). Remote renderers remain responsible for rendering source code correctly.
45diff --git a/docs/views.md b/docs/views.md
46index 36b7d67..e6c7c7f 100644
47--- a/docs/views.md
48+++ b/docs/views.md
49@@ -55,7 +55,7 @@ The relay also sends the equivalent `X-Tiny-View`, `X-Tiny-Relay` and `X-Tiny-Si
50 {"artifacts": [{"block": 2, "type": "image/svg+xml", "body": "<svg ...>", "engine": "mermaid"}], "errors": [{"block": 5, "error": "unknown shape on line 3"}]}
51 ```
52
53-Each artifact names the block it rendered, its `type`, either `image/svg+xml` or `image/png`, and its `body`, the SVG source or a base64 PNG. `engine` is optional and is recorded on the artifact. An artifact larger than the view's `max_bytes` is refused. SVG bodies are screened with regular expressions for script elements, event handlers, foreign objects and certain external-reference attributes. Matching artifacts are rejected, not sanitized. These checks are not a complete XML/CSS security validator; PNG validation checks its signature prefix rather than decoding the complete image. A block listed under `errors`, or left out of the answer entirely, keeps showing its code.
54+Each artifact names the block it rendered, its `type`, either `image/svg+xml` or `image/png`, and its `body`, the SVG source or a base64 PNG. `engine` is optional and is recorded on the artifact. An artifact larger than the view's `max_bytes` is refused. SVG bodies must be well-formed XML with one SVG root. The relay rejects scripts, event handlers, embedded documents, animation and external references in links and CSS. PNG bodies must decode successfully and contain no more than 16,777,216 pixels. Invalid artifacts are rejected, not repaired. A block listed under `errors`, or left out of the answer entirely, keeps showing its code.
55
56 SVG artifacts must include their own colors and background rules. Page styles do not reach into the SVG document. Diagramzip's `/transform/blocks` endpoint defaults to `auto-transparent`, which omits the canvas and includes light and dark palettes. The diagram follows the page's color scheme. After changing a transform's styling, rebuild the stored artifacts to apply the change.
57
58@@ -71,7 +71,7 @@ Each artifact is served at `/views/<name>/<hash>` with its stored media type, un
59
60 A valid event is accepted even when its optional transform work cannot be planned immediately. The relay makes up to three background planning attempts within 15 minutes, rechecks the source and view, and skips work that is no longer relevant. Recovery fills missing work without restarting completed transforms or backfilling views registered after the event.
61
62-A response outside 2xx, a timeout or a connection failure counts as a failure. The relay tries the event again after one minute and once more after five, three attempts in all, then drops it. After 20 failures in a row the view is paused and its status records the reason. A valid JSON response with a 2xx status resets the failure count and can record `ok` even if every artifact is rejected. Check the actual artifact before treating a run as a successful render.
63+A response outside 2xx, a timeout or a connection failure counts as a failure. The relay tries the event again after one minute and once more after five, three attempts in all, then drops it. After 20 failures in a row the view is paused and its status records the reason. A response with no valid artifacts also counts as a failure. A run records `ok` when every requested block produces a valid artifact, or `partial` when only some do. Both clear the failure count; missing blocks keep showing their source.
64
65 ## Manage views
66
67diff --git a/internal/daemon/custom_views_media_test.go b/internal/daemon/custom_views_media_test.go
68index 69a2677..aa505d6 100644
69--- a/internal/daemon/custom_views_media_test.go
70+++ b/internal/daemon/custom_views_media_test.go
71@@ -17,7 +17,7 @@ func TestCustomViewMediaPathServesStoredType(t *testing.T) {
72 artifact := svgArtifact(0)
73 artifact["type"] = mediaType
74 if mediaType == "image/png" {
75- artifact["body"] = "iVBORw0KGgoAAAANSUhEUg=="
76+ artifact["body"] = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
77 }
78 server.answer(http.StatusOK, artifact)
79 e := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "```mermaid\ngraph TD; a-->b;\n```")
80diff --git a/internal/daemon/custom_views_test.go b/internal/daemon/custom_views_test.go
81index 4febce3..480e6a9 100644
82--- a/internal/daemon/custom_views_test.go
83+++ b/internal/daemon/custom_views_test.go
84@@ -26,7 +26,7 @@ import (
85
86 const (
87 testSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>`
88- testPNGRaw = "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
89+ testPNGRaw = "\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x04\x00\x00\x00\xb5\x1c\x0c\x02\x00\x00\x00\x0bIDATx\xda\x63\x64\xf8\x0f\x00\x01\x05\x01\x01\x27\x18\xe3\x66\x00\x00\x00\x00IEND\xaeB\x60\x82"
90 )
91
92 // transformServer stands in for a view transform: it records every request
93@@ -740,14 +740,21 @@ func TestCustomViewArtifactValidation(t *testing.T) {
94 {"png size", viewArtifact{Type: "image/png", Body: png}, 4, "invalid: artifact exceeds"},
95 {"png base64", viewArtifact{Type: "image/png", Body: "not base64!"}, 1024, "invalid: png body must be base64"},
96 {"png signature", viewArtifact{Type: "image/png", Body: base64.StdEncoding.EncodeToString([]byte("GIF89a"))}, 1024, "invalid: png body is not a PNG"},
97+ {"png truncated", viewArtifact{Type: "image/png", Body: png[:len(png)-8]}, 1024, "invalid: png body is not a valid PNG"},
98 {"svg element", viewArtifact{Type: "image/svg+xml", Body: "<div/>"}, 1024, "invalid: svg body has no svg"},
99+ {"svg malformed", viewArtifact{Type: "image/svg+xml", Body: `<svg><rect></svg>`}, 1024, "invalid: svg is not well-formed XML"},
100 {"script", viewArtifact{Type: "image/svg+xml", Body: `<svg><SCRIPT>alert(1)</SCRIPT></svg>`}, 1024, "invalid: svg contains script"},
101 {"handler", viewArtifact{Type: "image/svg+xml", Body: `<svg onload = "x()"/>`}, 1024, "invalid: svg contains an event handler"},
102 {"foreign object", viewArtifact{Type: "image/svg+xml", Body: `<svg><foreignobject/></svg>`}, 1024, "invalid: svg contains a foreign object"},
103 {"javascript href", viewArtifact{Type: "image/svg+xml", Body: `<svg><a href="javascript:alert(1)"/></svg>`}, 1024, "invalid: svg contains a javascript"},
104 {"external href", viewArtifact{Type: "image/svg+xml", Body: `<svg><image href="https://evil.example/x.png"/></svg>`}, 1024, "invalid: svg references an external"},
105+ {"external css", viewArtifact{Type: "image/svg+xml", Body: `<svg><style>.x { fill: url(https://evil.example/x) }</style></svg>`}, 1024, "invalid: svg references an external"},
106 {"external xlink", viewArtifact{Type: "image/svg+xml", Body: `<svg><use xlink:href='/x.svg#a'/></svg>`}, 1024, "invalid: svg references an external"},
107- {"unquoted href", viewArtifact{Type: "image/svg+xml", Body: `<svg><a href=data:text/html,x>`}, 1024, "invalid: svg references an external"},
108+ {"unquoted href", viewArtifact{Type: "image/svg+xml", Body: `<svg><a href=data:text/html,x>`}, 1024, "invalid: svg is not well-formed XML"},
109+ {"css escape", viewArtifact{Type: "image/svg+xml", Body: `<svg><style>.x{fill:u\72l(https://evil.example/x)}</style></svg>`}, 1024, "invalid: svg references an external"},
110+ {"css comment split", viewArtifact{Type: "image/svg+xml", Body: `<svg><style>.x{fill:u/**/rl(https://evil.example/x)}</style></svg>`}, 1024, "invalid: svg references an external"},
111+ {"css import", viewArtifact{Type: "image/svg+xml", Body: `<svg><style>@import "https://evil.example/x.css"</style></svg>`}, 1024, "invalid: svg references an external"},
112+ {"css local palette", viewArtifact{Type: "image/svg+xml", Body: `<svg><style>@media (prefers-color-scheme:dark){.x{fill:url('#dark')}}</style><path class="x" fill="url(#light)"/></svg>`}, 1024, ""},
113 } {
114 _, err := checkArtifact(tc.artifact, tc.max)
115 if tc.want == "" && err != nil {
116diff --git a/internal/daemon/custom_views_transform.go b/internal/daemon/custom_views_transform.go
117index e24b758..d079e80 100644
118--- a/internal/daemon/custom_views_transform.go
119+++ b/internal/daemon/custom_views_transform.go
120@@ -19,7 +19,6 @@ import (
121 "fmt"
122 "io"
123 "net/http"
124- "regexp"
125 "sort"
126 "strconv"
127 "strings"
128@@ -27,10 +26,10 @@ import (
129 "time"
130
131 "github.com/FelineStateMachine/tinyrelay/internal/event"
132- gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
133 "github.com/FelineStateMachine/tinyrelay/internal/replication"
134 "github.com/FelineStateMachine/tinyrelay/internal/views"
135 "github.com/FelineStateMachine/tinyrelay/internal/work"
136+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
137 )
138
139 // viewPayload is the work intent body: the source event, so the run does
140@@ -265,6 +264,7 @@ func (t *customViewService) handleViewTransform(ctx context.Context, intent work
141 if postErr == nil {
142 stored, refused := 0, 0
143 sent := map[int]views.Block{}
144+ storedBlocks := map[int]bool{}
145 for _, block := range pending {
146 sent[block.Index] = block
147 }
148@@ -274,6 +274,10 @@ func (t *customViewService) handleViewTransform(ctx context.Context, intent work
149 refused++
150 continue
151 }
152+ if storedBlocks[artifact.Block] {
153+ refused++
154+ continue
155+ }
156 if err := t.storeArtifact(ctx, view, block, artifact, source, expires, now); err != nil {
157 if strings.HasPrefix(err.Error(), "invalid:") {
158 refused++
159@@ -281,18 +285,22 @@ func (t *customViewService) handleViewTransform(ctx context.Context, intent work
160 }
161 return err
162 }
163+ storedBlocks[artifact.Block] = true
164 stored++
165 }
166- outcome = "ok"
167- if refused > 0 {
168- outcome = "invalid"
169- }
170- if _, err := t.store.DB().ExecContext(ctx, `UPDATE custom_views SET last_run_at=?, last_status='ok', failures=0 WHERE name=?`, now, view.Name); err != nil {
171- return err
172+ if stored > 0 {
173+ outcome = "ok"
174+ if stored < len(pending) {
175+ outcome = "partial"
176+ }
177+ if _, err := t.store.DB().ExecContext(ctx, `UPDATE custom_views SET last_run_at=?, last_status=?, failures=0 WHERE name=?`, now, outcome, view.Name); err != nil {
178+ return err
179+ }
180+ // Counts only: no source, block or body leaves the relay in a log.
181+ t.telemetry.Logger().Info("view transform delivered", "tenant", t.tenantName, "attempt", payload.Attempt, "status", status, "blocks", len(pending), "artifacts", stored, "refused", refused, "errors", len(response.Errors))
182+ return t.pruneViewArtifacts(ctx, view.Name, intent.ID)
183 }
184- // Counts only: no source, block or body leaves the relay in a log.
185- t.telemetry.Logger().Info("view transform delivered", "tenant", t.tenantName, "attempt", payload.Attempt, "status", status, "blocks", len(pending), "artifacts", stored, "refused", refused, "errors", len(response.Errors))
186- return t.pruneViewArtifacts(ctx, view.Name, intent.ID)
187+ postErr = errors.New("no valid artifacts")
188 }
189 reason := postErr.Error()
190 failures := view.Failures + 1
191@@ -390,74 +398,6 @@ func (t *customViewService) postViewTransform(ctx context.Context, view customVi
192 return response.StatusCode, decoded, nil
193 }
194
195-var (
196- svgScript = regexp.MustCompile(`(?i)<script`)
197- svgHandler = regexp.MustCompile(`(?i)\bon[a-z]+\s*=`)
198- svgForeignObject = regexp.MustCompile(`(?i)<foreignObject`)
199- svgReference = regexp.MustCompile(`(?i)(?:xlink:)?href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))`)
200- pngSignature = []byte("\x89PNG\r\n\x1a\n")
201-)
202-
203-// checkArtifact validates a transform's artifact: an accepted type, a body
204-// within the view's size limit, a PNG that decodes from base64 and an SVG
205-// without script, handlers, foreign objects or references outside itself.
206-// It returns the body as stored: the SVG text or the PNG in base64.
207-func checkArtifact(artifact viewArtifact, maxBytes int) (string, error) {
208- switch artifact.Type {
209- case "image/svg+xml":
210- if len(artifact.Body) > maxBytes {
211- return "", errors.New("invalid: artifact exceeds the size limit")
212- }
213- if err := checkSVG(artifact.Body); err != nil {
214- return "", err
215- }
216- return artifact.Body, nil
217- case "image/png":
218- body := strings.TrimSpace(artifact.Body)
219- decoded, err := base64.StdEncoding.DecodeString(body)
220- if err != nil {
221- if decoded, err = base64.RawStdEncoding.DecodeString(body); err != nil {
222- return "", errors.New("invalid: png body must be base64")
223- }
224- }
225- if len(decoded) > maxBytes {
226- return "", errors.New("invalid: artifact exceeds the size limit")
227- }
228- if !bytes.HasPrefix(decoded, pngSignature) {
229- return "", errors.New("invalid: png body is not a PNG")
230- }
231- return base64.StdEncoding.EncodeToString(decoded), nil
232- default:
233- return "", errors.New("invalid: artifact type must be image/svg+xml or image/png")
234- }
235-}
236-
237-func checkSVG(body string) error {
238- if !strings.Contains(strings.ToLower(body), "<svg") {
239- return errors.New("invalid: svg body has no svg element")
240- }
241- if svgScript.MatchString(body) {
242- return errors.New("invalid: svg contains script")
243- }
244- if svgHandler.MatchString(body) {
245- return errors.New("invalid: svg contains an event handler")
246- }
247- if svgForeignObject.MatchString(body) {
248- return errors.New("invalid: svg contains a foreign object")
249- }
250- for _, match := range svgReference.FindAllStringSubmatch(body, -1) {
251- target := strings.TrimSpace(match[1] + match[2] + match[3])
252- if target == "" || strings.HasPrefix(target, "#") {
253- continue
254- }
255- if strings.HasPrefix(strings.ToLower(target), "javascript:") {
256- return errors.New("invalid: svg contains a javascript reference")
257- }
258- return errors.New("invalid: svg references an external target")
259- }
260- return nil
261-}
262-
263 // storeArtifact checks one artifact and keeps it as a signed record
264 // attached to its source.
265 func (t *customViewService) storeArtifact(ctx context.Context, view customView, block views.Block, artifact viewArtifact, source string, expires int64, now int64) error {
266diff --git a/internal/daemon/custom_views_validation.go b/internal/daemon/custom_views_validation.go
267new file mode 100644
268index 0000000..2b96777
269--- /dev/null
270+++ b/internal/daemon/custom_views_validation.go
271@@ -0,0 +1,202 @@
272+package daemon
273+
274+import (
275+ "bytes"
276+ "encoding/base64"
277+ "encoding/xml"
278+ "errors"
279+ "image/png"
280+ "io"
281+ "strings"
282+)
283+
284+var pngSignature = []byte("\x89PNG\r\n\x1a\n")
285+
286+const maxArtifactPixels = int64(16 * 1024 * 1024)
287+
288+func checkArtifact(artifact viewArtifact, maxBytes int) (string, error) {
289+ switch artifact.Type {
290+ case "image/svg+xml":
291+ if len(artifact.Body) > maxBytes {
292+ return "", errors.New("invalid: artifact exceeds the size limit")
293+ }
294+ if err := checkSVG(artifact.Body); err != nil {
295+ return "", err
296+ }
297+ return artifact.Body, nil
298+ case "image/png":
299+ body := strings.TrimSpace(artifact.Body)
300+ decoded, err := base64.StdEncoding.DecodeString(body)
301+ if err != nil {
302+ decoded, err = base64.RawStdEncoding.DecodeString(body)
303+ }
304+ if err != nil {
305+ return "", errors.New("invalid: png body must be base64")
306+ }
307+ if len(decoded) > maxBytes {
308+ return "", errors.New("invalid: artifact exceeds the size limit")
309+ }
310+ if !bytes.HasPrefix(decoded, pngSignature) {
311+ return "", errors.New("invalid: png body is not a PNG")
312+ }
313+ config, err := png.DecodeConfig(bytes.NewReader(decoded))
314+ if err != nil || config.Width <= 0 || config.Height <= 0 {
315+ return "", errors.New("invalid: png body is not a valid PNG")
316+ }
317+ pixels := int64(config.Width) * int64(config.Height)
318+ if int64(config.Width) > maxArtifactPixels || int64(config.Height) > maxArtifactPixels || pixels > maxArtifactPixels {
319+ return "", errors.New("invalid: png dimensions exceed the limit")
320+ }
321+ if _, err := png.Decode(bytes.NewReader(decoded)); err != nil {
322+ return "", errors.New("invalid: png body is not a valid PNG")
323+ }
324+ return base64.StdEncoding.EncodeToString(decoded), nil
325+ default:
326+ return "", errors.New("invalid: artifact type must be image/svg+xml or image/png")
327+ }
328+}
329+
330+func checkSVG(body string) error {
331+ decoder := xml.NewDecoder(strings.NewReader(body))
332+ depth, roots, styleDepth := 0, 0, 0
333+ var style strings.Builder
334+ for {
335+ token, err := decoder.Token()
336+ if errors.Is(err, io.EOF) {
337+ break
338+ }
339+ if err != nil {
340+ return errors.New("invalid: svg is not well-formed XML")
341+ }
342+ switch token := token.(type) {
343+ case xml.Directive:
344+ return errors.New("invalid: svg contains a declaration")
345+ case xml.ProcInst:
346+ if strings.EqualFold(token.Target, "xml-stylesheet") {
347+ return errors.New("invalid: svg contains a stylesheet instruction")
348+ }
349+ case xml.StartElement:
350+ if styleDepth > 0 {
351+ return errors.New("invalid: svg style contains an element")
352+ }
353+ name := strings.ToLower(token.Name.Local)
354+ if depth == 0 {
355+ roots++
356+ if name != "svg" {
357+ return errors.New("invalid: svg body has no svg element")
358+ }
359+ }
360+ if token.Name.Space != "" && token.Name.Space != "http://www.w3.org/2000/svg" {
361+ return errors.New("invalid: svg has an invalid namespace")
362+ }
363+ if name == "script" {
364+ return errors.New("invalid: svg contains script")
365+ }
366+ if name == "foreignobject" {
367+ return errors.New("invalid: svg contains a foreign object")
368+ }
369+ if name == "iframe" || name == "object" || name == "embed" || name == "animate" || name == "set" || name == "animatemotion" || name == "animatetransform" {
370+ return errors.New("invalid: svg contains active content")
371+ }
372+ if name == "style" {
373+ styleDepth = depth + 1
374+ style.Reset()
375+ }
376+ for _, attr := range token.Attr {
377+ attrName := strings.ToLower(attr.Name.Local)
378+ if attrName == "base" && (attr.Name.Space == "xml" || attr.Name.Space == "http://www.w3.org/XML/1998/namespace") {
379+ return errors.New("invalid: svg has an external base")
380+ }
381+ if strings.HasPrefix(attrName, "on") && len(attrName) > 2 {
382+ return errors.New("invalid: svg contains an event handler")
383+ }
384+ value := strings.TrimSpace(attr.Value)
385+ if attrName == "href" || attrName == "src" {
386+ if value == "" || !strings.HasPrefix(value, "#") {
387+ if strings.HasPrefix(strings.ToLower(value), "javascript:") {
388+ return errors.New("invalid: svg contains a javascript reference")
389+ }
390+ return errors.New("invalid: svg references an external target")
391+ }
392+ }
393+ if isCSSAttribute(attrName) && !safeSVGCSSText(value) {
394+ return errors.New("invalid: svg references an external target")
395+ }
396+ }
397+ depth++
398+ case xml.EndElement:
399+ if styleDepth == depth {
400+ if !safeSVGCSSText(style.String()) {
401+ return errors.New("invalid: svg references an external target")
402+ }
403+ styleDepth = 0
404+ }
405+ depth--
406+ case xml.CharData:
407+ if depth == 0 && strings.TrimSpace(string(token)) != "" {
408+ return errors.New("invalid: svg has content outside its root element")
409+ }
410+ if styleDepth > 0 {
411+ style.Write([]byte(token))
412+ }
413+ }
414+ }
415+ if roots != 1 || depth != 0 || styleDepth != 0 {
416+ return errors.New("invalid: svg is not well-formed XML")
417+ }
418+ return nil
419+}
420+
421+func isCSSAttribute(name string) bool {
422+ return name == "style" || strings.Contains(name, "fill") || strings.Contains(name, "stroke") || strings.Contains(name, "filter") || strings.Contains(name, "mask") || strings.Contains(name, "clip-path") || strings.Contains(name, "marker-")
423+}
424+
425+func safeSVGCSSText(value string) bool {
426+ clean, ok := stripCSSComments(value)
427+ if !ok || strings.Contains(clean, "\\") {
428+ return false
429+ }
430+ lower := strings.ToLower(clean)
431+ compact := strings.ReplaceAll(lower, " ", "")
432+ return !strings.Contains(lower, "@import") && !strings.Contains(compact, "@import") && localSVGURLs(clean) && localSVGURLs(compact)
433+}
434+
435+func stripCSSComments(value string) (string, bool) {
436+ var out strings.Builder
437+ for len(value) > 0 {
438+ start := strings.Index(value, "/*")
439+ if start < 0 {
440+ out.WriteString(value)
441+ return out.String(), true
442+ }
443+ out.WriteString(value[:start])
444+ value = value[start+2:]
445+ end := strings.Index(value, "*/")
446+ if end < 0 {
447+ return "", false
448+ }
449+ out.WriteByte(' ')
450+ value = value[end+2:]
451+ }
452+ return out.String(), true
453+}
454+
455+func localSVGURLs(value string) bool {
456+ lower := strings.ToLower(value)
457+ for {
458+ at := strings.Index(lower, "url(")
459+ if at < 0 {
460+ return true
461+ }
462+ end := strings.IndexByte(lower[at+4:], ')')
463+ if end < 0 {
464+ return false
465+ }
466+ target := strings.Trim(strings.TrimSpace(value[at+4:at+4+end]), "\"'")
467+ if !strings.HasPrefix(target, "#") {
468+ return false
469+ }
470+ lower = lower[at+4+end+1:]
471+ value = value[at+4+end+1:]
472+ }
473+}
474diff --git a/internal/daemon/custom_views_validation_test.go b/internal/daemon/custom_views_validation_test.go
475new file mode 100644
476index 0000000..46fb4c3
477--- /dev/null
478+++ b/internal/daemon/custom_views_validation_test.go
479@@ -0,0 +1,117 @@
480+package daemon
481+
482+import (
483+ "context"
484+ "encoding/base64"
485+ "encoding/binary"
486+ "hash/crc32"
487+ "net/http"
488+ "strings"
489+ "testing"
490+ "time"
491+)
492+
493+func TestCustomViewTransformCountsSuccessfulBlocks(t *testing.T) {
494+ for _, tc := range []struct {
495+ name string
496+ artifacts []map[string]any
497+ status string
498+ stored int
499+ failures int
500+ retries int
501+ }{
502+ {"complete", []map[string]any{svgArtifact(0), svgArtifact(1)}, "ok", 2, 0, 0},
503+ {"partial", []map[string]any{svgArtifact(0)}, "partial", 1, 0, 0},
504+ {"duplicate", []map[string]any{svgArtifact(0), svgArtifact(0)}, "partial", 1, 0, 0},
505+ {"invalid", []map[string]any{{"block": 0, "type": "image/png", "body": "invalid"}}, "no valid artifacts", 0, 1, 1},
506+ {"empty", nil, "no valid artifacts", 0, 1, 1},
507+ } {
508+ t.Run(tc.name, func(t *testing.T) {
509+ tenant, server, _ := viewTenant(t, []int{1}, nil)
510+ server.answer(http.StatusOK, tc.artifacts...)
511+ note := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "```mermaid\nA\n```\n\n```mermaid\nB\n```")
512+ if err := publishAs(t, tenant, note); err != nil {
513+ t.Fatal(err)
514+ }
515+ runPendingView(t, tenant, "diagrams", note.ID)
516+ var status string
517+ var failures, stored int
518+ if err := tenant.store.DB().QueryRowContext(context.Background(), `SELECT last_status,failures FROM custom_views WHERE name='diagrams'`).Scan(&status, &failures); err != nil {
519+ t.Fatal(err)
520+ }
521+ if err := tenant.store.DB().QueryRowContext(context.Background(), `SELECT count(*) FROM custom_view_artifacts`).Scan(&stored); err != nil {
522+ t.Fatal(err)
523+ }
524+ retries := len(pendingViewIntents(t, tenant, "diagrams", note.ID))
525+ if status != tc.status || stored != tc.stored || failures != tc.failures || retries != tc.retries {
526+ t.Fatalf("status=%q stored=%d failures=%d retries=%d; want %q %d %d %d", status, stored, failures, retries, tc.status, tc.stored, tc.failures, tc.retries)
527+ }
528+ })
529+ }
530+}
531+
532+func TestCustomViewInvalidArtifactsRetryThenPause(t *testing.T) {
533+ tenant, server, _ := viewTenant(t, []int{1}, nil)
534+ server.answer(http.StatusOK, map[string]any{"block": 0, "type": "image/svg+xml", "body": "<svg><script/></svg>"})
535+ note := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "```mermaid\nA\n```")
536+ if err := publishAs(t, tenant, note); err != nil {
537+ t.Fatal(err)
538+ }
539+ for range 3 {
540+ runPendingView(t, tenant, "diagrams", note.ID)
541+ }
542+ if got := pendingViewIntents(t, tenant, "diagrams", note.ID); len(got) != 0 {
543+ t.Fatalf("invalid artifacts exceeded retry limit: %+v", got)
544+ }
545+ ctx := context.Background()
546+ if _, err := tenant.store.DB().ExecContext(ctx, `UPDATE custom_views SET failures=19 WHERE name='diagrams'`); err != nil {
547+ t.Fatal(err)
548+ }
549+ other := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "```mermaid\nB\n```")
550+ if err := publishAs(t, tenant, other); err != nil {
551+ t.Fatal(err)
552+ }
553+ runPendingView(t, tenant, "diagrams", other.ID)
554+ var failures, enabled int
555+ var status string
556+ if err := tenant.store.DB().QueryRowContext(ctx, `SELECT failures,enabled,last_status FROM custom_views WHERE name='diagrams'`).Scan(&failures, &enabled, &status); err != nil {
557+ t.Fatal(err)
558+ }
559+ if failures != 20 || enabled != 0 || status != "paused after 20 failures: no valid artifacts" {
560+ t.Fatalf("failures=%d enabled=%d status=%q", failures, enabled, status)
561+ }
562+}
563+
564+func TestCustomViewSVGReferenceBoundaries(t *testing.T) {
565+ for _, tc := range []struct {
566+ name, body string
567+ valid bool
568+ }{
569+ {"XML declaration", `<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"/>`, true},
570+ {"literal text", `<svg><text>@import url(example)</text></svg>`, true},
571+ {"CSS comment", `<svg><style>/* colors */ .x{fill:url(#paint)}</style></svg>`, true},
572+ {"style chunks", `<svg><style>.x{fill:u<![CDATA[rl]]>(https://example.com/x)}</style></svg>`, false},
573+ {"nested style", `<svg><style>.x{fill:url(https://example.com/x)}<style/></style></svg>`, false},
574+ {"XML base", `<svg xml:base="https://example.com/"><use href="#secret"/></svg>`, false},
575+ {"attribute import", `<svg style="@import 'https://example.com/a.css'"/>`, false},
576+ {"other namespace", `<svg xmlns="http://www.w3.org/1999/xhtml"/>`, false},
577+ {"stylesheet instruction", `<?xml-stylesheet href="https://example.com/a.css"?><svg/>`, false},
578+ } {
579+ t.Run(tc.name, func(t *testing.T) {
580+ if err := checkSVG(tc.body); (err == nil) != tc.valid {
581+ t.Fatalf("valid=%v, error=%v", tc.valid, err)
582+ }
583+ })
584+ }
585+}
586+
587+func TestCustomViewRejectsOversizedPNGDimensions(t *testing.T) {
588+ image := []byte(testPNGRaw)
589+ binary.BigEndian.PutUint32(image[16:20], 8192)
590+ binary.BigEndian.PutUint32(image[20:24], 8192)
591+ binary.BigEndian.PutUint32(image[29:33], crc32.ChecksumIEEE(image[12:29]))
592+ _, err := checkArtifact(viewArtifact{Type: "image/png", Body: base64.StdEncoding.EncodeToString(image)}, 1024)
593+ if err == nil || !strings.Contains(err.Error(), "dimensions") {
594+ t.Fatalf("oversized PNG error=%v", err)
595+ }
596+}

About

ownerc01503e8035f
visibilitypublic
head4cfc8b0000c7

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs