|
2 | 2 | "Pure sanitisation helpers for the two doc fields that ship raw |
3 | 3 | user-supplied strings into the DOM: `:inner-html` (raw SVG/HTML on |
4 | 4 | `:raw-html-slot?` components like x-icon) and `:attrs` URL values |
5 | | - (`href`, `src`, …). |
| 5 | + (`href`, `src`, …). Plus an identifier-shape scanner that guards |
| 6 | + the export pipeline against doc fields that the codegen later |
| 7 | + interpolates verbatim into emitted JS / CLJS source. |
6 | 8 |
|
7 | | - Two layers of protection: |
| 9 | + Three layers of protection: |
8 | 10 |
|
9 | 11 | - **Block-list scanners** (`unsafe-findings`): given a doc, return a |
10 | 12 | vector of `[path reason]` entries naming each suspect site. Used at |
11 | 13 | the load boundary by `storage/project-file/validate-project` to |
12 | 14 | refuse a malicious payload outright with an explanatory message. |
| 15 | + Covers XSS payloads in `:inner-html` / URL attrs **and** unsafe |
| 16 | + identifier shapes in attr keys, binding keys, field / action |
| 17 | + names, and trigger action-refs (everything codegen splices into |
| 18 | + a JS or CLJS string literal). |
13 | 19 |
|
14 | 20 | - **Best-effort sanitisers** (`sanitize-svg-fragment`, |
15 | 21 | `sanitize-doc`): strip the obvious payloads — `<script>` / |
|
117 | 123 | ;; with javascript-like schemes. |
118 | 124 | (str/replace dangerous-url-attr-re "")))) |
119 | 125 |
|
| 126 | +;; --- identifier safety --------------------------------------------------- |
| 127 | + |
| 128 | +(def ^:private safe-attr-key-re |
| 129 | + ;; HTML / SVG / ARIA attribute name. Letters or `_` to start; letters, |
| 130 | + ;; digits, `-`, `_`, `.`, `:` after (covers `xlink:href`, `aria-*`, |
| 131 | + ;; CSS-variable-shaped attrs). Refuses anything codegen would have to |
| 132 | + ;; escape — whitespace, quotes, backslash, parens, brackets, `;`, `#`, |
| 133 | + ;; comment markers, etc. |
| 134 | + #"^[A-Za-z_][A-Za-z0-9_\-.:]*$") |
| 135 | + |
| 136 | +(def ^:private safe-identifier-name-re |
| 137 | + ;; Local-name component of a keyword (field / action / action-ref |
| 138 | + ;; local) emitted into CLJS or JS source. Tighter than attr keys: |
| 139 | + ;; no colons (colons inside a local keyword name would re-namespace |
| 140 | + ;; it at the keyword reader; in JS strings they're a syntax hazard |
| 141 | + ;; on object literals). |
| 142 | + #"^[A-Za-z][A-Za-z0-9_\-.]*$") |
| 143 | + |
| 144 | +(defn- safe-attr-key? [s] |
| 145 | + (and (string? s) (boolean (re-matches safe-attr-key-re s)))) |
| 146 | + |
| 147 | +(defn- safe-identifier-name? [s] |
| 148 | + (and (string? s) (boolean (re-matches safe-identifier-name-re s)))) |
| 149 | + |
| 150 | +(defn- keyword-name-safe? [kw] |
| 151 | + (and (keyword? kw) (safe-identifier-name? (cljs.core/name kw)))) |
| 152 | + |
| 153 | +(defn- action-ref-safe? |
| 154 | + "True when a qualified keyword's namespace segments and local name |
| 155 | + all parse as safe identifiers. Codegen splices each piece into a |
| 156 | + JS string literal (`dispatch([\"<alias>/<name>\"])`); anything |
| 157 | + outside `safe-identifier-name-re` can break out of that string." |
| 158 | + [kw] |
| 159 | + (and (qualified-keyword? kw) |
| 160 | + (every? safe-identifier-name? (str/split (namespace kw) #"\.")) |
| 161 | + (safe-identifier-name? (cljs.core/name kw)))) |
| 162 | + |
| 163 | +(defn- node-identifier-findings |
| 164 | + "Collect identifier-shape findings for one node. The codegen splices |
| 165 | + each of these values into emitted source code; an unsafe character |
| 166 | + in any of them produces malformed (or malicious) output." |
| 167 | + [path node] |
| 168 | + (concat |
| 169 | + ;; Attribute keys — both static `:attrs` and `:bindings`. |
| 170 | + (for [[k _] (:attrs node) |
| 171 | + :when (not (safe-attr-key? k))] |
| 172 | + {:path (conj path :attrs k) |
| 173 | + :reason (str "attr key " (pr-str k) |
| 174 | + " contains characters unsafe for codegen") |
| 175 | + :preview (pr-str k)}) |
| 176 | + (for [[k _] (:bindings node) |
| 177 | + :when (not (safe-attr-key? k))] |
| 178 | + {:path (conj path :bindings k) |
| 179 | + :reason (str "binding prop name " (pr-str k) |
| 180 | + " contains characters unsafe for codegen") |
| 181 | + :preview (pr-str k)}) |
| 182 | + ;; Binding `:field` keywords — emitted as the dispatched setter name. |
| 183 | + (for [[_ b] (:bindings node) |
| 184 | + :when (and (:field b) (not (keyword-name-safe? (:field b))))] |
| 185 | + {:path (conj path :bindings) |
| 186 | + :reason (str "binding :field " (pr-str (:field b)) |
| 187 | + " contains characters unsafe for codegen") |
| 188 | + :preview (pr-str (:field b))}) |
| 189 | + ;; Trigger `:action-ref` qualified keywords. |
| 190 | + (for [t (:events node) |
| 191 | + :when (and (:action-ref t) (not (action-ref-safe? (:action-ref t))))] |
| 192 | + {:path (conj path :events) |
| 193 | + :reason (str "trigger :action-ref " (pr-str (:action-ref t)) |
| 194 | + " contains characters unsafe for codegen") |
| 195 | + :preview (pr-str (:action-ref t))}) |
| 196 | + ;; Payload entries that reference fields by name. |
| 197 | + (for [t (:events node) |
| 198 | + pe (:payload t) |
| 199 | + :when (and (:field pe) (not (keyword-name-safe? (:field pe))))] |
| 200 | + {:path (conj path :events) |
| 201 | + :reason (str "trigger payload :field " (pr-str (:field pe)) |
| 202 | + " contains characters unsafe for codegen") |
| 203 | + :preview (pr-str (:field pe))}) |
| 204 | + ;; Field-def names + action names (referenced verbatim by setter |
| 205 | + ;; / sub / handler emission). |
| 206 | + (for [fd (:fields node) |
| 207 | + :when (and (:name fd) (not (keyword-name-safe? (:name fd))))] |
| 208 | + {:path (conj path :fields) |
| 209 | + :reason (str "field-def :name " (pr-str (:name fd)) |
| 210 | + " contains characters unsafe for codegen") |
| 211 | + :preview (pr-str (:name fd))}) |
| 212 | + (for [a (:actions node) |
| 213 | + :when (and (:name a) (not (keyword-name-safe? (:name a))))] |
| 214 | + {:path (conj path :actions) |
| 215 | + :reason (str "action :name " (pr-str (:name a)) |
| 216 | + " contains characters unsafe for codegen") |
| 217 | + :preview (pr-str (:name a))}))) |
| 218 | + |
120 | 219 | ;; --- doc walker ----------------------------------------------------------- |
121 | 220 |
|
122 | 221 | (defn- walk-nodes-with-path |
|
137 | 236 |
|
138 | 237 | (defn unsafe-findings |
139 | 238 | "Walk `doc` and return a vector of `{:path :reason :preview}` maps, |
140 | | - one per unsafe `:inner-html` or URL-typed attr value. Empty |
141 | | - vector means the doc is clean. The load-boundary check uses |
| 239 | + one per unsafe site. Three families: |
| 240 | +
|
| 241 | + - `:inner-html` carrying a script / event / javascript-url payload. |
| 242 | + - URL-typed attrs (`href`, `src`, …) with a dangerous scheme. |
| 243 | + - Identifier-shaped fields the exporter splices into emitted source |
| 244 | + (attr keys, binding prop names, binding `:field`, trigger |
| 245 | + `:action-ref`, payload `:field`, field-def `:name`, action |
| 246 | + `:name`) that contain characters unsafe for codegen. |
| 247 | +
|
| 248 | + Empty vector means the doc is clean. The load-boundary check uses |
142 | 249 | this directly: any non-empty result refuses the load." |
143 | 250 | [doc] |
144 | 251 | (vec |
|
154 | 261 | :when (and (url-attr? k) (string? v) (not (safe-url? v)))] |
155 | 262 | {:path (conj path :attrs k) |
156 | 263 | :reason (str "attr " (pr-str k) " carries unsafe URL scheme") |
157 | | - :preview v}))) |
| 264 | + :preview v}) |
| 265 | + (node-identifier-findings path node))) |
158 | 266 | (walk-nodes-with-path doc)))) |
159 | 267 |
|
160 | 268 | (defn- sanitize-node-attrs |
|
0 commit comments