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
7 changes: 6 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ on:
push:
branches: [main]

permissions:
contents: write
pull-requests: write
id-token: write

jobs:
release:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -73,7 +78,7 @@ jobs:
fi
- name: Create release PR or publish via Changesets
if: steps.release-mode.outputs.mode == 'changesets'
uses: changesets/action@v1
uses: changesets/action@6a0a831ff30acef54f2c6aa1cbbc1096b066edaf
with:
version: pnpm changeset:version
publish: pnpm -r publish --access public --provenance
Expand Down
6 changes: 4 additions & 2 deletions packages/components/src/lib/breadcrumbs/Breadcrumbs.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
getBreadcrumbsRecipeCase,
serializeBreadcrumbsSlotStyles
} from './breadcrumbs.recipe.js';
import { sanitizeHref } from '../shared/url.js';
import type { BreadcrumbsSize } from './breadcrumbs.spec.js';

export let items: BreadcrumbItem[] = [];
Expand All @@ -40,11 +41,12 @@
<ol>
{#each items as item, index (item.label + index)}
{@const current = index === resolvedCurrentIndex}
{@const safeHref = sanitizeHref(item.href)}
<li class="breadcrumb-item">
{#if current}
<span class="breadcrumb-current" style={slotStyles.current} aria-current="page">{item.label}</span>
{:else if item.href}
<a class="breadcrumb-link" style={slotStyles.item} href={item.href}>{item.label}</a>
{:else if safeHref}
<a class="breadcrumb-link" style={slotStyles.item} href={safeHref}>{item.label}</a>
{:else}
<span class="breadcrumb-link" style={slotStyles.item}>{item.label}</span>
{/if}
Expand Down
14 changes: 14 additions & 0 deletions packages/components/src/lib/breadcrumbs/Breadcrumbs.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,18 @@ describe('Breadcrumbs', () => {
expect(screen.getByRole('link', { name: 'Workspace' })).toBeTruthy();
expect(screen.getByText('Production').getAttribute('aria-current')).toBe('page');
});

it('renders unsafe href schemes as non-links', () => {
render(Breadcrumbs, {
props: {
items: [
{ label: 'Workspace', href: 'javascript:alert(1)' },
{ label: 'Production', current: true }
]
}
});

expect(screen.queryByRole('link', { name: 'Workspace' })).toBeNull();
expect(screen.getByText('Workspace').tagName).toBe('SPAN');
});
});
7 changes: 5 additions & 2 deletions packages/components/src/lib/button/Button.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
getButtonRecipeCase,
serializeButtonSlotStyles
} from './button.recipe.js';
import { sanitizeHref } from '../shared/url.js';
import type { ButtonContentMode, ButtonSize, ButtonVariant } from './button.spec.js';

