@heyramzi/ui 3.9.0 → 3.9.1

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.
@@ -0,0 +1,13 @@
1
+ // WHY: Svelte writes an href into the page as given, and following an <a href="javascript:...">
2
+ // (or setting location to one) runs that script in the page's origin, with the viewer's session.
3
+ const SCRIPT_SCHEME = /^(?:javascript|vbscript):/i;
4
+ /**
5
+ * The href as given, or undefined when following it would run script. App routes, https,
6
+ * mailto and app schemes like descript: pass, so a link a component builds from data stays a link.
7
+ */
8
+ export function safeHref(href) {
9
+ if (!href) return undefined;
10
+ // WHY: the URL parser drops tabs, newlines and leading spaces or control bytes, so " java\tscript:" is javascript:.
11
+ const probe = [...href].filter((c) => c.charCodeAt(0) > 32 && c.charCodeAt(0) !== 127).join("");
12
+ return SCRIPT_SCHEME.test(probe) ? undefined : href;
13
+ }
@@ -4,6 +4,20 @@ import { formatDay, formatMoney } from "./format.js";
4
4
  function hasOptions(field) {
5
5
  return field.type === "choice" || field.type === "multi";
6
6
  }
7
+ const WEB_PROTOCOLS = new Set([
8
+ "http:",
9
+ "https:",
10
+ "mailto:"
11
+ ]);
12
+ /** A url field's link: http, https or mailto only. Anything else, javascript: above all, shows as text. */
13
+ function webLink(text) {
14
+ try {
15
+ const url = new URL(text);
16
+ return WEB_PROTOCOLS.has(url.protocol) ? url.href : undefined;
17
+ } catch {
18
+ return undefined;
19
+ }
20
+ }
7
21
  function isEmpty(value) {
8
22
  return value == null || value === "" || Array.isArray(value) && value.length === 0;
9
23
  }
@@ -107,7 +121,7 @@ export function fieldRow(field, value, ctx = {}) {
107
121
  break;
108
122
  case "url":
109
123
  row.value = String(value).replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, "");
110
- row.href = String(value);
124
+ row.href = webLink(String(value));
111
125
  break;
112
126
  case "email":
