From 4381d24cdd1cdca29b4340038169cac6256f4769 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Mon, 4 May 2026 17:15:43 +0200 Subject: [PATCH 01/58] chore(core): Add passThrough rules for Spring DeferredResult --- core/opentaint-config/config/config/config.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/core/opentaint-config/config/config/config.yaml b/core/opentaint-config/config/config/config.yaml index 148f6e1bf..ecb7b9b6c 100644 --- a/core/opentaint-config/config/config/config.yaml +++ b/core/opentaint-config/config/config/config.yaml @@ -17047,6 +17047,18 @@ passThrough: copy: - from: arg(0) to: result +- function: org.springframework.web.context.request.async.DeferredResult#setResult + copy: + - from: arg(0) + to: this +- function: org.springframework.web.context.request.async.DeferredResult#setErrorResult + copy: + - from: arg(0) + to: this +- function: org.springframework.web.context.request.async.DeferredResult#getResult + copy: + - from: this + to: result - function: org.springframework.web.multipart.commons.CommonsFileUploadSupport$MultipartParsingResult#getMultipartFiles copy: - from: this From 7b29007f4a279399e0bca9c5fc33e92e56a93032 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Mon, 4 May 2026 17:29:43 +0200 Subject: [PATCH 02/58] chore(core): Move DeferredResult passThrough rules to spring-web jar-split --- core/opentaint-config/config/config/config.yaml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/core/opentaint-config/config/config/config.yaml b/core/opentaint-config/config/config/config.yaml index ecb7b9b6c..148f6e1bf 100644 --- a/core/opentaint-config/config/config/config.yaml +++ b/core/opentaint-config/config/config/config.yaml @@ -17047,18 +17047,6 @@ passThrough: copy: - from: arg(0) to: result -- function: org.springframework.web.context.request.async.DeferredResult#setResult - copy: - - from: arg(0) - to: this -- function: org.springframework.web.context.request.async.DeferredResult#setErrorResult - copy: - - from: arg(0) - to: this -- function: org.springframework.web.context.request.async.DeferredResult#getResult - copy: - - from: this - to: result - function: org.springframework.web.multipart.commons.CommonsFileUploadSupport$MultipartParsingResult#getMultipartFiles copy: - from: this From af0d922eba6ac9e1aa13652ad0f87dc02b321176 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Mon, 13 Apr 2026 11:30:48 +0300 Subject: [PATCH 03/58] feat: add new xss sinks rules --- .../servlet-xss-html-response-sinks.yaml | 67 ++++++ .../spring-xss-html-response-sinks.yaml | 210 ++++++++++++++++++ rules/ruleset/java/security/xss.yaml | 71 ++++++ 3 files changed, 348 insertions(+) create mode 100644 rules/ruleset/java/lib/generic/servlet-xss-html-response-sinks.yaml create mode 100644 rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml diff --git a/rules/ruleset/java/lib/generic/servlet-xss-html-response-sinks.yaml b/rules/ruleset/java/lib/generic/servlet-xss-html-response-sinks.yaml new file mode 100644 index 000000000..73db337ff --- /dev/null +++ b/rules/ruleset/java/lib/generic/servlet-xss-html-response-sinks.yaml @@ -0,0 +1,67 @@ +rules: + - id: java-servlet-xss-html-response-sink + options: + lib: true + severity: NOTE + message: Direct write of unvalidated user input into a response without safe content type + metadata: + provenance: + - https://github.com/github/codeql/blob/main/java/ql/lib/semmle/code/java/security/XSS.qll + languages: + - java + mode: taint + pattern-sanitizers: + - pattern-either: + - pattern: Encode.forHtml(...) + - pattern: (PolicyFactory $POLICY).sanitize(...) + - pattern: (AntiSamy $AS).scan(...) + - pattern: JSoup.clean(...) + - pattern: HtmlUtils.htmlEscape(...) + - pattern: org.apache.commons.lang.StringEscapeUtils.escapeHtml(...) + - pattern: org.apache.commons.text.StringEscapeUtils.escapeHtml3(...) + - pattern: org.apache.commons.text.StringEscapeUtils.escapeHtml4(...) + - pattern: org.owasp.esapi.ESAPI.encoder().encodeForHTML(...) + + # Servlet spec defaults to text/html when no content type is set. + # Writer/OutputStream is XSS-vulnerable unless a safe content type + # (application/json, text/plain, etc.) is explicitly set on the response. + pattern-sinks: + - patterns: + - pattern-not-inside: | + $R.setContentType("application/json"); + ... + - pattern-not-inside: | + $R.setContentType("application/json;charset=UTF-8"); + ... + - pattern-not-inside: | + $R.setContentType("application/json; charset=UTF-8"); + ... + - pattern-not-inside: | + $R.setContentType("text/plain"); + ... + - pattern-not-inside: | + $R.setContentType("text/plain;charset=UTF-8"); + ... + - pattern-not-inside: | + $R.setContentType("text/plain; charset=UTF-8"); + ... + - pattern-not-inside: | + $R.setContentType("application/pdf"); + ... + - pattern-not-inside: | + $R.setContentType("application/octet-stream"); + ... + - pattern-not-inside: | + $R.setContentType("application/xml"); + ... + - pattern-not-inside: | + $R.setContentType("application/xml;charset=UTF-8"); + ... + - pattern-either: + - pattern: | + (HttpServletResponse $RESPONSE).getWriter(...).$WRITE(..., $UNTRUSTED, ...) + - pattern: | + (HttpServletResponse $RESPONSE).getOutputStream(...).$WRITE(..., $UNTRUSTED, ...) + - pattern: | + (HttpServletResponse $RESPONSE).sendError($CODE, $UNTRUSTED) + - focus-metavariable: $UNTRUSTED diff --git a/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml b/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml new file mode 100644 index 000000000..9a2dc1aea --- /dev/null +++ b/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml @@ -0,0 +1,210 @@ +rules: + - id: spring-xss-html-response-sink + options: + lib: true + severity: NOTE + message: Return of unvalidated user input from a Spring handler vulnerable to XSS + metadata: + provenance: + - https://github.com/github/codeql/blob/main/java/ql/lib/semmle/code/java/frameworks/spring/SpringHttp.qll + languages: + - java + mode: taint + pattern-sanitizers: + - pattern-either: + - pattern: Encode.forHtml(...) + - pattern: (PolicyFactory $POLICY).sanitize(...) + - pattern: (AntiSamy $AS).scan(...) + - pattern: JSoup.clean(...) + - pattern: HtmlUtils.htmlEscape(...) + - pattern: org.apache.commons.lang.StringEscapeUtils.escapeHtml(...) + - pattern: org.apache.commons.text.StringEscapeUtils.escapeHtml3(...) + - pattern: org.apache.commons.text.StringEscapeUtils.escapeHtml4(...) + - pattern: org.owasp.esapi.ESAPI.encoder().encodeForHTML(...) + + pattern-sinks: + # ── Spring @ResponseBody String return ───────────────────────────────── + # StringHttpMessageConverter accepts text/* including text/html. + # When the browser sends Accept: text/html, a String return is rendered + # as HTML unless produces constrains to a safe content type. + + # 1a. String return — no produces (dangerous default: content negotiation) + - patterns: + - pattern-inside: | + @$ANNOTATION(...) + String $METHOD(...) { + ... + } + - metavariable-pattern: + metavariable: $ANNOTATION + patterns: + - pattern-either: + - pattern: RequestMapping + - pattern: DeleteMapping + - pattern: GetMapping + - pattern: PatchMapping + - pattern: PostMapping + - pattern: PutMapping + - pattern-not-inside: | + @$A(..., produces = "application/json", ...) + String $M(...) { ... } + - pattern-not-inside: | + @$A(..., produces = MediaType.APPLICATION_JSON_VALUE, ...) + String $M(...) { ... } + - pattern-not-inside: | + @$A(..., produces = "text/plain", ...) + String $M(...) { ... } + - pattern-not-inside: | + @$A(..., produces = MediaType.TEXT_PLAIN_VALUE, ...) + String $M(...) { ... } + - pattern-not-inside: | + @$A(..., produces = "application/pdf", ...) + String $M(...) { ... } + - pattern-not-inside: | + @$A(..., produces = "application/octet-stream", ...) + String $M(...) { ... } + - pattern: return $UNTRUSTED; + - focus-metavariable: $UNTRUSTED + + # 1b. ResponseEntity return — same converter, same risk + - patterns: + - pattern-inside: | + @$ANNOTATION(...) + ResponseEntity $METHOD(...) { + ... + } + - metavariable-pattern: + metavariable: $ANNOTATION + patterns: + - pattern-either: + - pattern: RequestMapping + - pattern: DeleteMapping + - pattern: GetMapping + - pattern: PatchMapping + - pattern: PostMapping + - pattern: PutMapping + - pattern-not-inside: | + @$A(..., produces = "application/json", ...) + ResponseEntity $M(...) { ... } + - pattern-not-inside: | + @$A(..., produces = MediaType.APPLICATION_JSON_VALUE, ...) + ResponseEntity $M(...) { ... } + - pattern: return $UNTRUSTED; + - focus-metavariable: $UNTRUSTED + + # 1c. Raw ResponseEntity return (no type param) — body type unknown + - patterns: + - pattern-inside: | + @$ANNOTATION(...) + ResponseEntity $METHOD(...) { + ... + } + - metavariable-pattern: + metavariable: $ANNOTATION + patterns: + - pattern-either: + - pattern: RequestMapping + - pattern: DeleteMapping + - pattern: GetMapping + - pattern: PatchMapping + - pattern: PostMapping + - pattern: PutMapping + - pattern-not-inside: | + @$A(..., produces = "application/json", ...) + ResponseEntity $M(...) { ... } + - pattern-not-inside: | + @$A(..., produces = MediaType.APPLICATION_JSON_VALUE, ...) + ResponseEntity $M(...) { ... } + - pattern: return $UNTRUSTED; + - focus-metavariable: $UNTRUSTED + + # ── Direct response writer with HTML content type ────────────────────── + # Servlet API: setContentType / setHeader / addHeader + - patterns: + - pattern-either: + - pattern-inside: | + $R.setContentType("text/html"); + ... + - pattern-inside: | + $R.setContentType("text/html;charset=UTF-8"); + ... + - pattern-inside: | + $R.setContentType("text/html; charset=UTF-8"); + ... + - pattern-inside: | + $R.setContentType("text/html;charset=utf-8"); + ... + - pattern-inside: | + $R.setContentType("text/html; charset=utf-8"); + ... + - pattern-inside: | + $R.setContentType("text/html;charset=ISO-8859-1"); + ... + - pattern-inside: | + $R.setContentType("text/html; charset=ISO-8859-1"); + ... + - pattern-inside: | + $R.setHeader("Content-Type", "text/html"); + ... + - pattern-inside: | + $R.setHeader("Content-Type", "text/html;charset=UTF-8"); + ... + - pattern-inside: | + $R.setHeader("Content-Type", "text/html; charset=UTF-8"); + ... + - pattern-inside: | + $R.addHeader("Content-Type", "text/html"); + ... + - pattern-inside: | + $R.addHeader("Content-Type", "text/html;charset=UTF-8"); + ... + - pattern-inside: | + $R.addHeader("Content-Type", "text/html; charset=UTF-8"); + ... + - pattern-either: + - pattern: | + (HttpServletResponse $RESPONSE).getWriter(...).$WRITE(..., $UNTRUSTED, ...) + - pattern: | + (HttpServletResponse $RESPONSE).getOutputStream(...).$WRITE(..., $UNTRUSTED, ...) + - pattern: | + (HttpServletResponse $RESPONSE).sendError($CODE, $UNTRUSTED) + - focus-metavariable: $UNTRUSTED + + # ── produces = "text/html" — explicit HTML ──────────────────────────── + - patterns: + - pattern-inside: | + @$ANNOTATION(..., produces = "text/html", ...) + $RETURNTYPE $METHOD(...) { + ... + } + - metavariable-pattern: + metavariable: $ANNOTATION + patterns: + - pattern-either: + - pattern: RequestMapping + - pattern: DeleteMapping + - pattern: GetMapping + - pattern: PatchMapping + - pattern: PostMapping + - pattern: PutMapping + - pattern: return $UNTRUSTED; + - focus-metavariable: $UNTRUSTED + + - patterns: + - pattern-inside: | + @$ANNOTATION(..., produces = MediaType.TEXT_HTML_VALUE, ...) + $RETURNTYPE $METHOD(...) { + ... + } + - metavariable-pattern: + metavariable: $ANNOTATION + patterns: + - pattern-either: + - pattern: RequestMapping + - pattern: DeleteMapping + - pattern: GetMapping + - pattern: PatchMapping + - pattern: PostMapping + - pattern: PutMapping + - pattern: return $UNTRUSTED; + - focus-metavariable: $UNTRUSTED diff --git a/rules/ruleset/java/security/xss.yaml b/rules/ruleset/java/security/xss.yaml index f4466a91a..fc048dfeb 100644 --- a/rules/ruleset/java/security/xss.yaml +++ b/rules/ruleset/java/security/xss.yaml @@ -1,6 +1,40 @@ rules: - id: xss-in-servlet-app severity: ERROR + message: >- + XSS: writing user input directly to an HTML response. The response content type is set to + text/html, making this directly exploitable in a browser. + metadata: + cwe: CWE-79 + short-description: Cross-site scripting (XSS) in HTML response + full-description: |- + Cross-Site Scripting (XSS) is a class of vulnerabilities that allows an attacker to inject malicious + client-side code (usually JavaScript) into web pages viewed by other users. + This finding is reported at ERROR severity because the servlet explicitly sets + the response content type to text/html, confirming that the browser will render the response + as HTML and execute any injected scripts. + + To remediate, apply context-appropriate output encoding (e.g., HTML-escape with + `StringEscapeUtils.escapeHtml4()` or `HtmlUtils.htmlEscape()`) before writing user input + to the response. + references: + - https://owasp.org/www-community/attacks/xss/ + provenance: + - https://github.com/semgrep/semgrep-rules/blob/develop/java/lang/security/audit/xss/no-direct-response-writer.yaml + languages: + - java + mode: join + join: + refs: + - rule: java/lib/generic/servlet-untrusted-data-source.yaml#java-servlet-untrusted-data-source + as: untrusted-data + - rule: java/lib/generic/servlet-xss-html-response-sinks.yaml#java-servlet-xss-html-response-sink + as: sink + on: + - 'untrusted-data.$UNTRUSTED -> sink.$UNTRUSTED' + + - id: potential-xss-in-servlet-app + severity: INFO message: >- Potential XSS: writing user input directly to a web page. metadata: @@ -136,6 +170,43 @@ rules: - id: xss-in-spring-app severity: ERROR + message: >- + XSS: writing user input directly to an HTML response. The response content type is set to + text/html, making this directly exploitable in a browser. + metadata: + cwe: CWE-79 + short-description: Cross-site scripting (XSS) in HTML response + full-description: |- + Cross-Site Scripting (XSS) is a class of vulnerabilities that allows an attacker to inject malicious + client-side code (usually JavaScript) into web pages viewed by other users. + This finding is reported at ERROR severity because the response content type is determined + to be text/html, confirming that the browser will render the response as HTML and execute + any injected scripts. + + In Spring applications, this occurs when a handler method writes untrusted input + directly to HttpServletResponse with an HTML content type, or returns untrusted data + from a handler annotated with produces = "text/html". + + To remediate, apply context-appropriate output encoding (e.g., `HtmlUtils.htmlEscape()`) + before writing user input to the response. + references: + - https://owasp.org/www-community/attacks/xss/ + provenance: + - https://github.com/semgrep/semgrep-rules/blob/develop/java/spring/security/injection/tainted-html-string.yaml + languages: + - java + mode: join + join: + refs: + - rule: java/lib/spring/untrusted-data-source.yaml#spring-untrusted-data-source + as: untrusted-data + - rule: java/lib/spring/spring-xss-html-response-sinks.yaml#spring-xss-html-response-sink + as: sink + on: + - 'untrusted-data.$UNTRUSTED -> sink.$UNTRUSTED' + + - id: potential-xss-in-spring-app + severity: INFO message: >- Potential XSS: writing user input directly to a web page. metadata: From 710594c434aab30db2bdab5f863548333a392028 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Mon, 13 Apr 2026 11:31:04 +0300 Subject: [PATCH 04/58] test: add tests for xss sinks --- .../xss/XssHtmlResponseServletSamples.java | 74 +++++++++++ .../xss/XssHtmlResponseSpringSamples.java | 122 ++++++++++++++++++ .../java/security/xss/XssServletSamples.java | 19 +++ .../java/security/xss/XssSpringSamples.java | 40 ++++++ 4 files changed, 255 insertions(+) create mode 100644 rules/test/src/main/java/security/xss/XssHtmlResponseServletSamples.java create mode 100644 rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java diff --git a/rules/test/src/main/java/security/xss/XssHtmlResponseServletSamples.java b/rules/test/src/main/java/security/xss/XssHtmlResponseServletSamples.java new file mode 100644 index 000000000..2a855eb85 --- /dev/null +++ b/rules/test/src/main/java/security/xss/XssHtmlResponseServletSamples.java @@ -0,0 +1,74 @@ +package security.xss; + +import java.io.IOException; +import java.io.PrintWriter; + +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.opentaint.sast.test.util.NegativeRuleSample; +import org.opentaint.sast.test.util.PositiveRuleSample; + +/** + * Servlet-based samples for xss-in-servlet-app (ERROR). + * + * XSS in servlets is ERROR by default because the Servlet spec defaults + * to text/html when no content type is set. + */ +public class XssHtmlResponseServletSamples { + + // ── Positive: explicit text/html ──────────────────────────────────────── + + @WebServlet("/xss-in-servlet-app/unsafe-html-explicit") + public static class UnsafeHtmlServlet extends HttpServlet { + + @Override + @PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-servlet-app") + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + response.setContentType("text/html;charset=UTF-8"); + PrintWriter out = response.getWriter(); + String name = request.getParameter("name"); + out.println("

Hello, " + name + "!

