fix(#27): address security issues in intent classification
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- Add input sanitization to prevent LLM prompt injection
  (escapes quotes, backslashes, replaces newlines)
- Add MaxLength(500) validation to DTO to prevent DoS
- Add entity validation to filter malicious LLM responses
- Add confidence validation to clamp values to 0.0-1.0
- Make LLM model configurable via INTENT_CLASSIFICATION_MODEL env var
- Add 12 new security tests (total: 72 tests, from 60)

Security fixes identified by code review:
- CVE-mitigated: Prompt injection via unescaped user input
- CVE-mitigated: Unvalidated entity data from LLM response
- CVE-mitigated: Missing input length validation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 16:50:32 -06:00
parent fd93be6032
commit f2b25079d9
3 changed files with 412 additions and 8 deletions

View File

@@ -7,6 +7,9 @@ import type {
ExtractedEntity,
} from "./interfaces";
/** Valid entity types for validation */
const VALID_ENTITY_TYPES = ["date", "time", "person", "project", "priority", "status", "text"];
/**
* Intent Classification Service
*
@@ -31,6 +34,16 @@ export class IntentClassificationService {
private readonly patterns: IntentPattern[];
private readonly RULE_CONFIDENCE_THRESHOLD = 0.7;
/** Configurable LLM model for intent classification */
private readonly intentModel =
// eslint-disable-next-line @typescript-eslint/dot-notation -- env vars use bracket notation
process.env["INTENT_CLASSIFICATION_MODEL"] ?? "llama3.2";
/** Configurable temperature (low for consistent results) */
private readonly intentTemperature = parseFloat(
// eslint-disable-next-line @typescript-eslint/dot-notation -- env vars use bracket notation
process.env["INTENT_CLASSIFICATION_TEMPERATURE"] ?? "0.1"
);
constructor(@Optional() private readonly llmService?: LlmService) {
this.patterns = this.buildPatterns();
this.logger.log("Intent classification service initialized");
@@ -146,8 +159,8 @@ export class IntentClassificationService {
content: prompt,
},
],
model: "llama3.2", // Default model, can be configured
temperature: 0.1, // Low temperature for consistent results
model: this.intentModel,
temperature: this.intentTemperature,
});
const result = this.parseLlmResponse(response.message.content, query);
@@ -383,6 +396,33 @@ export class IntentClassificationService {
/* eslint-enable security/detect-unsafe-regex */
}
/**
* Sanitize user query for safe inclusion in LLM prompt.
* Prevents prompt injection by escaping special characters and limiting length.
*
* @param query - Raw user query
* @returns Sanitized query safe for LLM prompt
*/
private sanitizeQueryForPrompt(query: string): string {
// Escape quotes and backslashes to prevent prompt injection
const sanitized = query
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, " ")
.replace(/\r/g, " ");
// Limit length to prevent prompt overflow (500 chars max)
const maxLength = 500;
if (sanitized.length > maxLength) {
this.logger.warn(
`Query truncated from ${String(sanitized.length)} to ${String(maxLength)} chars`
);
return sanitized.slice(0, maxLength);
}
return sanitized;
}
/**
* Build the prompt for LLM classification.
*
@@ -390,6 +430,8 @@ export class IntentClassificationService {
* @returns Formatted prompt
*/
private buildLlmPrompt(query: string): string {
const sanitizedQuery = this.sanitizeQueryForPrompt(query);
return `Classify the following user query into one of these intents:
- query_tasks: User wants to see their tasks
- query_events: User wants to see their calendar/events
@@ -404,7 +446,7 @@ export class IntentClassificationService {
Also extract any entities (dates, times, priorities, statuses, people).
Query: "${query}"
Query: "${sanitizedQuery}"
Respond with ONLY this JSON format (no other text):
{
@@ -422,6 +464,63 @@ Respond with ONLY this JSON format (no other text):
}`;
}
/**
* Validate and sanitize confidence score from LLM.
* Ensures confidence is a valid number between 0.0 and 1.0.
*
* @param confidence - Raw confidence value from LLM
* @returns Validated confidence (0.0 - 1.0)
*/
private validateConfidence(confidence: unknown): number {
if (typeof confidence !== "number" || isNaN(confidence) || !isFinite(confidence)) {
return 0;
}
return Math.max(0, Math.min(1, confidence));
}
/**
* Validate an entity from LLM response.
* Ensures entity has valid structure and safe values.
*
* @param entity - Raw entity from LLM
* @returns True if entity is valid
*/
private isValidEntity(entity: unknown): entity is ExtractedEntity {
if (typeof entity !== "object" || entity === null) {
return false;
}
const e = entity as Record<string, unknown>;
// Validate type
if (typeof e.type !== "string" || !VALID_ENTITY_TYPES.includes(e.type)) {
return false;
}
// Validate value (string, max 200 chars)
if (typeof e.value !== "string" || e.value.length > 200) {
return false;
}
// Validate raw (string, max 200 chars)
if (typeof e.raw !== "string" || e.raw.length > 200) {
return false;
}
// Validate positions (non-negative integers, end > start)
if (
typeof e.start !== "number" ||
typeof e.end !== "number" ||
e.start < 0 ||
e.end <= e.start ||
e.end > 10000
) {
return false;
}
return true;
}
/**
* Parse LLM response into IntentClassification.
*
@@ -458,12 +557,20 @@ Respond with ONLY this JSON format (no other text):
? (parsedObj.intent as IntentType)
: "unknown";
// Validate and filter entities
const rawEntities: unknown[] = Array.isArray(parsedObj.entities) ? parsedObj.entities : [];
const validEntities = rawEntities.filter((e): e is ExtractedEntity => this.isValidEntity(e));
if (rawEntities.length !== validEntities.length) {
this.logger.warn(
`Filtered ${String(rawEntities.length - validEntities.length)} invalid entities from LLM response`
);
}
return {
intent,
confidence: typeof parsedObj.confidence === "number" ? parsedObj.confidence : 0,
entities: Array.isArray(parsedObj.entities)
? (parsedObj.entities as ExtractedEntity[])
: [],
confidence: this.validateConfidence(parsedObj.confidence),
entities: validEntities,
method: "llm",
query,
};