Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions configurator/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@
<div class="{embedded ? 'w-full h-full' : 'w-screen h-screen'} flex flex-col overflow-hidden bg-slate-50 dark:bg-[#0a0a0f] text-slate-800 dark:text-slate-200 font-sans">
<!-- Top header bar -->
<StudioHeader
{overrides}
{overridesCount}
{canUndo}
{canRedo}
Expand Down
3 changes: 2 additions & 1 deletion configurator/src/components/panels/ExportPanel.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { Check, Copy, Download, Link } from '@lucide/svelte';
import { generateCSS, buildShareUrl } from '../../lib/codec';
import { getShareBaseUrl } from '../../lib/persistence';

let { overrides }: {
overrides: Record<string, string>;
Expand All @@ -23,7 +24,7 @@

async function handleCopyLink() {
try {
const url = buildShareUrl(overrides);
const url = buildShareUrl(overrides, getShareBaseUrl());
await navigator.clipboard.writeText(url);
copiedLink = true;
setTimeout(() => { copiedLink = false; }, 2000);
Expand Down
44 changes: 24 additions & 20 deletions configurator/src/components/shell/StudioHeader.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
import { tick } from 'svelte';
import { Undo2, Redo2, Trash2, Share2, FolderOpen, Check, Save, Loader2, AlertTriangle, Sun, Moon } from '@lucide/svelte';
import { themeState, toggleTheme } from '../../lib/theme.svelte';
import { buildShareUrl } from '../../lib/codec';
import { getShareBaseUrl } from '../../lib/persistence';

const version = typeof __SLASHED_VERSION__ !== "undefined" ? __SLASHED_VERSION__ : "";

let { overridesCount, canUndo, canRedo, hasPendingChanges, saveState, onUndo, onRedo, onResetAll, onImport, onExport, onSave }: {
let { overrides, overridesCount, canUndo, canRedo, hasPendingChanges, saveState, onUndo, onRedo, onResetAll, onImport, onExport, onSave }: {
overrides: Record<string, string>;
overridesCount: number;
canUndo: boolean;
canRedo: boolean;
Expand All @@ -32,7 +35,8 @@

async function handleShare() {
try {
await navigator.clipboard.writeText(window.location.href);
const url = buildShareUrl(overrides, getShareBaseUrl());
await navigator.clipboard.writeText(url);
shareFeedback = true;
setTimeout(() => { shareFeedback = false; }, 2000);
} catch {
Expand Down Expand Up @@ -83,13 +87,13 @@

<div class="flex-1"></div>

<div class="flex items-center gap-1.5">
<div class="flex items-center gap-1.5 min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<button
onclick={onSave}
disabled={!hasPendingChanges || saveState === 'saving'}
title={saveState === 'error' ? "Save failed — click to retry" : hasPendingChanges ? "Save changes (Ctrl+S)" : "No unsaved changes"}
class={[
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold transition-colors cursor-pointer",
"flex items-center gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] font-bold transition-colors cursor-pointer shrink-0",
saveState === 'error'
? "bg-red-100 dark:bg-red-900/40 border border-red-500/30 text-red-700 dark:text-red-300"
: saveState === 'saved'
Expand All @@ -100,43 +104,43 @@
].join(' ')}
>
{#if saveState === 'saving'}
<Loader2 class="w-3.5 h-3.5 animate-spin" />
Saving…
<Loader2 class="w-3.5 h-3.5 animate-spin shrink-0" />
<span class="hidden sm:inline">Saving…</span>
{:else if saveState === 'saved'}
<Check class="w-3.5 h-3.5" />
Saved
<Check class="w-3.5 h-3.5 shrink-0" />
<span class="hidden sm:inline">Saved</span>
{:else if saveState === 'error'}
<AlertTriangle class="w-3.5 h-3.5" />
Save failed
<AlertTriangle class="w-3.5 h-3.5 shrink-0" />
<span class="hidden sm:inline">Save failed</span>
{:else}
<Save class="w-3.5 h-3.5" />
Save
<Save class="w-3.5 h-3.5 shrink-0" />
<span class="hidden sm:inline">Save</span>
{/if}
</button>

<button
onclick={onUndo}
disabled={!canUndo}
title="Undo (Ctrl+Z)"
class="p-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-black/8 dark:hover:bg-white/8 disabled:opacity-25 disabled:pointer-events-none transition-all cursor-pointer"
class="p-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-black/8 dark:hover:bg-white/8 disabled:opacity-25 disabled:pointer-events-none transition-all cursor-pointer shrink-0"
>
<Undo2 class="w-3.5 h-3.5" />
</button>
<button
onclick={onRedo}
disabled={!canRedo}
title="Redo (Ctrl+Shift+Z)"
class="p-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-black/8 dark:hover:bg-white/8 disabled:opacity-25 disabled:pointer-events-none transition-all cursor-pointer"
class="p-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-black/8 dark:hover:bg-white/8 disabled:opacity-25 disabled:pointer-events-none transition-all cursor-pointer shrink-0"
>
<Redo2 class="w-3.5 h-3.5" />
</button>

<div class="w-px h-4 bg-black/10 dark:bg-white/10 mx-1"></div>
<div class="w-px h-4 bg-black/10 dark:bg-white/10 mx-1 shrink-0"></div>

<button
onclick={handleShare}
title="Copy share link"
class="p-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-black/8 dark:hover:bg-white/8 transition-all cursor-pointer"
class="p-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-black/8 dark:hover:bg-white/8 transition-all cursor-pointer shrink-0"
>
{#if shareFeedback}
<Check class="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" />
Expand All @@ -148,7 +152,7 @@
<button
onclick={onImport}
title="Import CSS overrides"
class="p-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-black/8 dark:hover:bg-white/8 transition-all cursor-pointer"
class="p-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-black/8 dark:hover:bg-white/8 transition-all cursor-pointer shrink-0"
>
<FolderOpen class="w-3.5 h-3.5" />
</button>
Expand All @@ -157,19 +161,19 @@
onclick={handleResetAllClick}
disabled={overridesCount === 0}
title="Reset all overrides"
class="p-1.5 rounded-lg text-rose-600 dark:text-rose-400 hover:text-rose-700 dark:hover:text-rose-300 hover:bg-rose-500/10 disabled:opacity-25 disabled:pointer-events-none transition-all cursor-pointer"
class="p-1.5 rounded-lg text-rose-600 dark:text-rose-400 hover:text-rose-700 dark:hover:text-rose-300 hover:bg-rose-500/10 disabled:opacity-25 disabled:pointer-events-none transition-all cursor-pointer shrink-0"
>
<Trash2 class="w-3.5 h-3.5" />
</button>

<div class="w-px h-4 bg-black/10 dark:bg-white/10 mx-1"></div>
<div class="w-px h-4 bg-black/10 dark:bg-white/10 mx-1 shrink-0"></div>

<button
onclick={toggleTheme}
title={themeState.value === "dark" ? "Switch to light mode" : "Switch to dark mode"}
aria-label={themeState.value === "dark" ? "Switch to light mode" : "Switch to dark mode"}
aria-pressed={themeState.value === "dark"}
class="p-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-black/8 dark:hover:bg-white/8 transition-all cursor-pointer"
class="p-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-black/8 dark:hover:bg-white/8 transition-all cursor-pointer shrink-0"
>
{#if themeState.value === "dark"}
<Sun class="w-3.5 h-3.5" />
Expand Down
34 changes: 34 additions & 0 deletions configurator/src/lib/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export function computeDerivedOverrides(ov: Record<string, string>, { reduceMoti
interface SlashedAppBoot {
rest?: { url?: string; nonce?: string };
overrides?: Record<string, string>;
pluginSettings?: { configurator_url?: string };
}

function wpBoot(): SlashedAppBoot | null {
Expand All @@ -185,6 +186,39 @@ export function isEmbedded(): boolean {
return Boolean(wpBoot()?.rest?.url);
}

/**
* Base URL share links should point at. Embedded hosts (e.g. the WP plugin)
* persist overrides server-side rather than in the URL hash (see
* saveStandalone() below, which the WP save path skips entirely), so the
* current page's URL is a logged-in admin screen, not something worth
* sharing — use the host's public standalone configurator URL instead.
* Returns undefined in standalone mode, where buildShareUrl()'s own
* window.location.href fallback is already correct.
*
* The value is host-controlled boot data, so validate it before handing it to
* buildShareUrl()'s `new URL(baseUrl)`: a relative/malformed string would throw
* (silently swallowed by the share/copy handlers, so nothing gets copied and
* the user gets no feedback), and a non-http(s) scheme (javascript:, data:…)
* would be faithfully propagated into a copied "share link". On any of those,
* return undefined so buildShareUrl() falls back to the current page URL.
*/
export function getShareBaseUrl(): string | undefined {
const raw = wpBoot()?.pluginSettings?.configurator_url;
const trimmed = raw?.trim();
if (!trimmed) return undefined;
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
console.warn(`[slashed] ignoring configurator_url with unsupported scheme: ${parsed.protocol}`);
return undefined;
}
return trimmed;
} catch {
console.warn(`[slashed] ignoring invalid configurator_url: ${trimmed}`);
return undefined;
}
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Comment on lines +205 to +220

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Unsafe url schemes copied 🐞 Bug ⛨ Security

configurator_url is accepted without protocol validation, so a host can supply
javascript:/data:/other non-http(s) schemes that will be copied as a “share link”. This is
primarily a misconfiguration/phishing footgun, since the app will faithfully propagate the unsafe
scheme into the copied URL.
Agent Prompt
## Issue description
The host-controlled `pluginSettings.configurator_url` is not validated. If it contains a non-http(s) scheme (e.g. `javascript:`), the app will copy that scheme into the generated share link.

## Issue Context
Even though the app only copies the string to the clipboard, users may paste/open it, so it’s worth preventing obviously unsafe schemes.

## Fix Focus Areas
- configurator/src/lib/persistence.ts[189-201]
- configurator/src/lib/codec.ts[278-285]

## Recommended fix
- When parsing/normalizing the base URL (either in `getShareBaseUrl()` or `buildShareUrl()`), explicitly allow only `http:` and `https:` protocols.
- If the parsed protocol is not allowed (or parsing fails), return `undefined` / fall back to `window.location.href` so share links remain functional.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d96eba6getShareBaseUrl() now allowlists only http:/https: schemes; a javascript:/data: value is rejected (warned + undefined) rather than propagated into a copied link.


Generated by Claude Code


Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* Whether a host (e.g. the WP admin page) mounted us into its own container,
* regardless of whether REST persistence is configured. This is the same
Expand Down
1 change: 1 addition & 0 deletions configurator/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface Window {
rest?: { url?: string; nonce?: string };
overrides?: Record<string, string>;
versions?: { plugin?: string; framework?: string; css_source?: string };
pluginSettings?: { configurator_url?: string; [key: string]: unknown };
[key: string]: unknown;
};
}
35 changes: 34 additions & 1 deletion configurator/tests-components/header.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@
* Component smoke test for StudioHeader.
* Verifies that the header renders the branding text and responds to props.
*/
import { describe, test, expect, vi } from 'vitest';
import { describe, test, expect, vi, afterEach } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import StudioHeader from '../src/components/shell/StudioHeader.svelte';
import data from '../src/data/api-index.generated.json';

const realToken = data.tokens.find((t) => t.role === 'knob')?.name ?? data.tokens[0].name;

const baseProps = {
overrides: {},
overridesCount: 0,
canUndo: false,
canRedo: false,
Expand Down Expand Up @@ -93,4 +97,33 @@ describe('StudioHeader', () => {
expect(screen.getByTitle('Save changes (Ctrl+S)')).toBeDisabled();
});
});

// SL-shareLink: the share button must copy a config link built from the
// *live* overrides (via buildShareUrl), not a raw copy of the current
// page's URL — a plain window.location.href never carries the override
// code in embedded hosts, which persist overrides server-side instead of
// in the URL hash.
describe('share link', () => {
afterEach(() => {
delete window.slashedApp;
});

test('copies a link that carries the current overrides as a config code', async () => {
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue();
render(StudioHeader, { props: { ...baseProps, overrides: { [realToken]: '1.25rem' } } });
await screen.getByTitle('Copy share link').click();
expect(writeText).toHaveBeenCalledOnce();
expect(writeText.mock.calls[0][0]).toContain('#c=');
});

test('points at the host configurator URL when embedded (e.g. the WP plugin)', async () => {
window.slashedApp = { pluginSettings: { configurator_url: 'https://slashed.codeslash.dev/configurator/' } };
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue();
render(StudioHeader, { props: { ...baseProps, overrides: { [realToken]: '1.25rem' } } });
await screen.getByTitle('Copy share link').click();
const url = writeText.mock.calls[0][0];
expect(url.startsWith('https://slashed.codeslash.dev/configurator/')).toBe(true);
expect(url).toContain('#c=');
});
});
});
30 changes: 29 additions & 1 deletion configurator/tests/persistence.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
import registry from '../src/data/token-registry.generated.json';
import { encodeOverrides } from '../src/lib/codec';
import { loadInitialOverrides, saveOverrides, isEmbedded, hasWpBoot } from '../src/lib/persistence';
import { loadInitialOverrides, saveOverrides, isEmbedded, hasWpBoot, getShareBaseUrl } from '../src/lib/persistence';

const LS_KEY = 'slashed-studio/overrides/v2';
const active = registry.tokens.filter((t) => !t.removed);
Expand Down Expand Up @@ -171,3 +171,31 @@ describe('isEmbedded / hasWpBoot', () => {
expect(isEmbedded()).toBe(true);
});
});

describe('getShareBaseUrl', () => {
test('returns undefined in standalone mode (no host boot)', () => {
expect(getShareBaseUrl()).toBeUndefined();
});

test('returns undefined when configurator_url is absent or blank', () => {
window.slashedApp = { pluginSettings: {} };
expect(getShareBaseUrl()).toBeUndefined();
window.slashedApp = { pluginSettings: { configurator_url: ' ' } };
expect(getShareBaseUrl()).toBeUndefined();
});

test('returns a valid absolute http(s) configurator_url', () => {
window.slashedApp = { pluginSettings: { configurator_url: 'https://slashed.codeslash.dev/configurator/' } };
expect(getShareBaseUrl()).toBe('https://slashed.codeslash.dev/configurator/');
});

test('rejects a relative/malformed configurator_url so buildShareUrl can fall back', () => {
window.slashedApp = { pluginSettings: { configurator_url: '/configurator' } };
expect(getShareBaseUrl()).toBeUndefined();
});

test('rejects a non-http(s) scheme (e.g. javascript:) that must never be copied as a link', () => {
window.slashedApp = { pluginSettings: { configurator_url: 'javascript:alert(1)' } };
expect(getShareBaseUrl()).toBeUndefined();
});
});