saasufy-components 10.5.3 → 10.5.5

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.
package/README.md CHANGED
@@ -73,6 +73,11 @@ In order to render HTML which was generated dynamically, you need to use triple
73
73
 
74
74
  Saasufy components support nested expressions but you should not inject untrusted/unsanitized input via a nested expression as it can make your application vulerable to XSS attacks.
75
75
 
76
+ Escaping covers element content and quoted attribute values (a newline becomes `<br>`). Two contexts it cannot protect, so avoid them with values you do not control:
77
+
78
+ - Unquoted attributes, as in `<div class={{value}}>`, which the value can add attributes to. Always quote: `<div class="{{value}}">`.
79
+ - Attributes which are code, such as `onclick` or `style`, since the browser decodes entities before the handler runs. Put the value in a `data-` attribute and read it from your handler instead.
80
+
76
81
  When an expression fails to render (I.e. throws an error), it will remain unchanged in its original expression format within the component/template. If an expression is nested inside a child component, the parent will attempt to evaluate it first. If it fails, then it will remain as-is and the child may later also attempt to evaluate it as part of its own rendering (e.g. once the child provides the missing state).
77
82
 
78
83
  ### Styling
@@ -686,7 +691,7 @@ Used for displaying and editing a single field of a model instance in realtime.
686
691
  - `type`: The type of the input component; can be `text`, `number`, `select`, `textarea`, `checkbox` or `file`.
687
692
  - `placeholder`: Can be used to set the placeholder text on the inner input element.
688
693
  - `consumers`: This allows you to connect this `model-input` to other elements on your page. It takes a list of selectors with optional attributes to target in the format `element-selector:attribute-name`. For example `.my-input:value` will find all elements with a `my-input` class and update their `value` attributes with the value of the `model-input` component in real-time. You can specify multiple selectors separated by commas such as `.my-input,my-div`; in this case, because attribute names are not specified, values will be injected into the `value` attribute (for input elements) or into the `innerHTML` property (for other kinds of elements). The default attribute/property depends on the element type. Note that `model-input` elements of the `file` type are treated as write-only unless the `consumers` attribute is present.
689
- - `provider-template`: A template string within which the `{{value}}` can be injected before passing to consumers.
694
+ - `provider-template`: A template string within which the `{{value}}` can be injected before passing to consumers. Markup in the template itself is preserved, which is how a consumer is given HTML; the `{{value}}` substituted into it is escaped. With no `provider-template`, the value is escaped before being written to a consumer's `innerHTML`.
690
695
  - `show-error-message`: If this attribute is present, an error message will be displayed above the input if it fails to update the value.
691
696
  - `error-consumers`: Sends error messages to other elements on your page instead of (or as well as) showing them next to the input. It takes the same list of selectors with optional attributes as `consumers`, in the format `element-selector:attribute-name`. This is useful when a message appearing beside the input would move the surrounding layout; the messages of a list of rows can be collected into a single area above the list, for example. Each input should be given a target of its own, since inputs which share one will overwrite each other's messages. The message is cleared (an empty string is sent) as soon as the input saves successfully. Can be used with or without `show-error-message`.
692
697
  - `error-provider-template`: A template string within which the `{{value}}` of an error message can be injected before passing it to the elements named by `error-consumers`. Serves the same purpose for errors as `provider-template` does for values. Note that an error message is escaped before the template is applied, so markup in the message itself is shown as text while markup in the template is not.
@@ -694,7 +699,7 @@ Used for displaying and editing a single field of a model instance in realtime.
694
699
  - `height`: Allows you to set the height of the inner `input` element programmatically.
695
700
  - `default-value`: A default value to show the user if the underlying model field's value is null or undefined. Note that setting a default value will not affect the underlying model's value.
696
701
  - `value`: Used to set the model's value.
697
- - `computable-value`: If this attribute is present, it will be possible to execute expressions using the `{{expression}}` syntax inside the `value` attribute.
702
+ - `computable-value`: If this attribute is present, it will be possible to execute expressions using the `{{expression}}` syntax inside the `value` attribute. The `value` is evaluated as JavaScript, so only use this attribute where the `value` is written by you; do not turn it on for an input whose `value` is fed from a record or from another component.
698
703
  - `slice-to`: Optional attribute which can be set to a number to trim strings down to a maximum number of characters. This is useful when dealing with potentially very long field values. Extra care should be taken when using this attribute on a `model-input` element as it will cause values to be overwritten with shortened values if the user edits the input box. Note also that it may affect caching if the same field is being referenced in multiple parts of the application at the same time within the same `socket-provider`.
699
704
  - `hide-error-logs`: By default, this component will log errors to the console. If set, this attribute will suppress such errors from showing up on the console.
700
705
  - `autocapitalize`: Can be `on` or `off` - It will set the auto-capitalize attribute on the inner input element. This is useful for mobile devices to enable or disable auto-capitalization of the first character which is typed into the input element.
