hono-sanitizer
v0.0.2-beta
Published
> A flexible, production-ready middleware for [Hono](https://hono.dev) that sanitizes request data using [isomorphic-dompurify](https://www.npmjs.com/package/isomorphic-dompurify).
Readme
hono-sanitizer
A flexible, production-ready middleware for Hono that sanitizes request data using isomorphic-dompurify.
Features
- 🛡️ Secure by Default: Strip malicious HTML/XSS from user input
- 🎯 Flexible Targeting: Sanitize body, query, params, or headers
- ⚙️ Highly Configurable: Whitelist, blacklist, per-field rules
- 🔧 Custom Sanitizers: Use your own sanitization logic
- 📦 TypeScript First: Full type safety and IntelliSense
- 🚀 Zero Config: Works out of the box with sensible defaults
- 🎨 Rich Text Support: Allow safe HTML with DOMPurify config
- 🌳 Deep Sanitization: Handle nested objects and arrays
- ⚡ Production Ready: Error handling, callbacks, and presets
Installation
npm install hono-sanitizeryarn add hono-sanitizerpnpm add hono-sanitizerQuick Start
import { Hono } from 'hono'
import { sanitizer } from 'hono-sanitizer'
const app = new Hono()
// Sanitize all request bodies (strips HTML by default)
app.use('*', sanitizer())
app.post('/message', async (c) => {
const { message } = await c.req.json()
// message is now sanitized and safe to store
return c.json({ message })
})
export default appUsage Examples
Basic Usage
import { sanitizer } from 'hono-sanitizer'
// Strip all HTML from request body
app.use('*', sanitizer())
// POST { message: '<script>alert("xss")</script>Hello' }
// Result: { message: 'Hello' }Target Specific Request Parts
// Sanitize body and query parameters
app.use('*', sanitizer({
targets: ['body', 'query']
}))
// Sanitize everything
app.use('*', sanitizer({
targets: ['body', 'query', 'params', 'headers']
}))Whitelist Specific Fields
// Only sanitize specific fields
app.use('*', sanitizer({
whitelist: ['username', 'message', 'bio'],
mode: 'strict'
}))Blacklist (Skip) Fields
// Sanitize everything except richContent
app.use('*', sanitizer({
blacklist: ['richContent', 'htmlBody']
}))Per-Field Configuration
app.use('*', sanitizer({
fields: {
// Strip all HTML from messages
'message': { mode: 'strict' },
// Allow safe HTML in descriptions
'description': {
mode: 'html',
config: {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'target']
}
},
// Don't sanitize metadata
'metadata': { mode: 'skip' },
// Custom sanitizer
'username': {
mode: 'custom',
sanitizer: (value) => String(value).toLowerCase().trim()
}
}
}))Using Presets
import { sanitizer, presets } from 'hono-sanitizer'
// Strict mode - strip all HTML
app.use('/api/comments/*', sanitizer(presets.strict))
// Rich text - allow safe HTML tags
app.use('/api/posts/*', sanitizer(presets.richText))
// Markdown - minimal HTML
app.use('/api/articles/*', sanitizer(presets.markdown))Route-Specific Rules
// Different rules for different routes
app.use('/api/posts/*', sanitizer({
fields: {
'title': { mode: 'strict' },
'content': {
mode: 'html',
config: {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'h1', 'h2']
}
}
}
}))
app.use('/api/comments/*', sanitizer({
mode: 'strict' // No HTML in comments
}))Deep Object Sanitization
app.use('*', sanitizer({
deep: true,
maxDepth: 5,
fields: {
'user.bio': { mode: 'html' },
'user.email': { mode: 'strict' }
}
}))
// POST { user: { bio: '<p>Hello</p>', email: '[email protected]' } }
// Both nested fields are sanitized according to their rulesArray Handling
app.use('*', sanitizer({
arrays: 'each' // Sanitize each array element (default)
}))
// Other options:
// arrays: 'skip' - Don't sanitize arrays
// arrays: 'join' - Join array elements and sanitize as single stringWith Callbacks
app.use('*', sanitizer({
onSanitize: (field, original, sanitized) => {
if (original !== sanitized) {
console.log(`Sanitized ${field}:`, { original, sanitized })
}
},
onSkip: (field, value) => {
console.log(`Skipped ${field}`)
},
onError: (error, field) => {
console.error(`Error sanitizing ${field}:`, error)
}
}))Accessing Sanitized Data
import { sanitizer, getSanitizedBody, getSanitizedQuery } from 'hono-sanitizer'
app.use('*', sanitizer())
app.post('/message', async (c) => {
// Method 1: Use helper functions
const body = getSanitizedBody(c)
const query = getSanitizedQuery(c)
// Method 2: Regular request methods (data is already sanitized)
const data = await c.req.json()
return c.json({ body, query })
})API Reference
sanitizer(options?)
Creates a Hono middleware that sanitizes request data.
Options
type SanitizerOptions = {
/** Which parts of the request to sanitize. Default: ['body'] */
targets?: ('body' | 'query' | 'params' | 'headers')[]
/** Default sanitization mode. Default: 'strict' */
mode?: 'strict' | 'html' | 'skip' | 'custom'
/** Only sanitize these fields */
whitelist?: string[]
/** Sanitize all except these fields */
blacklist?: string[]
/** Per-field configuration */
fields?: Record<string, FieldConfig>
/** Enable deep object sanitization. Default: true */
deep?: boolean
/** Maximum recursion depth. Default: 10 */
maxDepth?: number
/** Array handling strategy. Default: 'each' */
arrays?: 'skip' | 'each' | 'join'
/** DOMPurify config for 'html' mode */
config?: DOMPurifyConfig
/** Callback after sanitization */
onSanitize?: (field: string, original: unknown, sanitized: unknown) => void
/** Callback when field is skipped */
onSkip?: (field: string, value: unknown) => void
/** Callback on error */
onError?: (error: Error, field: string) => void
/** Throw errors instead of logging. Default: false */
throwOnError?: boolean
}FieldConfig
type FieldConfig = {
/** Sanitization mode for this field */
mode: 'strict' | 'html' | 'skip' | 'custom'
/** DOMPurify config (when mode is 'html') */
config?: DOMPurifyConfig
/** Custom sanitizer function (when mode is 'custom') */
sanitizer?: (value: unknown) => unknown
}Sanitization Modes
strict: Strip all HTML tags (safest, default)html: Allow safe HTML with DOMPurify configurationskip: Don't sanitize this fieldcustom: Use a custom sanitizer function
Helper Functions
// Get sanitized data from context
getSanitizedBody<T>(c: Context): T | null
getSanitizedQuery<T>(c: Context): T | null
getSanitizedParams<T>(c: Context): T | null
getSanitizedHeaders<T>(c: Context): T | nullPresets
import { presets } from 'hono-sanitizer'
presets.strict // Strip all HTML from body, query, params
presets.richText // Allow common HTML tags (blog posts)
presets.markdown // Allow minimal HTML (markdown content)
presets.comments // Very strict (user comments)Configuration Examples
Blog Platform
// Posts - allow rich formatting
app.use('/api/posts/*', sanitizer({
fields: {
'title': { mode: 'strict' },
'content': {
mode: 'html',
config: {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'h1', 'h2', 'h3',
'ul', 'ol', 'li', 'a', 'blockquote', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'target', 'rel']
}
},
'excerpt': { mode: 'strict' },
'tags': { mode: 'strict' }
}
}))
// Comments - no HTML allowed
app.use('/api/comments/*', sanitizer(presets.strict))User Registration
app.use('/api/auth/register', sanitizer({
fields: {
'username': {
mode: 'custom',
sanitizer: (value) => String(value).toLowerCase().trim().slice(0, 50)
},
'email': {
mode: 'custom',
sanitizer: (value) => String(value).toLowerCase().trim()
},
'password': { mode: 'skip' }, // Don't sanitize passwords
'bio': { mode: 'strict' }
}
}))E-commerce Product
app.use('/api/products/*', sanitizer({
fields: {
'name': { mode: 'strict' },
'description': {
mode: 'html',
config: {
ALLOWED_TAGS: ['p', 'br', 'ul', 'ol', 'li', 'strong', 'em'],
ALLOWED_ATTR: []
}
},
'price': { mode: 'skip' }, // Number, not string
'tags': { mode: 'strict' },
'metadata': { mode: 'skip' } // JSON data
}
}))Security Best Practices
- Always sanitize user input before storing in database
- Use strict mode by default - only allow HTML when necessary
- Validate data types before sanitization (use Zod, Valibot, etc.)
- Sanitize at the edge - as close to input as possible
- Don't trust sanitized data completely - use parameterized queries
- Log sanitization events for security monitoring
- Keep dependencies updated - especially DOMPurify
Example: Complete Security Setup
import { Hono } from 'hono'
import { sanitizer } from 'hono-sanitizer'
import { z } from 'zod'
const app = new Hono()
// 1. Sanitize input
app.use('*', sanitizer({
mode: 'strict',
onSanitize: (field, original, sanitized) => {
if (original !== sanitized) {
console.warn(`[Security] Sanitized ${field}`)
}
}
}))
// 2. Validate with schema
const messageSchema = z.object({
message: z.string().min(1).max(1000),
username: z.string().min(3).max(50)
})
app.post('/message', async (c) => {
const body = await c.req.json()
// Validate
const result = messageSchema.safeParse(body)
if (!result.success) {
return c.json({ error: 'Invalid data' }, 400)
}
const { message, username } = result.data
// 3. Use parameterized queries (example with Drizzle)
await db.insert(messages).values({
message,
username,
createdAt: new Date()
})
return c.json({ success: true })
})Performance Considerations
- Sanitization has minimal overhead for simple strings
- Deep object sanitization may impact performance with large payloads
- Use
maxDepthto prevent deep recursion attacks - Consider using
whitelistfor better performance on large objects - Skip sanitization for trusted internal routes
Error Handling
app.use('*', sanitizer({
throwOnError: false, // Default: log errors without throwing
onError: (error, field) => {
// Log to monitoring service
console.error(`Sanitization error in ${field}:`, error)
// Send to error tracking (Sentry, etc.)
}
}))TypeScript Support
Full TypeScript support with type inference:
import type { SanitizerOptions, FieldConfig } from 'hono-sanitizer'
const config: SanitizerOptions = {
targets: ['body', 'query'],
mode: 'strict',
fields: {
'username': { mode: 'custom', sanitizer: (v) => String(v).toLowerCase() }
}
}
app.use('*', sanitizer(config))Contributing
Contributions are welcome! Please read our Contributing Guide first.
License
MIT © Aziz Becha
Support
Made with ❤️ for the Hono community