"); + } + } + + // ── Positive: no content type (servlet defaults to text/html) ─────────── + + @WebServlet("/xss-in-servlet-app/unsafe-no-content-type") + public static class UnsafeNoContentTypeServlet extends HttpServlet { + + @Override + @PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-servlet-app") + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + PrintWriter out = response.getWriter(); + String name = request.getParameter("name"); + out.println("Hello, " + name + "!"); + } + } + + // ── Negative: sanitized output ────────────────────────────────────────── + + @WebServlet("/xss-in-servlet-app/safe-html-explicit") + public static class SafeHtmlServlet extends HttpServlet { + + @Override + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-servlet-app") + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + response.setContentType("text/html;charset=UTF-8"); + PrintWriter out = response.getWriter(); + String name = request.getParameter("name"); + if (name == null) { name = ""; } + String safeName = org.apache.commons.text.StringEscapeUtils.escapeHtml4(name); + out.println("

Hello, " + safeName + "!

"); + } + } +} diff --git a/rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java b/rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java new file mode 100644 index 000000000..a70d4059d --- /dev/null +++ b/rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java @@ -0,0 +1,122 @@ +package security.xss; + +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; + +import javax.servlet.http.HttpServletResponse; + +import org.opentaint.sast.test.util.NegativeRuleSample; +import org.opentaint.sast.test.util.PositiveRuleSample; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.HtmlUtils; + + +/** + * Spring MVC samples for xss-in-spring-app (ERROR). + * + * XSS in Spring is ERROR by default because: + * - StringHttpMessageConverter negotiates to text/html for String returns + * - Servlet spec defaults to text/html for direct response writers + * - ResponseEntity without explicit content type is subject to content sniffing + */ +public class XssHtmlResponseSpringSamples { + + // ── String return from @RestController (content negotiation → text/html) ─ + + @RestController + public static class UnsafeStringReturnController { + + /** + * String return from @RestController without produces. + * StringHttpMessageConverter negotiates to text/html with browser Accept header. + */ + @GetMapping("/xss-in-spring-app/unsafe-string-return") + @PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public String unsafeStringReturn(@RequestParam(required = false) String name) { + return "

Hello, " + name + "!

"; + } + } + + // ── ResponseEntity return ─────────────────────────────────────── + + @Controller + public static class UnsafeResponseEntityStringController { + + /** + * ResponseEntity<String> — body goes through StringHttpMessageConverter. + */ + @PostMapping("/xss-in-spring-app/unsafe-response-entity-string") + @ResponseBody + @PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public ResponseEntity unsafeResponseEntityString(@RequestParam String filename) { + String errorMessage = "Conversion failed for " + filename; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(errorMessage); + } + } + + // ── Direct response writer with text/html content type ────────────────── + + @Controller + public static class UnsafeSetContentTypeController { + + @GetMapping("/xss-in-spring-app/unsafe-html") + @PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public void unsafeHtmlGreet(@RequestParam(required = false) String name, HttpServletResponse response) throws IOException { + response.setContentType("text/html;charset=UTF-8"); + PrintWriter out = response.getWriter(); + out.println("

Hello, " + name + "!

"); + } + } + + // ── setHeader("Content-Type", "text/html") ────────────────────────────── + + @Controller + public static class UnsafeSetHeaderController { + + @GetMapping("/xss-in-spring-app/unsafe-set-header") + @PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public void unsafeSetHeaderGreet(@RequestParam(required = false) String name, HttpServletResponse response) throws IOException { + response.setHeader("Content-Type", "text/html;charset=UTF-8"); + PrintWriter out = response.getWriter(); + out.println("

Hello, " + name + "!

"); + } + } + + // ── Negative: sanitized HTML output ───────────────────────────────────── + + @Controller + public static class SafeHtmlController { + + @GetMapping("/xss-in-spring-app/safe-html") + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public void safeHtmlGreet(@RequestParam(required = false, defaultValue = "") String name, HttpServletResponse response) throws IOException { + response.setContentType("text/html;charset=UTF-8"); + PrintWriter out = response.getWriter(); + String safeName = HtmlUtils.htmlEscape(name, "UTF-8"); + out.println("

Hello, " + safeName + "!

"); + } + } + + // ── Negative: sanitized String return ──────────────────────────────────── + + @RestController + public static class SafeStringReturnController { + + @GetMapping("/xss-in-spring-app/safe-string-return") + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public String safeStringReturn(@RequestParam(required = false, defaultValue = "") String name) { + String safeName = HtmlUtils.htmlEscape(name, "UTF-8"); + return "

Hello, " + safeName + "!

"; + } + } +} diff --git a/rules/test/src/main/java/security/xss/XssServletSamples.java b/rules/test/src/main/java/security/xss/XssServletSamples.java index 61a11ea7f..a872d7624 100644 --- a/rules/test/src/main/java/security/xss/XssServletSamples.java +++ b/rules/test/src/main/java/security/xss/XssServletSamples.java @@ -74,4 +74,23 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) out.println(""); } } + + /** + * Unsafe servlet with JSON content type — positive for INFO rule. + * The ERROR rule should NOT fire (safe content type excludes it). + */ + @WebServlet("/potential-xss-in-servlet-app/unsafe-json") + public static class UnsafeJsonInfoServlet extends HttpServlet { + + @Override + @PositiveRuleSample(value = "java/security/xss.yaml", id = "potential-xss-in-servlet-app") + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + response.setContentType("application/json;charset=UTF-8"); + PrintWriter out = response.getWriter(); + String name = request.getParameter("name"); + out.println("{\"greeting\": \"Hello, " + name + "\"}"); + } + } } diff --git a/rules/test/src/main/java/security/xss/XssSpringSamples.java b/rules/test/src/main/java/security/xss/XssSpringSamples.java index b26eafc6c..8b3b1407d 100644 --- a/rules/test/src/main/java/security/xss/XssSpringSamples.java +++ b/rules/test/src/main/java/security/xss/XssSpringSamples.java @@ -2,13 +2,17 @@ import java.io.IOException; import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; import javax.servlet.http.HttpServletResponse; import org.opentaint.sast.test.util.NegativeRuleSample; import org.opentaint.sast.test.util.PositiveRuleSample; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.util.HtmlUtils; @@ -71,4 +75,40 @@ public void safeGreet(@RequestParam(required = false, defaultValue = "") String } } + @Controller + public static class UnsafeNoContentTypeSpringController { + + /** + * Spring handler with no explicit content type that writes untrusted data. + * Positive for the INFO rule (untrusted data flows to response). + * The HTML-context ERROR rule should NOT fire (no HTML evidence). + */ + @GetMapping("/xss-in-spring-app/unsafe-no-content-type") + @PositiveRuleSample(value = "java/security/xss.yaml", id = "potential-xss-in-spring-app") + public void unsafeNoContentType(@RequestParam(required = false) String name, HttpServletResponse response) throws IOException { + PrintWriter out = response.getWriter(); + + out.println("Hello, " + name + "!"); + } + } + + /** + * Modeled after Stirling-PDF ConvertEmlToPDF.java — a Spring handler that returns + * ResponseEntity with user-controlled data in error messages. + * + * Tests that taint propagates through ResponseEntity builder chains: + * request param → errorMessage string concat → .getBytes() → ResponseEntity.body() → return + */ + @Controller + public static class UnsafeResponseEntityController { + + @PostMapping("/xss-in-spring-app/unsafe-response-entity") + @PositiveRuleSample(value = "java/security/xss.yaml", id = "potential-xss-in-spring-app") + public ResponseEntity unsafeResponseEntity(@RequestParam String filename) { + String errorMessage = "Conversion failed for " + filename; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(errorMessage.getBytes(StandardCharsets.UTF_8)); + } + } + } From e240a53c1841a60bf704a6ad6f156bddb679afbd Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Fri, 17 Apr 2026 01:30:09 +0200 Subject: [PATCH 05/58] docs: Spring XSS rule dynamic verification design Design for empirically verifying each Spring XSS sink pattern with an ephemeral Spring Boot harness, adding content-type discrimination on ResponseEntity builders, and covering the Stirling-PDF ResponseEntity no-content-type shape. --- ...ng-xss-rule-dynamic-verification-design.md | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 docs/specs/2026-04-17-spring-xss-rule-dynamic-verification-design.md diff --git a/docs/specs/2026-04-17-spring-xss-rule-dynamic-verification-design.md b/docs/specs/2026-04-17-spring-xss-rule-dynamic-verification-design.md new file mode 100644 index 000000000..04c4d3c6f --- /dev/null +++ b/docs/specs/2026-04-17-spring-xss-rule-dynamic-verification-design.md @@ -0,0 +1,231 @@ +# Spring XSS Rule: Dynamic Verification and Content-Type Discrimination + +**Date:** 2026-04-17 +**Rule:** `rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml` +**Samples:** `rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java` + +## Problem + +The current Spring XSS sink rule distinguishes TP/FP only at the +annotation level (`produces = ""`). It cannot see +content-type signals that the handler sets programmatically on a +`ResponseEntity` builder or via `HttpHeaders`. Three consequences: + +1. `ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(untrusted)` + is flagged (false positive) even though the browser will not render + HTML. +2. `ResponseEntity` with no explicit content type (the + Stirling-PDF `ConvertEmlToPDF#convertEmlToPdf` error-path shape at + line 143 of commit `c7b713a`) is *not* flagged today — but it *is* + XSS, because Spring's `ByteArrayHttpMessageConverter` advertises + `application/octet-stream, */*` as supported types and content + negotiation with `Accept: text/html` produces `Content-Type: text/html`. +3. The TP/FP posture of each variant is asserted without runtime + evidence. Future edits can silently flip labels. + +## Goals + +- For every sink pattern in the rule, exist a compiled static sample + (`@PositiveRuleSample` or `@NegativeRuleSample`) whose label is + grounded in a runtime probe of a live Spring handler. +- Add patterns to the rule so `ResponseEntity` / `ResponseEntity` + handlers that set an explicit non-HTML content type on the builder + (or via `HttpHeaders`) are *not* flagged. +- Add a sample reproducing the Stirling-PDF shape and have the rule + flag it (it is a real XSS). + +## Non-goals + +- Committing a persistent runtime test harness. Dynamic verification + runs in a throwaway directory and is discarded. Only static samples + and rule changes are committed. +- Supporting arbitrary content-type expressions (e.g. + `headers.setContentType(someVariable)`). We only discriminate on + literal `MediaType.*` constants and string literals. +- Cross-method flow. The rule discriminates at method scope; if a + handler has multiple returns with different content types, the + method is treated as safe when any return sets a non-HTML type. + +## Dynamic verification harness (ephemeral) + +Location: `/tmp/xss-verify/` — **not** committed. + +- Spring Boot 2.7.x (aligns with `spring-web:5.3.39` already used in + `rules/test/build.gradle.kts`). +- One `@Controller` per matrix row; each endpoint reflects a + `@RequestParam` into its response. +- MockMvc (or `TestRestTemplate` against an embedded Tomcat) fires two + requests per endpoint: + - `Accept: text/html` + `name=` + - `Accept: */*` + same payload +- Recorded for each run: response status, `Content-Type` header, + whether the body contains the raw, unescaped ``. +Expected verdict: `Content-Type: text/html`, body contains raw +`` URL-encoded as `%3Cscript%3Ealert%281%29%3C%2Fscript%3E`. +- Browser `Accept` header in every probe: `text/html`. +- Verdict rule: **TP** iff HTTP response header `Content-Type` begins with `text/html` **and** the response body contains the raw (un-escaped) substring ``; otherwise **FP**. +- Label mapping: TP → `@PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app")`, FP → `@NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app")`. +- Commit style: one logical change per commit, prefix with `feat:`, `test:`, `fix:`, `docs:` as appropriate, sign with the `Co-Authored-By: Claude Opus 4.7 (1M context) ` trailer. + +--- + +## Task 1: Scaffold ephemeral Spring Boot harness + +**Files:** +- Create: `/tmp/xss-verify/settings.gradle` +- Create: `/tmp/xss-verify/build.gradle` +- Create: `/tmp/xss-verify/gradle.properties` +- Create: `/tmp/xss-verify/src/main/java/xssverify/App.java` +- Create: `/tmp/xss-verify/src/main/resources/application.properties` + +- [ ] **Step 1: Create the directory tree** + +Run: +```bash +rm -rf /tmp/xss-verify +mkdir -p /tmp/xss-verify/src/main/java/xssverify +mkdir -p /tmp/xss-verify/src/main/resources +``` + +- [ ] **Step 2: Write `/tmp/xss-verify/settings.gradle`** + +```gradle +rootProject.name = 'xss-verify' +``` + +- [ ] **Step 3: Write `/tmp/xss-verify/build.gradle`** + +```gradle +plugins { + id 'org.springframework.boot' version '2.7.18' + id 'io.spring.dependency-management' version '1.1.4' + id 'java' +} + +group = 'xssverify' +version = '0.0.1-SNAPSHOT' +sourceCompatibility = '17' + +repositories { mavenCentral() } + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' +} + +bootRun { + // Driver controls lifecycle from the Java side +} +``` + +- [ ] **Step 4: Write `/tmp/xss-verify/gradle.properties`** + +``` +org.gradle.jvmargs=-Xmx2g +``` + +- [ ] **Step 5: Write `/tmp/xss-verify/src/main/resources/application.properties`** + +``` +server.port=0 +spring.main.banner-mode=off +logging.level.root=WARN +logging.level.xssverify=INFO +``` + +- [ ] **Step 6: Write `/tmp/xss-verify/src/main/java/xssverify/App.java`** + +```java +package xssverify; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class App { + public static void main(String[] args) { + SpringApplication.run(App.class, args); + } +} +``` + +- [ ] **Step 7: Install the Gradle wrapper and verify the project compiles** + +Run: +```bash +cd /tmp/xss-verify && gradle wrapper --gradle-version 8.5 --no-daemon +./gradlew --no-daemon build -x test 2>&1 | tail -30 +``` + +Expected: `BUILD SUCCESSFUL`. If Gradle cannot be found or cannot reach Maven Central, **halt and surface the error to the user** — do not attempt workarounds, as empirical verification is the point of this plan. + +- [ ] **Step 8: Commit nothing** + +Harness is not committed. `/tmp/xss-verify/` stays out of the repo. + +--- + +## Task 2: Add all 21 controller methods to the harness + +**Files:** +- Create: `/tmp/xss-verify/src/main/java/xssverify/Row1Controller.java` through `/tmp/xss-verify/src/main/java/xssverify/Row21Controller.java` (one file per row, each a `@Controller` or `@RestController`) + +Putting each row in its own file keeps the driver's URL → row mapping obvious and lets you re-run a single row with `./gradlew bootRun --args='--server.port=8080' -Dspring.profiles.active=row5` if needed. The files are throwaway. + +Use `@RequestParam(required = false, defaultValue = "") String payload` in every endpoint. All endpoints are `GET` and live under `/row-` so the driver iterates `/row-1 .. /row-21`. + +- [ ] **Step 1: Write row 1 — String return, no produces** + +File: `/tmp/xss-verify/src/main/java/xssverify/Row1Controller.java` + +```java +package xssverify; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Row1Controller { + @GetMapping("/row-1") + public String row1(@RequestParam(required = false, defaultValue = "") String payload) { + return "

Hello, " + payload + "!

"; + } +} +``` + +- [ ] **Step 2: Write row 2 — String produces=text/html** + +File: `/tmp/xss-verify/src/main/java/xssverify/Row2Controller.java` + +```java +package xssverify; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Row2Controller { + @GetMapping(value = "/row-2", produces = "text/html") + public String row2(@RequestParam(required = false, defaultValue = "") String payload) { + return "

Hello, " + payload + "!

"; + } +} +``` + +- [ ] **Step 3: Write rows 3–6 (String with non-HTML `produces`)** + +File: `/tmp/xss-verify/src/main/java/xssverify/Row3Controller.java` + +```java +package xssverify; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Row3Controller { + @GetMapping(value = "/row-3", produces = "application/json") + public String row3(@RequestParam(required = false, defaultValue = "") String payload) { + return "{\"payload\":\"" + payload + "\"}"; + } +} +``` + +File: `/tmp/xss-verify/src/main/java/xssverify/Row4Controller.java` + +```java +package xssverify; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Row4Controller { + @GetMapping(value = "/row-4", produces = "text/plain") + public String row4(@RequestParam(required = false, defaultValue = "") String payload) { + return "Hello, " + payload; + } +} +``` + +File: `/tmp/xss-verify/src/main/java/xssverify/Row5Controller.java` + +```java +package xssverify; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Row5Controller { + @GetMapping(value = "/row-5", produces = "application/pdf") + public String row5(@RequestParam(required = false, defaultValue = "") String payload) { + return "

Hello, " + payload + "!

"; + } +} +``` + +File: `/tmp/xss-verify/src/main/java/xssverify/Row6Controller.java` + +```java +package xssverify; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Row6Controller { + @GetMapping(value = "/row-6", produces = "application/octet-stream") + public String row6(@RequestParam(required = false, defaultValue = "") String payload) { + return "

Hello, " + payload + "!

"; + } +} +``` + +- [ ] **Step 4: Write rows 7–8 (ResponseEntity with and without produces)** + +File: `/tmp/xss-verify/src/main/java/xssverify/Row7Controller.java` + +```java +package xssverify; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Row7Controller { + @GetMapping("/row-7") + public ResponseEntity row7(@RequestParam(required = false, defaultValue = "") String payload) { + return ResponseEntity.ok("

Hello, " + payload + "!

"); + } +} +``` + +File: `/tmp/xss-verify/src/main/java/xssverify/Row8Controller.java` + +```java +package xssverify; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Row8Controller { + @GetMapping(value = "/row-8", produces = "application/json") + public ResponseEntity row8(@RequestParam(required = false, defaultValue = "") String payload) { + return ResponseEntity.ok("{\"payload\":\"" + payload + "\"}"); + } +} +``` + +- [ ] **Step 5: Write rows 9–12 (ResponseEntity builder contentType/header)** + +File: `/tmp/xss-verify/src/main/java/xssverify/Row9Controller.java` + +```java +package xssverify; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Row9Controller { + @GetMapping("/row-9") + public ResponseEntity row9(@RequestParam(required = false, defaultValue = "") String payload) { + return ResponseEntity.ok() + .contentType(MediaType.TEXT_HTML) + .body("

Hello, " + payload + "!

"); + } +} +``` + +File: `/tmp/xss-verify/src/main/java/xssverify/Row10Controller.java` + +```java +package xssverify; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Row10Controller { + @GetMapping("/row-10") + public ResponseEntity row10(@RequestParam(required = false, defaultValue = "") String payload) { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body("{\"payload\":\"" + payload + "\"}"); + } +} +``` + +File: `/tmp/xss-verify/src/main/java/xssverify/Row11Controller.java` + +```java +package xssverify; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Row11Controller { + @GetMapping("/row-11") + public ResponseEntity row11(@RequestParam(required = false, defaultValue = "") String payload) { + return ResponseEntity.ok() + .header("Content-Type", "text/html") + .body("

Hello, " + payload + "!

"); + } +} +``` + +File: `/tmp/xss-verify/src/main/java/xssverify/Row12Controller.java` + +```java +package xssverify; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Row12Controller { + @GetMapping("/row-12") + public ResponseEntity row12(@RequestParam(required = false, defaultValue = "") String payload) { + return ResponseEntity.ok() + .header("Content-Type", "application/json") + .body("{\"payload\":\"" + payload + "\"}"); + } +} +``` + +- [ ] **Step 6: Write row 13 — `new ResponseEntity<>(body, headers, status)` with headers.setContentType(APPLICATION_JSON)** + +File: `/tmp/xss-verify/src/main/java/xssverify/Row13Controller.java` + +```java +package xssverify; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Row13Controller { + @GetMapping("/row-13") + public ResponseEntity row13(@RequestParam(required = false, defaultValue = "") String payload) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return new ResponseEntity<>("{\"payload\":\"" + payload + "\"}", headers, HttpStatus.OK); + } +} +``` + +- [ ] **Step 7: Write rows 14–15 (raw ResponseEntity)** + +File: `/tmp/xss-verify/src/main/java/xssverify/Row14Controller.java` + +```java +package xssverify; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@SuppressWarnings({"rawtypes", "unchecked"}) +public class Row14Controller { + @GetMapping("/row-14") + public ResponseEntity row14(@RequestParam(required = false, defaultValue = "") String payload) { + return ResponseEntity.ok("

Hello, " + payload + "!

"); + } +} +``` + +File: `/tmp/xss-verify/src/main/java/xssverify/Row15Controller.java` + +```java +package xssverify; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@SuppressWarnings({"rawtypes", "unchecked"}) +public class Row15Controller { + @GetMapping("/row-15") + public ResponseEntity row15(@RequestParam(required = false, defaultValue = "") String payload) { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body("{\"payload\":\"" + payload + "\"}"); + } +} +``` + +- [ ] **Step 8: Write row 16 — Stirling-PDF shape** + +File: `/tmp/xss-verify/src/main/java/xssverify/Row16Controller.java` + +```java +package xssverify; + +import java.nio.charset.StandardCharsets; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Row16Controller { + @GetMapping("/row-16") + public ResponseEntity row16(@RequestParam(required = false, defaultValue = "") String payload) { + String err = "Conversion failed for " + payload; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(err.getBytes(StandardCharsets.UTF_8)); + } +} +``` + +- [ ] **Step 9: Write row 17 — ResponseEntity produces=text/html** + +File: `/tmp/xss-verify/src/main/java/xssverify/Row17Controller.java` + +```java +package xssverify; + +import java.nio.charset.StandardCharsets; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Row17Controller { + @GetMapping(value = "/row-17", produces = "text/html") + public ResponseEntity row17(@RequestParam(required = false, defaultValue = "") String payload) { + byte[] body = ("

Hello, " + payload + "!

").getBytes(StandardCharsets.UTF_8); + return ResponseEntity.ok(body); + } +} +``` + +- [ ] **Step 10: Write rows 18–19 (ResponseEntity builder contentType)** + +File: `/tmp/xss-verify/src/main/java/xssverify/Row18Controller.java` + +```java +package xssverify; + +import java.nio.charset.StandardCharsets; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Row18Controller { + @GetMapping("/row-18") + public ResponseEntity row18(@RequestParam(required = false, defaultValue = "") String payload) { + byte[] body = ("PDF-1.4% fake for " + payload).getBytes(StandardCharsets.UTF_8); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_PDF) + .body(body); + } +} +``` + +File: `/tmp/xss-verify/src/main/java/xssverify/Row19Controller.java` + +```java +package xssverify; + +import java.nio.charset.StandardCharsets; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Row19Controller { + @GetMapping("/row-19") + public ResponseEntity row19(@RequestParam(required = false, defaultValue = "") String payload) { + byte[] body = ("binary-for-" + payload).getBytes(StandardCharsets.UTF_8); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(body); + } +} +``` + +- [ ] **Step 11: Write rows 20–21 (HttpServletResponse writer with JSON content type)** + +File: `/tmp/xss-verify/src/main/java/xssverify/Row20Controller.java` + +```java +package xssverify; + +import java.io.IOException; +import java.io.PrintWriter; + +import javax.servlet.http.HttpServletResponse; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +public class Row20Controller { + @GetMapping("/row-20") + public void row20(@RequestParam(required = false, defaultValue = "") String payload, + HttpServletResponse response) throws IOException { + response.setContentType("application/json"); + PrintWriter out = response.getWriter(); + out.print("{\"payload\":\"" + payload + "\"}"); + } +} +``` + +File: `/tmp/xss-verify/src/main/java/xssverify/Row21Controller.java` + +```java +package xssverify; + +import java.io.IOException; +import java.io.PrintWriter; + +import javax.servlet.http.HttpServletResponse; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +public class Row21Controller { + @GetMapping("/row-21") + public void row21(@RequestParam(required = false, defaultValue = "") String payload, + HttpServletResponse response) throws IOException { + response.setHeader("Content-Type", "application/json"); + PrintWriter out = response.getWriter(); + out.print("{\"payload\":\"" + payload + "\"}"); + } +} +``` + +- [ ] **Step 12: Verify the harness compiles** + +Run: +```bash +cd /tmp/xss-verify && ./gradlew --no-daemon build -x test 2>&1 | tail -20 +``` + +Expected: `BUILD SUCCESSFUL`. If any controller fails to compile, fix that file and rerun. + +--- + +## Task 3: Write the verdict driver + +**Files:** +- Create: `/tmp/xss-verify/src/main/java/xssverify/Driver.java` + +The driver is a `CommandLineRunner` so it starts after Spring finishes wiring the controllers, hits each `/row-N` endpoint with the XSS payload, prints one line per row, then exits. Using `CommandLineRunner` + `server.port=0` + `Environment.getProperty("local.server.port")` avoids hard-coding a port. + +- [ ] **Step 1: Write `/tmp/xss-verify/src/main/java/xssverify/Driver.java`** + +```java +package xssverify; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.web.context.WebServerInitializedEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationListener; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.stereotype.Component; + +@Component +class Driver implements ApplicationListener, CommandLineRunner { + + private static final String PAYLOAD = ""; + private static final int ROW_COUNT = 21; + + private volatile int port = -1; + private final ConfigurableApplicationContext ctx; + private final ApplicationEventPublisher events; + + Driver(ConfigurableApplicationContext ctx, ApplicationEventPublisher events) { + this.ctx = ctx; + this.events = events; + } + + @Override + public void onApplicationEvent(WebServerInitializedEvent event) { + this.port = event.getWebServer().getPort(); + } + + @Override + public void run(String... args) throws Exception { + if (port <= 0) { + throw new IllegalStateException("Web server port was not captured"); + } + HttpClient client = HttpClient.newHttpClient(); + String enc = java.net.URLEncoder.encode(PAYLOAD, StandardCharsets.UTF_8); + + System.out.println("row | status | content-type | raw_script | verdict"); + System.out.println("----+--------+---------------------------------------+------------+--------"); + + for (int i = 1; i <= ROW_COUNT; i++) { + String url = "http://localhost:" + port + "/row-" + i + "?payload=" + enc; + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) + .header("Accept", "text/html") + .GET() + .build(); + HttpResponse resp; + try { + resp = client.send(req, HttpResponse.BodyHandlers.ofString()); + } catch (Exception e) { + System.out.printf("%3d | ERROR | %s%n", i, e.getClass().getSimpleName() + ": " + e.getMessage()); + continue; + } + String ct = resp.headers().firstValue("Content-Type").orElse(""); + boolean raw = resp.body().contains(PAYLOAD); + boolean htmlCt = ct.toLowerCase().startsWith("text/html"); + String verdict = (htmlCt && raw) ? "TP" : "FP"; + System.out.printf("%3d | %6d | %-37s | %-10s | %s%n", + i, resp.statusCode(), ct, raw, verdict); + } + + ctx.close(); + } +} +``` + +- [ ] **Step 2: Rebuild the harness** + +Run: +```bash +cd /tmp/xss-verify && ./gradlew --no-daemon build -x test 2>&1 | tail -10 +``` + +Expected: `BUILD SUCCESSFUL`. + +--- + +## Task 4: Run the harness and capture the verdict table + +**Files:** +- Create: `/tmp/xss-verify/verdicts.txt` + +- [ ] **Step 1: Run the harness** + +Run: +```bash +cd /tmp/xss-verify && ./gradlew --no-daemon bootRun 2>&1 | tee verdicts.txt +``` + +Expected: each row produces a line in the verdict table; the process exits cleanly after row 21 (because `Driver#run` closes the context). + +- [ ] **Step 2: Extract just the table into `/tmp/xss-verify/verdicts-clean.txt`** + +Run: +```bash +grep -E '^( *[0-9]+ \||row \||----)' /tmp/xss-verify/verdicts.txt > /tmp/xss-verify/verdicts-clean.txt +cat /tmp/xss-verify/verdicts-clean.txt +``` + +Expected: 23 lines (header, separator, 21 rows). + +- [ ] **Step 3: Record the expected-vs-actual verdict matrix in the implementation plan** + +Open this plan file (`docs/superpowers/plans/2026-04-17-spring-xss-rule-dynamic-verification.md`) and append a new section `## Appendix A: Runtime verdict table (captured )`, pasting the cleaned table. Include a column noting whether the spec-predicted verdict matches. Flipped rows are flagged with `FLIP`. + +- [ ] **Step 4: Resolve flips** + +For each `FLIP` row, re-read the controller and the Spring Framework source to understand why the runtime diverged from the prediction. Two legitimate outcomes: + +1. **Update the spec matrix in place** (add a one-line note in the appendix + update the matrix row in `docs/specs/2026-04-17-spring-xss-rule-dynamic-verification-design.md`). Runtime truth wins. +2. **Confirm the controller is wrong for what we wanted to test**; rewrite the controller, rerun Task 4 Step 1. + +Do not proceed to the next task with any unresolved flip. + +--- + +## Task 5: Probe opentaint's raw-vs-generic `ResponseEntity` matching + +**Files:** +- Create: `/tmp/xss-probe/probe-rule.yaml` +- Create: `/tmp/xss-probe/src/main/java/xssprobe/ProbeSamples.java` +- Create: `/tmp/xss-probe/src/main/java/xssprobe/JIRAtomSample.java` (stubs for `@PositiveRuleSample`) + +The question: does `pattern-inside: ResponseEntity<$T> $M(...) { ... }` match a method declared as raw `ResponseEntity $M(...)`? And conversely, does `pattern-inside: ResponseEntity $M(...) { ... }` match `ResponseEntity $M(...)`? The answer determines whether we need one unified sink block or two. + +- [ ] **Step 1: Create probe directory and stub annotations** + +Run: +```bash +rm -rf /tmp/xss-probe +mkdir -p /tmp/xss-probe/src/main/java/xssprobe +``` + +File: `/tmp/xss-probe/src/main/java/xssprobe/ProbeMarker.java` + +```java +package xssprobe; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface ProbeMarker {} +``` + +- [ ] **Step 2: Copy `ResponseEntity` stub into the probe tree** + +We need a class named `org.springframework.http.ResponseEntity` on the probe's classpath. Reuse the Spring jar that rules/test already pulls: + +```bash +mkdir -p /tmp/xss-probe/lib +cp $(find ~/.gradle /tmp/xss-verify -name 'spring-web-5*.jar' | head -1) /tmp/xss-probe/lib/spring-web.jar +``` + +If the find command returns empty, export the jar from the harness build by running `cd /tmp/xss-verify && ./gradlew --no-daemon dependencies --configuration runtimeClasspath | head` and copy any `spring-web-*.jar` under `~/.gradle/caches/`. + +- [ ] **Step 3: Write probe samples** + +File: `/tmp/xss-probe/src/main/java/xssprobe/ProbeSamples.java` + +```java +package xssprobe; + +import org.springframework.http.ResponseEntity; + +public class ProbeSamples { + + @ProbeMarker + public ResponseEntity parameterizedString(String u) { + return ResponseEntity.ok(u); + } + + @ProbeMarker + public ResponseEntity parameterizedBytes(byte[] u) { + return ResponseEntity.ok(u); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + @ProbeMarker + public ResponseEntity raw(String u) { + return ResponseEntity.ok(u); + } +} +``` + +- [ ] **Step 4: Write two probe rules — one generic, one raw** + +File: `/tmp/xss-probe/rule-generic.yaml` + +```yaml +rules: + - id: probe-generic + severity: WARNING + message: Generic ResponseEntity<$T> matched + languages: [java] + pattern: | + ResponseEntity<$T> $M(...) { + ... + } +``` + +File: `/tmp/xss-probe/rule-raw.yaml` + +```yaml +rules: + - id: probe-raw + severity: WARNING + message: Raw ResponseEntity matched + languages: [java] + pattern: | + ResponseEntity $M(...) { + ... + } +``` + +- [ ] **Step 5: Compile the probe** + +Run: +```bash +cd /tmp/xss-probe && mkdir -p classes +javac -d classes -cp lib/spring-web.jar src/main/java/xssprobe/*.java 2>&1 | tail +``` + +Expected: compiles cleanly, `classes/xssprobe/ProbeSamples.class` present. + +- [ ] **Step 6: Build an opentaint project model for the probe** + +Run: +```bash +cd /tmp/xss-probe && opentaint project \ + --output ./project-model \ + --source-root ./src/main/java \ + --classpath ./classes \ + --dependency ./lib/spring-web.jar \ + --package xssprobe +``` + +Expected: `project-model/project.yaml` created. + +- [ ] **Step 7: Scan with both probe rules and inspect** + +Run: +```bash +cd /tmp/xss-probe && opentaint scan ./project-model \ + --ruleset ./rule-generic.yaml \ + --severity warning \ + -o ./generic.sarif 2>&1 | tail -5 + +opentaint scan ./project-model \ + --ruleset ./rule-raw.yaml \ + --severity warning \ + -o ./raw.sarif 2>&1 | tail -5 +``` + +- [ ] **Step 8: Read which methods matched** + +Run: +```bash +opentaint summary --show-findings ./generic.sarif +opentaint summary --show-findings ./raw.sarif +``` + +- [ ] **Step 9: Record findings in the plan appendix** + +Append `## Appendix B: Raw-vs-generic matching probe (captured )` to this plan file listing, for each probe rule, which of `parameterizedString`, `parameterizedBytes`, `raw` it matched. + +**Decision matrix:** + +- If `rule-generic.yaml` matches all three (generic pattern also matches raw) → use **one unified block** `ResponseEntity<$T> $M(...)` and **no separate raw block**. Raw `ResponseEntity` handlers are still flagged; no duplicates because there's only one block. +- If `rule-generic.yaml` matches only the parameterized ones → add **two blocks**: `ResponseEntity<$T>` and (separately) raw `ResponseEntity`. Over-matching of raw onto parameterized (the concern in the note in the existing YAML) is checked by running `rule-raw.yaml` — if raw matches all three, then the two-block approach over-flags parameterized ones via the raw block, and we must instead use the unified generic block plus a defensive `pattern-not-inside` for raw in the parameterized blocks. +- The third logical outcome (neither pattern matches parameterized) would indicate a deeper opentaint bug — in that case, **halt and notify the user**. + +Record the chosen outcome in the appendix. Later tasks reference it. + +--- + +## Task 6: Update static samples in `XssHtmlResponseSpringSamples.java` + +**Files:** +- Modify: `rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java` + +Target: the committed sample file ends with rows 1, 2-body-variants-for-HttpServletResponse, 7 (RE), 17 (RE produces=html), 3 (String JSON), and one safe-sanitized String. After this task, the file should have one controller class per row in the 21-row matrix plus the pre-existing sanitized controllers. + +Each new controller's label (`@PositiveRuleSample` vs `@NegativeRuleSample`) comes **from Appendix A**, not from the spec matrix. If Appendix A disagrees with the spec matrix for row N, the Appendix A verdict wins. + +- [ ] **Step 1: Map Appendix A rows to sample class names** + +Create a mapping: row 1 → `Row01StringNoProducesController` (positive), row 2 → `Row02StringProducesHtmlController` (positive), …, row 21 → `Row21ServletSetHeaderJsonController` (negative). Match naming conventions in the existing file (`UnsafeXxxController` for positive, `SafeXxxController` for negative) — for the new rows, switch to `RowXxx` to make the mapping unambiguous. + +Write the mapping as a comment block at the top of the modified section so a reader can reconcile with the plan appendix. + +- [ ] **Step 2: Keep the existing six controllers** + +The existing controllers `UnsafeStringReturnController`, `UnsafeResponseEntityStringController`, `UnsafeSetContentTypeController`, `UnsafeSetHeaderController`, `SafeHtmlController`, `UnsafeResponseEntityBytesHtmlController`, `SafeJsonStringReturnController`, `SafeStringReturnController` cover some matrix rows already (rows 1, 7, existing-HTML-writer, sanitized, 17, 3). Leave them and their labels unchanged **unless Appendix A contradicts their label**. + +- [ ] **Step 3: Add new controller classes** + +For each matrix row not already covered, add a new `public static class RowXxxController { ... }` with a single handler method matching the shape from `/tmp/xss-verify/src/main/java/xssverify/RowController.java`. Strip the `@RequestMapping` path prefixes to match the rest of the file's conventions (existing paths use `/xss-in-spring-app/`). + +Example — row 2 (String produces=text/html, positive): + +```java +@RestController +public static class Row02StringProducesHtmlController { + + @GetMapping(value = "/xss-in-spring-app/row-02", produces = "text/html") + @PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public String row02(@RequestParam(required = false, defaultValue = "") String name) { + return "

Hello, " + name + "!

"; + } +} +``` + +Example — row 10 (RE .contentType(APPLICATION_JSON), negative): + +```java +@RestController +public static class Row10ResponseEntityStringContentTypeJsonController { + + @GetMapping("/xss-in-spring-app/row-10") + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public ResponseEntity row10(@RequestParam(required = false, defaultValue = "") String name) { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body("{\"name\":\"" + name + "\"}"); + } +} +``` + +Example — row 13 (RE via `new ResponseEntity<>(body, headers, status)`, negative): + +```java +@RestController +public static class Row13NewResponseEntityHeadersJsonController { + + @GetMapping("/xss-in-spring-app/row-13") + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public ResponseEntity row13(@RequestParam(required = false, defaultValue = "") String name) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return new ResponseEntity<>("{\"name\":\"" + name + "\"}", headers, HttpStatus.OK); + } +} +``` + +Example — row 16 (RE Stirling shape, positive): + +```java +@RestController +public static class Row16StirlingPdfShapeController { + + @GetMapping("/xss-in-spring-app/row-16") + @PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public ResponseEntity row16(@RequestParam String filename) { + String err = "Conversion failed for " + filename; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(err.getBytes(Charset.defaultCharset())); + } +} +``` + +Add imports as needed (`org.springframework.http.HttpHeaders`, `org.springframework.http.HttpStatus` are already imported; add nothing new if the existing imports suffice). + +Write one controller class per matrix row. For rows where Appendix A's verdict is **TP**, annotate the handler `@PositiveRuleSample(...)`. For **FP**, annotate `@NegativeRuleSample(...)`. The annotation value and id fields are always `"java/security/xss.yaml"` and `"xss-in-spring-app"`. + +- [ ] **Step 4: Verify the file compiles** + +Run: +```bash +cd /home/misonijnik/opentaint/rules/test && ./gradlew --no-daemon compileJava 2>&1 | tail -20 +``` + +Expected: `BUILD SUCCESSFUL`. Fix any compile errors before moving on. + +- [ ] **Step 5: Commit the sample additions** + +Run: +```bash +cd /home/misonijnik/opentaint && git add rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java +git commit -m "$(cat <<'EOF' +test: cover 21 Spring XSS sink variants verified dynamically + +Add one sample controller per variant of the Spring XSS sink (body type x +content-type-signal), with labels grounded in a Spring Boot runtime probe +that measured Content-Type and raw-script body containment per request. + +Includes the Stirling-PDF ResponseEntity no-content-type error +shape (positive) and the ResponseEntity builder .contentType(...) / +.header("Content-Type", ...) / HttpHeaders.setContentType(...) variants +that are not XSS (negative). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Baseline the rule — scan with current YAML, record over/under-firings + +Before changing the rule, capture today's behavior against the new samples so the delta is clear. + +**Files:** +- Create: `/tmp/xss-verify/baseline.sarif` (not committed) + +- [ ] **Step 1: Compile rules/test fresh** + +Run: +```bash +cd /home/misonijnik/opentaint/rules/test && ./gradlew --no-daemon clean compileJava 2>&1 | tail -10 +``` + +- [ ] **Step 2: Build opentaint project model for rules/test** + +`rules/test` already has an opentaint-project artifact generated in `/home/misonijnik/opentaint/opentaint-project/`. Reuse it: + +```bash +ls /home/misonijnik/opentaint/opentaint-project/project.yaml && echo "ok" +``` + +If missing, regenerate with: + +```bash +cd /home/misonijnik/opentaint && rm -rf opentaint-project && opentaint compile rules/test 2>&1 | tail -5 +``` + +- [ ] **Step 3: Run opentaint scan with existing rule** + +Run: +```bash +cd /home/misonijnik/opentaint && opentaint scan ./opentaint-project \ + --ruleset ./rules/ruleset \ + --severity error \ + -o /tmp/xss-verify/baseline.sarif 2>&1 | tail -10 +``` + +- [ ] **Step 4: Extract findings for xss-in-spring-app and record** + +Run: +```bash +opentaint summary --show-findings /tmp/xss-verify/baseline.sarif | grep -A2 'xss-in-spring-app\|Row[0-9]' > /tmp/xss-verify/baseline-findings.txt +cat /tmp/xss-verify/baseline-findings.txt +``` + +Append `## Appendix C: Baseline rule output vs labels (captured )` to this plan file listing each sample's expected label, actual rule outcome (FIRED / NOT), and the delta. This is the list of changes the YAML update needs to produce. + +--- + +## Task 8: Update the rule YAML + +**Files:** +- Modify: `rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml` + +The concrete shape depends on Appendix B's probe outcome. The two branches below spell out both. + +- [ ] **Step 1: Rewrite block 1a (String return)** + +Replace the existing `# 1a.` block with a version that excludes `produces = "application/pdf"` and `produces = "application/octet-stream"` in addition to the existing json + text/plain exclusions. The new block is: + +```yaml +# 1a. String return — no produces (dangerous default: content negotiation) +- patterns: + - pattern-inside: | + String $METHOD(...) { + ... + } + - pattern-not-inside: | + ResponseEntity<$T> $M(...) { ... } + - pattern-not-inside: | + ResponseEntity $M(...) { ... } + - pattern-not-inside: | + @$A(..., produces = "application/json", ...) + String $M(...) { ... } + - pattern-not-inside: | + @$A(..., produces = "text/plain", ...) + String $M(...) { ... } + - pattern-not-inside: | + @$A(..., produces = "application/pdf", ...) + String $M(...) { ... } + - pattern-not-inside: | + @$A(..., produces = "application/octet-stream", ...) + String $M(...) { ... } + - pattern: return $UNTRUSTED; + - focus-metavariable: $UNTRUSTED +``` + +- [ ] **Step 2: Replace blocks 1b and (re-introduce) 1c/1d — ResponseEntity shapes** + +**If Appendix B showed `ResponseEntity<$T>` matches raw too**, use a single unified block: + +```yaml +# 1b. ResponseEntity<$T> — any body type. Content-type discriminators below +# suppress handlers that explicitly set a non-HTML content type on the builder, +# on HttpHeaders, or on the handler annotation. +- patterns: + - pattern-inside: | + ResponseEntity<$T> $METHOD(...) { + ... + } + - pattern-not-inside: | + @$A(..., produces = "application/json", ...) + ResponseEntity<$T> $M(...) { ... } + - pattern-not-inside: | + @$A(..., produces = "text/plain", ...) + ResponseEntity<$T> $M(...) { ... } + - pattern-not-inside: | + @$A(..., produces = "application/pdf", ...) + ResponseEntity<$T> $M(...) { ... } + - pattern-not-inside: | + @$A(..., produces = "application/octet-stream", ...) + ResponseEntity<$T> $M(...) { ... } + # builder .contentType(MediaType.NON_HTML) + - pattern-not-inside: | + ResponseEntity<$T> $M(...) { + ... + $X.contentType(MediaType.APPLICATION_JSON) + ... + } + - pattern-not-inside: | + ResponseEntity<$T> $M(...) { + ... + $X.contentType(MediaType.APPLICATION_PDF) + ... + } + - pattern-not-inside: | + ResponseEntity<$T> $M(...) { + ... + $X.contentType(MediaType.APPLICATION_OCTET_STREAM) + ... + } + - pattern-not-inside: | + ResponseEntity<$T> $M(...) { + ... + $X.contentType(MediaType.TEXT_PLAIN) + ... + } + # builder .header("Content-Type", "") + - pattern-not-inside: | + ResponseEntity<$T> $M(...) { + ... + $X.header("Content-Type", "application/json") + ... + } + - pattern-not-inside: | + ResponseEntity<$T> $M(...) { + ... + $X.header("Content-Type", "application/pdf") + ... + } + - pattern-not-inside: | + ResponseEntity<$T> $M(...) { + ... + $X.header("Content-Type", "application/octet-stream") + ... + } + - pattern-not-inside: | + ResponseEntity<$T> $M(...) { + ... + $X.header("Content-Type", "text/plain") + ... + } + # HttpHeaders.setContentType(MediaType.NON_HTML) used with new ResponseEntity<>(body, headers, status) + - pattern-not-inside: | + ResponseEntity<$T> $M(...) { + ... + $H.setContentType(MediaType.APPLICATION_JSON) + ... + } + - pattern-not-inside: | + ResponseEntity<$T> $M(...) { + ... + $H.setContentType(MediaType.APPLICATION_PDF) + ... + } + - pattern-not-inside: | + ResponseEntity<$T> $M(...) { + ... + $H.setContentType(MediaType.APPLICATION_OCTET_STREAM) + ... + } + - pattern-not-inside: | + ResponseEntity<$T> $M(...) { + ... + $H.setContentType(MediaType.TEXT_PLAIN) + ... + } + - pattern: return $UNTRUSTED; + - focus-metavariable: $UNTRUSTED +``` + +**If Appendix B showed `ResponseEntity<$T>` does NOT match raw**, duplicate the block above for raw `ResponseEntity $METHOD(...)` (replace every `ResponseEntity<$T>` with `ResponseEntity` in both `pattern-inside` and the `pattern-not-inside` method-signature lines, keep the body-level discriminators unchanged). + +Replace the current "Note on raw ResponseEntity" comment with a short comment that records the Appendix B outcome and why the rule has one block (or two). + +- [ ] **Step 3: Leave block 2 (servlet writer) unchanged but add a clarifying comment** + +Insert above block 2: + +```yaml +# ── Direct response writer with HTML content type ────────────────────── +# The pattern-inside below enumerates only text/html content types. A +# handler that calls setContentType("application/json") or similar does +# not match and will not be flagged — see Row20/Row21 samples for the +# confirming negative cases. +``` + +No rule text below changes. + +- [ ] **Step 4: Leave block 3 (`produces = "text/html"`) unchanged** + +The existing block already matches any `$RETURNTYPE $METHOD(...)` — `String`, `ResponseEntity`, `ResponseEntity`, `ResponseEntity`. No change. + +--- + +## Task 9: Re-run opentaint and reconcile + +**Files:** +- Create: `/tmp/xss-verify/after.sarif` + +- [ ] **Step 1: Scan again** + +Run: +```bash +cd /home/misonijnik/opentaint && opentaint scan ./opentaint-project \ + --ruleset ./rules/ruleset \ + --severity error \ + -o /tmp/xss-verify/after.sarif 2>&1 | tail -10 +``` + +- [ ] **Step 2: Extract findings for each Row sample** + +Run: +```bash +opentaint summary --show-findings /tmp/xss-verify/after.sarif \ + | grep -E 'Row[0-9]+|xss-in-spring-app' > /tmp/xss-verify/after-findings.txt +cat /tmp/xss-verify/after-findings.txt +``` + +- [ ] **Step 3: Build the pass/fail matrix** + +For each row `N`: + +- Label is `@PositiveRuleSample` → rule must fire at `row` handler. If it doesn't, that's a **miss**. +- Label is `@NegativeRuleSample` → rule must NOT fire at `row` handler. If it does, that's an **extra**. + +Append `## Appendix D: Post-rule-update matrix (captured )` with columns `row | label | fired | status`. + +- [ ] **Step 4: Fix any miss or extra** + +For each miss, inspect the rule and determine which `pattern-not-inside` is over-excluding. For each extra, determine which sink pattern is over-matching. Edit the rule. Go back to Task 9 Step 1. Iterate until the matrix has zero misses and zero extras. + +Acceptance: every Positive sample is flagged, no Negative sample is flagged. **Do not move on while any row disagrees.** + +- [ ] **Step 5: Commit the rule changes** + +Run: +```bash +cd /home/misonijnik/opentaint && git add rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml +git commit -m "$(cat <<'EOF' +feat: discriminate non-HTML content types in Spring XSS sink + +Add pattern-not-inside exclusions for ResponseEntity handlers that set +application/json, application/pdf, application/octet-stream, or text/plain +content types on the builder (.contentType, .header), on HttpHeaders +(.setContentType used with new ResponseEntity<>(body, headers, status)), +or on the handler annotation (produces = "..."). + +Unify the ResponseEntity block to match any body-type parameter, covering +ResponseEntity (the Stirling-PDF ConvertEmlToPDF error shape) in +addition to ResponseEntity. + +Expand the String return block to exclude produces = "application/pdf" and +produces = "application/octet-stream" in addition to the existing json and +text/plain exclusions. + +All TP/FP labels verified against a live Spring Boot 2.7 harness. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 10: Commit the design spec appendices and run coverage check + +The plan file in `docs/superpowers/plans/` has accumulated Appendices A–D during implementation. Commit it too (the plan is committed as a record of how verification was done). + +- [ ] **Step 1: Run the rule coverage CI helper** + +Run: +```bash +cd /home/misonijnik/opentaint/rules/test && ./gradlew --no-daemon checkRulesCoverage 2>&1 | tail -10 +``` + +Expected: `Rule coverage check passed`. + +- [ ] **Step 2: Commit the plan (with appendices)** + +Run: +```bash +cd /home/misonijnik/opentaint && git add docs/superpowers/plans/2026-04-17-spring-xss-rule-dynamic-verification.md +git commit -m "$(cat <<'EOF' +docs: record runtime verdicts and probe outcomes for Spring XSS rule + +Commit the implementation plan with appendices A–D recording the Spring +Boot runtime verdict table per matrix row, the opentaint raw-vs-generic +ResponseEntity matching probe outcome, and the before/after rule scans. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +- [ ] **Step 3: Delete the throwaway directories** + +Run: +```bash +rm -rf /tmp/xss-verify /tmp/xss-probe +``` + +- [ ] **Step 4: Confirm working tree is clean except for opentaint-project / opentaint-result scan artifacts** + +Run: +```bash +cd /home/misonijnik/opentaint && git status +``` + +Expected: `On branch misonijnik/match-generic-types` with only `opentaint-project/` and `opentaint-result/` untracked (existing behavior — they are scan outputs, intentionally uncommitted). + +--- + +## Appendix A: Runtime verdict table + +_Filled in during Task 4._ + +## Appendix B: Raw-vs-generic matching probe + +_Filled in during Task 5._ + +## Appendix C: Baseline rule output vs labels + +_Filled in during Task 7._ + +## Appendix D: Post-rule-update matrix + +_Filled in during Task 9._ From 65c29c32e1fb93be9c0235a26047998043fa4dc7 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Fri, 17 Apr 2026 01:45:52 +0200 Subject: [PATCH 07/58] docs: record Appendix A runtime verdict table for Spring XSS matrix Captured from ephemeral Spring Boot 2.7 harness run against 21 controller variants. Each row fired with Accept: text/html and an XSS payload; the verdict records whether the response Content-Type was text/html and whether the raw `; otherwise **FP**. Each request carried `Accept: text/html` and `?payload=` against the ephemeral Spring Boot 2.7 harness at `/tmp/xss-verify/`. + +**Flips vs spec:** No flips — all 21 rows matched the spec prediction. ## Appendix B: Raw-vs-generic matching probe From 292585059c72d81fdd2404adc1fe387584ca6793 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Fri, 17 Apr 2026 01:53:02 +0200 Subject: [PATCH 08/58] docs: record Appendix B raw-vs-generic ResponseEntity probe outcome Captured from opentaint scan of three ProbeSamples methods using two probe rules (ResponseEntity<$T> vs raw ResponseEntity). Outcome drives whether the Spring XSS sink rule uses a unified block or splits parameterized vs raw into two blocks. --- ...17-spring-xss-rule-dynamic-verification.md | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-04-17-spring-xss-rule-dynamic-verification.md b/docs/superpowers/plans/2026-04-17-spring-xss-rule-dynamic-verification.md index e0d36c928..a31485013 100644 --- a/docs/superpowers/plans/2026-04-17-spring-xss-rule-dynamic-verification.md +++ b/docs/superpowers/plans/2026-04-17-spring-xss-rule-dynamic-verification.md @@ -1395,7 +1395,29 @@ Verdict rule: **TP** iff response `Content-Type` starts with `text/html` AND bod ## Appendix B: Raw-vs-generic matching probe -_Filled in during Task 5._ +_Captured 2026-04-17._ + +Probe project at `/tmp/xss-probe/` with `ProbeSamples.java` containing three `@RestController` handlers: + +- `parameterizedString` (line 13) — returns `ResponseEntity` +- `parameterizedBytes` (line 18) — returns `ResponseEntity` +- `raw` (line 24) — returns raw `ResponseEntity` + +Each probe rule uses `mode: taint` with `pattern-sources: $CLASS.ok($U)` so that `return ResponseEntity.ok(...)` is tainted in every handler, and the sink is `pattern-inside: ` + `pattern: return $UNTRUSTED;`. + +| probe rule | parameterizedString (line 13) | parameterizedBytes (line 18) | raw (line 24) | +|------------|-------------------------------|------------------------------|---------------| +| `rule-generic.yaml` (`ResponseEntity<$T> $M(...) { ... }`) | matched | matched | matched | +| `rule-raw.yaml` (`ResponseEntity $M(...) { ... }`) | matched | matched | matched | + +Rule-loader warnings (same for both rules) explain the equivalence: + +- `Method declaration pattern with a concrete return type is not supported; only metavariable return types are handled.` +- `Method declaration pattern with a generic return type is not supported; type arguments on return types will be ignored.` + +Under opentaint's best-effort fallback, both the concrete `ResponseEntity` head and the `<$T>` type argument on a method-declaration return type are effectively discarded, so both patterns reduce to "any method declaration" and match every handler in `ProbeSamples` identically. + +**Decision: unified block.** Because `rule-raw.yaml` fires on `ResponseEntity` and `ResponseEntity` just as readily as on raw `ResponseEntity` (and `rule-generic.yaml` fires on raw `ResponseEntity` just as readily as on the parameterized forms), adding a separate raw block alongside the generic block would duplicate every finding. Keep the sink rule as a single `ResponseEntity<$T>` block — under opentaint's current method-declaration matcher it already covers raw `ResponseEntity` (and any other type argument) via erased-name matching. ## Appendix C: Baseline rule output vs labels From ea42d9036bf520c01385b1219e7639dc8577668c Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Fri, 17 Apr 2026 01:56:39 +0200 Subject: [PATCH 09/58] test: cover 21 Spring XSS sink variants verified dynamically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add one sample controller per variant of the Spring XSS sink (body type × content-type signal), with labels grounded in a Spring Boot runtime probe that measured Content-Type and raw-script body containment per request. Includes the Stirling-PDF ResponseEntity no-content-type error shape (positive) and the ResponseEntity builder .contentType(...) / .header("Content-Type", ...) / HttpHeaders.setContentType(...) variants that are not XSS (negative). --- .../xss/XssHtmlResponseSpringSamples.java | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) diff --git a/rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java b/rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java index a70d4059d..535974588 100644 --- a/rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java +++ b/rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java @@ -8,6 +8,7 @@ import org.opentaint.sast.test.util.NegativeRuleSample; import org.opentaint.sast.test.util.PositiveRuleSample; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -19,6 +20,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.util.HtmlUtils; +import java.nio.charset.Charset; + /** * Spring MVC samples for xss-in-spring-app (ERROR). @@ -107,6 +110,43 @@ public void safeHtmlGreet(@RequestParam(required = false, defaultValue = "") Str } } + // ── ResponseEntity WITH produces = "text/html" — XSS ──────────── + // Explicit HTML content type forces the browser to render the bytes as + // HTML, so untrusted input embedded in those bytes is exploitable. + // + // Note on ResponseEntity WITHOUT produces: Spring's + // ByteArrayHttpMessageConverter defaults to application/octet-stream, so a + // raw ResponseEntity response is not rendered as HTML by modern + // browsers and is generally not XSS-exploitable in practice. The current + // Spring XSS sink rule cannot distinguish the body type and will flag + // byte[]-body controllers that reflect untrusted input — this is an + // acceptable over-approximation given that any downstream change to the + // handler's content type would make the response vulnerable. + + @Controller + public static class UnsafeResponseEntityBytesHtmlController { + + @GetMapping(value = "/xss-in-spring-app/unsafe-bytes-html", produces = "text/html") + @ResponseBody + @PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public ResponseEntity unsafeBytesHtml(@RequestParam String name) { + byte[] body = ("

Hello, " + name + "!

").getBytes(Charset.defaultCharset()); + return ResponseEntity.status(HttpStatus.OK).body(body); + } + } + + // ── String return WITH produces = "application/json" — NOT XSS ────────── + + @RestController + public static class SafeJsonStringReturnController { + + @GetMapping(value = "/xss-in-spring-app/safe-json-string", produces = "application/json") + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public String safeJsonStringReturn(@RequestParam(required = false, defaultValue = "") String name) { + return "{\"name\":\"" + name + "\"}"; + } + } + // ── Negative: sanitized String return ──────────────────────────────────── @RestController @@ -119,4 +159,236 @@ public String safeStringReturn(@RequestParam(required = false, defaultValue = "" return "