@@ -809,12 +814,12 @@ This component can be configured to provide its data to multiple components (con
809
814
  - `disable-instant-flush`: If specified, changes will only be saved once they have settled (E.g. after the element has lost focus or when the enter key is pressed).
810
815
  - `placeholder`: Can be used to set the placeholder text on the inner input element.
811
816
  - `consumers`: This allows you to connect this `input-provider` to other elements on your page. It takes a list of selectors with optional attributes to target in the format `element-selector:attribute-name`. For example `.my-input:value` will find all elements with a `my-input` class and update their `value` attributes with the value of the `input-provider` component in real-time. You can specify multiple selectors separated by commas such as `.my-input,my-div`; in this case, because attribute names are not specified, values will be injected into the `value` attribute (for input elements) or into the `innerHTML` property (for other kinds of elements). The default attribute/property depends on the element type.
812
- - `provider-template`: A template string within which the `{{value}}` can be injected before passing to consumers.
817
+ - `provider-template`: A template string within which the `{{value}}` can be injected before passing to consumers. Markup in the template itself is preserved, which is how a consumer is given HTML; the `{{value}}` substituted into it is escaped. With no `provider-template`, the value is escaped before being written to a consumer's `innerHTML`.
813
818
  - `debounce-delay`: The delay in milliseconds to wait before triggering a change event. This is useful to batch multiple updates together (as is common when user is typing). Default is 800ms.
814
819
  - `options`: A comma-separated list of options to provide - This only works if the input type is `select`. Each option is in the format `label:type=value`; the `:type` and `=value` parts are optional. If the value is not specified, the label is used as the value. The type can be `string` (default), `number` or `boolean` and determines the type of the value which will be passed to consumers when the option is selected; for example `options="30 min:number=30,1 hour:number=60"` will pass the number `30` or `60` instead of the string `'30'` or `'60'`.
815
820
  - `height`: Allows you to set the height of the inner `input` element programmatically.
816
821
  - `value`: Used to set the input's value.
817
- - `computable-value`: If this attribute is present, it will be possible to execute expressions using the `{{expression}}` syntax inside the `value` attribute.
822
+ - `computable-value`: If this attribute is present, it will be possible to execute expressions using the `{{expression}}` syntax inside the `value` attribute. The `value` is evaluated as JavaScript, so only use this attribute where the `value` is written by you; do not turn it on for an input whose `value` is fed from a record or from another component.
818
823
  - `autocapitalize`: Can be `on` or `off` - It will set the auto-capitalize attribute on the inner input element. This is useful for mobile devices to enable or disable auto-capitalization of the first character which is typed into the input element.
819
824
  - `autocorrect`: Can be `on` or `off` - It will set the auto-correct attribute on the inner input element. This is useful for mobile devices to enable or disable auto-correct.
820
825
  - `input-props`: Can be used to set additional attributes on the inner `input` element. Must be in the format `attr1=value1,attr2=value2`.
@@ -912,7 +917,7 @@ The following example shows how to bind an `input-provider` component to an `inp
912
917
  **Attributes**
913
918
 
914
919
  - `consumers`: This allows you to connect this `input-transformer` to other elements on your page. It takes a list of selectors with optional attributes to target in the format `element-selector:attribute-name`. For example `.my-input:value` will find all elements with a `my-input` class and update their `value` attributes with the value of the `input-provider` component in real-time. You can specify multiple selectors separated by commas such as `.my-input,my-div`; in this case, because attribute names are not specified, values will be injected into the `value` attribute (for input elements) or into the `innerHTML` property (for other kinds of elements). The default attribute/property depends on the element type.
915
- - `provider-template`: A template string within which the `{{value}}` can be injected before passing to consumers.
920
+ - `provider-template`: A template string within which the `{{value}}` can be injected before passing to consumers. Markup in the template itself is preserved, which is how a consumer is given HTML; the `{{value}}` substituted into it is escaped. With no `provider-template`, the value is escaped before being written to a consumer's `innerHTML`.
916
921
  - `output-type`: The default output type is `string`. This property can be used to convert the output to either `boolean` or `number`.
917
922
  - `debounce-delay`: The delay in milliseconds to wait before triggering a change event. This is useful to batch multiple updates together. Default is 0ms.
918
923
 
@@ -985,7 +990,7 @@ Logging out of Keycloak as well as of the app:
985
990
 
986
991
  **Attributes**
987
992
 
988
- - `logout-url`: The log out endpoint of the OAuth provider; for OpenID Connect providers this is the `end_session_endpoint`. If it is not specified, the component only deauthenticates the socket. The redirect is skipped if the user did not log in via an OAuth provider.
993
+ - `logout-url`: The log out endpoint of the OAuth provider; for OpenID Connect providers this is the `end_session_endpoint`. If it is not specified, the component only deauthenticates the socket. The redirect is skipped if the user did not log in via an OAuth provider. It must be an `http` or `https` URL; relative paths are supported and are resolved against your app's origin.
989
994
  - `provider`: The name of the OAuth provider, as specified on the `Authentication` page of your Saasufy control panel. If it is set, the redirect only happens when the user logged in via that specific provider; this is useful when your app offers several different ways to log in.
990
995
  - `client-id`: The client ID to pass to the provider as the `client_id` query parameter. OpenID Connect providers require either this or an ID token in order to log the user out without asking them to confirm.
991
996
  - `post-logout-redirect-uri`: The URL to send the user back to once the provider has logged them out, passed as the `post_logout_redirect_uri` query parameter. It usually needs to be registered with the provider in advance. If it is not specified, the user is left on the provider's own logged out page.
@@ -1046,7 +1051,7 @@ When using an OAuth provider, the callback URL which you register with the provi
1046
1051
 
1047
1052
  - `provider` (required): The name of the provider. It must match the provider name specified on the `Authentication` page of your Saasufy control panel and also the value provided to the `oauth-link` component at the start of the OAuth flow.
1048
1053
  - `success-location-hash`: If authentication is successful, the browser's `location.hash` will be set to this value. It can be used to redirect the user to their dashboard, for example.
1049
- - `success-url`: If authentication is successful, the browser's `location.href` will be set to this value. It can be used to redirect the user to their dashboard, for example.
1054
+ - `success-url`: If authentication is successful, the browser's `location.href` will be set to this value. It can be used to redirect the user to their dashboard, for example. It must be an `http` or `https` URL; relative paths are supported and are resolved against the current page.
1050
1055
  - `redirect-uri`: A URI to redirect the user to once OAuth is successful. This attribute may be required depending on the OAuth provider/client.
1051
1056
  - `extra-data`: This attribute can be used to pass additional data to the OAuth `getAccessTokenURL` endpoint. For Google OAuth, for example, an additional `redirect_uri` field is required, so it should be set using `extra-data="redirect_uri=http://localhost.com:8000/"` (substitute the URI with your own).
1052
1057
  - `navigate-event-path`: If authentication is successful and a path is set via this attribute, the component will emit a `navigate` event which will bubble up the component hierarchy and can be used by a parent component to perform the success redirection. This approach can be used as an alternative to `success-location-hash` or `success-url` for doing the final redirect.
@@ -4,7 +4,8 @@ import {
4
4
  convertStringToFieldParams,
5
5
  getTypeCastFunction,
6
6
  formatError,
7
- renderTemplate
7
+ renderTemplate,
8
+ toSafeHTML
8
9
  } from './utils.js';
9
10
  import AGCollection from '/node_modules/ag-collection/ag-collection.js';
10
11
 
@@ -281,10 +282,14 @@ class CollectionAdder extends SocketConsumer {
281
282
  let autocapitalize = this.getAttribute('autocapitalize');
282
283
  let autocorrect = this.getAttribute('autocorrect');
283
284
 
285
+ // Every value interpolated into the form markup below comes from an
286
+ // attribute, which the browser has already decoded, so each one is escaped
287
+ // on the way back into markup. Escaping is a no-op on entities the template
288
+ // layer produced, so labels keep rendering exactly as they did.
284
289
  let extraAttributesString = `${
285
- autocapitalize ? ` autocapitalize="${autocapitalize}"` : ''
290
+ autocapitalize ? ` autocapitalize="${toSafeHTML(autocapitalize)}"` : ''
286
291
  }${
287
- autocorrect ? ` autocorrect="${autocorrect}"` : ''
292
+ autocorrect ? ` autocorrect="${toSafeHTML(autocorrect)}"` : ''
288
293
  }`;
289
294
 
290
295
  if (this.collection) this.collection.destroy();
@@ -309,72 +314,72 @@ class CollectionAdder extends SocketConsumer {
309
314
  let optionLabel = optionLabels[field] == null ? 'Options' : optionLabels[field];
310
315
  if (inputType === 'checkbox') {
311
316
  items.push(
312
- `<div class="collection-adder-field-container" data-field="${field}">
317
+ `<div class="collection-adder-field-container" data-field="${toSafeHTML(field)}">
313
318
  <div class="collection-adder-field-error-container error"></div>
314
319
  <label>
315
320
  ${
316
- inputLabel
321
+ toSafeHTML(inputLabel)
317
322
  }
318
323
  <input class="collection-adder-input" type="${
319
- inputType
324
+ toSafeHTML(inputType)
320
325
  }" name="${
321
- field
326
+ toSafeHTML(field)
322
327
  }" />
323
328
  </label>
324
329
  </div>`
325
330
  );
