| Metadata | Value |
|---|---|
| Status | Completed |
| Owner | Backend Team |
| Priority | CRITICAL |
| Created | 2026-01-23 |
| Completed | 2026-01-23 |
| Related Tracks | TRACK-001 (Bulk Import), TRACK-011 (Quality Scoring) |
| Implementation Plan | bulk-import-fixes-implementation-plan.md |
Fix critical correctness issues and security vulnerabilities identified in code reviews (Claude, Goose, Codex) that block production deployment. These fixes ensure compliance with clarified requirements and prevent security exploits.
Four critical issues prevent safe production deployment:
Issue: failManifestTask() updates task/job status but never updates batch status to FAILED.
Files:
modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/BulkImportWorkerService.ktChanges:
private suspend fun failManifestTask(task: ImportTaskRunDto, job: ImportJobDto, startedAt: OffsetDateTime, errorJson: String) {
val now = OffsetDateTime.now(ZoneOffset.UTC)
dal.bulkImport.updateTaskStatus(
id = task.id,
status = TaskStatus.FAILED,
error = errorJson,
durationMs = elapsedMsSince(startedAt),
completedAt = now
)
dal.bulkImport.updateJobStatus(id = job.id, status = TaskStatus.FAILED, result = errorJson, completedAt = now)
dal.bulkImport.createEvent(refType = "batch", refId = job.batchId, eventType = "MANIFEST_INGEST_FAILED", data = errorJson)
// ✅ NEW: Mark batch as FAILED (per clarified requirements 2026-01)
dal.bulkImport.updateBatchStatus(id = job.batchId, status = BatchStatus.FAILED, completedAt = now)
}
Testing:
Issue: Tasks are marked RUNNING with startedAt at claim time (line 365 in BulkImportRepository.kt), but workers may not begin immediately when channels are full. Watchdog may mark these as RETRYABLE before execution starts.
Options:
startedAt when worker begins execution (not at claim time)Recommendation: Option B (simpler, no schema change)
Files:
modules/backend/dal/src/main/kotlin/com/sangita/grantha/backend/dal/repositories/BulkImportRepository.ktmodules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/BulkImportWorkerService.ktChanges:
startedAt in claimNextPendingTasks():
ImportTaskRunTable.update(where = { ImportTaskRunTable.id inList taskIds }) {
it[ImportTaskRunTable.status] = TaskStatus.RUNNING
// ❌ REMOVE: it[ImportTaskRunTable.startedAt] = now
it[ImportTaskRunTable.updatedAt] = now
}
startedAt when worker begins execution:
private suspend fun processManifestTask(task: ImportTaskRunDto, config: WorkerConfig) {
val startedAt = OffsetDateTime.now(ZoneOffset.UTC)
// ✅ NEW: Set startedAt when execution begins (not at claim time)
dal.bulkImport.updateTaskStatus(
id = task.id,
startedAt = startedAt
)
// ... rest of existing logic
}
Testing:
Issues:
originalFileName used directly (no basename sanitization)Files:
modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/routes/BulkImportRoutes.ktChanges:
post {
val multipart = call.receiveMultipart()
var savedFilePath: String? = null
val MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
multipart.forEachPart { part ->
if (part is PartData.FileItem) {
// ✅ NEW: Validate filename
val originalFileName = part.originalFileName
?: throw IllegalArgumentException("File name is required")
// ✅ NEW: Sanitize filename (prevent path traversal)
val sanitizedFileName = Paths.get(originalFileName).fileName.toString()
.replace(Regex("[^a-zA-Z0-9._-]"), "_")
if (sanitizedFileName.isEmpty()) {
throw IllegalArgumentException("Invalid file name")
}
// ✅ NEW: Validate file extension
if (!sanitizedFileName.endsWith(".csv", ignoreCase = true)) {
throw IllegalArgumentException("Only CSV files are allowed")
}
val fileBytes = part.provider().readRemaining().readBytes()
// ✅ NEW: Enforce file size limit
if (fileBytes.size > MAX_FILE_SIZE) {
throw IllegalArgumentException("File size exceeds maximum allowed size (10MB)")
}
// Ensure storage directory exists
val storageDir = Paths.get("storage/imports")
if (!Files.exists(storageDir)) {
Files.createDirectories(storageDir)
}
// Create unique file name to avoid collisions
val timestamp = System.currentTimeMillis()
val uniqueName = "${timestamp}_${sanitizedFileName}"
val file = File(storageDir.toFile(), uniqueName)
file.writeBytes(fileBytes)
savedFilePath = file.absolutePath
}
part.dispose()
}
if (savedFilePath != null) {
val created = service.createBatch(savedFilePath!!)
call.respond(HttpStatusCode.Accepted, created)
} else {
call.respondText("No file uploaded", status = HttpStatusCode.BadRequest)
}
}
Testing:
../../../etc/passwd) → sanitizedIssues:
Files:
modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/BulkImportWorkerService.ktChanges:
private fun parseCsvManifest(path: Path): List<CsvRow> {
// ✅ NEW: Use UTF-8 explicitly and ensure file is closed
path.toFile().bufferedReader(Charsets.UTF_8).use { reader ->
val parser = CSVFormat.DEFAULT.builder()
.setHeader()
.setSkipHeaderRecord(true)
.setIgnoreHeaderCase(true)
.setTrim(true)
.build()
.parse(reader)
// ... rest of existing parsing logic
}
}
Testing: