| 1 | diff --git a/docs/extensions/custom-view-transforms.md b/docs/extensions/custom-view-transforms.md |
| 2 | index 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. |
| 45 | diff --git a/docs/views.md b/docs/views.md |
| 46 | index 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 | |
| 67 | diff --git a/internal/daemon/custom_views_media_test.go b/internal/daemon/custom_views_media_test.go |
| 68 | index 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```") |
| 80 | diff --git a/internal/daemon/custom_views_test.go b/internal/daemon/custom_views_test.go |
| 81 | index 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 { |
| 116 | diff --git a/internal/daemon/custom_views_transform.go b/internal/daemon/custom_views_transform.go |
| 117 | index 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 { |
| 266 | diff --git a/internal/daemon/custom_views_validation.go b/internal/daemon/custom_views_validation.go |
| 267 | new file mode 100644 |
| 268 | index 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 | +} |
| 474 | diff --git a/internal/daemon/custom_views_validation_test.go b/internal/daemon/custom_views_validation_test.go |
| 475 | new file mode 100644 |
| 476 | index 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 | +} |