326
331
  } else if (inputType === 'select') {
327
332
  items.push(
328
- `<div class="collection-adder-field-container" data-field="${field}">
333
+ `<div class="collection-adder-field-container" data-field="${toSafeHTML(field)}">
329
334
  <div class="collection-adder-field-error-container error"></div>
330
335
  <label>
331
336
  ${
332
- inputLabel
337
+ toSafeHTML(inputLabel)
333
338
  }
334
- <select name="${field}" class="collection-adder-input collection-adder-select">
335
- <option value="" selected disabled hidden>${optionLabel}</option>
336
- ${inputTypeParams.map(param => `<option value="${param}">${param}</option>`).join('')}
339
+ <select name="${toSafeHTML(field)}" class="collection-adder-input collection-adder-select">
340
+ <option value="" selected disabled hidden>${toSafeHTML(optionLabel)}</option>
341
+ ${inputTypeParams.map(param => `<option value="${toSafeHTML(param)}">${toSafeHTML(param)}</option>`).join('')}
337
342
  </select>
338
343
  </label>
339
344
  </div>`
340
345
  );
341
346
  } else if (inputType === 'text-select') {
342
347
  items.push(
343
- `<div class="collection-adder-field-container" data-field="${field}">
348
+ `<div class="collection-adder-field-container" data-field="${toSafeHTML(field)}">
344
349
  <div class="collection-adder-field-error-container error"></div>
345
350
  <div class="collection-adder-select-text-row">
346
351
  <input class="collection-adder-input" type="text" name="${
347
- field
352
+ toSafeHTML(field)
348
353
  }" placeholder="${
349
- inputLabel
354
+ toSafeHTML(inputLabel)
350
355
  }"${extraAttributesString} />
351
356
  <select class="collection-adder-select">
352
- <option value="" selected disabled hidden>${optionLabel}</option>
353
- ${inputTypeParams.map(param => `<option value="${param}">${param}</option>`).join('')}
357
+ <option value="" selected disabled hidden>${toSafeHTML(optionLabel)}</option>
358
+ ${inputTypeParams.map(param => `<option value="${toSafeHTML(param)}">${toSafeHTML(param)}</option>`).join('')}
354
359
  </select>
355
360
  </div>
356
361
  </div>`
357
362
  );
358
363
  } else if (inputType === 'radio') {
359
364
  items.push(
360
- `<div class="collection-adder-field-container" data-field="${field}">
365
+ `<div class="collection-adder-field-container" data-field="${toSafeHTML(field)}">
361
366
  <div class="collection-adder-field-error-container error"></div>
362
367
  <div>
363
368
  ${
364
- inputLabel
369
+ toSafeHTML(inputLabel)
365
370
  }
366
371
  ${
367
372
  inputTypeParams.map(
368
373
  param => (
369
374
  `<label>
370
375
  <input class="collection-adder-radio" type="${
371
- inputType
376
+ toSafeHTML(inputType)
372
377
  }" name="${
373
- field
374
- }" value=${
375
- param
376
- } />
377
- ${param}
378
+ toSafeHTML(field)
379
+ }" value="${
380
+ toSafeHTML(param)
381
+ }" />
382
+ ${toSafeHTML(param)}
378
383
  </label>`
379
384
  )
380
385
  ).join('')
@@ -384,49 +389,49 @@ class CollectionAdder extends SocketConsumer {
384
389
  );
385
390
  } else if (inputType === 'textarea') {
386
391
  items.push(
387
- `<div class="collection-adder-field-container" data-field="${field}">
392
+ `<div class="collection-adder-field-container" data-field="${toSafeHTML(field)}">
388
393
  <div class="collection-adder-field-error-container error"></div>
389
394
  <textarea class="collection-adder-input" name="${
390
- field
395
+ toSafeHTML(field)
391
396
  }" placeholder="${
392
- inputLabel
397
+ toSafeHTML(inputLabel)
393
398
  }"${extraAttributesString}></textarea>
394
399
  </div>`
395
400
  );
396
401
  } else if (inputType === 'file') {
397
402
  items.push(
398
- `<div class="collection-adder-field-container" data-field="${field}">
403
+ `<div class="collection-adder-field-container" data-field="${toSafeHTML(field)}">
399
404
  <div class="collection-adder-field-error-container error"></div>
400
405
  <input class="collection-adder-input" type="${
401
- inputType
406
+ toSafeHTML(inputType)
402
407
  }" name="${
403
- field
408
+ toSafeHTML(field)
404
409
  }"${extraAttributesString} />
405
410
  </div>`
406
411
  );
407
412
  } else if (inputType === 'number') {
408
413
  items.push(
409
- `<div class="collection-adder-field-container" data-field="${field}">
414
+ `<div class="collection-adder-field-container" data-field="${toSafeHTML(field)}">
410
415
  <div class="collection-adder-field-error-container error"></div>
411
416
  <input class="collection-adder-input" type="${
412
- inputType
417
+ toSafeHTML(inputType)
413
418
  }" name="${
414
- field
419
+ toSafeHTML(field)
415
420
  }" placeholder="${
416
- inputLabel
421
+ toSafeHTML(inputLabel)
417
422
  }" step="any"${extraAttributesString} />
418
423
  </div>`
419
424
  );
420
425
  } else {
421
426
  items.push(
422
- `<div class="collection-adder-field-container" data-field="${field}">
427
+ `<div class="collection-adder-field-container" data-field="${toSafeHTML(field)}">
423
428
  <div class="collection-adder-field-error-container error"></div>
424
429
  <input class="collection-adder-input" type="${
425
- inputType
430
+ toSafeHTML(inputType)
426
431
  }" name="${
427
- field
432
+ toSafeHTML(field)
428
433
  }" placeholder="${
429
- inputLabel
434
+ toSafeHTML(inputLabel)
430
435
  }"${extraAttributesString} />
431
436
  </div>`
432
437
  );
@@ -438,7 +443,7 @@ class CollectionAdder extends SocketConsumer {
438
443
  <div class="collection-adder-message-container"></div>
439
444
  <div class="collection-adder-form-content">
440
445
  ${items.join('')}
441
- ${hideSubmitButton ? '' : `<input type="submit" value="${submitButtonLabel}" />`}
446
+ ${hideSubmitButton ? '' : `<input type="submit" value="${toSafeHTML(submitButtonLabel)}" />`}
442
447
  </div>
443
448
  </form>
444
449
  `;
package/confirm-modal.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import './overlay-modal.js';
2
+ import { toSafeHTML } from './utils.js';
2
3
 
3
4
  class ConfirmModal extends HTMLElement {
4
5
  constructor() {
@@ -65,14 +66,19 @@ class ConfirmModal extends HTMLElement {
65
66
  let confirmButtonLabel = this.getAttribute('confirm-button-label') || 'Confirm';
66
67
  let cancelButtonLabel = this.getAttribute('cancel-button-label') || 'Cancel';
67
68
 
69
+ // These are documented as text and typically come from an attribute which a
70
+ // template wrote, such as confirm-message="Delete {{Product.name}}?". The
71
+ // browser decodes entities when the attribute is parsed, so getAttribute
72
+ // hands back the original characters and they have to be escaped again
73
+ // before going into markup here.
68
74
  this.innerHTML = `
69
75
  <overlay-modal${this.isHidden ? ' style="display: none;"' : ''}>
70
- <div slot="title">${heading}</div>
76
+ <div slot="title">${toSafeHTML(heading)}</div>
71
77
  <div class="confirm-modal-content" slot="content">
72
- <div>${message}</div>
78
+ <div>${toSafeHTML(message)}</div>
73
79
  <div class="confirm-modal-buttons-container">
74
- <input class="modal-confirm-button" type="button" value="${confirmButtonLabel}" />
75
- <input class="modal-cancel-button" type="button" value="${cancelButtonLabel}" />
80
+ <input class="modal-confirm-button" type="button" value="${toSafeHTML(confirmButtonLabel)}" />
81
+ <input class="modal-cancel-button" type="button" value="${toSafeHTML(cancelButtonLabel)}" />
76
82
  </div>
77
83
  </div>
78
84
  </overlay-modal>
package/input-provider.js CHANGED
@@ -4,7 +4,8 @@ import {
4
4
  updateConsumerElements,
5
5
  convertStringToFieldParams,
6
6
  convertStringToFieldTypeValues,
7
- getTypeCastFunction
7
+ getTypeCastFunction,
8
+ toSafeHTML
8
9
  } from './utils.js';
9
10
 
10
11
  const DEFAULT_DEBOUNCE_DELAY = 800;
@@ -141,16 +142,20 @@ class InputProvider extends HTMLElement {
141
142
  if (options != null) {
142
143
  let fieldTypeValues = convertStringToFieldTypeValues(options, true, true);
143
144
  optionValues = new Map();
145
+ // The options attribute can be written by a consumer rather than by hand,
146
+ // in which case it carries whatever a record held. Escaping is a no-op on
147
+ // the entities the template layer already produced, so a label keeps
148
+ // rendering as it did while markup in it stays text.
144
149
  let optionElements = fieldTypeValues
145
150
  .map((option) => {
146
151
  if (option.field === placeholder) {
147
- return `<option value="" selected class="select-default-option">${option.field}</option>`;
152
+ return `<option value="" selected class="select-default-option">${toSafeHTML(option.field)}</option>`;
148
153
  }
149
154
  let Type = getTypeCastFunction(option.type);
150
155
  let optionValue = Type(option.value ?? option.field);
151
156
  let elementValue = String(optionValue);
152
157
  optionValues.set(elementValue, optionValue);
153
- return `<option value="${elementValue}">${option.field}</option>`;
158
+ return `<option value="${toSafeHTML(elementValue)}">${toSafeHTML(option.field)}</option>`;
154
159
  })
155
160
  .join('');
156
161
  this.inputElement.innerHTML = optionElements;
package/log-in-form.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import '/node_modules/capitalisk-auth-client/dist/capitalisk-log-in.js';
2
2
  import { SocketConsumer } from './socket.js';
3
+ import { toSafeHTML } from './utils.js';
3
4
 
4
5
  const DEFAULT_AUTH_TIMEOUT = 10000;
5
6
 
@@ -45,11 +46,11 @@ class LogInForm extends SocketConsumer {
45
46
 
46
47
  this.innerHTML = `
47
48
  <capitalisk-log-in
48
- hostname="${hostname}"
49
- port="${port}"
50
- network-symbol="${networkSymbol}"
51
- chain-module-name="${chainModuleName}"
52
- secure="${secure}"
49
+ hostname="${toSafeHTML(hostname)}"
50
+ port="${toSafeHTML(port)}"
51
+ network-symbol="${toSafeHTML(networkSymbol)}"
52
+ chain-module-name="${toSafeHTML(chainModuleName)}"
53
+ secure="${toSafeHTML(secure)}"
53
54
  disabled="true"
54
55
  ></capitalisk-log-in>
55
56
  `;
package/log-out.js CHANGED
@@ -20,6 +20,16 @@ class LogOut extends SocketConsumer {
20
20
  );
21
21
  }
22
22
 
23
+ // A javascript: or data: URL would execute when assigned to location.href.
24
+ if (
25
+ providerLogOutURL.protocol !== 'https:' &&
26
+ providerLogOutURL.protocol !== 'http:'
27
+ ) {
28
+ throw new Error(
29
+ 'The logout-url attribute of log-out must be an http or https URL'
30
+ );
31
+ }
32
+
23
33
  let clientId = this.getAttribute('client-id');
24
34
  let postLogoutRedirectURI = this.getAttribute('post-logout-redirect-uri');
25
35
  let searchParams = providerLogOutURL.searchParams;
package/model-input.js CHANGED
@@ -215,16 +215,20 @@ class ModelInput extends SocketConsumer {
215
215
  if (options != null) {
216
216
  let fieldTypeValues = convertStringToFieldTypeValues(options, true, true);
217
217
  optionValues = new Map();
218
+ // The options attribute can be written by a consumer rather than by hand,
219
+ // in which case it carries whatever a record held. Escaping is a no-op on
220
+ // the entities the template layer already produced, so a label keeps
221
+ // rendering as it did while markup in it stays text.
218
222
  let optionElements = fieldTypeValues
219
223
  .map((option) => {
220
224
  if (option.field === placeholder) {
221
- return `<option value="" selected class="select-default-option">${option.field}</option>`;
225
+ return `<option value="" selected class="select-default-option">${toSafeHTML(option.field)}</option>`;
222
226
  }
223
227
  let Type = getTypeCastFunction(option.type);
224
228
  let optionValue = Type(option.value ?? option.field);
225
229
  let elementValue = String(optionValue);
226
230
  optionValues.set(elementValue, optionValue);
227
- return `<option value="${elementValue}">${option.field}</option>`;
231
+ return `<option value="${toSafeHTML(elementValue)}">${toSafeHTML(option.field)}</option>`;
228
232
  })
229
233
  .join('');
230
234
  this.inputElement.innerHTML = optionElements;
package/oauth-handler.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { SocketConsumer } from './socket.js';
2
- import { generateRandomHexString, convertStringToFieldParams } from './utils.js';
2
+ import { generateRandomHexString, convertStringToFieldParams, toSafeHTML } from './utils.js';
3
3
 
4
4
  const DEFAULT_AUTH_TIMEOUT = 20000;
5
5
 
@@ -12,7 +12,25 @@ class OAuthHandler extends SocketConsumer {
12
12
  if (successLocationHash) {
13
13
  location.hash = successLocationHash;
14
14
  } else if (successURL) {
15
- location.href = successURL;
15
+ let resolvedURL;
16
+ // Resolved against the document URL so relative paths behave as they did
17
+ // with the plain assignment; only an absolute URL brings its own scheme.
18
+ try {
19
+ resolvedURL = new URL(successURL, location.href);
20
+ } catch (error) {
21
+ throw new Error(
22
+ 'The success-url attribute of oauth-handler was not a valid URL'
23
+ );
24
+ }
25
+ if (
26
+ resolvedURL.protocol !== 'https:' &&
27
+ resolvedURL.protocol !== 'http:'
28
+ ) {
29
+ throw new Error(
30
+ 'The success-url attribute of oauth-handler must be an http or https URL'
31
+ );
32
+ }
33
+ location.href = resolvedURL.href;
16
34
  } else {
17
35
  this.dispatchEvent(
18
36
  new CustomEvent('navigate', {
@@ -42,7 +60,8 @@ class OAuthHandler extends SocketConsumer {
42
60
 
43
61
  let useLocalStorage = !this.hasAttribute('use-session-storage');
44
62
  let storageKey = this.getAttribute('state-storage-key') || 'oauth.state';
45
- let expectedOAuthState = (useLocalStorage ? localStorage : sessionStorage).getItem(storageKey);
63
+ let storage = useLocalStorage ? localStorage : sessionStorage;
64
+ let expectedOAuthState = storage.getItem(storageKey);
46
65
  if (!expectedOAuthState) {
47
66
  this.innerHTML = `
48
67
  <div class="error">OAuth authentication failed because the state was missing.</div>
@@ -83,11 +102,25 @@ class OAuthHandler extends SocketConsumer {
83
102
  history.replaceState({}, document.title, location.pathname);
84
103
 
85
104
  if (relevantStates.some(expectedState => expectedState === state)) {
105
+ // Single-use, so consumed before the exchange. Re-read rather than
106
+ // written back from the list captured above, which would drop a state
107
+ // another handler or tab added since.
108
+ let remainingStates = (storage.getItem(storageKey) || '')
109
+ .split(',')
110
+ .filter(existingState => existingState && existingState !== state);
111
+ if (remainingStates.length) {
112
+ storage.setItem(storageKey, remainingStates.join(','));
113
+ } else {
114
+ storage.removeItem(storageKey);
115
+ }
116
+
86
117
  try {
87
118
  await socket.invoke('log-in-oauth', { provider, data: { code, ...extraData } });
88
119
  } catch (error) {
120
+ // The message is quoted back by the service and can carry parts of the
121
+ // query string which brought the browser here.
89
122
  this.innerHTML = `
90
- <div class="error">OAuth authentication failed. ${error.message}</div>
123
+ <div class="error">OAuth authentication failed. ${toSafeHTML(error.message)}</div>
91
124
  `;
92
125
  return;
93
126
  }
package/oauth-link.js CHANGED
@@ -11,7 +11,9 @@ class OAuthLink extends HTMLElement {
11
11
  };
12
12
 
13
13
  this.handleClickEvent = () => {
14
- this.updateState();
14
+ // Re-asserts the stored state in case it was evicted; not regenerated,
15
+ // since the href was built from it at render time.
16
+ this.storeState();
15
17
  };
16
18
  }
17
19
 
@@ -73,33 +75,34 @@ class OAuthLink extends HTMLElement {
73
75
  viewportNode.innerHTML = itemString;
74
76
  }
75
77
 
78
+ // A fresh nonce per render; the handler consumes a state on use. Render only,
79
+ // since the href is rebuilt from the new state.
76
80
  updateState() {
77
81
  let provider = this.getAttribute('provider');
78
82
  if (!provider) {
79
83
  throw new Error('The provider attribute of oauth-link was not specified');
80
84
  }
81
- let useLocalStorage = !this.hasAttribute('use-session-storage');
82
85
  let stateSize = Number(this.getAttribute('state-size') || 20);
86
+ this.provider = provider;
87
+ this.state = `${provider}-${generateRandomHexString(stateSize)}`;
88
+ this.storeState();
89
+ }
90
+
91
+ // Re-read immediately before writing so another provider's state, from
92
+ // another link or tab, is not dropped.
93
+ storeState() {
94
+ if (!this.state) return;
95
+ let useLocalStorage = !this.hasAttribute('use-session-storage');
83
96
  let storageKey = this.getAttribute('state-storage-key') || 'oauth.state';
84
-
85
97
  let storage = useLocalStorage ? localStorage : sessionStorage;
86
98
  let existingState = storage.getItem(storageKey);
87
- let stateList;
88
- if (existingState) {
89
- stateList = existingState.split(',').filter(Boolean);
90
- let maxStateCount = Number(this.getAttribute('max-state-count') ?? 20);
91
- if (stateList.length >= maxStateCount) {
92
- stateList.pop();
93
- }
94
- } else {
95
- stateList = []
96
- }
97
- if (this.provider !== provider) {
98
- this.state = `${provider}-${generateRandomHexString(stateSize)}`;
99
- this.provider = provider;
99
+ let stateList = existingState ? existingState.split(',').filter(Boolean) : [];
100
+ if (stateList.includes(this.state)) return;
101
+ let maxStateCount = Number(this.getAttribute('max-state-count') ?? 20);
102
+ if (stateList.length >= maxStateCount) {
103
+ stateList.pop();
100
104
  }
101
105
  stateList.unshift(this.state);
102
-
103
106
  storage.setItem(storageKey, stateList.join(','));
104
107
  }
105
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saasufy-components",
3
- "version": "10.5.3",
3
+ "version": "10.5.5",
4
4
  "description": "Components for Saasufy.",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/utils.js CHANGED
@@ -11,28 +11,61 @@ let backtickRegExp = /`/g;
11
11
  let templateMultiStartBracesRegExp = /{{{*/g
12
12
  let templateMultiEndBracesRegExp = /}}}*/g
13
13
 
14
+ // Expression brackets in their encoded form. Escaping leaves these alone so
15
+ // that it stays idempotent, but the browser turns them back into brackets when
16
+ // it parses them out of an attribute, which would hand a value to any component
17
+ // that re-evaluates that attribute (computable-value, computable-model-values).
18
+ // They are flattened here for the same reason the literal brackets above are.
19
+ let templateEncodedStartBracesRegExp = /(?:&#123;){2,}/g
20
+ let templateEncodedEndBracesRegExp = /(?:&#125;){2,}/g
21
+
22
+ // Line breaks are the one piece of markup a value is allowed to carry; they are
23
+ // escaped along with everything else and then put back, so that only a genuine
24
+ // break tag survives and a stray angle bracket elsewhere in the value cannot.
25
+ let safeBreakRegExp = /&lt;(br ?\/?)&gt;/g;
26
+
14
27
  export function toSafeString(value) {
15
28
  return value.replace(expressionCharsRegExp, '$!{$1}')
16
29
  .replace(backtickRegExp, `'`)
17
30
  .replace(templateMultiStartBracesRegExp, '[[')
18
- .replace(templateMultiEndBracesRegExp, ']]');
31
+ .replace(templateMultiEndBracesRegExp, ']]')
32
+ .replace(templateEncodedStartBracesRegExp, '[[')
33
+ .replace(templateEncodedEndBracesRegExp, ']]');
19
34
  }
20
35
 
21
36
  export function toSafeHTML(value) {
22
- if (typeof value === 'string') {
23
- return toSafeString(value)
24
- .replace(/&(?!(amp|lt|gt|quot|#039|#123|#125);)/g, '&amp;')
25
- .replace(/<(?!br ?\/?>)/g, '&lt;')
26
- .replace(/(?<!br ?\/?)>/g, '&gt;')
27
- .replace(/"/g, '&quot;')
28
- .replace(/'/g, '&#039;')
29
- .replace(/\{/g, '&#123;')
30
- .replace(/\}/g, '&#125;')
31
- .replace(/\n/g, '<br>');
32
- } else if (value != null && typeof value.toString !== 'function') {
33
- return '[invalid]';
37
+ // null and undefined are passed through so that callers can tell a missing
38
+ // value apart from an empty one and fall back to their own default.
39
+ if (value == null) return value;
40
+ if (typeof value !== 'string') {
41
+ if (typeof value.toString !== 'function') {
42
+ return '[invalid]';
43
+ }
44
+ // Anything which is not a string still has to be escaped; a field holding a
45
+ // list or an object would otherwise reach the page as markup once it was
46
+ // stringified further down the line.
47
+ value = String(value);
34
48
  }
35
- return value;
49
+ return toSafeString(value)
50
+ // Entities which this function itself produces are left alone so that
51
+ // escaping an already-escaped value is a no-op. Consumer chains pass values
52
+ // through several templates and would otherwise encode them once per hop.
53
+ .replace(/&(?!(amp|lt|gt|quot|#039|#123|#125);)/g, '&amp;')
54
+ .replace(/</g, '&lt;')
55
+ .replace(/>/g, '&gt;')
56
+ .replace(safeBreakRegExp, '<$1>')
57
+ .replace(/"/g, '&quot;')
58
+ .replace(/'/g, '&#039;')
59
+ .replace(/\{/g, '&#123;')
60
+ .replace(/\}/g, '&#125;')
61
+ .replace(/\n/g, '<br>')
62
+ // The escaping above re-creates the brackets which toSafeString flattened,
63
+ // so the encoded form is flattened again here, once nothing further will
64
+ // produce it. A value such as &#123;{expression}&#125; would otherwise
65
+ // leave as two adjacent encoded brackets which the browser decodes back
66
+ // into a live expression for whichever component next evaluates it.
67
+ .replace(templateEncodedStartBracesRegExp, '[[')
68
+ .replace(templateEncodedEndBracesRegExp, ']]');
36
69
  }
37
70
 
38
71
  export function toExpression(value) {
@@ -307,7 +340,13 @@ function replaceExpressions(string, replaceFn) {
307
340
  let match = matches[i];
308
341
  charList.splice(match.startIndex, match.length, ...charSubs[i]);
309
342
  }
310
- return charList.join('');
343
+ // Adjacent brackets can also form where two separately escaped values meet,
344
+ // which no single-value pass can see, so the joined result is flattened too.
345
+ // Only the encoded form is touched; expressions written by the page author
346
+ // stay literal and remain available to nested components to evaluate later.
347
+ return charList.join('')
348
+ .replace(templateEncodedStartBracesRegExp, '[[')
349
+ .replace(templateEncodedEndBracesRegExp, ']]');
311
350
  }
312
351
 
313
352
  export function renderTemplate(templateString, data, socket, autoExecFunction) {
@@ -363,6 +402,12 @@ export function updateConsumerElements(consumers, value, template, sourceElement
363
402
  })
364
403
  .filter(([ selector, attributeName ]) => selector);
365
404
 
405
+ // A template is the supported way to send markup to a consumer: the template
406
+ // itself is written by the page author and is left intact, while the value
407
+ // substituted into it is escaped by the {{ }} expression. Without a template
408
+ // there is no author-written markup to preserve, so the value is escaped
409
+ // before it can reach the innerHTML of a consumer further down.
410
+ let isTemplateApplied = !!template;
366
411
  if (template) {
367
412
  value = renderTemplate.call(this, template, { value }, null, true);
368
413
  }
@@ -412,7 +457,7 @@ export function updateConsumerElements(consumers, value, template, sourceElement
412
457
  sourceElementName ? `${sourceElementName}:${value}` : value
413
458
  );
414
459
  } else {
415
- element.innerHTML = value;
460
+ element.innerHTML = isTemplateApplied ? value : toSafeHTML(value);
416
461
  }
417
462
  }
418
463
  }