@zubra/runtime 5.0.3 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,6 +49,12 @@ export function isSafeUrl(value) {
49
49
  return ['http', 'https', 'mailto', 'tel'].includes(beforePath.slice(0, colon).toLowerCase());
50
50
  }
51
51
 
52
+ /** A srcset whose every candidate's URL is safe (src/validate.rs `is_safe_srcset`). */
53
+ export function isSafeSrcset(value) {
54
+ const urls = String(value).split(',').map((candidate) => candidate.trim().split(/\s+/)[0]).filter(Boolean);
55
+ return urls.length > 0 && urls.every(isSafeUrl);
56
+ }
57
+
52
58
  const clone = (value) => (value && typeof value === 'object' ? structuredClone(value) : value);
53
59
 
54
60
  /** Attribute text → prop value, typed by the prop's default (JSON for arrays and objects). */
@@ -1075,6 +1081,11 @@ function bindAttribute(part, node) {
1075
1081
  const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
1076
1082
  // No URL yet (a prop's "" default, before the app sets it): no attribute, and nothing to report.
1077
1083
  if (part.url && text.trim() === '') return node.removeAttribute(name);
1084
+ if (part.srcset && text.trim() === '') return node.removeAttribute(name);
1085
+ if (part.srcset && !isSafeSrcset(text)) {
1086
+ console.error(`zubra: blocked an unsafe URL in "${name}"${see('safety')}:`, text);
1087
+ return node.removeAttribute(name);
1088
+ }
1078
1089
  if (part.url && !isSafeUrl(text)) {
1079
1090
  console.error(`zubra: blocked an unsafe URL in "${name}"${see('safety')}:`, text);
1080
1091
  return node.removeAttribute(name);
@@ -8,5 +8,5 @@ export {
8
8
  createContext, provide, inject, ContextRequestEvent, ContextProviderEvent,
9
9
  formValue, validity, onFormReset, onFormRestore, onFormDisabled,
10
10
  prevent, stop, selfOnly, submit, keys, model, hasSlot, hydrated, signal, from, raw,
11
- isSafeUrl, $r, $t,
11
+ isSafeUrl, isSafeSrcset, $r, $t,
12
12
  } from './element.js';
@@ -2,7 +2,7 @@
2
2
  // {...spread} in compiled components: an object's entries as an element's attributes,
3
3
  // properties and listeners. Its own entry (`@zubra/runtime/spread`), which a compiled module imports
4
4
  // only when its template spreads, so components that don't, don't load it.
5
- import { $r, isSafeUrl } from './element.js';
5
+ import { $r, isSafeSrcset, isSafeUrl } from './element.js';
6
6
  import { effect } from './effect.js';
7
7
 
8
8
  const see = (section) => ` (see https://github.com/journey-to-code-org/zubra#${section})`;
@@ -130,6 +130,13 @@ export function spread(part, node) {
130
130
  continue;
131
131
  }
132
132
  const text = String(value);
133
+ if (name.toLowerCase() === 'srcset') {
134
+ if (text.trim() === '' || !isSafeSrcset(text)) {
135
+ if (text.trim() !== '') console.error(`zubra: blocked an unsafe URL in "${name}"${see('safety')}:`, text);
136
+ node.removeAttribute(name);
137
+ continue;
138
+ }
139
+ }
133
140
  if (SPREAD_URLS.has(name.toLowerCase())) {
134
141
  if (text.trim() === '') {
135
142
  node.removeAttribute(name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zubra/runtime",
3
- "version": "5.0.3",
3
+ "version": "5.1.0",
4
4
  "description": "Browser runtime for Zubra compiled components, built on the TC39 Signals API",
5
5
  "license": "ISC",
6
6
  "repository": {
package/src/element.js CHANGED
@@ -48,6 +48,12 @@ export function isSafeUrl(value) {
48
48
  return ['http', 'https', 'mailto', 'tel'].includes(beforePath.slice(0, colon).toLowerCase());
49
49
  }
50
50
 
51
+ /** A srcset whose every candidate's URL is safe (src/validate.rs `is_safe_srcset`). */
52
+ export function isSafeSrcset(value) {
53
+ const urls = String(value).split(',').map((candidate) => candidate.trim().split(/\s+/)[0]).filter(Boolean);
54
+ return urls.length > 0 && urls.every(isSafeUrl);
55
+ }
56
+
51
57
  const clone = (value) => (value && typeof value === 'object' ? structuredClone(value) : value);
52
58
 
53
59
  /** Attribute text → prop value, typed by the prop's default (JSON for arrays and objects). */
@@ -1090,6 +1096,11 @@ function bindAttribute(part, node) {
1090
1096
  const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
1091
1097
  // No URL yet (a prop's "" default, before the app sets it): no attribute, and nothing to report.
1092
1098
  if (part.url && text.trim() === '') return node.removeAttribute(name);
1099
+ if (part.srcset && text.trim() === '') return node.removeAttribute(name);
1100
+ if (part.srcset && !isSafeSrcset(text)) {
1101
+ console.error(`zubra: blocked an unsafe URL in "${name}"${see('safety')}:`, text);
1102
+ return node.removeAttribute(name);
1103
+ }
1093
1104
  if (part.url && !isSafeUrl(text)) {
1094
1105
  console.error(`zubra: blocked an unsafe URL in "${name}"${see('safety')}:`, text);
1095
1106
  return node.removeAttribute(name);
package/src/index.js CHANGED
@@ -7,5 +7,5 @@ export {
7
7
  createContext, provide, inject, ContextRequestEvent, ContextProviderEvent,
8
8
  formValue, validity, onFormReset, onFormRestore, onFormDisabled,
9
9
  prevent, stop, selfOnly, submit, keys, model, hasSlot, hydrated, signal, from, raw,
10
- isSafeUrl, $r, $t,
10
+ isSafeUrl, isSafeSrcset, $r, $t,
11
11
  } from './element.js';
package/src/spread.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // {...spread} in compiled components: an object's entries as an element's attributes,
2
2
  // properties and listeners. Its own entry (`@zubra/runtime/spread`), which a compiled module imports
3
3
  // only when its template spreads, so components that don't, don't load it.
4
- import { $r, isSafeUrl } from './element.js';
4
+ import { $r, isSafeSrcset, isSafeUrl } from './element.js';
5
5
  import { effect } from './effect.js';
6
6
 
7
7
  const see = (section) => ` (see https://github.com/journey-to-code-org/zubra#${section})`;
@@ -129,6 +129,13 @@ export function spread(part, node) {
129
129
  continue;
130
130
  }
131
131
  const text = String(value);
132
+ if (name.toLowerCase() === 'srcset') {
133
+ if (text.trim() === '' || !isSafeSrcset(text)) {
134
+ if (text.trim() !== '') console.error(`zubra: blocked an unsafe URL in "${name}"${see('safety')}:`, text);
135
+ node.removeAttribute(name);
136
+ continue;
137
+ }
138
+ }
132
139
  if (SPREAD_URLS.has(name.toLowerCase())) {
133
140
  if (text.trim() === '') {
134
141
  node.removeAttribute(name);
package/types/index.d.ts CHANGED
@@ -199,6 +199,8 @@ export declare const ABI: number;
199
199
 
200
200
  /** Whether a URL is safe for `href`/`src`: relative, or http(s), mailto or tel. */
201
201
  export declare function isSafeUrl(value: string): boolean;
202
+ /** True when every candidate in a srcset has a safe URL (the check a dynamic `srcset` gets). */
203
+ export declare function isSafeSrcset(value: string): boolean;
202
204
  /** @internal Read a value that may be a signal. */
203
205
  export declare function $r<T>(value: MaybeSignal<T>): T;
204
206
  /** @internal A value as template text. */