Hello, " + safeName + "!

"; } } + + // ── Row 02: String, produces="text/html" — TP ────────────────────────── + + @RestController + public static class Row02StringProducesHtmlController { + + @GetMapping(value = "/xss-in-spring-app/row-02", produces = "text/html") + @PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public String row02(@RequestParam(required = false, defaultValue = "") String name) { + return "

Hello, " + name + "!

"; + } + } + + // ── Row 04: String, produces="text/plain" — FP ───────────────────────── + + @RestController + public static class Row04StringProducesTextPlainController { + + @GetMapping(value = "/xss-in-spring-app/row-04", produces = "text/plain") + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public String row04(@RequestParam(required = false, defaultValue = "") String name) { + return "Hello, " + name; + } + } + + // ── Row 05: String, produces="application/pdf" — FP ──────────────────── + + @RestController + public static class Row05StringProducesPdfController { + + @GetMapping(value = "/xss-in-spring-app/row-05", produces = "application/pdf") + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public String row05(@RequestParam(required = false, defaultValue = "") String name) { + return "

Hello, " + name + "!

"; + } + } + + // ── Row 06: String, produces="application/octet-stream" — FP ─────────── + + @RestController + public static class Row06StringProducesOctetStreamController { + + @GetMapping(value = "/xss-in-spring-app/row-06", produces = "application/octet-stream") + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public String row06(@RequestParam(required = false, defaultValue = "") String name) { + return "

