🐛 Fix linear gradients in SVG text exports (#11272)

* 🐛 Use gradient type instead of export type in SVG renderer

data->gradient-def was comparing the render `type` parameter (:svg,
:png, :pdf) against "linear" to decide between linearGradient and
radialGradient elements. Since the export type is never "linear",
the comparison always fell through to radialGradient, causing all
linear gradients to be exported as radial in SVG output.

Read the gradient type from the data map instead:
(get-in data ["gradient" "type"])

Closes #5972

* 🐛 Add SVG gradient export regression test

Extract SVG gradient definition generation from the renderer so it can
be tested directly. Add exporter test build wiring and cover both
linear and radial gradient output.

AI-assisted-by: gpt-5.6-luna

*  Standardize exporter testing workflow

Align exporter scripts with the frontend testing pattern. Add a
dedicated GitHub Actions workflow and document the canonical exporter
commands in Serena memories.

AI-assisted-by: gpt-5.6-luna

*  Add focused exporter test execution

Mirror frontend test-runner behavior for focused namespaces and test
vars. Support --focus, --log-level, and --help, and document the
commands.

AI-assisted-by: gpt-5.6-luna

* 🐛 Replace shell exec with execFile in exporter

Replace child_process.exec with execFile to eliminate shell
interpretation. Add hex color validation in exporter and frontend
to reject malformed input before command construction.

This fixes GHSA-4f36-m4hj-cv86 (CVSS 9.9 Critical), an authenticated
OS command injection vulnerability where malicious fill-color values
could execute arbitrary commands in the exporter container.

Defense in depth:
- Layer 1: execFile passes arguments directly without shell parsing
- Layer 2: Exporter validates colors with strict hex regex
- Layer 3: Frontend filters invalid colors before DOM emission

All three independent reporters' attack vectors are addressed:
- Quote breakout (lyhtheori)
- Command substitution (B1gN0Se)
- Path traversal (KimiSecurityTeam)

AI-assisted-by: qwen3.7-plus

* 🐛 Use existing hex-color-string? and fix test path mismatch

Address code review feedback:

- Replace duplicated hex-color-rx and valid-hex-color? with existing
  hex-color-string? from app.common.types.color
- Fix RCE test to use marker path in payload instead of hardcoded /tmp/pwned

AI-assisted-by: qwen3.7-plus

---------

Co-authored-by: Sumit Ridhal <sridhal@redhat.com>
This commit is contained in:
Andrey Antukh 2026-08-19 13:29:04 +02:00 committed by GitHub
parent 1886697458
commit 60d87a6342
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 462 additions and 51 deletions

58
.github/workflows/tests-exporter.yml vendored Normal file
View File

@ -0,0 +1,58 @@
name: "CI: Exporter"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'exporter/**'
- 'common/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'exporter/**'
- 'common/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-exporter:
if: ${{ !github.event.pull_request.draft }}
name: "Exporter Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./exporter
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:clj
pnpm run lint:clj
- name: Tests
working-directory: ./exporter
run: |
./scripts/test

View File

@ -5,9 +5,10 @@
## Layout and commands ## Layout and commands
- Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`. - Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`.
- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`. - From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; test bundle `pnpm run build:test`; tests `pnpm run test` or `pnpm run test:quiet`; lint `pnpm run lint:clj`; format check/fix `pnpm run check-fmt:clj` / `pnpm run fmt:clj`.
- Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`. - Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`.
- Cross-cutting testing principles and anti-patterns: `mem:testing`. - Cross-cutting testing principles and anti-patterns: `mem:testing`.
- Exporter test conventions and CI: `mem:exporter/testing`.
## HTTP and browser pool ## HTTP and browser pool

View File

@ -0,0 +1,16 @@
# Exporter Testing
- READ `mem:testing` first.
- Tests use `cljs.test` and live under `exporter/test/exporter_tests/`.
- Register every test namespace in `exporter-tests.runner`.
- From `exporter/`: `pnpm run build:test` builds the Node test bundle without running tests.
- From `exporter/`: `pnpm run test` builds and runs tests with full output.
- From `exporter/`: `pnpm run test:quiet` builds and runs tests with reduced build output.
- After `build:test`, reuse the compiled bundle with `node target/tests/test.js`.
- For iterative focused runs, build once and reuse the compiled bundle.
- Focus a test namespace with `node target/tests/test.js --focus exporter-tests.renderer-svg-test`.
- Focus a test var with `node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element`.
- Set app log level by appending `--log-level warn` (or `trace|debug|info|warn|error`).
- `test:quiet` accepts forwarded options but rebuilds the bundle; prefer the direct runner after `build:test` for focused runs.
- From `exporter/`: `pnpm run check-fmt:clj` checks ClojureScript formatting.
- From `exporter/`: `pnpm run lint:clj` runs ClojureScript linting.

View File

@ -35,8 +35,11 @@
"build:app": "clojure -M:dev:shadow-cljs release main", "build:app": "clojure -M:dev:shadow-cljs release main",
"build:wasm": "../render-wasm/build export", "build:wasm": "../render-wasm/build export",
"build": "pnpm run clear:shadow-cache && pnpm run build:app", "build": "pnpm run clear:shadow-cache && pnpm run build:app",
"fmt": "cljfmt fix --parallel=true src/", "fmt:clj": "cljfmt fix --parallel=true src/ test/",
"check-fmt": "cljfmt check --parallel=true src/", "check-fmt:clj": "cljfmt check --parallel=true src/ test/",
"lint": "clj-kondo --parallel --lint src/" "lint:clj": "clj-kondo --parallel --lint src/ test/",
"build:test": "clojure -M:dev:shadow-cljs compile test",
"test": "pnpm run build:test && node target/tests/test.js",
"test:quiet": "node ./scripts/test-quiet.js"
} }
} }

7
exporter/scripts/test Executable file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -ex
corepack enable;
corepack install;
pnpm install;
pnpm run test;

View File

@ -0,0 +1,29 @@
import { spawnSync } from "node:child_process";
const BUILD_STEPS = [
{ label: "Building test bundle", cmd: "pnpm", args: ["run", "build:test"] },
];
const progress = (msg) => process.stderr.write(`${msg}\n`);
for (const step of BUILD_STEPS) {
progress(`${step.label}...`);
const result = spawnSync(step.cmd, step.args, {
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 64 * 1024 * 1024,
});
if (result.status !== 0) {
progress(`${step.label} failed`);
if (result.stdout?.length) process.stdout.write(result.stdout);
if (result.stderr?.length) process.stderr.write(result.stderr);
process.exit(result.status ?? 1);
}
}
progress("Running tests...");
const result = spawnSync(
"node",
["target/tests/test.js", ...process.argv.slice(2)],
{ stdio: "inherit" },
);
process.exit(result.status ?? 1);

View File

@ -31,4 +31,12 @@
:pseudo-names true :pseudo-names true
:pretty-print true :pretty-print true
:anon-fn-naming-policy :off :anon-fn-naming-policy :off
:source-map-detail-level :all}}}}} :source-map-detail-level :all}}}
:test
{:target :esm
:output-dir "target/tests"
:runtime :node
:js-options {:js-provider :import}
:modules
{:test {:init-fn exporter-tests.runner/-main}}}}}

View File

@ -117,7 +117,7 @@
[file-id paths] [file-id paths]
(p/let [prefix (str/concat "penpot.pdfunite." file-id ".") (p/let [prefix (str/concat "penpot.pdfunite." file-id ".")
path (sh/tempfile :prefix prefix :suffix ".pdf")] path (sh/tempfile :prefix prefix :suffix ".pdf")]
(sh/run-cmd! (str "pdfunite " (str/join " " paths) " " path)) (apply sh/run-cmd! "pdfunite" (conj (vec paths) path))
path)) path))
(defn- move-file (defn- move-file

View File

@ -38,7 +38,7 @@
:webp (p/let [png-path (sh/tempfile :prefix "penpot.tmp.bitmap." :suffix ".png")] :webp (p/let [png-path (sh/tempfile :prefix "penpot.tmp.bitmap." :suffix ".png")]
;; playwright only supports jpg and png, we need to convert it afterwards ;; playwright only supports jpg and png, we need to convert it afterwards
(bw/screenshot node {:omit-background? true :type :png :path png-path}) (bw/screenshot node {:omit-background? true :type :png :path png-path})
(sh/run-cmd! (str "convert " png-path " -quality 100 WEBP:" path)))) (sh/run-cmd! "convert" png-path "-quality" "100" (str "WEBP:" path))))
(on-object (assoc object :path path)))) (on-object (assoc object :path path))))
(render [uri page] (render [uri page]

View File

@ -10,9 +10,12 @@
["xml-js" :as xml] ["xml-js" :as xml]
[app.browser :as bw] [app.browser :as bw]
[app.common.data :as d] [app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l] [app.common.logging :as l]
[app.common.types.color :as ctc]
[app.common.uri :as u] [app.common.uri :as u]
[app.config :as cf] [app.config :as cf]
[app.renderer.svg-gradient :as svg-gradient]
[app.util.mime :as mime] [app.util.mime :as mime]
[app.util.shell :as sh] [app.util.shell :as sh]
[clojure.walk :as walk] [clojure.walk :as walk]
@ -125,19 +128,23 @@
(letfn [(convert-to-ppm [pngpath] (letfn [(convert-to-ppm [pngpath]
(let [ppmpath (str/concat pngpath "origin.ppm")] (let [ppmpath (str/concat pngpath "origin.ppm")]
(l/trace :fn :convert-to-ppm :path ppmpath) (l/trace :fn :convert-to-ppm :path ppmpath)
(-> (sh/run-cmd! (str "convert " pngpath " " ppmpath)) (-> (sh/run-cmd! "convert" pngpath ppmpath)
(p/then (constantly ppmpath))))) (p/then (constantly ppmpath)))))
(trace-color-mask [pbmpath] (trace-color-mask [pbmpath]
(l/trace :fn :trace-color-mask :pbmpath pbmpath) (l/trace :fn :trace-color-mask :pbmpath pbmpath)
(let [svgpath (str/concat pbmpath ".svg")] (let [svgpath (str/concat pbmpath ".svg")]
(-> (sh/run-cmd! (str "potrace --flat -b svg " pbmpath " -o " svgpath)) (-> (sh/run-cmd! "potrace" "--flat" "-b" "svg" pbmpath "-o" svgpath)
(p/then (constantly svgpath))))) (p/then (constantly svgpath)))))
(generate-color-layer [ppmpath color] (generate-color-layer [ppmpath color]
(when-not (ctc/hex-color-string? color)
(ex/raise :type :validation
:code :invalid-color
:hint (str "invalid hex color: " color)))
(l/trace :fn :generate-color-layer :ppmpath ppmpath :color color) (l/trace :fn :generate-color-layer :ppmpath ppmpath :color color)
(let [pbmpath (str/concat ppmpath ".mask-" (subs color 1) ".pbm")] (let [pbmpath (str/concat ppmpath ".mask-" (subs color 1) ".pbm")]
(-> (sh/run-cmd! (str/format "ppmcolormask \"%s\" %s" color ppmpath)) (-> (sh/run-cmd! "ppmcolormask" color ppmpath)
(p/then (fn [stdout] (p/then (fn [stdout]
(-> (sh/write-file! pbmpath stdout) (-> (sh/write-file! pbmpath stdout)
(p/then (constantly pbmpath))))) (p/then (constantly pbmpath)))))
@ -166,33 +173,11 @@
:else :else
(update node "attributes" assoc "fill" color)))) (update node "attributes" assoc "fill" color))))
(get-stops [data]
(->> (get-in data ["gradient" "stops"])
(mapv (fn [stop-data]
{"type" "element"
"name" "stop"
"attributes" {"offset" (get stop-data "offset")
"stop-color" (get stop-data "color")
"stop-opacity" (get stop-data "opacity")}}))))
(data->gradient-def [id [color data]]
(let [id (str "gradient-" id "-" (subs color 1))]
(if (= type "linear")
{"type" "element"
"name" "linearGradient"
"attributes" {"id" id "x1" "0.5" "y1" "1" "x2" "0.5" "y2" "0"}
"elements" (get-stops data)}
{"type" "element"
"name" "radialGradient"
"attributes" {"id" id "cx" "0.5" "cy" "0.5" "r" "0.5"}
"elements" (get-stops data)})))
(get-gradients [id mapping] (get-gradients [id mapping]
(->> mapping (->> mapping
(filter (fn [[_color data]] (filter (fn [[_color data]]
(= (get data "type") "gradient"))) (= (get data "type") "gradient")))
(mapv (partial data->gradient-def id)))) (mapv (partial svg-gradient/data->gradient-def id))))
(join-color-layers [{:keys [id x y width height mapping] :as node} layers] (join-color-layers [{:keys [id x y width height mapping] :as node} layers]
(l/trace :fn :join-color-layers :mapping mapping) (l/trace :fn :join-color-layers :mapping mapping)
@ -369,4 +354,3 @@
(assoc :query (u/map->query-string params)))] (assoc :query (u/map->query-string params)))]
(bw/exec! (prepare-options uri) (bw/exec! (prepare-options uri)
(partial render uri))))) (partial render uri)))))

View File

@ -0,0 +1,32 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.renderer.svg-gradient)
(defn- get-stops
[data]
(->> (get-in data ["gradient" "stops"])
(mapv (fn [stop-data]
{"type" "element"
"name" "stop"
"attributes" {"offset" (get stop-data "offset")
"stop-color" (get stop-data "color")
"stop-opacity" (get stop-data "opacity")}}))))
(defn data->gradient-def
[id [color data]]
(let [id (str "gradient-" id "-" (subs color 1))
gradient-type (get-in data ["gradient" "type"])]
(if (= gradient-type "linear")
{"type" "element"
"name" "linearGradient"
"attributes" {"id" id "x1" "0.5" "y1" "1" "x2" "0.5" "y2" "0"}
"elements" (get-stops data)}
{"type" "element"
"name" "radialGradient"
"attributes" {"id" id "cx" "0.5" "cy" "0.5" "r" "0.5"}
"elements" (get-stops data)})))

View File

@ -94,14 +94,14 @@
(.readFile fs/promises fpath)) (.readFile fs/promises fpath))
(defn run-cmd! (defn run-cmd!
[cmd] [cmd & args]
(p/create (p/create
(fn [resolve reject] (fn [resolve reject]
(l/trace :fn :run-cmd :cmd cmd) (l/trace :fn :run-cmd :cmd cmd :args args)
(proc/exec cmd #js {:encoding "buffer"} (proc/execFile cmd (clj->js args) #js {:encoding "buffer"}
(fn [error stdout _stderr] (fn [error stdout _stderr]
;; (l/trace :fn :run-cmd :stdout stdout) ;; (l/trace :fn :run-cmd :stdout stdout)
(if error (if error
(reject error) (reject error)
(resolve stdout))))))) (resolve stdout)))))))

View File

@ -0,0 +1,25 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns exporter-tests.renderer-svg-test
(:require
[app.renderer.svg-gradient :as svg-gradient]
[cljs.test :refer [deftest is testing]]))
(def gradient-stops
[{"color" "#000000" "offset" 0 "opacity" 1}
{"color" "#ffffff" "offset" 1 "opacity" 1}])
(deftest creates-the-correct-gradient-element
(doseq [[gradient-type element-name]
[["linear" "linearGradient"]
["radial" "radialGradient"]]]
(testing gradient-type
(let [gradient-data {"type" "gradient"
"gradient" {"type" gradient-type
"stops" gradient-stops}}
result (svg-gradient/data->gradient-def "text-id" ["#000001" gradient-data])]
(is (= element-name (get result "name")))))))

View File

@ -0,0 +1,172 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns exporter-tests.runner
(:require
[app.common.logging :as l]
[cljs.test :as t]
[clojure.string :as str]
[clojure.tools.cli :refer [parse-opts]]
[exporter-tests.renderer-svg-test]
[exporter-tests.shell-test]
[goog.object :as gobj]))
(enable-console-print!)
(def test-namespaces
['exporter-tests.renderer-svg-test
'exporter-tests.shell-test])
(assert (every? find-ns-obj test-namespaces)
"test-namespaces contains a namespace that isn't required in runner.cljs")
(defmethod t/report [:cljs.test/default :begin-test-var]
[m]
(let [v (:var m)]
(println (str " ▸ " (:ns (meta v)) "/" (:name (meta v))))))
(defmethod t/report [:cljs.test/default :end-run-tests]
[result]
(.exit js/process (if (cljs.test/successful? result) 0 1)))
(def ^:private log-levels
#{:trace :debug :info :warn :error})
(def cli-options
[["-f" "--focus FOCUS" "Run one test namespace or one test var, e.g. exporter-tests.renderer-svg-test/creates-the-correct-gradient-element"]
["-l" "--log-level LEVEL" "Set app logger level: trace|debug|info|warn|error"
:parse-fn keyword
:validate [log-levels "must be one of trace, debug, info, warn, error"]]
["-h" "--help"]])
(defn- argv
[]
(let [args (->> (.-argv js/process)
(array-seq)
(drop 2))]
;; `pnpm run test -- --focus ...` forwards the separator to the node
;; process, so drop one leading `--` before handing args to tools.cli.
(cond-> args
(= "--" (first args)) rest)))
(defn- usage
[summary]
(str "Usage: node target/tests/test.js [options]\n\n"
"Options:\n"
summary "\n\n"
"Build first with: pnpm run build:test\n\n"
"Focus examples:\n"
" node target/tests/test.js --focus exporter-tests.renderer-svg-test\n"
" node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element\n\n"
"Log level example:\n"
" node target/tests/test.js --focus exporter-tests.renderer-svg-test --log-level warn"))
(defn- fail!
[message]
(js/console.error message)
(.exit js/process 1))
(defn- parse-focus
[focus]
(let [[ns-name test-name & extra] (str/split focus #"/")]
(cond
(or (str/blank? ns-name) (seq extra))
(fail! (str "Invalid --focus value: " focus))
(some? test-name)
{:ns (symbol ns-name) :test test-name}
:else
{:ns (symbol ns-name)})))
(defn- fixture-value
[ns-obj fixture-name]
(let [value (gobj/get ns-obj (munge fixture-name))]
(when-not (undefined? value)
value)))
(defn- ns-test-vars
[ns-sym]
(when-let [ns-obj (find-ns-obj ns-sym)]
(->> (js-keys ns-obj)
(keep (fn [key]
(some-> (gobj/get ns-obj key)
(.-cljs$lang$var))))
(filter (comp :test meta))
(sort-by (comp :line meta)))))
(defn- ns-fixtures
[ns-sym vars]
(when-let [ns-obj (find-ns-obj ns-sym)]
(let [ns-key (or (some-> vars first meta :ns) ns-sym)
once-fixtures (fixture-value ns-obj "cljs-test-once-fixtures")
each-fixtures (fixture-value ns-obj "cljs-test-each-fixtures")]
{:once (when once-fixtures {ns-key once-fixtures})
:each (when each-fixtures {ns-key each-fixtures})})))
(defn- selected-tests
[{:keys [ns test]}]
(when-not (some #{ns} test-namespaces)
(fail! (str "Unknown test namespace: " ns)))
(let [vars (vec (ns-test-vars ns))]
(when (empty? vars)
(fail! (str "No tests found in namespace: " ns)))
(if test
(let [test-sym (symbol test)
test-var (some #(when (= test-sym (:name (meta %))) %) vars)]
(if test-var
{:vars [test-var]
:fixtures (ns-fixtures ns [test-var])}
(fail! (str "Unknown test var: " ns "/" test))))
{:vars vars
:fixtures (ns-fixtures ns vars)})))
(defn- merge-fixtures
[fixtures]
{:once (apply merge (keep :once fixtures))
:each (apply merge (keep :each fixtures))})
(defn- run-test-vars!
[tests]
(let [vars (vec (mapcat :vars tests))
fixtures (merge-fixtures (map :fixtures tests))
env (assoc (t/empty-env)
:once-fixtures (:once fixtures)
:each-fixtures (:each fixtures))
summary (volatile! {:test 0 :pass 0 :fail 0 :error 0 :type :summary})]
(t/set-env! env)
(t/run-block
(concat (t/test-vars-block vars)
[(fn []
(vswap! summary
(partial merge-with +)
(:report-counters (t/get-current-env))))
(fn []
(t/report @summary)
(t/report (assoc @summary :type :end-run-tests)))]))))
(defn- run-focused-test!
[focus]
(run-test-vars! [(selected-tests (parse-focus focus))]))
(defn -main
[]
(let [{:keys [options errors summary]} (parse-opts (argv) cli-options)]
(cond
(seq errors)
(fail! (str/join "\n" errors))
(:help options)
(do
(println (usage summary))
(.exit js/process 0))
:else
(do
(l/setup! {:app (or (:log-level options) :warn)})
(if (:focus options)
(run-focused-test! (:focus options))
(run-test-vars! (map #(selected-tests {:ns %}) test-namespaces)))))))

View File

@ -0,0 +1,70 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns exporter-tests.shell-test
"Tests to verify GHSA-4f36-m4hj-cv86 is fixed: OS Command Injection in SVG exporter.
These tests prove that:
1. execFile does NOT interpret shell metacharacters (safe execution)
2. Malicious colors fail validation regex
3. The injection does NOT execute commands (no RCE)"
(:require
["node:child_process" :as proc]
["node:fs" :as fs]
[cljs.test :as t :include-macros true]))
(def ^:private hex-color-rx
#"^#(?:[0-9a-fA-F]{3}){1,2}$")
(defn- valid-hex-color?
[color]
(and (string? color)
(some? (re-matches hex-color-rx color))))
(t/deftest execfile-does-not-interpret-shell-metacharacters
(t/testing "Proves execFile passes arguments literally (no shell interpretation)"
(t/async done
(let [cmd "echo"
args #js ["$(echo PWNED)"]]
(proc/execFile cmd args #js {:encoding "buffer"}
(fn [error stdout _stderr]
(if error
(do
(t/is false (str "unexpected error: " (.-message error)))
(done))
(let [output (.toString stdout "utf8")]
(t/is (= "$(echo PWNED)\n" output)
"execFile passes $(...) literally, no shell interpretation")
(done)))))))))
(t/deftest malicious-color-fails-validation
(t/testing "Proves malicious colors are rejected by validation"
(let [malicious "#000000$(echo PWNED)"
valid-color "#000000"
short-valid "#abc"]
(t/is (not (valid-hex-color? malicious))
"malicious color with $(...) fails validation")
(t/is (valid-hex-color? valid-color)
"valid 6-digit hex color passes validation")
(t/is (valid-hex-color? short-valid)
"valid 3-digit hex color passes validation"))))
(t/deftest execfile-does-not-execute-injected-commands
(t/testing "Proves execFile does NOT execute injected commands (no RCE)"
(t/async done
(let [marker "/tmp/penpot-exporter-rce-test"
malicious (str "#000000$(touch " marker ")")
cmd "echo"
args #js [malicious]]
(when (fs/existsSync marker)
(fs/unlinkSync marker))
(proc/execFile cmd args #js {:encoding "buffer"}
(fn [_error _stdout _stderr]
;; Command completes (or fails), but no injection occurs
(t/is (not (fs/existsSync marker))
"no RCE: marker file was NOT created")
(when (fs/existsSync marker)
(fs/unlinkSync marker))
(done)))))))

View File

@ -79,15 +79,21 @@
{:type :gradient {:type :gradient
:gradient fill-color-gradient} :gradient fill-color-gradient}
(and (string? fill-color) (some? fill-opacity) (not= fill-opacity 1)) (and (string? fill-color)
(cc/hex-color-string? fill-color)
(some? fill-opacity)
(not= fill-opacity 1))
{:type :transparent {:type :transparent
:hex fill-color :hex fill-color
:opacity fill-opacity} :opacity fill-opacity}
(string? fill-color) (and (string? fill-color)
(cc/hex-color-string? fill-color))
{:type :solid {:type :solid
:hex fill-color :hex fill-color
:map-to fill-color})) :map-to fill-color}
:else nil))
(defn- retrieve-colors (defn- retrieve-colors
"Given a text shape returns a triple with the values: "Given a text shape returns a triple with the values:

View File

@ -26,7 +26,7 @@ declare -A LINT_CMD=(
[backend]="pnpm run lint:clj" [backend]="pnpm run lint:clj"
[common]="pnpm run lint:clj" [common]="pnpm run lint:clj"
[render-wasm]="./lint" [render-wasm]="./lint"
[exporter]="pnpm run lint" [exporter]="pnpm run lint:clj"
[mcp]="" [mcp]=""
[plugins]="pnpm run lint" [plugins]="pnpm run lint"
[library]="pnpm run lint" [library]="pnpm run lint"
@ -37,7 +37,7 @@ declare -A TEST_CMD=(
[backend]="clojure -M:dev:test" [backend]="clojure -M:dev:test"
[common]="clojure -M:dev:test && pnpm run test:quiet" [common]="clojure -M:dev:test && pnpm run test:quiet"
[render-wasm]="./test" [render-wasm]="./test"
[exporter]="" [exporter]="pnpm run test:quiet"
[mcp]="pnpm run test" [mcp]="pnpm run test"
[plugins]="pnpm run test" [plugins]="pnpm run test"
[library]="pnpm run test" [library]="pnpm run test"
@ -48,7 +48,7 @@ declare -A FMT_CHECK_CMD=(
[backend]="pnpm run check-fmt" [backend]="pnpm run check-fmt"
[common]="pnpm run check-fmt:clj && pnpm run check-fmt:js" [common]="pnpm run check-fmt:clj && pnpm run check-fmt:js"
[render-wasm]="cargo fmt --check" [render-wasm]="cargo fmt --check"
[exporter]="pnpm run check-fmt" [exporter]="pnpm run check-fmt:clj"
[mcp]="pnpm run fmt:check" [mcp]="pnpm run fmt:check"
[plugins]="pnpm run format:check" [plugins]="pnpm run format:check"
[library]="pnpm run check-fmt" [library]="pnpm run check-fmt"
@ -59,7 +59,7 @@ declare -A FMT_FIX_CMD=(
[backend]="pnpm run fmt" [backend]="pnpm run fmt"
[common]="pnpm run fmt:clj && pnpm run fmt:js" [common]="pnpm run fmt:clj && pnpm run fmt:js"
[render-wasm]="cargo fmt" [render-wasm]="cargo fmt"
[exporter]="pnpm run fmt" [exporter]="pnpm run fmt:clj"
[mcp]="pnpm run fmt" [mcp]="pnpm run fmt"
[plugins]="pnpm run format" [plugins]="pnpm run format"
[library]="pnpm run fmt" [library]="pnpm run fmt"
@ -70,7 +70,7 @@ declare -A PAREN_REPAIR_CMD=(
[backend]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" [backend]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair"
[common]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" [common]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair"
[render-wasm]="" [render-wasm]=""
[exporter]="find src -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" [exporter]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair"
[mcp]="" [mcp]=""
[plugins]="" [plugins]=""
[library]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" [library]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair"