diff --git a/src/main/java/com/clearfolio/viewer/controller/ViewerUiController.java b/src/main/java/com/clearfolio/viewer/controller/ViewerUiController.java index 0b8369b3..423df1f0 100644 --- a/src/main/java/com/clearfolio/viewer/controller/ViewerUiController.java +++ b/src/main/java/com/clearfolio/viewer/controller/ViewerUiController.java @@ -2,6 +2,7 @@ import java.util.UUID; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -35,15 +36,34 @@ public ResponseEntity home() { * @param docId document identifier * @return HTML payload or redirect */ - @GetMapping(value = "/viewer/{docId:[0-9a-fA-F\u002d]{36}}", produces = MediaType.TEXT_HTML_VALUE) - public ResponseEntity viewer(@PathVariable UUID docId) { - return ResponseEntity.ok() - .contentType(MediaType.TEXT_HTML) - .body(viewerShellHtml(docId, "LOADING")); + @GetMapping(value = "/viewer/{docId}", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity viewer(@PathVariable String docId) { + try { + UUID parsed = UUID.fromString(docId); + return ResponseEntity.ok() + .contentType(MediaType.TEXT_HTML) + .body(viewerShellHtml(parsed.toString(), "LOADING")); + } catch (IllegalArgumentException ex) { + // Friendly invalid-document shell (instead of a raw framework 404) + // so integrator deep links, e.g. an admin console linking + // /viewer/{docId}, land on a readable page. + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .contentType(MediaType.TEXT_HTML) + .body(viewerShellHtml(docId, "NOT_FOUND")); + } + } + + private static String escapeHtmlAttribute(String value) { + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); } - private static String viewerShellHtml(UUID docId, String initialState) { - String docIdString = docId.toString(); + private static String viewerShellHtml(String docId, String initialState) { + String docIdString = escapeHtmlAttribute(docId); String template = """ diff --git a/src/test/java/com/clearfolio/viewer/controller/ViewerUiControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/ViewerUiControllerTest.java index dadb8949..6095e194 100644 --- a/src/test/java/com/clearfolio/viewer/controller/ViewerUiControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/ViewerUiControllerTest.java @@ -149,4 +149,45 @@ void viewerReturnsHtmlWithDocIdMeta() { .expectBody(String.class) .value(body -> assertTrue(body.contains("clearfolio-doc-id\" content=\"" + docId))); } + + @Test + void viewerRejectsNonUuidDocIdWithFriendlyShell() { + webTestClient.get() + .uri("/viewer/not-a-uuid") + .exchange() + .expectStatus().isNotFound() + .expectHeader().contentTypeCompatibleWith(MediaType.TEXT_HTML) + .expectBody(String.class) + .value(body -> { + assertTrue(body.contains("clearfolio-doc-id")); + assertTrue(body.contains("not-a-uuid")); + assertTrue(body.contains("clearfolio-initial-state\" content=\"NOT_FOUND")); + assertTrue(body.contains("/assets/viewer/viewer.js")); + }); + } + + @Test + void viewerEscapesHtmlInInvalidDocId() { + webTestClient.get() + .uri("/viewer/{docId}", "\">") + .exchange() + .expectStatus().isNotFound() + .expectBody(String.class) + .value(body -> { + assertTrue(!body.contains("")); + assertTrue(body.contains("<script>")); + }); + } + + @Test + void viewerStillServesUuidDocId() { + UUID docId = UUID.randomUUID(); + webTestClient.get() + .uri("/viewer/" + docId) + .exchange() + .expectStatus().isOk() + .expectHeader().contentTypeCompatibleWith(MediaType.TEXT_HTML) + .expectBody(String.class) + .value(body -> assertTrue(body.contains(docId.toString()))); + } }