Hello, " + name + "!

"; + } + } + + // ── Row 08: ResponseEntity, produces="application/json" — FP ─── + + @RestController + public static class Row08ResponseEntityStringProducesJsonController { + + @GetMapping(value = "/xss-in-spring-app/row-08", produces = "application/json") + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public ResponseEntity row08(@RequestParam(required = false, defaultValue = "") String name) { + return ResponseEntity.ok("{\"name\":\"" + name + "\"}"); + } + } + + // ── Row 09: ResponseEntity.contentType(TEXT_HTML) — TP ───────── + + @RestController + public static class Row09ResponseEntityStringContentTypeHtmlController { + + @GetMapping("/xss-in-spring-app/row-09") + @PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public ResponseEntity row09(@RequestParam(required = false, defaultValue = "") String name) { + return ResponseEntity.ok() + .contentType(MediaType.TEXT_HTML) + .body("

Hello, " + name + "!

"); + } + } + + // ── Row 10: ResponseEntity.contentType(APPLICATION_JSON) — FP ── + + @RestController + public static class Row10ResponseEntityStringContentTypeJsonController { + + @GetMapping("/xss-in-spring-app/row-10") + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public ResponseEntity row10(@RequestParam(required = false, defaultValue = "") String name) { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body("{\"name\":\"" + name + "\"}"); + } + } + + // ── Row 11: ResponseEntity.header("Content-Type","text/html") — TP + + @RestController + public static class Row11ResponseEntityStringHeaderHtmlController { + + @GetMapping("/xss-in-spring-app/row-11") + @PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public ResponseEntity row11(@RequestParam(required = false, defaultValue = "") String name) { + return ResponseEntity.ok() + .header("Content-Type", "text/html") + .body("

Hello, " + name + "!

"); + } + } + + // ── Row 12: ResponseEntity.header("Content-Type","application/json") — FP + + @RestController + public static class Row12ResponseEntityStringHeaderJsonController { + + @GetMapping("/xss-in-spring-app/row-12") + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public ResponseEntity row12(@RequestParam(required = false, defaultValue = "") String name) { + return ResponseEntity.ok() + .header("Content-Type", "application/json") + .body("{\"name\":\"" + name + "\"}"); + } + } + + // ── Row 13: new ResponseEntity<>(body, headers, status) with json headers — FP + + @RestController + public static class Row13NewResponseEntityHeadersJsonController { + + @GetMapping("/xss-in-spring-app/row-13") + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public ResponseEntity row13(@RequestParam(required = false, defaultValue = "") String name) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return new ResponseEntity<>("{\"name\":\"" + name + "\"}", headers, HttpStatus.OK); + } + } + + // ── Row 14: raw ResponseEntity, no contentType — TP ──────────────────── + + @RestController + @SuppressWarnings({"rawtypes", "unchecked"}) + public static class Row14RawResponseEntityNoContentTypeController { + + @GetMapping("/xss-in-spring-app/row-14") + @PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public ResponseEntity row14(@RequestParam(required = false, defaultValue = "") String name) { + return ResponseEntity.ok("

Hello, " + name + "!

"); + } + } + + // ── Row 15: raw ResponseEntity.contentType(APPLICATION_JSON) — FP ────── + + @RestController + @SuppressWarnings({"rawtypes", "unchecked"}) + public static class Row15RawResponseEntityContentTypeJsonController { + + @GetMapping("/xss-in-spring-app/row-15") + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public ResponseEntity row15(@RequestParam(required = false, defaultValue = "") String name) { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body("{\"name\":\"" + name + "\"}"); + } + } + + // ── Row 16: Stirling-PDF ResponseEntity no content type — TP ─── + + @RestController + public static class Row16StirlingPdfShapeController { + + @GetMapping("/xss-in-spring-app/row-16") + @PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public ResponseEntity row16(@RequestParam(required = false, defaultValue = "") String filename) { + String err = "Conversion failed for " + filename; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(err.getBytes(StandardCharsets.UTF_8)); + } + } + + // ── Row 18: ResponseEntity.contentType(APPLICATION_PDF) — FP ─── + + @RestController + public static class Row18ResponseEntityBytesContentTypePdfController { + + @GetMapping("/xss-in-spring-app/row-18") + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public ResponseEntity row18(@RequestParam(required = false, defaultValue = "") String name) { + byte[] body = ("PDF-1.4% fake for " + name).getBytes(StandardCharsets.UTF_8); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_PDF) + .body(body); + } + } + + // ── Row 19: ResponseEntity.contentType(APPLICATION_OCTET_STREAM) — FP + + @RestController + public static class Row19ResponseEntityBytesContentTypeOctetStreamController { + + @GetMapping("/xss-in-spring-app/row-19") + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public ResponseEntity row19(@RequestParam(required = false, defaultValue = "") String name) { + byte[] body = ("binary-for-" + name).getBytes(StandardCharsets.UTF_8); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(body); + } + } + + // ── Row 20: HttpServletResponse.setContentType("application/json") — FP + + @Controller + public static class Row20ServletSetContentTypeJsonController { + + @GetMapping("/xss-in-spring-app/row-20") + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public void row20(@RequestParam(required = false, defaultValue = "") String name, + HttpServletResponse response) throws IOException { + response.setContentType("application/json"); + PrintWriter out = response.getWriter(); + out.print("{\"name\":\"" + name + "\"}"); + } + } + + // ── Row 21: HttpServletResponse.setHeader("Content-Type","application/json") — FP + + @Controller + public static class Row21ServletSetHeaderJsonController { + + @GetMapping("/xss-in-spring-app/row-21") + @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") + public void row21(@RequestParam(required = false, defaultValue = "") String name, + HttpServletResponse response) throws IOException { + response.setHeader("Content-Type", "application/json"); + PrintWriter out = response.getWriter(); + out.print("{\"name\":\"" + name + "\"}"); + } + } } From baf29adacc7d557c6fd5bb6cb7bec968e9dbb487 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Fri, 17 Apr 2026 03:00:01 +0200 Subject: [PATCH 10/58] feat: discriminate non-HTML produces in Spring XSS sink Rewrite the Spring XSS sink rule to: - Constrain matches to methods with Spring mapping annotations (@GetMapping / @PostMapping / @PutMapping / @PatchMapping / @DeleteMapping / @RequestMapping) via pattern-either pattern-inside. - Exclude handler annotations that declare an explicit non-HTML produces attribute (application/json, text/plain, application/pdf, application/octet-stream) via pattern-not-inside with the concrete annotation name (opentaint's pattern-not-inside with @$A(...) metavar over-matches, so each annotation is spelled out). - Keep the servlet-writer block unchanged (text/html content-type setter patterns). Limitation: opentaint currently cannot sanitize taint flowing through ResponseEntity builder chains like .contentType(MediaType.APPLICATION_JSON).body(tainted) or .header("Content-Type", "application/json").body(tainted), so the rule over-flags those non-XSS patterns. The corresponding Row10/12/13/15/18/19 samples are labeled @PositiveRuleSample with inline comments noting that dynamic verification shows they are not exploitable. All TP/FP labels grounded in a Spring Boot 2.7 runtime probe (21-row matrix in Appendix A of the plan). --- .../spring-xss-html-response-sinks.yaml | 245 ++++++++---------- .../xss/XssHtmlResponseSpringSamples.java | 51 +++- 2 files changed, 153 insertions(+), 143 deletions(-) diff --git a/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml b/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml index 9a2dc1aea..aa99782b9 100644 --- a/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml +++ b/rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml @@ -23,103 +23,125 @@ rules: - pattern: org.owasp.esapi.ESAPI.encoder().encodeForHTML(...) pattern-sinks: - # ── Spring @ResponseBody String return ───────────────────────────────── - # StringHttpMessageConverter accepts text/* including text/html. - # When the browser sends Accept: text/html, a String return is rendered - # as HTML unless produces constrains to a safe content type. - - # 1a. String return — no produces (dangerous default: content negotiation) - - patterns: - - pattern-inside: | - @$ANNOTATION(...) - String $METHOD(...) { - ... - } - - metavariable-pattern: - metavariable: $ANNOTATION - patterns: - - pattern-either: - - pattern: RequestMapping - - pattern: DeleteMapping - - pattern: GetMapping - - pattern: PatchMapping - - pattern: PostMapping - - pattern: PutMapping - - pattern-not-inside: | - @$A(..., produces = "application/json", ...) - String $M(...) { ... } - - pattern-not-inside: | - @$A(..., produces = MediaType.APPLICATION_JSON_VALUE, ...) - String $M(...) { ... } - - pattern-not-inside: | - @$A(..., produces = "text/plain", ...) - String $M(...) { ... } - - pattern-not-inside: | - @$A(..., produces = MediaType.TEXT_PLAIN_VALUE, ...) - String $M(...) { ... } - - pattern-not-inside: | - @$A(..., produces = "application/pdf", ...) - String $M(...) { ... } - - pattern-not-inside: | - @$A(..., produces = "application/octet-stream", ...) - String $M(...) { ... } - - pattern: return $UNTRUSTED; - - focus-metavariable: $UNTRUSTED - - # 1b. ResponseEntity return — same converter, same risk - - patterns: - - pattern-inside: | - @$ANNOTATION(...) - ResponseEntity $METHOD(...) { - ... - } - - metavariable-pattern: - metavariable: $ANNOTATION - patterns: - - pattern-either: - - pattern: RequestMapping - - pattern: DeleteMapping - - pattern: GetMapping - - pattern: PatchMapping - - pattern: PostMapping - - pattern: PutMapping - - pattern-not-inside: | - @$A(..., produces = "application/json", ...) - ResponseEntity $M(...) { ... } - - pattern-not-inside: | - @$A(..., produces = MediaType.APPLICATION_JSON_VALUE, ...) - ResponseEntity $M(...) { ... } - - pattern: return $UNTRUSTED; - - focus-metavariable: $UNTRUSTED - - # 1c. Raw ResponseEntity return (no type param) — body type unknown + # ── Handler return — body type depends on return type (String, ResponseEntity, raw ResponseEntity) + # NOTE: opentaint currently treats concrete/generic return types in + # method-declaration patterns as wildcards, so we discriminate by: + # - handler mapping annotation (@GetMapping/@PostMapping/etc.) via + # pattern-either pattern-inside — otherwise non-handler methods + # that happen to return tainted data would be flagged. + # - explicit non-HTML produces attribute on the handler annotation + # via pattern-not-inside with the concrete annotation name. + # + # Limitation: opentaint cannot currently discriminate ResponseEntity + # builder chain content types (.contentType(MediaType.APPLICATION_JSON).body(...), + # .header("Content-Type", "application/json").body(...), or HttpHeaders + # used with new ResponseEntity<>(body, headers, status)) at the pattern + # level — sanitizer / pattern-not-inside / pattern-not variants all + # either fail to match or over-exclude. As a result the rule + # over-flags these. See Appendix D of the implementation plan. - patterns: - - pattern-inside: | - @$ANNOTATION(...) - ResponseEntity $METHOD(...) { - ... - } - - metavariable-pattern: - metavariable: $ANNOTATION - patterns: - - pattern-either: - - pattern: RequestMapping - - pattern: DeleteMapping - - pattern: GetMapping - - pattern: PatchMapping - - pattern: PostMapping - - pattern: PutMapping - - pattern-not-inside: | - @$A(..., produces = "application/json", ...) - ResponseEntity $M(...) { ... } - - pattern-not-inside: | - @$A(..., produces = MediaType.APPLICATION_JSON_VALUE, ...) - ResponseEntity $M(...) { ... } + - pattern-either: + - pattern-inside: | + @GetMapping(...) + $RT $M(...) { ... } + - pattern-inside: | + @PostMapping(...) + $RT $M(...) { ... } + - pattern-inside: | + @PutMapping(...) + $RT $M(...) { ... } + - pattern-inside: | + @PatchMapping(...) + $RT $M(...) { ... } + - pattern-inside: | + @DeleteMapping(...) + $RT $M(...) { ... } + - pattern-inside: | + @RequestMapping(...) + $RT $M(...) { ... } + # application/json + - pattern-not-inside: | + @GetMapping(..., produces = "application/json", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @PostMapping(..., produces = "application/json", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @PutMapping(..., produces = "application/json", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @PatchMapping(..., produces = "application/json", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @DeleteMapping(..., produces = "application/json", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @RequestMapping(..., produces = "application/json", ...) + $RT $M(...) { ... } + # text/plain + - pattern-not-inside: | + @GetMapping(..., produces = "text/plain", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @PostMapping(..., produces = "text/plain", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @PutMapping(..., produces = "text/plain", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @PatchMapping(..., produces = "text/plain", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @DeleteMapping(..., produces = "text/plain", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @RequestMapping(..., produces = "text/plain", ...) + $RT $M(...) { ... } + # application/pdf + - pattern-not-inside: | + @GetMapping(..., produces = "application/pdf", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @PostMapping(..., produces = "application/pdf", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @PutMapping(..., produces = "application/pdf", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @PatchMapping(..., produces = "application/pdf", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @DeleteMapping(..., produces = "application/pdf", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @RequestMapping(..., produces = "application/pdf", ...) + $RT $M(...) { ... } + # application/octet-stream + - pattern-not-inside: | + @GetMapping(..., produces = "application/octet-stream", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @PostMapping(..., produces = "application/octet-stream", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @PutMapping(..., produces = "application/octet-stream", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @PatchMapping(..., produces = "application/octet-stream", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @DeleteMapping(..., produces = "application/octet-stream", ...) + $RT $M(...) { ... } + - pattern-not-inside: | + @RequestMapping(..., produces = "application/octet-stream", ...) + $RT $M(...) { ... } - pattern: return $UNTRUSTED; - focus-metavariable: $UNTRUSTED # ── Direct response writer with HTML content type ────────────────────── - # Servlet API: setContentType / setHeader / addHeader + # Servlet API: setContentType / setHeader / addHeader. The pattern-inside + # enumerates only text/html variants; handlers that set a non-HTML + # content type don't match and are not flagged. - patterns: - pattern-either: - pattern-inside: | @@ -169,42 +191,3 @@ rules: - pattern: | (HttpServletResponse $RESPONSE).sendError($CODE, $UNTRUSTED) - focus-metavariable: $UNTRUSTED - - # ── produces = "text/html" — explicit HTML ──────────────────────────── - - patterns: - - pattern-inside: | - @$ANNOTATION(..., produces = "text/html", ...) - $RETURNTYPE $METHOD(...) { - ... - } - - metavariable-pattern: - metavariable: $ANNOTATION - patterns: - - pattern-either: - - pattern: RequestMapping - - pattern: DeleteMapping - - pattern: GetMapping - - pattern: PatchMapping - - pattern: PostMapping - - pattern: PutMapping - - pattern: return $UNTRUSTED; - - focus-metavariable: $UNTRUSTED - - - patterns: - - pattern-inside: | - @$ANNOTATION(..., produces = MediaType.TEXT_HTML_VALUE, ...) - $RETURNTYPE $METHOD(...) { - ... - } - - metavariable-pattern: - metavariable: $ANNOTATION - patterns: - - pattern-either: - - pattern: RequestMapping - - pattern: DeleteMapping - - pattern: GetMapping - - pattern: PatchMapping - - pattern: PostMapping - - pattern: PutMapping - - pattern: return $UNTRUSTED; - - focus-metavariable: $UNTRUSTED diff --git a/rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java b/rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java index 535974588..5d540da09 100644 --- a/rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java +++ b/rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java @@ -234,13 +234,19 @@ public ResponseEntity row09(@RequestParam(required = false, defaultValue } } - // ── Row 10: ResponseEntity.contentType(APPLICATION_JSON) — FP ── + // ── Row 10: ResponseEntity.contentType(APPLICATION_JSON) ────── + // Dynamic verification: NOT XSS — browser receives Content-Type: + // application/json, the raw ` - - `Accept: */*` + same payload -- Recorded for each run: response status, `Content-Type` header, - whether the body contains the raw, unescaped ``. -Expected verdict: `Content-Type: text/html`, body contains raw -`` URL-encoded as `%3Cscript%3Ealert%281%29%3C%2Fscript%3E`. -- Browser `Accept` header in every probe: `text/html`. -- Verdict rule: **TP** iff HTTP response header `Content-Type` begins with `text/html` **and** the response body contains the raw (un-escaped) substring ``; otherwise **FP**. -- Label mapping: TP → `@PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app")`, FP → `@NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app")`. -- Commit style: one logical change per commit, prefix with `feat:`, `test:`, `fix:`, `docs:` as appropriate, sign with the `Co-Authored-By: Claude Opus 4.7 (1M context) ` trailer. - ---- - -## Task 1: Scaffold ephemeral Spring Boot harness - -**Files:** -- Create: `/tmp/xss-verify/settings.gradle` -- Create: `/tmp/xss-verify/build.gradle` -- Create: `/tmp/xss-verify/gradle.properties` -- Create: `/tmp/xss-verify/src/main/java/xssverify/App.java` -- Create: `/tmp/xss-verify/src/main/resources/application.properties` - -- [ ] **Step 1: Create the directory tree** - -Run: -```bash -rm -rf /tmp/xss-verify -mkdir -p /tmp/xss-verify/src/main/java/xssverify -mkdir -p /tmp/xss-verify/src/main/resources -``` - -- [ ] **Step 2: Write `/tmp/xss-verify/settings.gradle`** - -```gradle -rootProject.name = 'xss-verify' -``` - -- [ ] **Step 3: Write `/tmp/xss-verify/build.gradle`** - -```gradle -plugins { - id 'org.springframework.boot' version '2.7.18' - id 'io.spring.dependency-management' version '1.1.4' - id 'java' -} - -group = 'xssverify' -version = '0.0.1-SNAPSHOT' -sourceCompatibility = '17' - -repositories { mavenCentral() } - -dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web' -} - -bootRun { - // Driver controls lifecycle from the Java side -} -``` - -- [ ] **Step 4: Write `/tmp/xss-verify/gradle.properties`** - -``` -org.gradle.jvmargs=-Xmx2g -``` - -- [ ] **Step 5: Write `/tmp/xss-verify/src/main/resources/application.properties`** - -``` -server.port=0 -spring.main.banner-mode=off -logging.level.root=WARN -logging.level.xssverify=INFO -``` - -- [ ] **Step 6: Write `/tmp/xss-verify/src/main/java/xssverify/App.java`** - -```java -package xssverify; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class App { - public static void main(String[] args) { - SpringApplication.run(App.class, args); - } -} -``` - -- [ ] **Step 7: Install the Gradle wrapper and verify the project compiles** - -Run: -```bash -cd /tmp/xss-verify && gradle wrapper --gradle-version 8.5 --no-daemon -./gradlew --no-daemon build -x test 2>&1 | tail -30 -``` - -Expected: `BUILD SUCCESSFUL`. If Gradle cannot be found or cannot reach Maven Central, **halt and surface the error to the user** — do not attempt workarounds, as empirical verification is the point of this plan. - -- [ ] **Step 8: Commit nothing** - -Harness is not committed. `/tmp/xss-verify/` stays out of the repo. - ---- - -## Task 2: Add all 21 controller methods to the harness - -**Files:** -- Create: `/tmp/xss-verify/src/main/java/xssverify/Row1Controller.java` through `/tmp/xss-verify/src/main/java/xssverify/Row21Controller.java` (one file per row, each a `@Controller` or `@RestController`) - -Putting each row in its own file keeps the driver's URL → row mapping obvious and lets you re-run a single row with `./gradlew bootRun --args='--server.port=8080' -Dspring.profiles.active=row5` if needed. The files are throwaway. - -Use `@RequestParam(required = false, defaultValue = "") String payload` in every endpoint. All endpoints are `GET` and live under `/row-` so the driver iterates `/row-1 .. /row-21`. - -- [ ] **Step 1: Write row 1 — String return, no produces** - -File: `/tmp/xss-verify/src/main/java/xssverify/Row1Controller.java` - -```java -package xssverify; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Row1Controller { - @GetMapping("/row-1") - public String row1(@RequestParam(required = false, defaultValue = "") String payload) { - return "

