From 414334569f67ce2ee65cc9d0a151f3cc07e2b048 Mon Sep 17 00:00:00 2001 From: Henderson Damian Mejia Gonzales Date: Fri, 15 May 2026 07:35:44 -0500 Subject: [PATCH] Harden MediaConvert video webhook --- .../v1/controllers/AccountController.php | 103 +++++++++++++++++- tests/check-mediaconvert-webhook-hardening.py | 27 +++++ 2 files changed, 127 insertions(+), 3 deletions(-) create mode 100755 tests/check-mediaconvert-webhook-hardening.py diff --git a/candidate/modules/v1/controllers/AccountController.php b/candidate/modules/v1/controllers/AccountController.php index 171d01ff..b515ac6a 100644 --- a/candidate/modules/v1/controllers/AccountController.php +++ b/candidate/modules/v1/controllers/AccountController.php @@ -267,6 +267,15 @@ public function actionVideoByWebhook() { $jobId = $detail->jobId; + if (!$this->isMediaConvertWebhookEvent($data, $detail, $jobId)) { + Yii::warning("[Rejected MediaConvert webhook] " . print_r($data, true), 'webhook'); + + return [ + 'operation' => 'error', + "message" => "Invalid MediaConvert webhook" + ]; + } + $model = Candidate::find() ->andWhere([ 'candidate_video_job_id' => $jobId @@ -280,11 +289,26 @@ public function actionVideoByWebhook() { ]; } - if($detail->status == 'ERROR') { + $metadataUser = isset($detail->userMetadata) && isset($detail->userMetadata->User) + ? $detail->userMetadata->User + : null; + + if ((string) $metadataUser !== (string) $model->candidate_id) { + Yii::warning("[Rejected MediaConvert webhook user mismatch] jobId=" . $jobId, 'webhook'); + + return [ + 'operation' => 'error', + 'message' => Yii::t('candidate', "Invalid Job ID") + ]; + } + + $status = strtoupper((string) $detail->status); + + if($status == 'ERROR') { //log to sentry - Yii::error($detail->errorMessage, 'candidate'); + Yii::error(isset($detail->errorMessage) ? $detail->errorMessage : 'MediaConvert job failed', 'candidate'); //remove video @@ -299,7 +323,25 @@ public function actionVideoByWebhook() { ]; } - $fileName = basename($detail->outputGroupDetails[0]->outputDetails[0]->outputFilePaths[0]); + if ($status != 'COMPLETE') { + return [ + 'operation' => 'error', + 'message' => Yii::t('candidate', "MediaConvert Job Not Complete") + ]; + } + + $outputFilePath = $this->getMediaConvertOutputFilePath($detail); + + if (!$outputFilePath) { + Yii::warning("[Rejected MediaConvert webhook without output path] jobId=" . $jobId, 'webhook'); + + return [ + 'operation' => 'error', + 'message' => Yii::t('candidate', "MediaConvert output file missing") + ]; + } + + $fileName = basename($outputFilePath); $model->candidate_video = explode('.', $fileName)[0]; @@ -325,6 +367,61 @@ public function actionVideoByWebhook() { ]; } + /** + * Validate the public MediaConvert webhook before mutating candidate video state. + */ + private function isMediaConvertWebhookEvent($data, $detail, $jobId) + { + if (!isset($data->source) || $data->source !== 'aws.mediaconvert') { + return false; + } + + if (!isset($data->{'detail-type'}) || $data->{'detail-type'} !== 'MediaConvert Job State Change') { + return false; + } + + if (!isset($detail->status) || $detail->status === '') { + return false; + } + + if (isset($data->account) && isset($detail->accountId) && (string) $data->account !== (string) $detail->accountId) { + return false; + } + + if (empty($data->resources) || !is_array($data->resources)) { + return false; + } + + foreach ($data->resources as $resource) { + if (substr((string) $resource, -strlen((string) $jobId)) === (string) $jobId) { + return true; + } + } + + return false; + } + + private function getMediaConvertOutputFilePath($detail) + { + if (empty($detail->outputGroupDetails) || !is_array($detail->outputGroupDetails)) { + return null; + } + + $group = $detail->outputGroupDetails[0]; + + if (empty($group->outputDetails) || !is_array($group->outputDetails)) { + return null; + } + + $output = $group->outputDetails[0]; + + if (empty($output->outputFilePaths) || !is_array($output->outputFilePaths)) { + return null; + } + + return $output->outputFilePaths[0]; + } + /** * Remove Video */ diff --git a/tests/check-mediaconvert-webhook-hardening.py b/tests/check-mediaconvert-webhook-hardening.py new file mode 100755 index 00000000..407e4224 --- /dev/null +++ b/tests/check-mediaconvert-webhook-hardening.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +controller = ROOT / "candidate/modules/v1/controllers/AccountController.php" +source = controller.read_text() + +required_snippets = [ + "isMediaConvertWebhookEvent($data, $detail, $jobId)", + "$data->source !== 'aws.mediaconvert'", + "$data->{'detail-type'} !== 'MediaConvert Job State Change'", + "(string) $metadataUser !== (string) $model->candidate_id", + "$status = strtoupper((string) $detail->status)", + "$status != 'COMPLETE'", + "getMediaConvertOutputFilePath($detail)", +] + +missing = [snippet for snippet in required_snippets if snippet not in source] + +if missing: + raise SystemExit( + "Missing MediaConvert webhook hardening checks:\n- " + + "\n- ".join(missing) + ) + +print("MediaConvert webhook hardening checks are present.")