Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,13 @@ dependencies {

// Testing
testImplementation("com.ritense.valtimo:building-block")
testImplementation("com.ritense.valtimo:core")
testImplementation("com.ritense.valtimo:form")
testImplementation("com.ritense.valtimo:plugin")
testImplementation("com.ritense.valtimo:temporary-resource-storage")
testImplementation("com.ritense.valtimo:test-utils-common")

testImplementation("org.springframework.boot:spring-boot-starter-data-jpa")
testImplementation("org.springframework.boot:spring-boot-starter-test")

testImplementation("org.postgresql:postgresql")
Expand Down
2 changes: 1 addition & 1 deletion backend/plugin/plugin.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
pluginGroupId=com.ritense.valtimoplugins
pluginArtifactId=publictask
pluginVersion=2.1.1
pluginVersion=2.1.2
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.ritense.valtimoplugins.publictask.htmlrenderer.config

import freemarker.cache.ClassTemplateLoader
import freemarker.core.HTMLOutputFormat
import freemarker.template.Configuration
import freemarker.template.TemplateExceptionHandler

Expand All @@ -25,5 +26,8 @@ class FreemarkerConfig : Configuration(VERSION_2_3_31) {
templateLoader = ClassTemplateLoader(javaClass, "/config/template")
defaultEncoding = Charsets.UTF_8.toString()
templateExceptionHandler = TemplateExceptionHandler.RETHROW_HANDLER
// Templates render HTML that is served to the public, so every interpolation is HTML escaped unless the
// template asks for a different encoder. Without this, a value taken from case data can inject markup.
outputFormat = HTMLOutputFormat.INSTANCE
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.util.UriComponentsBuilder
import java.time.LocalDate
import java.time.format.DateTimeParseException
import java.util.UUID

class PublicTaskService(
Expand Down Expand Up @@ -66,9 +67,7 @@ class PublicTaskService(
}

fun createPublicTaskHtml(publicTaskId: UUID): ResponseEntity<String> {
val publicTaskEntity =
publicTaskRepository.findById(publicTaskId).orElse(null)
?: return TASK_NOT_AVAILABLE_ERROR
val publicTaskEntity = findAvailablePublicTask(publicTaskId) ?: return TASK_NOT_AVAILABLE_ERROR

val formHtml =
try {
Expand Down Expand Up @@ -96,13 +95,7 @@ class PublicTaskService(
publicTaskId: UUID,
submission: JsonNode,
): ResponseEntity<String> {
val publicTaskEntity =
publicTaskRepository.findById(publicTaskId).orElse(null)
?: return TASK_NOT_AVAILABLE_ERROR

if (LocalDate.parse(publicTaskEntity.taskExpirationDate).isBefore(LocalDate.now())) {
return TASK_NOT_AVAILABLE_ERROR
}
val publicTaskEntity = findAvailablePublicTask(publicTaskId) ?: return TASK_NOT_AVAILABLE_ERROR

val operatonTask =
try {
Expand Down Expand Up @@ -137,11 +130,36 @@ class PublicTaskService(
return ResponseEntity("Your response has been submitted", HttpStatus.OK)
}

/**
* Returns the public task only while it is still available: not completed through the public form and not past
* its expiration date. Both showing the form and submitting it go through this, so that the two cannot drift
* apart and leave the form - which contains case data - retrievable for longer than the task itself lives.
*/
private fun findAvailablePublicTask(publicTaskId: UUID): PublicTaskEntity? =
publicTaskRepository
.findById(publicTaskId)
.orElse(null)
?.takeIf { it.isAvailable() }

private fun PublicTaskEntity.isAvailable(): Boolean {
if (isCompletedByPublicTask) {
return false
}
val expirationDate =
try {
LocalDate.parse(taskExpirationDate)
} catch (e: DateTimeParseException) {
logger.warn(e) { "Public task has an unusable expiration date and is treated as expired" }
return false
}
return !expirationDate.isBefore(LocalDate.now())
}

private fun publicTaskUrl(publicTaskId: UUID): String =
UriComponentsBuilder
.fromUriString(baseUrl.removeSuffix("/"))
.path(PUBLIC_TASK_URL)
.queryParam("publicTaskId", publicTaskId)
.pathSegment(publicTaskId.toString())
.build()
.toUriString()

Expand All @@ -157,7 +175,9 @@ class PublicTaskService(
isCompletedByPublicTask = publicTaskData.isCompletedByPublicTask,
),
).also {
logger.debug { "Saved public task entity $it" }
// Only the user task is logged: the public task id is what grants access to the form, so it must
// not end up in log files.
logger.debug { "Saved public task entity for user task ${it.userTaskId}" }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.JsonNode
import com.ritense.valtimoplugins.publictask.service.PublicTaskService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
Expand All @@ -32,13 +33,31 @@ import java.util.UUID
class PublicTaskResource(
private val publicTaskService: PublicTaskService,
) {
@GetMapping
@GetMapping("/{publicTaskId}")
fun sendPublicTaskHtml(
@RequestParam publicTaskId: UUID,
@PathVariable publicTaskId: UUID,
): ResponseEntity<String> = publicTaskService.createPublicTaskHtml(publicTaskId)

@PostMapping
@PostMapping("/{publicTaskId}")
fun completeUserTask(
@PathVariable publicTaskId: UUID,
@RequestBody submission: JsonNode,
): ResponseEntity<String> = publicTaskService.completeUserTaskWithPublicTaskSubmission(publicTaskId, submission)

/**
* Kept so that public task links which were sent out before the id moved into the path keep working. New links
* use the path form, because an id in the query string ends up in Referer headers, proxy logs and browser
* history.
*/
@Deprecated("Use GET /api/v1/public-task/{publicTaskId}")
@GetMapping(params = ["publicTaskId"])
fun sendPublicTaskHtmlForQueryParameter(
@RequestParam publicTaskId: UUID,
): ResponseEntity<String> = publicTaskService.createPublicTaskHtml(publicTaskId)

@Deprecated("Use POST /api/v1/public-task/{publicTaskId}")
@PostMapping(params = ["publicTaskId"])
fun completeUserTaskForQueryParameter(
@RequestParam publicTaskId: UUID,
@RequestBody submission: JsonNode,
): ResponseEntity<String> = publicTaskService.completeUserTaskWithPublicTaskSubmission(publicTaskId, submission)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,25 @@
<body>
<div id="form" class="d-block"></div>
<div id="result" class="d-none"></div>
<#--
The form definition is emitted as a data block instead of as a JavaScript literal, so that its content is never
parsed as script. HTML escaping would be the wrong escaping here - the content of a script element is raw text,
where entities are not decoded - so the value is marked as no_esc and encoded for this context instead: in JSON a
'<' can only occur inside a string literal, where "\u003C" means exactly the same thing. Replacing every '<'
therefore keeps the JSON intact while removing every sequence ("</script", "<!--") that the HTML parser acts on.
-->
<script id="form-io-form" type="application/json">${form_io_form?replace("<", "\\u003C")?no_esc}</script>
<script src="https://cdn.form.io/formiojs/formio.full.min.js"></script>
<script>
const formContainer = document.getElementById('form');
const resultContainer = document.getElementById('result');
const formJson = ${form_io_form};
const formJson = JSON.parse(document.getElementById('form-io-form').textContent);

Formio.createForm(formContainer, formJson).then(function (form) {
form.on('submit', function (submission) {
console.debug('Form submitted', submission);
fetch('${public_task_url}', {
<#-- js_string, not the default HTML escaping: this value sits in a JavaScript string literal. -->
fetch('${public_task_url?js_string?no_esc}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(submission.data)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* Copyright 2026 Ritense BV, the Netherlands.
*
* Licensed under EUPL, Version 1.2 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.ritense.valtimoplugins.publictask.htmlrenderer.service

import com.fasterxml.jackson.databind.ObjectMapper
import com.ritense.valtimoplugins.publictask.BaseTest
import com.ritense.valtimoplugins.publictask.htmlrenderer.config.FreemarkerConfig
import freemarker.core.HTMLOutputFormat
import freemarker.template.Template
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.io.StringWriter

internal class PublicTaskHtmlTemplateTest : BaseTest() {
private val objectMapper = ObjectMapper()

private val htmlRenderService = HtmlRenderService(FreemarkerConfig())

@Test
fun `a form value that closes the script element cannot break out of it`() {
val formIoForm =
"""
{
"components": [
{ "key": "naam", "defaultValue": "</script><img src=x onerror=alert(1)>" }
]
}
""".trimIndent()

val html = render(formIoForm)

// No '<' survives in the data block, so nothing in it can close the element or open an HTML comment.
assertThat(jsonDataBlockOf(html)).doesNotContain("<")
assertThat(html).doesNotContain("<img")
assertThat(html).contains("\\u003C/script>\\u003Cimg src=x onerror=alert(1)>")
}

@Test
fun `a valid form definition is still readable as json after escaping`() {
val formIoForm =
objectMapper
.createObjectNode()
.put("quotes", """He said "hi" and \ left""")
.put("unicode", "Ruben ë ç 😀")
.put("newlines", "line one\nline two\ttabbed")
.put("slashes", "https://example.org/a/b?c=d&e=f")
.put("markup", "</script> <!-- <b>bold</b>")
.toPrettyString()

val html = render(formIoForm)

assertThat(objectMapper.readTree(jsonDataBlockOf(html)))
.isEqualTo(objectMapper.readTree(formIoForm))
}

@Test
fun `the submit url is escaped for the javascript string literal it is placed in`() {
val html = render(publicTaskUrl = """https://valtimo.example.org/api/v1/public-task/1' + alert(1) + '""")

assertThat(html).doesNotContain("""' + alert(1) + '""")
assertThat(html).contains("""\' + alert(1) + \'""")
}

@Test
fun `the freemarker configuration escapes interpolations by default`() {
assertThat(FreemarkerConfig().outputFormat).isEqualTo(HTMLOutputFormat.INSTANCE)

// Guards the setting behaviourally as well: an interpolation without an explicit encoder must be escaped.
val rendered =
StringWriter()
.apply {
Template("auto-escaping-check", "\${value}", FreemarkerConfig())
.process(mapOf("value" to "</script>"), this)
}.toString()

assertThat(rendered).doesNotContain("</script>")
}

private fun render(
formIoForm: String = "{}",
publicTaskUrl: String = "https://valtimo.example.org/api/v1/public-task/$PUBLIC_TASK_ID",
): String =
htmlRenderService.generatePublicTaskHtml(
fileName = "public_task_html",
variables =
mapOf(
"form_io_form" to formIoForm,
"public_task_url" to publicTaskUrl,
),
)

private fun jsonDataBlockOf(html: String): String =
requireNotNull(JSON_DATA_BLOCK.find(html)) {
"The rendered page does not contain a <script type=\"application/json\"> data block:\n$html"
}.groupValues[1]

companion object {
private const val PUBLIC_TASK_ID = "3f2a1c4e-0b7d-4a19-9c5e-8d6f0a1b2c3d"

private val JSON_DATA_BLOCK =
Regex(
"""<script id="form-io-form" type="application/json">(.*?)</script>""",
RegexOption.DOT_MATCHES_ALL,
)
}
}
Loading
Loading