penpot/exporter/src/app/util/shell.cljs
Andrey Antukh aa3bc1ae98 🐛 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>
2026-08-19 13:53:40 +02:00

108 lines
3.0 KiB
Clojure

;; 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.util.shell
"Shell & FS utilities."
(:require
["node:child_process" :as proc]
["node:fs" :as fs]
["node:path" :as path]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[cuerdas.core :as str]
[promesa.core :as p]))
(l/set-level! :trace)
(def ^:const default-deletion-delay
(* 60 60 1)) ;; 1h
(def tmpdir
(let [path (cf/get :tempdir)]
(l/inf :hint "tmptdir setup" :path path)
(when-not (fs/existsSync path)
(fs/mkdirSync path #js {:recursive true}))
path))
(defn schedule-deletion
([path] (schedule-deletion path default-deletion-delay))
([path delay]
(let [remove-path
(fn []
(try
(when (fs/existsSync path)
(fs/rmSync path #js {:recursive true})
(l/trc :hint "tempfile permanently deleted" :path path))
(catch :default cause
(l/err :hint "error on deleting temporal file"
:path path
:cause cause))))
scheduled-at
(-> (ct/now) (ct/plus #js {:seconds delay}))]
(l/trc :hint "schedule tempfile deletion"
:path path
:scheduled-at (ct/format-inst scheduled-at))
(js/setTimeout remove-path (* delay 1000))
path)))
(defn tempfile
[& {:keys [prefix suffix]
:or {prefix "penpot."
suffix ".tmp"}}]
(loop [i 0]
(if (< i 1000)
(let [path (path/join tmpdir (str/concat prefix (uuid/next) "-" i suffix))]
(if (fs/existsSync path)
(recur (inc i))
(schedule-deletion path)))
(ex/raise :type :internal
:code :unable-to-locate-temporal-file
:hint "unable to find a tempfile candidate"))))
(defn move!
[origin-path dest-path]
(.rename fs/promises origin-path dest-path))
(defn stat
[path]
(->> (.stat fs/promises path)
(p/fmap (fn [data]
{:path path
:created-at (inst-ms (.-ctime ^js data))
:size (.-size data)}))
(p/merr (fn [_cause]
(p/resolved nil)))))
(defn rmdir!
[path]
(.rm fs/promises path #js {:recursive true}))
(defn write-file!
[fpath content]
(.writeFile fs/promises fpath content))
(defn read-file
[fpath]
(.readFile fs/promises fpath))
(defn run-cmd!
[cmd & args]
(p/create
(fn [resolve reject]
(l/trace :fn :run-cmd :cmd cmd :args args)
(proc/execFile cmd (clj->js args) #js {:encoding "buffer"}
(fn [error stdout _stderr]
;; (l/trace :fn :run-cmd :stdout stdout)
(if error
(reject error)
(resolve stdout)))))))