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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ dist/
.vscode/
*.swp
*.swo
temp/*

# OS
.DS_Store
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ A comprehensive TypeScript library for capturing, storing, and appending UTM tra
## Features

- **Capture** UTM parameters from URLs
- **Sanitize** parameter values to prevent XSS and injection
- **Store** in sessionStorage for the browser session
- **Append** UTM parameters to share URLs
- **Configurable** key format (snake_case or camelCase)
Expand Down Expand Up @@ -110,6 +111,16 @@ const params = captureUtmParameters(url, {
keyFormat: 'camelCase', // 'snake_case' (default) or 'camelCase'
allowedParameters: ['utm_source', 'utm_campaign'], // Filter to specific params
});

// With sanitization (strips HTML, control chars)
const params = captureUtmParameters(url, {
sanitize: {
enabled: true,
stripHtml: true, // Remove < > " ' ` (default: true)
stripControlChars: true, // Remove control characters (default: true)
maxLength: 200, // Truncate values (default: 200)
},
});
```

#### `storeUtmParameters(params, options?)`
Expand Down Expand Up @@ -206,6 +217,37 @@ validateAndNormalize('example.com');
// { valid: true, normalizedUrl: 'https://example.com' }
```

### Value Sanitization

Sanitize UTM parameter values to prevent XSS when rendering in HTML or constructing URLs. Sanitization is disabled by default and runs at capture time only.

```typescript
import { captureUtmParameters, sanitizeValue, sanitizeParams } from '@jackmisner/utm-toolkit';

// Enable sanitization during capture
const params = captureUtmParameters('https://example.com?utm_source=<script>bad</script>', {
sanitize: { enabled: true },
});
// { utm_source: 'scriptbad/script' }

// Use standalone sanitization functions
sanitizeValue('<b>bold</b>', {
enabled: true,
stripHtml: true,
stripControlChars: true,
maxLength: 200,
});
// 'bbold/b'

// With a custom pattern
const params = captureUtmParameters(url, {
sanitize: {
enabled: true,
customPattern: /[!@#$%^&*]/g, // Strip additional characters
},
});
```

### Configuration

```typescript
Expand Down Expand Up @@ -289,13 +331,15 @@ installDebugHelpers();
| `defaultParams` | `object` | `{}` | Fallback params when none captured |
| `shareContextParams` | `object` | `{}` | Platform-specific params |
| `excludeFromShares` | `string[]` | `[]` | Params to exclude from shares |
| `sanitize` | `SanitizeConfig` | `{ enabled: false }` | Value sanitization settings |

## TypeScript Types

```typescript
import type {
UtmParameters,
UtmConfig,
SanitizeConfig,
SharePlatform,
UseUtmTrackingReturn,
} from '@jackmisner/utm-toolkit';
Expand Down
96 changes: 96 additions & 0 deletions __tests__/config/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,102 @@ describe('validateConfig', () => {
})
})

describe('sanitize config', () => {
it('createConfig includes sanitize defaults when not provided', () => {
const config = createConfig()
expect(config.sanitize).toEqual({
enabled: false,
stripHtml: true,
stripControlChars: true,
maxLength: 200,
})
})

it('createConfig merges partial sanitize config with defaults', () => {
const config = createConfig({
sanitize: { enabled: true, maxLength: 100 },
})
expect(config.sanitize.enabled).toBe(true)
expect(config.sanitize.maxLength).toBe(100)
expect(config.sanitize.stripHtml).toBe(true) // default preserved
expect(config.sanitize.stripControlChars).toBe(true) // default preserved
})

it('createConfig preserves customPattern when provided', () => {
const pattern = /[!@#]/g
const config = createConfig({
sanitize: { enabled: true, customPattern: pattern },
})
expect(config.sanitize.customPattern).toBe(pattern)
})

it('mergeConfig merges sanitize overrides', () => {
const base = getDefaultConfig()
const merged = mergeConfig(base, {
sanitize: { enabled: true },
})
expect(merged.sanitize.enabled).toBe(true)
expect(merged.sanitize.stripHtml).toBe(true) // preserved from base
})

it('validateConfig validates sanitize.enabled is boolean', () => {
const errors = validateConfig({ sanitize: { enabled: 'yes' } })
expect(errors).toContain('sanitize.enabled must be a boolean')
})

it('validateConfig validates sanitize.stripHtml is boolean', () => {
const errors = validateConfig({ sanitize: { stripHtml: 123 } })
expect(errors).toContain('sanitize.stripHtml must be a boolean')
})

it('validateConfig validates sanitize.stripControlChars is boolean', () => {
const errors = validateConfig({ sanitize: { stripControlChars: null } })
expect(errors).toContain('sanitize.stripControlChars must be a boolean')
})

it('validateConfig validates sanitize.maxLength is number', () => {
const errors = validateConfig({ sanitize: { maxLength: 'big' } })
expect(errors).toContain('sanitize.maxLength must be a positive finite number')
})

it('validateConfig validates sanitize.maxLength is positive', () => {
const errors = validateConfig({ sanitize: { maxLength: -1 } })
expect(errors).toContain('sanitize.maxLength must be a positive finite number')
})

it('validateConfig rejects NaN as sanitize.maxLength', () => {
const errors = validateConfig({ sanitize: { maxLength: NaN } })
expect(errors).toContain('sanitize.maxLength must be a positive finite number')
})

it('validateConfig rejects Infinity as sanitize.maxLength', () => {
const errors = validateConfig({ sanitize: { maxLength: Infinity } })
expect(errors).toContain('sanitize.maxLength must be a positive finite number')
})

it('validateConfig validates sanitize.customPattern is a RegExp', () => {
const errors = validateConfig({ sanitize: { customPattern: 'not a regex' } })
expect(errors).toContain('sanitize.customPattern must be a RegExp')
})

it('validateConfig accepts valid customPattern RegExp', () => {
const errors = validateConfig({ sanitize: { customPattern: /[!@#]/g } })
expect(errors).toEqual([])
})

it('validateConfig validates sanitize is an object', () => {
const errors = validateConfig({ sanitize: 'not an object' })
expect(errors).toContain('sanitize must be an object')
})

it('validateConfig accepts valid sanitize config', () => {
const errors = validateConfig({
sanitize: { enabled: true, stripHtml: true, stripControlChars: true, maxLength: 100 },
})
expect(errors).toEqual([])
})
})

describe('getDefaultConfig', () => {
it('returns a copy of default config', () => {
const config1 = getDefaultConfig()
Expand Down
47 changes: 47 additions & 0 deletions __tests__/core/capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,53 @@ describe('hasUtmParameters', () => {
})
})

describe('sanitization integration', () => {
const sanitizeConfig = {
enabled: true,
stripHtml: true,
stripControlChars: true,
maxLength: 200,
}

it('sanitizes values when sanitize config is enabled', () => {
const result = captureUtmParameters(
'https://example.com?utm_source=<script>bad</script>&utm_medium=email',
{ sanitize: sanitizeConfig },
)
expect(result.utm_source).toBe('scriptbad/script')
expect(result.utm_medium).toBe('email')
})

it('does not sanitize when sanitize is not provided', () => {
const result = captureUtmParameters('https://example.com?utm_source=<script>bad</script>')
expect(result.utm_source).toBe('<script>bad</script>')
})

it('does not sanitize when sanitize.enabled is false', () => {
const result = captureUtmParameters('https://example.com?utm_source=<script>bad</script>', {
sanitize: { ...sanitizeConfig, enabled: false },
})
expect(result.utm_source).toBe('<script>bad</script>')
})

it('sanitizes with camelCase key format', () => {
const result = captureUtmParameters('https://example.com?utm_source=<b>bold</b>', {
keyFormat: 'camelCase',
sanitize: sanitizeConfig,
})
expect(result.utmSource).toBe('bbold/b')
})

it('sanitizes after allowed parameter filtering', () => {
const result = captureUtmParameters(
'https://example.com?utm_source=<b>bold</b>&utm_campaign=test',
{ allowedParameters: ['utm_source'], sanitize: sanitizeConfig },
)
expect(result.utm_source).toBe('bbold/b')
expect(result).not.toHaveProperty('utm_campaign')
})
})

describe('captureFromCurrentUrl', () => {
beforeEach(() => {
vi.stubGlobal('location', {
Expand Down
Loading