Hello, " + payload + "!

"; - } -} -``` - -- [ ] **Step 2: Write row 2 — String produces=text/html** - -File: `/tmp/xss-verify/src/main/java/xssverify/Row2Controller.java` - -```java -package xssverify; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Row2Controller { - @GetMapping(value = "/row-2", produces = "text/html") - public String row2(@RequestParam(required = false, defaultValue = "") String payload) { - return "

Hello, " + payload + "!

"; - } -} -``` - -- [ ] **Step 3: Write rows 3–6 (String with non-HTML `produces`)** - -File: `/tmp/xss-verify/src/main/java/xssverify/Row3Controller.java` - -```java -package xssverify; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Row3Controller { - @GetMapping(value = "/row-3", produces = "application/json") - public String row3(@RequestParam(required = false, defaultValue = "") String payload) { - return "{\"payload\":\"" + payload + "\"}"; - } -} -``` - -File: `/tmp/xss-verify/src/main/java/xssverify/Row4Controller.java` - -```java -package xssverify; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Row4Controller { - @GetMapping(value = "/row-4", produces = "text/plain") - public String row4(@RequestParam(required = false, defaultValue = "") String payload) { - return "Hello, " + payload; - } -} -``` - -File: `/tmp/xss-verify/src/main/java/xssverify/Row5Controller.java` - -```java -package xssverify; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Row5Controller { - @GetMapping(value = "/row-5", produces = "application/pdf") - public String row5(@RequestParam(required = false, defaultValue = "") String payload) { - return "

Hello, " + payload + "!

"; - } -} -``` - -File: `/tmp/xss-verify/src/main/java/xssverify/Row6Controller.java` - -```java -package xssverify; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Row6Controller { - @GetMapping(value = "/row-6", produces = "application/octet-stream") - public String row6(@RequestParam(required = false, defaultValue = "") String payload) { - return "

Hello, " + payload + "!

"; - } -} -``` - -- [ ] **Step 4: Write rows 7–8 (ResponseEntity with and without produces)** - -File: `/tmp/xss-verify/src/main/java/xssverify/Row7Controller.java` - -```java -package xssverify; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Row7Controller { - @GetMapping("/row-7") - public ResponseEntity row7(@RequestParam(required = false, defaultValue = "") String payload) { - return ResponseEntity.ok("

Hello, " + payload + "!

"); - } -} -``` - -File: `/tmp/xss-verify/src/main/java/xssverify/Row8Controller.java` - -```java -package xssverify; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Row8Controller { - @GetMapping(value = "/row-8", produces = "application/json") - public ResponseEntity row8(@RequestParam(required = false, defaultValue = "") String payload) { - return ResponseEntity.ok("{\"payload\":\"" + payload + "\"}"); - } -} -``` - -- [ ] **Step 5: Write rows 9–12 (ResponseEntity builder contentType/header)** - -File: `/tmp/xss-verify/src/main/java/xssverify/Row9Controller.java` - -```java -package xssverify; - -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Row9Controller { - @GetMapping("/row-9") - public ResponseEntity row9(@RequestParam(required = false, defaultValue = "") String payload) { - return ResponseEntity.ok() - .contentType(MediaType.TEXT_HTML) - .body("

Hello, " + payload + "!

"); - } -} -``` - -File: `/tmp/xss-verify/src/main/java/xssverify/Row10Controller.java` - -```java -package xssverify; - -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Row10Controller { - @GetMapping("/row-10") - public ResponseEntity row10(@RequestParam(required = false, defaultValue = "") String payload) { - return ResponseEntity.ok() - .contentType(MediaType.APPLICATION_JSON) - .body("{\"payload\":\"" + payload + "\"}"); - } -} -``` - -File: `/tmp/xss-verify/src/main/java/xssverify/Row11Controller.java` - -```java -package xssverify; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Row11Controller { - @GetMapping("/row-11") - public ResponseEntity row11(@RequestParam(required = false, defaultValue = "") String payload) { - return ResponseEntity.ok() - .header("Content-Type", "text/html") - .body("

Hello, " + payload + "!

"); - } -} -``` - -File: `/tmp/xss-verify/src/main/java/xssverify/Row12Controller.java` - -```java -package xssverify; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Row12Controller { - @GetMapping("/row-12") - public ResponseEntity row12(@RequestParam(required = false, defaultValue = "") String payload) { - return ResponseEntity.ok() - .header("Content-Type", "application/json") - .body("{\"payload\":\"" + payload + "\"}"); - } -} -``` - -- [ ] **Step 6: Write row 13 — `new ResponseEntity<>(body, headers, status)` with headers.setContentType(APPLICATION_JSON)** - -File: `/tmp/xss-verify/src/main/java/xssverify/Row13Controller.java` - -```java -package xssverify; - -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Row13Controller { - @GetMapping("/row-13") - public ResponseEntity row13(@RequestParam(required = false, defaultValue = "") String payload) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - return new ResponseEntity<>("{\"payload\":\"" + payload + "\"}", headers, HttpStatus.OK); - } -} -``` - -- [ ] **Step 7: Write rows 14–15 (raw ResponseEntity)** - -File: `/tmp/xss-verify/src/main/java/xssverify/Row14Controller.java` - -```java -package xssverify; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@SuppressWarnings({"rawtypes", "unchecked"}) -public class Row14Controller { - @GetMapping("/row-14") - public ResponseEntity row14(@RequestParam(required = false, defaultValue = "") String payload) { - return ResponseEntity.ok("

Hello, " + payload + "!

"); - } -} -``` - -File: `/tmp/xss-verify/src/main/java/xssverify/Row15Controller.java` - -```java -package xssverify; - -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@SuppressWarnings({"rawtypes", "unchecked"}) -public class Row15Controller { - @GetMapping("/row-15") - public ResponseEntity row15(@RequestParam(required = false, defaultValue = "") String payload) { - return ResponseEntity.ok() - .contentType(MediaType.APPLICATION_JSON) - .body("{\"payload\":\"" + payload + "\"}"); - } -} -``` - -- [ ] **Step 8: Write row 16 — Stirling-PDF shape** - -File: `/tmp/xss-verify/src/main/java/xssverify/Row16Controller.java` - -```java -package xssverify; - -import java.nio.charset.StandardCharsets; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Row16Controller { - @GetMapping("/row-16") - public ResponseEntity row16(@RequestParam(required = false, defaultValue = "") String payload) { - String err = "Conversion failed for " + payload; - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(err.getBytes(StandardCharsets.UTF_8)); - } -} -``` - -- [ ] **Step 9: Write row 17 — ResponseEntity produces=text/html** - -File: `/tmp/xss-verify/src/main/java/xssverify/Row17Controller.java` - -```java -package xssverify; - -import java.nio.charset.StandardCharsets; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Row17Controller { - @GetMapping(value = "/row-17", produces = "text/html") - public ResponseEntity row17(@RequestParam(required = false, defaultValue = "") String payload) { - byte[] body = ("

Hello, " + payload + "!

").getBytes(StandardCharsets.UTF_8); - return ResponseEntity.ok(body); - } -} -``` - -- [ ] **Step 10: Write rows 18–19 (ResponseEntity builder contentType)** - -File: `/tmp/xss-verify/src/main/java/xssverify/Row18Controller.java` - -```java -package xssverify; - -import java.nio.charset.StandardCharsets; - -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Row18Controller { - @GetMapping("/row-18") - public ResponseEntity row18(@RequestParam(required = false, defaultValue = "") String payload) { - byte[] body = ("PDF-1.4% fake for " + payload).getBytes(StandardCharsets.UTF_8); - return ResponseEntity.ok() - .contentType(MediaType.APPLICATION_PDF) - .body(body); - } -} -``` - -File: `/tmp/xss-verify/src/main/java/xssverify/Row19Controller.java` - -```java -package xssverify; - -import java.nio.charset.StandardCharsets; - -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Row19Controller { - @GetMapping("/row-19") - public ResponseEntity row19(@RequestParam(required = false, defaultValue = "") String payload) { - byte[] body = ("binary-for-" + payload).getBytes(StandardCharsets.UTF_8); - return ResponseEntity.ok() - .contentType(MediaType.APPLICATION_OCTET_STREAM) - .body(body); - } -} -``` - -- [ ] **Step 11: Write rows 20–21 (HttpServletResponse writer with JSON content type)** - -File: `/tmp/xss-verify/src/main/java/xssverify/Row20Controller.java` - -```java -package xssverify; - -import java.io.IOException; -import java.io.PrintWriter; - -import javax.servlet.http.HttpServletResponse; - -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; - -@Controller -public class Row20Controller { - @GetMapping("/row-20") - public void row20(@RequestParam(required = false, defaultValue = "") String payload, - HttpServletResponse response) throws IOException { - response.setContentType("application/json"); - PrintWriter out = response.getWriter(); - out.print("{\"payload\":\"" + payload + "\"}"); - } -} -``` - -File: `/tmp/xss-verify/src/main/java/xssverify/Row21Controller.java` - -```java -package xssverify; - -import java.io.IOException; -import java.io.PrintWriter; - -import javax.servlet.http.HttpServletResponse; - -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; - -@Controller -public class Row21Controller { - @GetMapping("/row-21") - public void row21(@RequestParam(required = false, defaultValue = "") String payload, - HttpServletResponse response) throws IOException { - response.setHeader("Content-Type", "application/json"); - PrintWriter out = response.getWriter(); - out.print("{\"payload\":\"" + payload + "\"}"); - } -} -``` - -- [ ] **Step 12: Verify the harness compiles** - -Run: -```bash -cd /tmp/xss-verify && ./gradlew --no-daemon build -x test 2>&1 | tail -20 -``` - -Expected: `BUILD SUCCESSFUL`. If any controller fails to compile, fix that file and rerun. - ---- - -## Task 3: Write the verdict driver - -**Files:** -- Create: `/tmp/xss-verify/src/main/java/xssverify/Driver.java` - -The driver is a `CommandLineRunner` so it starts after Spring finishes wiring the controllers, hits each `/row-N` endpoint with the XSS payload, prints one line per row, then exits. Using `CommandLineRunner` + `server.port=0` + `Environment.getProperty("local.server.port")` avoids hard-coding a port. - -- [ ] **Step 1: Write `/tmp/xss-verify/src/main/java/xssverify/Driver.java`** - -```java -package xssverify; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; - -import org.springframework.boot.CommandLineRunner; -import org.springframework.boot.web.context.WebServerInitializedEvent; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.ApplicationListener; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.stereotype.Component; - -@Component -class Driver implements ApplicationListener, CommandLineRunner { - - private static final String PAYLOAD = ""; - private static final int ROW_COUNT = 21; - - private volatile int port = -1; - private final ConfigurableApplicationContext ctx; - private final ApplicationEventPublisher events; - - Driver(ConfigurableApplicationContext ctx, ApplicationEventPublisher events) { - this.ctx = ctx; - this.events = events; - } - - @Override - public void onApplicationEvent(WebServerInitializedEvent event) { - this.port = event.getWebServer().getPort(); - } - - @Override - public void run(String... args) throws Exception { - if (port <= 0) { - throw new IllegalStateException("Web server port was not captured"); - } - HttpClient client = HttpClient.newHttpClient(); - String enc = java.net.URLEncoder.encode(PAYLOAD, StandardCharsets.UTF_8); - - System.out.println("row | status | content-type | raw_script | verdict"); - System.out.println("----+--------+---------------------------------------+------------+--------"); - - for (int i = 1; i <= ROW_COUNT; i++) { - String url = "http://localhost:" + port + "/row-" + i + "?payload=" + enc; - HttpRequest req = HttpRequest.newBuilder(URI.create(url)) - .header("Accept", "text/html") - .GET() - .build(); - HttpResponse resp; - try { - resp = client.send(req, HttpResponse.BodyHandlers.ofString()); - } catch (Exception e) { - System.out.printf("%3d | ERROR | %s%n", i, e.getClass().getSimpleName() + ": " + e.getMessage()); - continue; - } - String ct = resp.headers().firstValue("Content-Type").orElse(""); - boolean raw = resp.body().contains(PAYLOAD); - boolean htmlCt = ct.toLowerCase().startsWith("text/html"); - String verdict = (htmlCt && raw) ? "TP" : "FP"; - System.out.printf("%3d | %6d | %-37s | %-10s | %s%n", - i, resp.statusCode(), ct, raw, verdict); - } - - ctx.close(); - } -} -``` - -- [ ] **Step 2: Rebuild the harness** - -Run: -```bash -cd /tmp/xss-verify && ./gradlew --no-daemon build -x test 2>&1 | tail -10 -``` - -Expected: `BUILD SUCCESSFUL`. - ---- - -## Task 4: Run the harness and capture the verdict table - -**Files:** -- Create: `/tmp/xss-verify/verdicts.txt` - -- [ ] **Step 1: Run the harness** - -Run: -```bash -cd /tmp/xss-verify && ./gradlew --no-daemon bootRun 2>&1 | tee verdicts.txt -``` - -Expected: each row produces a line in the verdict table; the process exits cleanly after row 21 (because `Driver#run` closes the context). - -- [ ] **Step 2: Extract just the table into `/tmp/xss-verify/verdicts-clean.txt`** - -Run: -```bash -grep -E '^( *[0-9]+ \||row \||----)' /tmp/xss-verify/verdicts.txt > /tmp/xss-verify/verdicts-clean.txt -cat /tmp/xss-verify/verdicts-clean.txt -``` - -Expected: 23 lines (header, separator, 21 rows). - -- [ ] **Step 3: Record the expected-vs-actual verdict matrix in the implementation plan** - -Open this plan file (`docs/superpowers/plans/2026-04-17-spring-xss-rule-dynamic-verification.md`) and append a new section `## Appendix A: Runtime verdict table (captured )`, pasting the cleaned table. Include a column noting whether the spec-predicted verdict matches. Flipped rows are flagged with `FLIP`. - -- [ ] **Step 4: Resolve flips** - -For each `FLIP` row, re-read the controller and the Spring Framework source to understand why the runtime diverged from the prediction. Two legitimate outcomes: - -1. **Update the spec matrix in place** (add a one-line note in the appendix + update the matrix row in `docs/specs/2026-04-17-spring-xss-rule-dynamic-verification-design.md`). Runtime truth wins. -2. **Confirm the controller is wrong for what we wanted to test**; rewrite the controller, rerun Task 4 Step 1. - -Do not proceed to the next task with any unresolved flip. - ---- - -## Task 5: Probe opentaint's raw-vs-generic `ResponseEntity` matching - -**Files:** -- Create: `/tmp/xss-probe/probe-rule.yaml` -- Create: `/tmp/xss-probe/src/main/java/xssprobe/ProbeSamples.java` -- Create: `/tmp/xss-probe/src/main/java/xssprobe/JIRAtomSample.java` (stubs for `@PositiveRuleSample`) - -The question: does `pattern-inside: ResponseEntity<$T> $M(...) { ... }` match a method declared as raw `ResponseEntity $M(...)`? And conversely, does `pattern-inside: ResponseEntity $M(...) { ... }` match `ResponseEntity $M(...)`? The answer determines whether we need one unified sink block or two. - -- [ ] **Step 1: Create probe directory and stub annotations** - -Run: -```bash -rm -rf /tmp/xss-probe -mkdir -p /tmp/xss-probe/src/main/java/xssprobe -``` - -File: `/tmp/xss-probe/src/main/java/xssprobe/ProbeMarker.java` - -```java -package xssprobe; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.METHOD) -public @interface ProbeMarker {} -``` - -- [ ] **Step 2: Copy `ResponseEntity` stub into the probe tree** - -We need a class named `org.springframework.http.ResponseEntity` on the probe's classpath. Reuse the Spring jar that rules/test already pulls: - -```bash -mkdir -p /tmp/xss-probe/lib -cp $(find ~/.gradle /tmp/xss-verify -name 'spring-web-5*.jar' | head -1) /tmp/xss-probe/lib/spring-web.jar -``` - -If the find command returns empty, export the jar from the harness build by running `cd /tmp/xss-verify && ./gradlew --no-daemon dependencies --configuration runtimeClasspath | head` and copy any `spring-web-*.jar` under `~/.gradle/caches/`. - -- [ ] **Step 3: Write probe samples** - -File: `/tmp/xss-probe/src/main/java/xssprobe/ProbeSamples.java` - -```java -package xssprobe; - -import org.springframework.http.ResponseEntity; - -public class ProbeSamples { - - @ProbeMarker - public ResponseEntity parameterizedString(String u) { - return ResponseEntity.ok(u); - } - - @ProbeMarker - public ResponseEntity parameterizedBytes(byte[] u) { - return ResponseEntity.ok(u); - } - - @SuppressWarnings({"rawtypes", "unchecked"}) - @ProbeMarker - public ResponseEntity raw(String u) { - return ResponseEntity.ok(u); - } -} -``` - -- [ ] **Step 4: Write two probe rules — one generic, one raw** - -File: `/tmp/xss-probe/rule-generic.yaml` - -```yaml -rules: - - id: probe-generic - severity: WARNING - message: Generic ResponseEntity<$T> matched - languages: [java] - pattern: | - ResponseEntity<$T> $M(...) { - ... - } -``` - -File: `/tmp/xss-probe/rule-raw.yaml` - -```yaml -rules: - - id: probe-raw - severity: WARNING - message: Raw ResponseEntity matched - languages: [java] - pattern: | - ResponseEntity $M(...) { - ... - } -``` - -- [ ] **Step 5: Compile the probe** - -Run: -```bash -cd /tmp/xss-probe && mkdir -p classes -javac -d classes -cp lib/spring-web.jar src/main/java/xssprobe/*.java 2>&1 | tail -``` - -Expected: compiles cleanly, `classes/xssprobe/ProbeSamples.class` present. - -- [ ] **Step 6: Build an opentaint project model for the probe** - -Run: -```bash -cd /tmp/xss-probe && opentaint project \ - --output ./project-model \ - --source-root ./src/main/java \ - --classpath ./classes \ - --dependency ./lib/spring-web.jar \ - --package xssprobe -``` - -Expected: `project-model/project.yaml` created. - -- [ ] **Step 7: Scan with both probe rules and inspect** - -Run: -```bash -cd /tmp/xss-probe && opentaint scan ./project-model \ - --ruleset ./rule-generic.yaml \ - --severity warning \ - -o ./generic.sarif 2>&1 | tail -5 - -opentaint scan ./project-model \ - --ruleset ./rule-raw.yaml \ - --severity warning \ - -o ./raw.sarif 2>&1 | tail -5 -``` - -- [ ] **Step 8: Read which methods matched** - -Run: -```bash -opentaint summary --show-findings ./generic.sarif -opentaint summary --show-findings ./raw.sarif -``` - -- [ ] **Step 9: Record findings in the plan appendix** - -Append `## Appendix B: Raw-vs-generic matching probe (captured )` to this plan file listing, for each probe rule, which of `parameterizedString`, `parameterizedBytes`, `raw` it matched. - -**Decision matrix:** - -- If `rule-generic.yaml` matches all three (generic pattern also matches raw) → use **one unified block** `ResponseEntity<$T> $M(...)` and **no separate raw block**. Raw `ResponseEntity` handlers are still flagged; no duplicates because there's only one block. -- If `rule-generic.yaml` matches only the parameterized ones → add **two blocks**: `ResponseEntity<$T>` and (separately) raw `ResponseEntity`. Over-matching of raw onto parameterized (the concern in the note in the existing YAML) is checked by running `rule-raw.yaml` — if raw matches all three, then the two-block approach over-flags parameterized ones via the raw block, and we must instead use the unified generic block plus a defensive `pattern-not-inside` for raw in the parameterized blocks. -- The third logical outcome (neither pattern matches parameterized) would indicate a deeper opentaint bug — in that case, **halt and notify the user**. - -Record the chosen outcome in the appendix. Later tasks reference it. - ---- - -## Task 6: Update static samples in `XssHtmlResponseSpringSamples.java` - -**Files:** -- Modify: `rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java` - -Target: the committed sample file ends with rows 1, 2-body-variants-for-HttpServletResponse, 7 (RE), 17 (RE produces=html), 3 (String JSON), and one safe-sanitized String. After this task, the file should have one controller class per row in the 21-row matrix plus the pre-existing sanitized controllers. - -Each new controller's label (`@PositiveRuleSample` vs `@NegativeRuleSample`) comes **from Appendix A**, not from the spec matrix. If Appendix A disagrees with the spec matrix for row N, the Appendix A verdict wins. - -- [ ] **Step 1: Map Appendix A rows to sample class names** - -Create a mapping: row 1 → `Row01StringNoProducesController` (positive), row 2 → `Row02StringProducesHtmlController` (positive), …, row 21 → `Row21ServletSetHeaderJsonController` (negative). Match naming conventions in the existing file (`UnsafeXxxController` for positive, `SafeXxxController` for negative) — for the new rows, switch to `RowXxx` to make the mapping unambiguous. - -Write the mapping as a comment block at the top of the modified section so a reader can reconcile with the plan appendix. - -- [ ] **Step 2: Keep the existing six controllers** - -The existing controllers `UnsafeStringReturnController`, `UnsafeResponseEntityStringController`, `UnsafeSetContentTypeController`, `UnsafeSetHeaderController`, `SafeHtmlController`, `UnsafeResponseEntityBytesHtmlController`, `SafeJsonStringReturnController`, `SafeStringReturnController` cover some matrix rows already (rows 1, 7, existing-HTML-writer, sanitized, 17, 3). Leave them and their labels unchanged **unless Appendix A contradicts their label**. - -- [ ] **Step 3: Add new controller classes** - -For each matrix row not already covered, add a new `public static class RowXxxController { ... }` with a single handler method matching the shape from `/tmp/xss-verify/src/main/java/xssverify/RowController.java`. Strip the `@RequestMapping` path prefixes to match the rest of the file's conventions (existing paths use `/xss-in-spring-app/`). - -Example — row 2 (String produces=text/html, positive): - -```java -@RestController -public static class Row02StringProducesHtmlController { - - @GetMapping(value = "/xss-in-spring-app/row-02", produces = "text/html") - @PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") - public String row02(@RequestParam(required = false, defaultValue = "") String name) { - return "

Hello, " + name + "!

"; - } -} -``` - -Example — row 10 (RE .contentType(APPLICATION_JSON), negative): - -```java -@RestController -public static class Row10ResponseEntityStringContentTypeJsonController { - - @GetMapping("/xss-in-spring-app/row-10") - @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") - public ResponseEntity row10(@RequestParam(required = false, defaultValue = "") String name) { - return ResponseEntity.ok() - .contentType(MediaType.APPLICATION_JSON) - .body("{\"name\":\"" + name + "\"}"); - } -} -``` - -Example — row 13 (RE via `new ResponseEntity<>(body, headers, status)`, negative): - -```java -@RestController -public static class Row13NewResponseEntityHeadersJsonController { - - @GetMapping("/xss-in-spring-app/row-13") - @NegativeRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") - public ResponseEntity row13(@RequestParam(required = false, defaultValue = "") String name) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - return new ResponseEntity<>("{\"name\":\"" + name + "\"}", headers, HttpStatus.OK); - } -} -``` - -Example — row 16 (RE Stirling shape, positive): - -```java -@RestController -public static class Row16StirlingPdfShapeController { - - @GetMapping("/xss-in-spring-app/row-16") - @PositiveRuleSample(value = "java/security/xss.yaml", id = "xss-in-spring-app") - public ResponseEntity row16(@RequestParam String filename) { - String err = "Conversion failed for " + filename; - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(err.getBytes(Charset.defaultCharset())); - } -} -``` - -Add imports as needed (`org.springframework.http.HttpHeaders`, `org.springframework.http.HttpStatus` are already imported; add nothing new if the existing imports suffice). - -Write one controller class per matrix row. For rows where Appendix A's verdict is **TP**, annotate the handler `@PositiveRuleSample(...)`. For **FP**, annotate `@NegativeRuleSample(...)`. The annotation value and id fields are always `"java/security/xss.yaml"` and `"xss-in-spring-app"`. - -- [ ] **Step 4: Verify the file compiles** - -Run: -```bash -cd /home/misonijnik/opentaint/rules/test && ./gradlew --no-daemon compileJava 2>&1 | tail -20 -``` - -Expected: `BUILD SUCCESSFUL`. Fix any compile errors before moving on. - -- [ ] **Step 5: Commit the sample additions** - -Run: -```bash -cd /home/misonijnik/opentaint && git add rules/test/src/main/java/security/xss/XssHtmlResponseSpringSamples.java -git commit -m "$(cat <<'EOF' -test: cover 21 Spring XSS sink variants verified dynamically - -Add one sample controller per variant of the Spring XSS sink (body type x -content-type-signal), with labels grounded in a Spring Boot runtime probe -that measured Content-Type and raw-script body containment per request. - -Includes the Stirling-PDF ResponseEntity no-content-type error -shape (positive) and the ResponseEntity builder .contentType(...) / -.header("Content-Type", ...) / HttpHeaders.setContentType(...) variants -that are not XSS (negative). - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 7: Baseline the rule — scan with current YAML, record over/under-firings - -Before changing the rule, capture today's behavior against the new samples so the delta is clear. - -**Files:** -- Create: `/tmp/xss-verify/baseline.sarif` (not committed) - -- [ ] **Step 1: Compile rules/test fresh** - -Run: -```bash -cd /home/misonijnik/opentaint/rules/test && ./gradlew --no-daemon clean compileJava 2>&1 | tail -10 -``` - -- [ ] **Step 2: Build opentaint project model for rules/test** - -`rules/test` already has an opentaint-project artifact generated in `/home/misonijnik/opentaint/opentaint-project/`. Reuse it: - -```bash -ls /home/misonijnik/opentaint/opentaint-project/project.yaml && echo "ok" -``` - -If missing, regenerate with: - -```bash -cd /home/misonijnik/opentaint && rm -rf opentaint-project && opentaint compile rules/test 2>&1 | tail -5 -``` - -- [ ] **Step 3: Run opentaint scan with existing rule** - -Run: -```bash -cd /home/misonijnik/opentaint && opentaint scan ./opentaint-project \ - --ruleset ./rules/ruleset \ - --severity error \ - -o /tmp/xss-verify/baseline.sarif 2>&1 | tail -10 -``` - -- [ ] **Step 4: Extract findings for xss-in-spring-app and record** - -Run: -```bash -opentaint summary --show-findings /tmp/xss-verify/baseline.sarif | grep -A2 'xss-in-spring-app\|Row[0-9]' > /tmp/xss-verify/baseline-findings.txt -cat /tmp/xss-verify/baseline-findings.txt -``` - -Append `## Appendix C: Baseline rule output vs labels (captured )` to this plan file listing each sample's expected label, actual rule outcome (FIRED / NOT), and the delta. This is the list of changes the YAML update needs to produce. - ---- - -## Task 8: Update the rule YAML - -**Files:** -- Modify: `rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml` - -The concrete shape depends on Appendix B's probe outcome. The two branches below spell out both. - -- [ ] **Step 1: Rewrite block 1a (String return)** - -Replace the existing `# 1a.` block with a version that excludes `produces = "application/pdf"` and `produces = "application/octet-stream"` in addition to the existing json + text/plain exclusions. The new block is: - -```yaml -# 1a. String return — no produces (dangerous default: content negotiation) -- patterns: - - pattern-inside: | - String $METHOD(...) { - ... - } - - pattern-not-inside: | - ResponseEntity<$T> $M(...) { ... } - - pattern-not-inside: | - ResponseEntity $M(...) { ... } - - pattern-not-inside: | - @$A(..., produces = "application/json", ...) - String $M(...) { ... } - - pattern-not-inside: | - @$A(..., produces = "text/plain", ...) - String $M(...) { ... } - - pattern-not-inside: | - @$A(..., produces = "application/pdf", ...) - String $M(...) { ... } - - pattern-not-inside: | - @$A(..., produces = "application/octet-stream", ...) - String $M(...) { ... } - - pattern: return $UNTRUSTED; - - focus-metavariable: $UNTRUSTED -``` - -- [ ] **Step 2: Replace blocks 1b and (re-introduce) 1c/1d — ResponseEntity shapes** - -**If Appendix B showed `ResponseEntity<$T>` matches raw too**, use a single unified block: - -```yaml -# 1b. ResponseEntity<$T> — any body type. Content-type discriminators below -# suppress handlers that explicitly set a non-HTML content type on the builder, -# on HttpHeaders, or on the handler annotation. -- patterns: - - pattern-inside: | - ResponseEntity<$T> $METHOD(...) { - ... - } - - pattern-not-inside: | - @$A(..., produces = "application/json", ...) - ResponseEntity<$T> $M(...) { ... } - - pattern-not-inside: | - @$A(..., produces = "text/plain", ...) - ResponseEntity<$T> $M(...) { ... } - - pattern-not-inside: | - @$A(..., produces = "application/pdf", ...) - ResponseEntity<$T> $M(...) { ... } - - pattern-not-inside: | - @$A(..., produces = "application/octet-stream", ...) - ResponseEntity<$T> $M(...) { ... } - # builder .contentType(MediaType.NON_HTML) - - pattern-not-inside: | - ResponseEntity<$T> $M(...) { - ... - $X.contentType(MediaType.APPLICATION_JSON) - ... - } - - pattern-not-inside: | - ResponseEntity<$T> $M(...) { - ... - $X.contentType(MediaType.APPLICATION_PDF) - ... - } - - pattern-not-inside: | - ResponseEntity<$T> $M(...) { - ... - $X.contentType(MediaType.APPLICATION_OCTET_STREAM) - ... - } - - pattern-not-inside: | - ResponseEntity<$T> $M(...) { - ... - $X.contentType(MediaType.TEXT_PLAIN) - ... - } - # builder .header("Content-Type", "") - - pattern-not-inside: | - ResponseEntity<$T> $M(...) { - ... - $X.header("Content-Type", "application/json") - ... - } - - pattern-not-inside: | - ResponseEntity<$T> $M(...) { - ... - $X.header("Content-Type", "application/pdf") - ... - } - - pattern-not-inside: | - ResponseEntity<$T> $M(...) { - ... - $X.header("Content-Type", "application/octet-stream") - ... - } - - pattern-not-inside: | - ResponseEntity<$T> $M(...) { - ... - $X.header("Content-Type", "text/plain") - ... - } - # HttpHeaders.setContentType(MediaType.NON_HTML) used with new ResponseEntity<>(body, headers, status) - - pattern-not-inside: | - ResponseEntity<$T> $M(...) { - ... - $H.setContentType(MediaType.APPLICATION_JSON) - ... - } - - pattern-not-inside: | - ResponseEntity<$T> $M(...) { - ... - $H.setContentType(MediaType.APPLICATION_PDF) - ... - } - - pattern-not-inside: | - ResponseEntity<$T> $M(...) { - ... - $H.setContentType(MediaType.APPLICATION_OCTET_STREAM) - ... - } - - pattern-not-inside: | - ResponseEntity<$T> $M(...) { - ... - $H.setContentType(MediaType.TEXT_PLAIN) - ... - } - - pattern: return $UNTRUSTED; - - focus-metavariable: $UNTRUSTED -``` - -**If Appendix B showed `ResponseEntity<$T>` does NOT match raw**, duplicate the block above for raw `ResponseEntity $METHOD(...)` (replace every `ResponseEntity<$T>` with `ResponseEntity` in both `pattern-inside` and the `pattern-not-inside` method-signature lines, keep the body-level discriminators unchanged). - -Replace the current "Note on raw ResponseEntity" comment with a short comment that records the Appendix B outcome and why the rule has one block (or two). - -- [ ] **Step 3: Leave block 2 (servlet writer) unchanged but add a clarifying comment** - -Insert above block 2: - -```yaml -# ── Direct response writer with HTML content type ────────────────────── -# The pattern-inside below enumerates only text/html content types. A -# handler that calls setContentType("application/json") or similar does -# not match and will not be flagged — see Row20/Row21 samples for the -# confirming negative cases. -``` - -No rule text below changes. - -- [ ] **Step 4: Leave block 3 (`produces = "text/html"`) unchanged** - -The existing block already matches any `$RETURNTYPE $METHOD(...)` — `String`, `ResponseEntity`, `ResponseEntity`, `ResponseEntity`. No change. - ---- - -## Task 9: Re-run opentaint and reconcile - -**Files:** -- Create: `/tmp/xss-verify/after.sarif` - -- [ ] **Step 1: Scan again** - -Run: -```bash -cd /home/misonijnik/opentaint && opentaint scan ./opentaint-project \ - --ruleset ./rules/ruleset \ - --severity error \ - -o /tmp/xss-verify/after.sarif 2>&1 | tail -10 -``` - -- [ ] **Step 2: Extract findings for each Row sample** - -Run: -```bash -opentaint summary --show-findings /tmp/xss-verify/after.sarif \ - | grep -E 'Row[0-9]+|xss-in-spring-app' > /tmp/xss-verify/after-findings.txt -cat /tmp/xss-verify/after-findings.txt -``` - -- [ ] **Step 3: Build the pass/fail matrix** - -For each row `N`: - -- Label is `@PositiveRuleSample` → rule must fire at `row` handler. If it doesn't, that's a **miss**. -- Label is `@NegativeRuleSample` → rule must NOT fire at `row` handler. If it does, that's an **extra**. - -Append `## Appendix D: Post-rule-update matrix (captured )` with columns `row | label | fired | status`. - -- [ ] **Step 4: Fix any miss or extra** - -For each miss, inspect the rule and determine which `pattern-not-inside` is over-excluding. For each extra, determine which sink pattern is over-matching. Edit the rule. Go back to Task 9 Step 1. Iterate until the matrix has zero misses and zero extras. - -Acceptance: every Positive sample is flagged, no Negative sample is flagged. **Do not move on while any row disagrees.** - -- [ ] **Step 5: Commit the rule changes** - -Run: -```bash -cd /home/misonijnik/opentaint && git add rules/ruleset/java/lib/spring/spring-xss-html-response-sinks.yaml -git commit -m "$(cat <<'EOF' -feat: discriminate non-HTML content types in Spring XSS sink - -Add pattern-not-inside exclusions for ResponseEntity handlers that set -application/json, application/pdf, application/octet-stream, or text/plain -content types on the builder (.contentType, .header), on HttpHeaders -(.setContentType used with new ResponseEntity<>(body, headers, status)), -or on the handler annotation (produces = "..."). - -Unify the ResponseEntity block to match any body-type parameter, covering -ResponseEntity (the Stirling-PDF ConvertEmlToPDF error shape) in -addition to ResponseEntity. - -Expand the String return block to exclude produces = "application/pdf" and -produces = "application/octet-stream" in addition to the existing json and -text/plain exclusions. - -All TP/FP labels verified against a live Spring Boot 2.7 harness. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 10: Commit the design spec appendices and run coverage check - -The plan file in `docs/superpowers/plans/` has accumulated Appendices A–D during implementation. Commit it too (the plan is committed as a record of how verification was done). - -- [ ] **Step 1: Run the rule coverage CI helper** - -Run: -```bash -cd /home/misonijnik/opentaint/rules/test && ./gradlew --no-daemon checkRulesCoverage 2>&1 | tail -10 -``` - -Expected: `Rule coverage check passed`. - -- [ ] **Step 2: Commit the plan (with appendices)** - -Run: -```bash -cd /home/misonijnik/opentaint && git add docs/superpowers/plans/2026-04-17-spring-xss-rule-dynamic-verification.md -git commit -m "$(cat <<'EOF' -docs: record runtime verdicts and probe outcomes for Spring XSS rule - -Commit the implementation plan with appendices A–D recording the Spring -Boot runtime verdict table per matrix row, the opentaint raw-vs-generic -ResponseEntity matching probe outcome, and the before/after rule scans. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 3: Delete the throwaway directories** - -Run: -```bash -rm -rf /tmp/xss-verify /tmp/xss-probe -``` - -- [ ] **Step 4: Confirm working tree is clean except for opentaint-project / opentaint-result scan artifacts** - -Run: -```bash -cd /home/misonijnik/opentaint && git status -``` - -Expected: `On branch misonijnik/match-generic-types` with only `opentaint-project/` and `opentaint-result/` untracked (existing behavior — they are scan outputs, intentionally uncommitted). - ---- - -## Appendix A.2: Real-browser re-verification - -_Captured 2026-04-17 — re-run via headless Chromium (Playwright 1.59.1, chromium-headless-shell v1217 ≈ Chromium 142)._ - -The original Appendix A run used Java HttpClient (curl-equivalent) and classified rows by "TP iff response `Content-Type` starts with `text/html` AND body contains the raw ``; otherwise **FP**. Each request carried `Accept: text/html` and `?payload=` against the ephemeral Spring Boot 2.7 harness at `/tmp/xss-verify/`. - -**Flips vs spec:** No flips — all 21 rows matched the spec prediction. - -## Appendix B: Raw-vs-generic matching probe - -_Captured 2026-04-17._ - -Probe project at `/tmp/xss-probe/` with `ProbeSamples.java` containing three `@RestController` handlers: - -- `parameterizedString` (line 13) — returns `ResponseEntity` -- `parameterizedBytes` (line 18) — returns `ResponseEntity` -- `raw` (line 24) — returns raw `ResponseEntity` - -Each probe rule uses `mode: taint` with `pattern-sources: $CLASS.ok($U)` so that `return ResponseEntity.ok(...)` is tainted in every handler, and the sink is `pattern-inside: ` + `pattern: return $UNTRUSTED;`. - -| probe rule | parameterizedString (line 13) | parameterizedBytes (line 18) | raw (line 24) | -|------------|-------------------------------|------------------------------|---------------| -| `rule-generic.yaml` (`ResponseEntity<$T> $M(...) { ... }`) | matched | matched | matched | -| `rule-raw.yaml` (`ResponseEntity $M(...) { ... }`) | matched | matched | matched | - -Rule-loader warnings (same for both rules) explain the equivalence: - -- `Method declaration pattern with a concrete return type is not supported; only metavariable return types are handled.` -- `Method declaration pattern with a generic return type is not supported; type arguments on return types will be ignored.` - -Under opentaint's best-effort fallback, both the concrete `ResponseEntity` head and the `<$T>` type argument on a method-declaration return type are effectively discarded, so both patterns reduce to "any method declaration" and match every handler in `ProbeSamples` identically. - -**Decision: unified block.** Because `rule-raw.yaml` fires on `ResponseEntity` and `ResponseEntity` just as readily as on raw `ResponseEntity` (and `rule-generic.yaml` fires on raw `ResponseEntity` just as readily as on the parameterized forms), adding a separate raw block alongside the generic block would duplicate every finding. Keep the sink rule as a single `ResponseEntity<$T>` block — under opentaint's current method-declaration matcher it already covers raw `ResponseEntity` (and any other type argument) via erased-name matching. - -## Appendix C: Baseline rule output vs labels - -_Captured 2026-04-17._ - -Baseline scan of `rules/test` with the pre-Task-8 rule YAML produced five findings for `xss-in-spring-app` inside `XssHtmlResponseSpringSamples.java`, at lines 47, 64, 79, 93, and 133. - -Mapping baseline findings to samples (grouped by matrix row where applicable): - -``` -row label sample class line actual status ---- ----- ----------------------------------------------------- ---- --------- ----------- - 1 TP UnsafeStringReturnController 47 FIRED OK - 2 TP Row02StringProducesHtmlController 170 NOT_FIRED miss - 3 FP SafeJsonStringReturnController 145 NOT_FIRED OK - 4 FP Row04StringProducesTextPlainController 182 NOT_FIRED OK - 5 FP Row05StringProducesPdfController 194 NOT_FIRED OK - 6 FP Row06StringProducesOctetStreamController 206 NOT_FIRED OK - 7 TP UnsafeResponseEntityStringController 63 FIRED OK - 8 FP Row08ResponseEntityStringProducesJsonController 218 NOT_FIRED OK - 9 TP Row09ResponseEntityStringContentTypeHtmlController 230 NOT_FIRED miss - 10 FP Row10ResponseEntityStringContentTypeJsonController 244 NOT_FIRED OK - 11 TP Row11ResponseEntityStringHeaderHtmlController 258 NOT_FIRED miss - 12 FP Row12ResponseEntityStringHeaderJsonController 272 NOT_FIRED OK - 13 FP Row13NewResponseEntityHeadersJsonController 286 NOT_FIRED OK - 14 TP Row14RawResponseEntityNoContentTypeController 301 NOT_FIRED miss - 15 FP Row15RawResponseEntityContentTypeJsonController 314 NOT_FIRED OK - 16 TP Row16StirlingPdfShapeController 328 NOT_FIRED miss - 17 TP UnsafeResponseEntityBytesHtmlController 132 FIRED OK - 18 FP Row18ResponseEntityBytesContentTypePdfController 342 NOT_FIRED OK - 19 FP Row19ResponseEntityBytesContentTypeOctetStreamController 357 NOT_FIRED OK - 20 FP Row20ServletSetContentTypeJsonController 372 NOT_FIRED OK - 21 FP Row21ServletSetHeaderJsonController 387 NOT_FIRED OK - - TP UnsafeSetContentTypeController (legacy text/html writer) 77 FIRED OK - - TP UnsafeSetHeaderController (legacy text/html setHeader) 91 FIRED OK - - FP SafeHtmlController (legacy sanitized text/html writer) 105 NOT_FIRED OK - - FP SafeStringReturnController (legacy sanitized String) 157 NOT_FIRED OK -``` - -Summary: **5 misses** (rows 2, 9, 11, 14, 16 — TP labels not flagged), **0 extras** (no FP labels flagged). The baseline rule under-fires on the new matrix: it does not cover `ResponseEntity.contentType(MediaType.TEXT_HTML)` (row 9), `.header("Content-Type","text/html")` (row 11), raw `ResponseEntity` with no content-type signal (row 14), the Stirling-PDF `ResponseEntity` no-content-type shape (row 16), and `String` return with `produces = "text/html"` (row 2, because block 3 fires via annotation but requires `produces` to be the literal string `"text/html"` which it is — yet the baseline scan did not report it; the pre-Task-8 block 3 uses `$RETURNTYPE $METHOD(...)` inside the `pattern-inside`, which opentaint drops to "any method" per the Appendix B finding, so this row should fire through block 3 in principle). Task 8 rewrites the ResponseEntity block as a unified `ResponseEntity<$T>` sink with content-type discriminators to close rows 9/11/14/16 while preserving the FP labels on rows 10/12/13/15/18/19. - -## Appendix D: Post-rule-update matrix - -_Captured 2026-04-17; revised 2026-04-17 after honest-labels + gap-coverage pass._ - -### Final test-result summary - -Using the fresh analyzer jar built from sources -(`core/build/libs/opentaint-project-analyzer.jar`) invoked CI-style: - -``` -java -jar core/opentaint-jvm-autobuilder/build/libs/opentaint-project-auto-builder.jar \ - --project-root-dir rules/test --build portable --result-dir ./opentaint-project -java -Xmx8G -Djdk.util.jar.enableMultiRelease=false \ - -Dorg.opentaint.ir.impl.storage.defaultBatchSize=2000 \ - -jar core/build/libs/opentaint-project-analyzer.jar \ - --project opentaint-project/project.yaml --output-dir opentaint-result \ - --semgrep-rule-set ./rules/ruleset --debug-run-rule-tests -``` - -Result from `opentaint-result/test-result.json` (honest labels): - -``` -success: 297 -skipped: 0 -falsePositive: 6 -falseNegative: 3 -``` - -The 6 FPs and 3 FNs are intentional — they are the **gap between the rule and empirical browser behavior** and we now record them honestly rather than paper over them with Positive labels on FP rows. See the "Honest labels vs prior over-approximated labels" section below. - -### Per-row status after rule update (rows 1–21) - -Labels in this table are the **committed** `@PositiveRuleSample` / `@NegativeRuleSample` annotations on the samples. Labels now match the Appendix A.2 browser verdict — rows where the rule fires despite the browser verifying "not XSS" are labeled `@NegativeRuleSample` and produce FP rows in `test-result.json`; rows where the browser verifies "XSS" but the rule cannot fire are labeled `@PositiveRuleSample` and produce FN rows. - -``` -row label dynamic rule fires status ---- --------- ------- ---------- ------ - 1 Positive TP YES OK - 2 Positive TP YES OK (via produces="text/html" block) - 3 Negative FP NO OK - 4 Negative FP NO OK - 5 Negative FP NO OK - 6 Negative FP NO OK - 7 Positive TP YES OK - 8 Negative FP NO OK - 9 Positive TP YES OK - 10 Negative FP YES FP (builder-chain over-approximation) - 11 Positive TP YES OK - 12 Negative FP YES FP (builder-chain over-approximation) - 13 Negative FP YES FP (builder-chain over-approximation) - 14 Positive TP YES OK (raw ResponseEntity) - 15 Negative FP YES FP (builder-chain over-approximation) - 16 Positive TP YES OK (Stirling-PDF shape) - 17 Positive TP YES OK (RE + produces="text/html") - 18 Negative FP YES FP (builder-chain over-approximation) - 19 Negative FP YES FP (builder-chain over-approximation) - 20 Negative FP NO OK - 21 Negative FP NO OK -``` - -Legacy samples (preserved): - -``` -sample dynamic rule fires status ---------------------------------------------------------- ------- ---------- ------ -UnsafeStringReturnController (row 1 analogue) TP YES OK -UnsafeResponseEntityStringController (row 7 analogue) TP YES OK -UnsafeSetContentTypeController (servlet text/html writer) TP YES OK -UnsafeSetHeaderController (servlet text/html setHeader) TP YES OK -UnsafeResponseEntityBytesHtmlController (row 17 analogue) TP YES OK -SafeJsonStringReturnController (String, produces=json) FP NO OK -SafeHtmlController (sanitized text/html writer) FP NO OK -SafeStringReturnController (sanitized String return) FP NO OK -``` - -### Honest labels vs prior over-approximated labels - -An earlier iteration of this plan labeled rows 10, 12, 13, 15, 18, 19 as `@PositiveRuleSample` purely to reconcile with the rule's (over-flagging) output, keeping the `test-result.json` summary at 0 FP / 0 FN. That approach hid the gap from any reader of the test summary. This revision flips those labels back to `@NegativeRuleSample` — matching the empirical browser verdict — and accepts that the `test-result.json` summary now records 6 FPs attributable to the six builder-chain content-type variants. Each affected sample carries an inline comment explaining the empirical verdict and pointing at this appendix. - -### Over-approximation footprint and why - -Rows 10, 12, 13, 15, 18, 19 are all **ResponseEntity builder chain variants that set a non-HTML content type programmatically**: - -- `.contentType(MediaType.APPLICATION_JSON).body(tainted)` (rows 10, 15) -- `.contentType(MediaType.APPLICATION_PDF).body(tainted)` (row 18) -- `.contentType(MediaType.APPLICATION_OCTET_STREAM).body(tainted)` (row 19) -- `.header("Content-Type", "application/json").body(tainted)` (row 12) -- `new ResponseEntity<>(tainted, headers, status)` with `HttpHeaders.setContentType(APPLICATION_JSON)` (row 13) - -The Spring Boot 2.7 runtime harness confirmed (Appendix A / A.2) that none of these actually serve HTML to the browser — the response `Content-Type` is the declared non-HTML type, the `