All checks were successful
ci/woodpecker/push/push Pipeline was successful
## Added - CoT (Cursor on Target) server on port 8089 enabling ATAK/iTAK device connectivity - Support for TAK stream protocol and traditional XML CoT messages - TLS/SSL support with automatic fallback to plain TCP - Username/password authentication for CoT connections - Real-time device position tracking with TTL-based expiration (90s default) - API endpoints: `/api/cot/config`, `/api/cot/server-package`, `/api/cot/truststore`, `/api/me/cot-password` - TAK Server section in Settings with QR code for iTAK setup - ATAK password management in Account page for OIDC users - CoT device markers on map showing real-time positions - Comprehensive documentation in `docs/` directory - Environment variables: `COT_PORT`, `COT_TTL_MS`, `COT_REQUIRE_AUTH`, `COT_SSL_CERT`, `COT_SSL_KEY`, `COT_DEBUG` - Dependencies: `fast-xml-parser`, `jszip`, `qrcode` ## Changed - Authentication system supports CoT password management for OIDC users - Database schema includes `cot_password_hash` field - Test suite refactored to follow functional design principles ## Removed - Consolidated utility modules: `authConfig.js`, `authSkipPaths.js`, `bootstrap.js`, `poiConstants.js`, `session.js` ## Security - XML entity expansion protection in CoT parser - Enhanced input validation and SQL injection prevention - Authentication timeout to prevent hanging connections ## Breaking Changes - Port 8089 must be exposed for CoT server. Update firewall rules and Docker/Kubernetes configurations. ## Migration Notes - OIDC users must set ATAK password via Account settings before connecting - Docker: expose port 8089 (`-p 8089:8089`) - Kubernetes: update Helm values to expose port 8089 Co-authored-by: Madison Grubb <madison@elastiflow.com> Reviewed-on: #6
151 lines
6.6 KiB
JavaScript
151 lines
6.6 KiB
JavaScript
/**
|
|
* Validation and sanitization utilities - pure functions for consistent input validation and cleaning.
|
|
*/
|
|
|
|
import { MAX_IDENTIFIER_LENGTH, MAX_STRING_LENGTH } from './constants.js'
|
|
import { DEVICE_TYPES, SOURCE_TYPES } from './deviceUtils.js'
|
|
|
|
// Constants
|
|
export const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])
|
|
|
|
// Sanitization functions
|
|
const IDENTIFIER_REGEX = /^\w+$/
|
|
|
|
export function sanitizeString(str, maxLength = MAX_STRING_LENGTH) {
|
|
if (typeof str !== 'string') return ''
|
|
const trimmed = str.trim()
|
|
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed
|
|
}
|
|
|
|
export function sanitizeIdentifier(str) {
|
|
if (typeof str !== 'string') return ''
|
|
const trimmed = str.trim()
|
|
if (trimmed.length === 0 || trimmed.length > MAX_IDENTIFIER_LENGTH) return ''
|
|
return IDENTIFIER_REGEX.test(trimmed) ? trimmed : ''
|
|
}
|
|
|
|
export function sanitizeLabel(str, maxLength = MAX_STRING_LENGTH) {
|
|
return sanitizeString(str, maxLength)
|
|
}
|
|
|
|
const ROLES = ['admin', 'leader', 'member']
|
|
|
|
const validateNumber = (value, field) => {
|
|
const num = Number(value)
|
|
return Number.isFinite(num) ? { valid: true, value: num } : { valid: false, error: `${field} must be a finite number` }
|
|
}
|
|
|
|
const validateEnum = (value, allowed, field) => allowed.includes(value) ? { valid: true, value } : { valid: false, error: `Invalid ${field}` }
|
|
|
|
const handleField = (d, field, handler, updates, errors, outputField = null) => {
|
|
if (d[field] !== undefined) {
|
|
const result = handler(d[field])
|
|
if (result.valid) updates[outputField || field] = result.value
|
|
else errors.push(result.error)
|
|
}
|
|
}
|
|
|
|
export function validateDevice(data) {
|
|
if (!data || typeof data !== 'object') return { valid: false, errors: ['body required'] }
|
|
const d = /** @type {Record<string, unknown>} */ (data)
|
|
const errors = []
|
|
const latCheck = validateNumber(d.lat, 'lat')
|
|
const lngCheck = validateNumber(d.lng, 'lng')
|
|
if (!latCheck.valid || !lngCheck.valid) errors.push('lat and lng required as finite numbers')
|
|
if (errors.length > 0) return { valid: false, errors }
|
|
return {
|
|
valid: true,
|
|
errors: [],
|
|
data: {
|
|
name: sanitizeString(d.name, 1000),
|
|
device_type: validateEnum(d.device_type, DEVICE_TYPES, 'device_type').value || 'feed',
|
|
vendor: d.vendor !== undefined ? sanitizeString(d.vendor, 255) : null,
|
|
lat: latCheck.value,
|
|
lng: lngCheck.value,
|
|
stream_url: typeof d.stream_url === 'string' ? sanitizeString(d.stream_url, 2000) : '',
|
|
source_type: validateEnum(d.source_type, SOURCE_TYPES, 'source_type').value || 'mjpeg',
|
|
config: d.config == null ? null : (typeof d.config === 'string' ? d.config : JSON.stringify(d.config)),
|
|
},
|
|
}
|
|
}
|
|
|
|
export function validateUpdateDevice(data) {
|
|
if (!data || typeof data !== 'object') return { valid: true, errors: [], data: {} }
|
|
const d = /** @type {Record<string, unknown>} */ (data)
|
|
const errors = []
|
|
const updates = {}
|
|
if (d.name !== undefined) updates.name = sanitizeString(d.name, 1000)
|
|
handleField(d, 'device_type', v => validateEnum(v, DEVICE_TYPES, 'device_type'), updates, errors)
|
|
if (d.vendor !== undefined) updates.vendor = d.vendor === null || d.vendor === '' ? null : sanitizeString(d.vendor, 255)
|
|
handleField(d, 'lat', v => validateNumber(v, 'lat'), updates, errors)
|
|
handleField(d, 'lng', v => validateNumber(v, 'lng'), updates, errors)
|
|
if (d.stream_url !== undefined) updates.stream_url = sanitizeString(d.stream_url, 2000)
|
|
handleField(d, 'source_type', v => validateEnum(v, SOURCE_TYPES, 'source_type'), updates, errors)
|
|
if (d.config !== undefined) updates.config = d.config === null ? null : (typeof d.config === 'string' ? d.config : JSON.stringify(d.config))
|
|
return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: updates }
|
|
}
|
|
|
|
export function validateUser(data) {
|
|
if (!data || typeof data !== 'object') return { valid: false, errors: ['body required'] }
|
|
const d = /** @type {Record<string, unknown>} */ (data)
|
|
const errors = []
|
|
const identifier = sanitizeIdentifier(d.identifier)
|
|
const password = typeof d.password === 'string' ? d.password : ''
|
|
const role = typeof d.role === 'string' ? d.role : ''
|
|
if (!identifier) errors.push('identifier required')
|
|
if (!password) errors.push('password required')
|
|
if (!role || !ROLES.includes(role)) errors.push('role must be admin, leader, or member')
|
|
return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: { identifier, password, role: role || 'member' } }
|
|
}
|
|
|
|
export function validateUpdateUser(data) {
|
|
if (!data || typeof data !== 'object') return { valid: true, errors: [], data: {} }
|
|
const d = /** @type {Record<string, unknown>} */ (data)
|
|
const errors = []
|
|
const updates = {}
|
|
if (d.role !== undefined) {
|
|
if (ROLES.includes(d.role)) updates.role = d.role
|
|
else errors.push('role must be admin, leader, or member')
|
|
}
|
|
if (d.identifier !== undefined) {
|
|
const identifier = sanitizeIdentifier(d.identifier)
|
|
if (!identifier) errors.push('identifier cannot be empty')
|
|
else updates.identifier = identifier
|
|
}
|
|
if (d.password !== undefined && d.password !== '') {
|
|
if (typeof d.password !== 'string' || !d.password) errors.push('password cannot be empty')
|
|
else updates.password = d.password
|
|
}
|
|
return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: updates }
|
|
}
|
|
|
|
export function validatePoi(data) {
|
|
if (!data || typeof data !== 'object') return { valid: false, errors: ['body required'] }
|
|
const d = /** @type {Record<string, unknown>} */ (data)
|
|
const latCheck = validateNumber(d.lat, 'lat')
|
|
const lngCheck = validateNumber(d.lng, 'lng')
|
|
if (!latCheck.valid || !lngCheck.valid) return { valid: false, errors: ['lat and lng required as finite numbers'] }
|
|
return {
|
|
valid: true,
|
|
errors: [],
|
|
data: {
|
|
lat: latCheck.value,
|
|
lng: lngCheck.value,
|
|
label: sanitizeLabel(d.label, 500),
|
|
icon_type: validateEnum(d.iconType, POI_ICON_TYPES, 'iconType').value || 'pin',
|
|
},
|
|
}
|
|
}
|
|
|
|
export function validateUpdatePoi(data) {
|
|
if (!data || typeof data !== 'object') return { valid: true, errors: [], data: {} }
|
|
const d = /** @type {Record<string, unknown>} */ (data)
|
|
const errors = []
|
|
const updates = {}
|
|
if (d.label !== undefined) updates.label = sanitizeLabel(d.label, 500)
|
|
handleField(d, 'iconType', v => validateEnum(v, POI_ICON_TYPES, 'iconType'), updates, errors, 'icon_type')
|
|
handleField(d, 'lat', v => validateNumber(v, 'lat'), updates, errors)
|
|
handleField(d, 'lng', v => validateNumber(v, 'lng'), updates, errors)
|
|
return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: updates }
|
|
}
|