diff --git a/services/api/src/main/kotlin/net/blueshell/api/domain/file/application/FileService.kt b/services/api/src/main/kotlin/net/blueshell/api/domain/file/application/FileService.kt index 058575841..28f062878 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/domain/file/application/FileService.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/domain/file/application/FileService.kt @@ -89,7 +89,8 @@ class FileService @Autowired constructor( val path = type.directory + "/" + hashedFilename val fullPath = rootLocation.resolve(path).normalize() - log.info("Storing {} at {}", multipart.originalFilename, fullPath) + val safeFilename = (multipart.originalFilename ?: "").replace(Regex("\\p{Cntrl}"), "_") + log.info("Storing {} at {}", safeFilename, fullPath) if (Files.exists(fullPath)) { Files.deleteIfExists(tmp) diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/config/advice/ExceptionLoggingResolver.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/config/advice/ExceptionLoggingResolver.kt index ec457ed00..0ea017fa1 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/platform/config/advice/ExceptionLoggingResolver.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/config/advice/ExceptionLoggingResolver.kt @@ -38,12 +38,15 @@ class ExceptionLoggingResolver : HandlerExceptionResolver { handler: Any?, ex: Exception, ): ModelAndView? { + // Strip CR/LF from the request URI and exception message before logging (log-injection, #464). + val safeUri = request.requestURI.replace('\r', '_').replace('\n', '_') + val safeMessage = (ex.message ?: "").replace('\r', '_').replace('\n', '_') log.error( "{} {} -> {}: {}", request.method, - request.requestURI, + safeUri, ex.javaClass.simpleName, - ex.message, + safeMessage, ex, ) return null diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/contact/adapter/brevo/BrevoListAdapter.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/contact/adapter/brevo/BrevoListAdapter.kt index f00c91b69..aab9506a4 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/contact/adapter/brevo/BrevoListAdapter.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/contact/adapter/brevo/BrevoListAdapter.kt @@ -48,20 +48,27 @@ class BrevoListAdapter( override val system = ContactSystem.BREVO override fun createList(name: String, folderName: String?): Long { - log.info("Creating Brevo list '{}'", name) + val safeName = sanitizeForLog(name) + log.info("Creating Brevo list '{}'", safeName) return try { val req = CreateListRequest() req.name = name req.folderId = contributionPeriodsFolder val response = contactsApi.createList(req) - log.info("Created Brevo list '{}' id={}", name, response.id) + log.info("Created Brevo list '{}' id={}", safeName, response.id) response.id } catch (e: RestClientResponseException) { - log.error("Failed to create Brevo list '{}'", name, e) + log.error("Failed to create Brevo list '{}'", safeName, e) throw ContactServiceException("Failed to create list", e) } } + private fun sanitizeForLog(value: String): String = buildString(value.length) { + value.forEach { ch -> + append(if (ch.isISOControl()) '_' else ch) + } + } + override fun addToList(externalUserId: Long, externalListId: Long) { log.info("Adding Brevo contact {} to list {}", externalUserId, externalListId) try { diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/email/web/EmailTrackingController.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/email/web/EmailTrackingController.kt index e1817b7b5..2e4870396 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/email/web/EmailTrackingController.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/email/web/EmailTrackingController.kt @@ -50,11 +50,11 @@ class EmailTrackingController( EmailDeliveryStatus.DELIVERED -> emailService.markOpened(outbox) else -> { /* already opened, bounced, or failed — no state change */ } } - log.debug("Tracking pixel fired for outbox id={} token={}", outbox.id, token) + log.debug("Tracking pixel fired for outbox id={}", outbox.id) } else { - log.warn("Tracking pixel fired for unknown token={}", token) + log.warn("Tracking pixel fired for unknown token (not found in outbox)") } - }.onFailure { log.warn("Error recording email open for token={}", token, it) } + }.onFailure { log.warn("Error recording email open", it) } val headers = HttpHeaders() headers.contentType = MediaType.IMAGE_GIF diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/mock/MockContactAdapter.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/mock/MockContactAdapter.kt index f75a92f36..dccdc68db 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/mock/MockContactAdapter.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/mock/MockContactAdapter.kt @@ -35,6 +35,9 @@ class MockContactAdapter : ContactAdapter, ContactListAdapter { private val contactIdSequence = AtomicLong(1000) private val listIdSequence = AtomicLong(2000) + private fun sanitizeForLog(value: String): String = + value.map { ch -> if (ch == '\r' || ch == '\n' || ch.isISOControl()) '_' else ch }.joinToString("") + // ── ContactSyncAdapter ──────────────────────────────────────────────────── override fun createContact(data: ContactData): Long { @@ -49,7 +52,8 @@ class MockContactAdapter : ContactAdapter, ContactListAdapter { isMember = data.isMember, attributes = data.attributes.toMutableMap() ) - log.info("Mock: Created contact id={} for {}", contactId, data.email) + val safeEmail = sanitizeForLog(data.email) + log.info("Mock: Created contact id={} for {}", contactId, safeEmail) return contactId } @@ -73,7 +77,8 @@ class MockContactAdapter : ContactAdapter, ContactListAdapter { val removed = contacts.remove(externalId) ?: throw ContactServiceException("Mock: Contact not found: $externalId") memberships.keys.removeIf { (contactId, _) -> contactId == externalId } - log.info("Mock: Deleted contact id={} ({})", externalId, removed.email) + val safeEmail = sanitizeForLog(removed.email) + log.info("Mock: Deleted contact id={} ({})", externalId, safeEmail) } // ── ListSyncAdapter ─────────────────────────────────────────────────────── @@ -81,7 +86,8 @@ class MockContactAdapter : ContactAdapter, ContactListAdapter { override fun createList(name: String, folderName: String?): Long { val listId = listIdSequence.getAndIncrement() lists[listId] = MockList(listId = listId, listName = name, folderName = folderName) - log.info("Mock: Created list id={} name='{}'", listId, name) + val safeName = sanitizeForLog(name) + log.info("Mock: Created list id={} name='{}'", listId, safeName) return listId } diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/web/oidc/ForwardAuthController.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/web/oidc/ForwardAuthController.kt index c1bb0f1d0..fa3c5f059 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/platform/web/oidc/ForwardAuthController.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/web/oidc/ForwardAuthController.kt @@ -48,8 +48,8 @@ class ForwardAuthController( private val log = LoggerFactory.getLogger(javaClass) - private fun sanitizeForLog(value: String): String = - value.replace(Regex("[\\r\\n\\t\\u0000-\\u001F\\u007F]"), " ") + private fun sanitizeForLog(value: String?): String = + value.orEmpty().replace(Regex("\\p{Cntrl}"), "_") @GetMapping @PermitAll @@ -58,6 +58,8 @@ class ForwardAuthController( request: HttpServletRequest, ): ResponseEntity { val forwardedHost = request.getHeader("X-Forwarded-Host").orEmpty() + // CR/LF-stripped copy of the attacker-controllable host header for logging (log-injection, #464). + val safeHost = sanitizeForLog(forwardedHost) val forwardedUri = request.getHeader("X-Forwarded-Uri").orEmpty().ifEmpty { "/" } val forwardedProto = request.getHeader("X-Forwarded-Proto").orEmpty().ifEmpty { "https" } val originalUrl = "$forwardedProto://$forwardedHost$forwardedUri" @@ -66,7 +68,7 @@ class ForwardAuthController( // Fail-closed: an unknown host (mis-configured IngressRoute, or // someone pointing forward-auth at us via Host injection) gets // ADMIN-required. Warn so the operator notices. - log.warn("forward-auth: unknown host '{}' — defaulting to ADMIN", sanitizeForLog(forwardedHost)) + log.warn("forward-auth: unknown host '{}' — defaulting to ADMIN", safeHost) Role.ADMIN } @@ -87,7 +89,7 @@ class ForwardAuthController( } else { log.warn( "forward-auth: rejecting redirect to untrusted host '{}' — omitting redirect param", - sanitizeForLog(forwardedHost), + safeHost, ) "" }