export let variant: ButtonVariant = 'solid';
Expand All @@ -28,6 +29,7 @@
let contentMode: ButtonContentMode = 'label';
let busy = false;
let anchorRel: string | undefined = undefined;
let safeHref: string | undefined = undefined;
let compiledCase = getButtonRecipeCase(defaultRegistration.recipe, {
variant,
size,
Expand All @@ -41,7 +43,8 @@
throw new Error('Button with iconOnly=true requires ariaLabel.');
}

$: elementTag = href || as === 'a' ? 'a' : 'button';
$: safeHref = sanitizeHref(href);
$: elementTag = safeHref || as === 'a' ? 'a' : 'button';
$: contentMode = resolveContentMode(iconOnly, Boolean($$slots.leading), Boolean($$slots.trailing));
$: compiledCase = getButtonRecipeCase(registration.recipe, {
variant,
Expand Down Expand Up @@ -91,7 +94,7 @@
aria-busy={loading ? 'true' : undefined}
aria-disabled={busy ? 'true' : undefined}
aria-label={iconOnly ? ariaLabel : undefined}
href={href}
href={safeHref}
target={target}
rel={anchorRel}
tabindex={busy ? -1 : undefined}
Expand Down
8 changes: 8 additions & 0 deletions packages/components/src/lib/button/Button.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ describe('Button', () => {
expect(anchorRender.container.querySelector('a')?.getAttribute('href')).toBe('/docs');
});

it('drops unsafe href schemes before rendering anchor mode', () => {
const { container } = render(ButtonHarness, {
props: { theme, href: 'javascript:alert(1)', as: 'a', variant: 'link' }
});

expect(container.querySelector('a')?.hasAttribute('href')).toBe(false);
});

it('renders leading and trailing slots and preserves variant metadata', () => {
const { container } = render(ButtonHarness, {
props: { theme, variant: 'destructive', showLeading: true, showTrailing: true }
Expand Down
34 changes: 34 additions & 0 deletions packages/components/src/lib/shared/recipe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';

import type { CompiledComponentCase } from '@dkcli/core';

import { serializeCompiledSlotStyles } from './recipe.ts';

function compiledCase(vars: Record<string, string>): CompiledComponentCase {
return {
axes: {},
caseKey: '',
slots: {
root: {
baseVars: vars,
stateVars: {}
}
}
};
}

describe('shared recipe style serialization', () => {
it('serializes safe custom properties', () => {
expect(serializeCompiledSlotStyles(compiledCase({ '--demo-color': 'red' })).root).toBe('--demo-color: red;');
});

it('rejects CSS declaration breakout values', () => {
expect(() =>
serializeCompiledSlotStyles(compiledCase({ '--demo-color': 'red; } body { outline: 1px solid red; }' }))
).toThrow(/Unsafe CSS value/);
});

it('rejects unsafe custom property names', () => {
expect(() => serializeCompiledSlotStyles(compiledCase({ color: 'red' }))).toThrow(/Unsafe CSS custom property name/);
});
});
7 changes: 6 additions & 1 deletion packages/components/src/lib/shared/recipe.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
assertSafeCssCustomPropertyName,
assertSafeCssValue,
componentCaseKey,
serializeStateVarName,
type CompiledComponentCase,
Expand All @@ -8,7 +10,10 @@ import {

function styleString(vars: Record<string, string>): string {
return Object.entries(vars)
.map(([name, value]) => `${name}: ${value};`)
.map(([name, value]) => {
const propertyName = assertSafeCssCustomPropertyName(name, 'compiled component slot style');
return `${propertyName}: ${assertSafeCssValue(value, propertyName)};`;
})
.join(' ');
}

Expand Down
19 changes: 19 additions & 0 deletions packages/components/src/lib/shared/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const SAFE_HREF_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);

export function sanitizeHref(href: string | undefined): string | undefined {
const value = href?.trim();
if (!value) {
return undefined;
}

if (value.startsWith('#') || value.startsWith('/') || value.startsWith('./') || value.startsWith('../')) {
return value;
}

try {
const parsed = new URL(value);
return SAFE_HREF_PROTOCOLS.has(parsed.protocol) ? value : undefined;
} catch {
return undefined;
}
}
6 changes: 4 additions & 2 deletions packages/components/src/lib/toast/Toast.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import { resolveTokenExpr, type ThemeContract } from '@dkcli/core';

import { portal } from '../internal/behavior/index.js';
import { sanitizeHref } from '../shared/url.js';
import {
DEFAULT_TOAST_THEME,
createToastRegistration,
Expand Down Expand Up @@ -114,6 +115,7 @@
{#if visibleItems.length > 0}
<div class="dk-toast-stack" style={slotStyles.root} data-placement={placement} use:portal>
{#each visibleItems as item (item.id)}
{@const safeActionHref = sanitizeHref(item.actionHref)}
<article class="toast-item" style={`${slotStyles.item} ${toneStyles(item.tone)}`} role="status">
<div class="toast-copy">
<h3 class="toast-title" style={slotStyles.title}>{item.title}</h3>
Expand All @@ -122,8 +124,8 @@
{/if}

{#if item.actionLabel}
{#if item.actionHref}
<a class="toast-action" style={slotStyles.action} href={item.actionHref} onclick={() => handleAction(item.id)}>
{#if safeActionHref}
<a class="toast-action" style={slotStyles.action} href={safeActionHref} onclick={() => handleAction(item.id)}>
{item.actionLabel}
</a>
{:else}
Expand Down
23 changes: 23 additions & 0 deletions packages/components/src/lib/toast/Toast.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,27 @@ describe('Toast', () => {
await fireEvent.click(screen.getByRole('button', { name: /dismiss deployment queued/i }));
expect(onDismiss).toHaveBeenCalledWith({ id: 'deploy' });
});

it('renders unsafe action href schemes as buttons', async () => {
const onAction = vi.fn();

render(Toast, {
props: {
onAction,
items: [
{
id: 'deploy',
tone: 'brand',
title: 'Deployment queued',
actionLabel: 'Open',
actionHref: 'javascript:alert(1)'
}
]
}
});

expect(screen.queryByRole('link', { name: 'Open' })).toBeNull();
await fireEvent.click(screen.getByRole('button', { name: 'Open' }));
expect(onAction).toHaveBeenCalledWith({ id: 'deploy' });
});
});
99 changes: 99 additions & 0 deletions packages/core/src/component-compiler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';

import { compileComponentRecipe } from './component-compiler.ts';
import type { ComponentSpec, ComponentStateName, ThemeContract, TokenExpr } from './index.ts';

function literal(value: string | number): TokenExpr {
return { literal: value };
}

function makeTheme(): ThemeContract {
return {
name: 'test-theme',
seed: {
color: '#295dff',
contrastProfile: 'default',
density: 'comfortable',
mode: 'light',
motion: 'snappy',
ratio: 'perfect-fourth'
},
meta: {
density: 'comfortable',
mode: 'light',
optimizedSeed: '#295dff',
paletteScore: 1,
ratioName: 'perfect fourth',
ratioValue: 1.333
},
families: {
color: {},
elevation: {},
motion: {},
radius: {},
space: {},
state: {},
type: {}
},
aliases: {}
};
}

function makeSpec(overrides: Partial<ComponentSpec> = {}): ComponentSpec {
return {
id: 'demo',
slots: [{ name: 'root', kind: 'container', required: true }],
axes: [],
states: ['rest'],
recipe: {
root: [
{
style: {
'--demo-color': literal('red')
}
}
]
},
proofs: {},
a11y: { role: 'group' },
...overrides
};
}

describe('component recipe compiler safety', () => {
it('rejects runtime-only prototype state names before they can pollute globals', () => {
delete (Object.prototype as Record<string, unknown>)['--polluted'];
const states = ['__proto__' as ComponentStateName];
const matchStates = JSON.parse('{"__proto__":true}') as Partial<Record<ComponentStateName, boolean>>;
const spec = makeSpec({
states,
recipe: {
root: [
{
match: { states: matchStates },
style: { '--polluted': literal('yes') }
}
]
}
});

expect(() => compileComponentRecipe(spec, makeTheme())).toThrow(/Unsupported component state/);
expect((Object.prototype as Record<string, unknown>)['--polluted']).toBeUndefined();
});

it('rejects component recipe CSS declaration breakout values', () => {
const spec = makeSpec({
recipe: {
root: [
{
style: {
'--demo-color': literal('red; } body { outline: 1px solid red; }')
}
}
]
}
});

expect(() => compileComponentRecipe(spec, makeTheme())).toThrow(/Unsafe CSS value/);
});
});
Loading
Loading