113
127
  row.value = String(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/ui",
3
- "version": "3.9.0",
3
+ "version": "3.9.1",
4
4
  "description": "Svelte 5 UI for apps: primitives, effects and animations, plus work (tasks and docs), charts, slides, an editor, onboarding and a canvas as subpaths.",
5
5
  "homepage": "https://ui.heyramzi.com",
6
6
  "license": "MIT",
@@ -42,6 +42,7 @@
42
42
  * optional pager. A row opens by `rowHref` or `onRowClick`, stretched over the row from the lead.
43
43
  */
44
44
  import { cn } from "../../lib/utils.js";
45
+ import { safeHref } from "../../lib/safe-href.js";
45
46
  import RatingChip from "./rating-chip.svelte";
46
47
  import Hint from "./hint.svelte";
47
48
  import {
@@ -216,7 +217,7 @@
216
217
  <tr class={cn(dataTrClass, "group", opens && "relative cursor-pointer")}>
217
218
  <td class={cn(dataTdClass, "py-3")}>
218
219
  {#if rowHref}
219
- <a href={rowHref(row)} class={stretch}>{@render lead(row)}</a>
220
+ <a href={safeHref(rowHref(row))} class={stretch}>{@render lead(row)}</a>
220
221
  {:else if onRowClick}
221
222
  <button type="button" class={stretch} onclick={() => onRowClick(row)}>
222
223
  {@render lead(row)}
@@ -6,6 +6,7 @@
6
6
  import { HoldToConfirm } from "../hold-to-confirm/index.js";
7
7
  import { ArrowUpRight, Copy, LinkSimple, Trash } from "phosphor-svelte";
8
8
  import { copyText } from "../copy/clipboard.js";
9
+ import { safeHref } from "../../lib/safe-href.js";
9
10
  import type { Snippet } from "svelte";
10
11
  import { MENU_KEYS, openMenuFromKey } from "./menu-key.js";
11
12
 
@@ -42,7 +43,8 @@
42
43
  // FUNCTIONS
43
44
  // =========================
44
45
  function handleOpen() {
45
- if (href) window.location.href = href;
46
+ const target = safeHref(href);
47
+ if (target) window.location.href = target;
46
48
  }
47
49
 
48
50
  // WHY the whole URL and not the path: what leaves this menu is pasted into a
@@ -68,7 +70,7 @@
68
70
  {/snippet}
69
71
  </ContextMenu.Trigger>
70
72
  <ContextMenu.Content class="w-48">
71
- {#if href}
73
+ {#if safeHref(href)}
72
74
  <ContextMenu.Item onclick={handleOpen}>
73
75
  <ArrowUpRight aria-hidden="true" size={14} />
74
76
  Open
@@ -25,6 +25,7 @@
25
25
  // =========================
26
26
  import { ArrowSquareOut } from "phosphor-svelte";
27
27
  import { cn } from "../../lib/utils.js";
28
+ import { safeHref } from "../../lib/safe-href.js";
28
29
  import { buttonVariants } from "../../shadcn/button/index.js";
29
30
 
30
31
  // =========================
@@ -78,7 +79,7 @@
78
79
  onkeydown={(e) => e.stopPropagation()}
79
80
  >
80
81
  <a
81
- {href}
82
+ href={safeHref(href)}
82
83
  target="_blank"
83
84
  rel="noopener noreferrer"
84
85
  aria-label={action}
@@ -38,6 +38,7 @@
38
38
  } from "../thread/index.js";
39
39
  import InboxCard from "../work/inbox/inbox-card.svelte";
40
40
  import { whenOf } from "../work/inbox/inbox-logic.js";
41
+ import { safeHref } from "../lib/safe-href.js";
41
42
  import type { InboxItem, InboxVerdict } from "../work/inbox/inbox-types.js";
42
43
  import {
43
44
  NO_FILTER,
@@ -490,8 +491,8 @@
490
491
  <Check aria-hidden="true" />{labels.clear}
491
492
  </Button>
492
493
  {/if}
493
- {#if current.subject.href}
494
- <Button size="sm" variant="ghost" href={current.subject.href}>
494
+ {#if safeHref(current.subject.href)}
495
+ <Button size="sm" variant="ghost" href={safeHref(current.subject.href)}>
495
496
  <ArrowSquareOut aria-hidden="true" />{labels.open}
496
497
  </Button>
497
498
  {/if}
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { safeHref } from "./safe-href.js";
3
+
4
+ describe("safeHref", () => {
5
+ it.each([
6
+ "/clients/42",
7
+ "#notes",
8
+ "https://example.com/a?b=1",
9
+ "mailto:hi@example.com",
10
+ "tel:+33123456789",
11
+ "descript://project/abc",
12
+ ])("keeps %s, which runs no script", (href) => {
13
+ expect(safeHref(href)).toBe(href);
14
+ });
15
+
16
+ // The record url finding's payload, and the spellings the URL parser reads as the same scheme.
17
+ it.each([
18
+ "javascript:alert(document.domain)",
19
+ "JavaScript:alert(1)",
20
+ " javascript:alert(1)",
21
+ "java\tscript:alert(1)",
22
+ "java\nscript:alert(1)",
23
+ "\u0001javascript:alert(1)",
24
+ "vbscript:msgbox(1)",
25
+ ])("drops %j, which runs script when followed", (href) => {
26
+ expect(safeHref(href)).toBeUndefined();
27
+ });
28
+
29
+ it("treats a missing href as no link", () => {
30
+ expect(safeHref(null)).toBeUndefined();
31
+ expect(safeHref(undefined)).toBeUndefined();
32
+ expect(safeHref("")).toBeUndefined();
33
+ });
34
+ });
@@ -0,0 +1,14 @@
1
+ // WHY: Svelte writes an href into the page as given, and following an <a href="javascript:...">
2
+ // (or setting location to one) runs that script in the page's origin, with the viewer's session.
3
+ const SCRIPT_SCHEME = /^(?:javascript|vbscript):/i;
4
+
5
+ /**
6
+ * The href as given, or undefined when following it would run script. App routes, https,
7
+ * mailto and app schemes like descript: pass, so a link a component builds from data stays a link.
8
+ */
9
+ export function safeHref(href: string | null | undefined): string | undefined {
10
+ if (!href) return undefined;
11
+ // WHY: the URL parser drops tabs, newlines and leading spaces or control bytes, so " java\tscript:" is javascript:.
12
+ const probe = [...href].filter((c) => c.charCodeAt(0) > 32 && c.charCodeAt(0) !== 127).join("");
13
+ return SCRIPT_SCHEME.test(probe) ? undefined : href;
14
+ }
@@ -15,6 +15,18 @@ function hasOptions(field: FieldDef): field is Extract<FieldDef, { type: "choice
15
15
 
16
16
  export type FieldValues = Record<string, FieldValue | undefined>;
17
17
 
18
+ const WEB_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
19
+
20
+ /** A url field's link: http, https or mailto only. Anything else, javascript: above all, shows as text. */
21
+ function webLink(text: string): string | undefined {
22
+ try {
23
+ const url = new URL(text);
24
+ return WEB_PROTOCOLS.has(url.protocol) ? url.href : undefined;
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ }
29
+
18
30
  function isEmpty(value: FieldValue | undefined): boolean {
19
31
  return value == null || value === "" || (Array.isArray(value) && value.length === 0);
20
32
  }
@@ -144,7 +156,7 @@ export function fieldRow(
144
156
  row.value = String(value)
145
157
  .replace(/^https?:\/\/(www\.)?/, "")
146
158
  .replace(/\/$/, "");
147
- row.href = String(value);
159
+ row.href = webLink(String(value));
148
160
  break;
149
161
  case "email":
150
162
  row.value = String(value);
@@ -14,6 +14,7 @@
14
14
  import { TagsInput } from "../components/tags-input/index.js";
15
15
  import StatusBadge from "../status/status-badge.svelte";
16
16
  import { resolveLabels, type InfoRow, type RecordLabels } from "./types.js";
17
+ import { safeHref } from "../lib/safe-href.js";
17
18
 
18
19
  // =========================
19
20
  // PROPS
@@ -171,9 +172,9 @@
171
172
  <StatusBadge tone={row.tone} capitalize={false} class="w-fit max-w-full"
172
173
  >{row.value}</StatusBadge
173
174
  >
174
- {:else if row.href && row.value}
175
+ {:else if safeHref(row.href) && row.value}
175
176
  <a
176
- href={row.href}
177
+ href={safeHref(row.href)}
177
178
  title={row.value}
178
179
  class={cn(
179
180
  "min-w-0 truncate text-sm font-medium underline-offset-2 hover:underline",
@@ -0,0 +1,26 @@
1
+ import { flushSync, mount, unmount } from "svelte";
2
+ import { describe, expect, it } from "vite-plus/test";
3
+ import InfoList from "./info-list.svelte";
4
+
5
+ describe("InfoList", () => {
6
+ // A host can fill InfoRow.href itself, so the list checks the scheme at the anchor too.
7
+ it("draws a javascript: href as plain text and keeps an https one a link", () => {
8
+ const target = document.createElement("div");
9
+ document.body.appendChild(target);
10
+ const app = mount(InfoList, {
11
+ target,
12
+ props: {
13
+ rows: [
14
+ { label: "Website", value: "click me", href: "javascript:alert(document.domain)" },
15
+ { label: "Docs", value: "example.com", href: "https://example.com/" },
16
+ ],
17
+ },
18
+ });
19
+ flushSync();
20
+ const hrefs = [...target.querySelectorAll("a")].map((a) => a.getAttribute("href"));
21
+ expect(hrefs).toEqual(["https://example.com/"]);
22
+ expect(target.textContent).toContain("click me");
23
+ void unmount(app);
24
+ target.remove();
25
+ });
26
+ });
@@ -10,6 +10,7 @@
10
10
  import { formatRange } from "./format.js";
11
11
  import { todayIso } from "../work/dates.js";
12
12
  import { resolveLabels, type RecordLabels, type RecordNext } from "./types.js";
13
+ import { safeHref } from "../lib/safe-href.js";
13
14
 
14
15
  // =========================
15
16
  // PROPS
@@ -61,8 +62,8 @@
61
62
  >{when}</span
62
63
  >
63
64
  {/if}
64
- {#if next.href}
65
- <a href={next.href} class="min-w-0 truncate underline-offset-2 hover:underline"
65
+ {#if safeHref(next.href)}
66
+ <a href={safeHref(next.href)} class="min-w-0 truncate underline-offset-2 hover:underline"
66
67
  >{next.label}</a
67
68
  >
68
69
  {:else}
@@ -3,6 +3,7 @@
3
3
  // IMPORTS
4
4
  // =========================
5
5
  import { cn } from "../lib/utils.js";
6
+ import { safeHref } from "../lib/safe-href.js";
6
7
  import { Progress } from "../shadcn/progress/index.js";
7
8
  import { TONE_TEXT } from "../lib/tone.js";
8
9
  import StatusBadge from "../status/status-badge.svelte";
@@ -115,10 +116,11 @@
115
116
  <ul class={cn("flex flex-col gap-1", className)}>
116
117
  {#each projects as project (project.id)}
117
118
  {@const active = project.id === activeId}
119
+ {@const href = safeHref(project.href)}
118
120
  <li>
119
- {#if project.href}
121
+ {#if href}
120
122
  <a
121
- href={project.href}
123
+ {href}
122
124
  aria-current={active ? "page" : undefined}
123
125
  class={cn(
124
126
  "flex flex-col gap-1.5 rounded-lg px-2 py-2 transition-colors any-pointer-coarse:py-3",
@@ -8,6 +8,7 @@
8
8
  import type { Tone } from "../lib/tone.js";
9
9
  import StatusBadge from "../status/status-badge.svelte";
10
10
  import { gutterClass } from "./gutter.js";
11
+ import { safeHref } from "../lib/safe-href.js";
11
12
 
12
13
  // =========================
13
14
  // PROPS
@@ -45,7 +46,7 @@
45
46
  <header class={cn("flex flex-col gap-2 border-b border-border py-3", gutterClass, className)}>
46
47
  {#if parent}
47
48
  <a
48
- href={parent.href}
49
+ href={safeHref(parent.href)}
49
50
  class="-ml-1 inline-flex min-h-6 w-fit max-w-full items-center gap-0.5 rounded-sm text-sm text-muted-foreground transition-colors hover:text-foreground any-pointer-coarse:min-h-11"
50
51
  >
51
52
  <CaretLeft class="size-3.5 shrink-0" aria-hidden="true" />
@@ -14,6 +14,7 @@
14
14
  import { DEFAULT_RECORD_FILTERS, DEFAULT_RECORD_KINDS } from "./kinds.js";
15
15
  import { formatDayHeader, formatMoney, formatTime } from "./format.js";
16
16
  import { measureClamp } from "./measure-clamp.js";
17
+ import { safeHref } from "../lib/safe-href.js";
17
18
  import {
18
19
  dayRelation,
19
20
  eventsForFilter,
@@ -172,9 +173,9 @@
172
173
  {@render custom(event)}
173
174
  {:else}
174
175
  <p class="text-sm leading-snug font-medium wrap-anywhere">
175
- {#if event.href}
176
+ {#if safeHref(event.href)}
176
177
  <a
177
- href={event.href}
178
+ href={safeHref(event.href)}
178
179
  class="-my-1 inline-block py-1 underline-offset-2 hover:underline"
179
180
  >{event.title}</a
180
181
  >
@@ -235,7 +236,7 @@
235
236
  {#if showProject && event.projectName}
236
237
  <Badge
237
238
  variant="outline"
238
- href={event.projectHref ?? undefined}
239
+ href={safeHref(event.projectHref)}
239
240
  class="max-w-48 truncate"
240
241
  >
241
242
  {event.projectName}
@@ -423,6 +423,25 @@ describe("fields", () => {
423
423
  expect(saved).toEqual([["budget", 9000]]);
424
424
  });
425
425
 
426
+ // The audit's finding: a url field rendered whatever scheme an editor saved as a live link.
427
+ it("links a url field only to http, https or mailto, and shows anything else as text", () => {
428
+ const site: FieldDef = { key: "site", label: "Website", type: "url" };
429
+ expect(fieldRow(site, "https://www.example.com/").href).toBe("https://www.example.com/");
430
+ expect(fieldRow(site, "http://example.com").href).toBe("http://example.com/");
431
+ expect(fieldRow(site, "mailto:hi@example.com").href).toBe("mailto:hi@example.com");
432
+ for (const hostile of [
433
+ "javascript:alert(document.domain)",
434
+ " javascript:alert(1)",
435
+ "java\tscript:alert(1)",
436
+ "data:text/html,<script>alert(1)</script>",
437
+ "vbscript:msgbox(1)",
438
+ ]) {
439
+ const row = fieldRow(site, hostile);
440
+ expect(row.href).toBeUndefined();
441
+ expect(row.value).toBe(hostile);
442
+ }
443
+ });
444
+
426
445
  it("leaves a row read-only when no onSave is given, and empty values undrawn", () => {
427
446
  const row = fieldRow(size, null);
428
447
  expect(row.onSave).toBeUndefined();
@@ -11,6 +11,7 @@
11
11
  import { formatAge } from "./format.js";
12
12
  import { measureClamp } from "./measure-clamp.js";
13
13
  import { resolveLabels, type RecordLabels } from "./types.js";
14
+ import { safeHref } from "../lib/safe-href.js";
14
15
 
15
16
  // =========================
16
17
  // PROPS
@@ -74,9 +75,9 @@
74
75
  >
75
76
  <span class="font-medium text-foreground">{t.whereWeAre}</span>
76
77
  {#if project}
77
- {#if project.href}
78
+ {#if safeHref(project.href)}
78
79
  <a
79
- href={project.href}
80
+ href={safeHref(project.href)}
80
81
  class="font-medium text-foreground underline-offset-2 hover:underline"
81
82
  >{project.name}</a
82
83
  >
@@ -2,6 +2,7 @@
2
2
  import * as TraceUI from "../shadcn/trace/index.js";
3
3
  import { TagChip } from "../components/chip/index.js";
4
4
  import type { ThreadEvent } from "./types.js";
5
+ import { safeHref } from "../lib/safe-href.js";
5
6
 
6
7
  interface Props {
7
8
  events: ThreadEvent[];
@@ -34,8 +35,9 @@
34
35
  {#if event.record}
35
36
  {@const record = event.record}
36
37
  {@const [before, after] = split(event)}
37
- {before}{#if record.href}<a
38
- href={record.href}
38
+ {@const recordHref = safeHref(record.href)}
39
+ {before}{#if recordHref}<a
40
+ href={recordHref}
39
41
  class="rounded-sm align-middle outline-none focus-visible:shadow-[var(--ring-focus)]"
40
42
  ><TagChip>
41
43
  {#snippet icon()}{#if record.icon}{@const RecordIcon = record.icon}<RecordIcon
@@ -2,6 +2,7 @@
2
2
  import { Button } from "../shadcn/button/index.js";
3
3
  import StatusBadge from "../status/status-badge.svelte";
4
4
  import { cn } from "../lib/utils.js";
5
+ import { safeHref } from "../lib/safe-href.js";
5
6
  import {
6
7
  DEFAULT_TRIAGE_TASK_LABELS,
7
8
  type TriageOutcome,
@@ -72,7 +73,7 @@
72
73
  <StatusBadge tone={outcome.tone} capitalize={false}>{outcome.label}</StatusBadge>
73
74
  {#if task}
74
75
  <a
75
- href={task.url}
76
+ href={safeHref(task.url)}
76
77
  target="_blank"
77
78
  rel="noreferrer"
78
79
  class="text-primary min-w-0 text-xs [overflow-wrap:anywhere] hover:underline"
@@ -32,6 +32,7 @@
32
32
  import { FolderSimple } from "phosphor-svelte";
33
33
  import { Copied } from "../../components/copy/copied.svelte.js";
34
34
  import type { InboxDraftEdits, InboxItem, InboxVerdict } from "./inbox-types.js";
35
+ import { safeHref } from "../../lib/safe-href.js";
35
36
 
36
37
  interface Props {
37
38
  item: InboxItem;
@@ -261,9 +262,9 @@
261
262
  {/if}
262
263
  <span class={FACT}>
263
264
  <!-- The linked record is a chip inside the line, a link when the project has a page. -->
264
- {#if item.project.href}
265
+ {#if safeHref(item.project.href)}
265
266
  <a
266
- href={item.project.href}
267
+ href={safeHref(item.project.href)}
267
268
  class={workLooks.chipLink}
268
269
  aria-label="Project: {item.project.name}"
269
270
  >
@@ -2,6 +2,7 @@
2
2
  import { Brain } from "phosphor-svelte";
3
3
  import * as Trace from "../../shadcn/trace/index.js";
4
4
  import type { InboxTrace } from "./inbox-types.js";
5
+ import { safeHref } from "../../lib/safe-href.js";
5
6
 
6
7
  interface Props {
7
8
  trace: InboxTrace;
@@ -30,12 +31,13 @@
30
31
  </Trace.CollapsibleTrigger>
31
32
  <Trace.CollapsibleContent class="mt-3 mb-0 ml-0 border-l-2 pr-0 pl-3">
32
33
  <div class="flex flex-col gap-3">
33
- {#if label || trace.source.href}
34
+ {#if label || safeHref(trace.source.href)}
34
35
  <p class="text-foreground">
35
36
  {label}
36
- {#if trace.source.href}
37
- <a href={trace.source.href} class="text-foreground underline underline-offset-2"
38
- >Open source</a
37
+ {#if safeHref(trace.source.href)}
38
+ <a
39
+ href={safeHref(trace.source.href)}
40
+ class="text-foreground underline underline-offset-2">Open source</a
39
41
  >
40
42
  {/if}
41
43
  </p>