fix(#27): address security issues in intent classification
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user