From 6c8cb416131691cf720f20ec23aa19ddaa5848e4 Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 5 Jun 2026 00:01:57 -0500 Subject: [PATCH 1/4] fix(usePermissions): guard permission keys against prototype pollution createPermissions built a nested plain object keyed by caller-supplied role/action/subject names with no guard, so a __proto__ key in a permissions config (e.g. one sourced from a backend) could pollute Object.prototype. Skip keys in the shared UNSAFE_KEYS blocklist at all three levels; export UNSAFE_KEYS from #v0/utilities so it is reused rather than duplicated. --- packages/0/src/composables/usePermissions/index.ts | 6 ++++++ packages/0/src/utilities/helpers.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/0/src/composables/usePermissions/index.ts b/packages/0/src/composables/usePermissions/index.ts index 49100c6d9..b12f0a4b3 100644 --- a/packages/0/src/composables/usePermissions/index.ts +++ b/packages/0/src/composables/usePermissions/index.ts @@ -34,6 +34,9 @@ import { V0PermissionsAdapter } from '#v0/composables/usePermissions/adapters' // Transformers import { toArray } from '#v0/composables/toArray' +// Utilities +import { UNSAFE_KEYS } from '#v0/utilities' + // Types import type { TokenContext, TokenOptions, TokenTicket } from '#v0/composables/createTokens' import type { PermissionsAdapter } from '#v0/composables/usePermissions/adapters' @@ -87,10 +90,13 @@ export function createPermissions (_options: PermissionOptions = {}): Permission const record: Record) => boolean)>>> = {} for (const role in permissions) { + if (UNSAFE_KEYS.has(role)) continue if (!record[role]) record[role] = {} for (const [actions, subjects, condition = true] of permissions[role]!) { for (const action of toArray(actions)) { + if (UNSAFE_KEYS.has(action)) continue for (const subject of toArray(subjects)) { + if (UNSAFE_KEYS.has(subject)) continue if (!record[role][action]) record[role][action] = {} record[role][action][subject] = condition diff --git a/packages/0/src/utilities/helpers.ts b/packages/0/src/utilities/helpers.ts index 9d1b7526e..740cbd46b 100644 --- a/packages/0/src/utilities/helpers.ts +++ b/packages/0/src/utilities/helpers.ts @@ -308,7 +308,7 @@ export function isNaN (item: unknown): item is number { } // Keys that could lead to prototype pollution -const UNSAFE_KEYS = /* @__PURE__ */ new Set(['__proto__', 'constructor', 'prototype']) +export const UNSAFE_KEYS = /* @__PURE__ */ new Set(['__proto__', 'constructor', 'prototype']) function isPlainObject (value: unknown): value is Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false From 1f5f99a5dad10465c128ee55914005f2f8630b53 Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 5 Jun 2026 00:01:57 -0500 Subject: [PATCH 2/4] fix(createRating): bound item allocation against hostile size range(toValue(size)) allocated an unbounded array, so a large or non-finite size could exhaust memory. Clamp the count to a finite upper bound before allocating, mirroring the guard in createPagination. --- packages/0/src/composables/createRating/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/0/src/composables/createRating/index.ts b/packages/0/src/composables/createRating/index.ts index f456df256..0ac56d324 100644 --- a/packages/0/src/composables/createRating/index.ts +++ b/packages/0/src/composables/createRating/index.ts @@ -39,6 +39,9 @@ import { computed, isRef, shallowRef, toRef, toValue } from 'vue' import type { ContextTrinity } from '#v0/composables/createTrinity' import type { ComputedRef, MaybeRefOrGetter, Ref, ShallowRef, WritableComputedRef } from 'vue' +// Upper bound on generated items; guards against an unbounded allocation from a hostile `size` +const MAX_SIZE = 1000 + export type RatingItemState = 'full' | 'half' | 'empty' export interface RatingItemDescriptor { @@ -162,8 +165,9 @@ export function createRating< const items = computed(() => { const current = value.value + const count = clamp(Math.floor(toValue(_size)), 0, MAX_SIZE) - return range(toValue(_size), 1).map(i => ({ + return range(count, 1).map(i => ({ value: i, state: getState(i, current), })) From 331667dd546e47489d8d4fbdec4e16b6bf0ea112 Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 5 Jun 2026 00:01:57 -0500 Subject: [PATCH 3/4] fix(useTheme): reject angle brackets in theme CSS sanitizer Extend ThemeAdapter UNSAFE_CSS to also reject < and >, closing a breakout on the SSR/unhead innerHTML path. Theme color values never legitimately contain angle brackets. --- packages/0/src/composables/useTheme/adapters/adapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/0/src/composables/useTheme/adapters/adapter.ts b/packages/0/src/composables/useTheme/adapters/adapter.ts index 85140fe1b..9caf69aa1 100644 --- a/packages/0/src/composables/useTheme/adapters/adapter.ts +++ b/packages/0/src/composables/useTheme/adapters/adapter.ts @@ -13,7 +13,7 @@ export interface ThemeAdapterSetupContext { } export abstract class ThemeAdapter { - private static UNSAFE_CSS = /url\s*\(|@import|expression\s*\(|[{}]/i + private static UNSAFE_CSS = /url\s*\(|@import|expression\s*\(|[{}<>]/i private static SAFE_IDENT = /^[a-zA-Z0-9_-]+$/ public stylesheetId = 'v0-theme-stylesheet' From ec12553df755722918664a3d35bec84d8c5cc4cf Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 5 Jun 2026 00:01:57 -0500 Subject: [PATCH 4/4] docs: add security-primitives reuse rule for packages/0/src Codify the four sink-to-guard patterns (UNSAFE_KEYS, CSS sanitizer, CSS.escape, bounded range) so new code reaches for the existing guard instead of reinventing it. --- .claude/rules/implementation.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.claude/rules/implementation.md b/.claude/rules/implementation.md index c6235e44a..bf9b44b8a 100644 --- a/.claude/rules/implementation.md +++ b/.claude/rules/implementation.md @@ -58,6 +58,21 @@ Before writing a new helper, check `#v0/utilities`. Available today: All helpers carry `/* #__NO_SIDE_EFFECTS__ */` and are tree-shakeable. Module-level allocating constants (e.g., a top-level `new Set([...])`) carry `/* @__PURE__ */` instead — see `utilities/helpers.ts` (the `UNSAFE_KEYS` set) and `utilities/instance.ts` (the `INSTANCE_KEY` literal) for the placement convention. Never add a new utility that introduces a top-level side effect — the barrel cannot absorb it. [PHILOSOPHY §2.7] +## Security primitives — reuse, never reinvent + +A headless lib exposes a small, fixed set of injection / DoS sinks, and v0 already ships the guard for each. When you write code in one of these shapes, reach for the existing primitive. Every gap found in the 2026-06-04 security audit was a sibling that missed the guard its twin already had. [user-feedback:2026-06-04] + +| When you… | Guard | Has it / missed it | +|-----------|-------|--------------------| +| Build a plain object keyed by caller- or registry-supplied strings | Skip keys in `UNSAFE_KEYS` (`#v0/utilities`) — `__proto__` / `constructor` / `prototype` | `mergeDeep` has it; `usePermissions` didn't | +| Interpolate a value into a CSS string or `