civicrm/webform-mw/upload accepted any session-authenticated POST, called
civicrm_api3('Attachment','create') (which skips permission checks from
PHP) against any contact/activity id, and stored any client mime type.
- Require X-Requested-With: XMLHttpRequest (already sent by the app), so
cross-site form posts can't ride a logged-in staff session.
- Require EDIT permission on the target contact/activity.
- Enforce the same mime allowlist as the app's /api/upload route.
159 lines
5.7 KiB
PHP
159 lines
5.7 KiB
PHP
<?php
|
|
|
|
/**
|
|
* File upload proxy.
|
|
*
|
|
* External callers (the WebForm-mw /api/upload route on Amplify) POST a
|
|
* multipart request here with a `file` part. We hand the uploaded temp
|
|
* path to APIv3 Attachment.create via `options.move-file`, which is the
|
|
* one upload pathway Civi reliably honors on this install:
|
|
*
|
|
* - APIv4 File.create + content:base64 -> stores base64 text on disk.
|
|
* - APIv3 Attachment.create + content:b64 -> same.
|
|
* - APIv3 Attachment.create + multipart -> /civicrm/ajax/rest doesn't
|
|
* expose $_FILES to the
|
|
* action, so "file" is
|
|
* silently ignored.
|
|
* - APIv3 Attachment.create + options.move-file -> WORKS. Civi reads
|
|
* the path, moves the file
|
|
* into civicrm.files/upload,
|
|
* writes correct bytes.
|
|
*
|
|
* Required POST fields:
|
|
* file the binary (multipart `file` part)
|
|
* entity_table e.g. "civicrm_contact" (per Attachment.create contract)
|
|
* entity_id the entity id to link to
|
|
* name (optional) file_name; defaults to the upload's name
|
|
* mime_type (optional) defaults to the upload's reported type
|
|
*
|
|
* Returns JSON: { id, name } on success, { error } with 4xx/5xx otherwise.
|
|
*
|
|
* Authorization: `access CiviCRM`, plus edit permission on the target
|
|
* contact/activity. AuthX is expected to authenticate the Bearer +
|
|
* Site-Key headers WebForm-mw sends.
|
|
*
|
|
* CSRF: browsers can't attach a custom header to a cross-site form post
|
|
* without a CORS preflight, which Civi doesn't grant. Requiring
|
|
* `X-Requested-With: XMLHttpRequest` (which WebForm-mw already sends)
|
|
* stops a malicious page from riding a logged-in staff session.
|
|
*/
|
|
class CRM_WebformMw_Page_Upload extends CRM_Core_Page {
|
|
|
|
/**
|
|
* Mirrors ALLOWED_MIME in the app's /api/upload route.
|
|
*/
|
|
const ALLOWED_MIME = [
|
|
'application/pdf',
|
|
'application/msword',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'application/vnd.ms-excel',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'image/jpeg',
|
|
'image/png',
|
|
'image/gif',
|
|
'image/webp',
|
|
];
|
|
|
|
public function run() {
|
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
|
$this->jsonError('POST required', 405);
|
|
return;
|
|
}
|
|
|
|
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') !== 'XMLHttpRequest') {
|
|
$this->jsonError('Missing X-Requested-With header', 403);
|
|
return;
|
|
}
|
|
|
|
if (!CRM_Core_Permission::check('access CiviCRM')) {
|
|
$this->jsonError('Permission denied', 403);
|
|
return;
|
|
}
|
|
|
|
if (empty($_FILES['file']) || !is_array($_FILES['file'])) {
|
|
$this->jsonError('Missing file part', 400);
|
|
return;
|
|
}
|
|
$upload = $_FILES['file'];
|
|
if ((int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
|
$this->jsonError('Upload failed (php error ' . (int) $upload['error'] . ')', 400);
|
|
return;
|
|
}
|
|
if (empty($upload['tmp_name']) || !is_uploaded_file($upload['tmp_name'])) {
|
|
$this->jsonError('Invalid upload tmp path', 400);
|
|
return;
|
|
}
|
|
|
|
$entityTable = (string) ($_POST['entity_table'] ?? '');
|
|
$entityId = (int) ($_POST['entity_id'] ?? 0);
|
|
// Whitelist entity tables to mirror the redirect route's defensive scope.
|
|
if (!in_array($entityTable, ['civicrm_contact', 'civicrm_activity'], TRUE)) {
|
|
$this->jsonError('Invalid entity_table', 400);
|
|
return;
|
|
}
|
|
if ($entityId <= 0) {
|
|
$this->jsonError('Invalid entity_id', 400);
|
|
return;
|
|
}
|
|
// civicrm_api3() from PHP skips permission checks, so check the target
|
|
// record here — otherwise any Civi user could attach files to any
|
|
// contact or activity.
|
|
$canEdit = $entityTable === 'civicrm_contact'
|
|
? CRM_Contact_BAO_Contact_Permission::allow($entityId, CRM_Core_Permission::EDIT)
|
|
: CRM_Activity_BAO_Activity::checkPermission($entityId, CRM_Core_Permission::EDIT);
|
|
if (!$canEdit) {
|
|
$this->jsonError('Permission denied', 403);
|
|
return;
|
|
}
|
|
|
|
$name = (string) ($_POST['name'] ?? $upload['name'] ?? 'upload');
|
|
$mime = (string) ($_POST['mime_type'] ?? $upload['type'] ?? 'application/octet-stream');
|
|
if (!in_array($mime, self::ALLOWED_MIME, TRUE)) {
|
|
$this->jsonError('File type not allowed', 415);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$result = civicrm_api3('Attachment', 'create', [
|
|
'entity_table' => $entityTable,
|
|
'entity_id' => $entityId,
|
|
'name' => $name,
|
|
'mime_type' => $mime,
|
|
'options' => [
|
|
'move-file' => $upload['tmp_name'],
|
|
],
|
|
]);
|
|
$fileId = NULL;
|
|
if (!empty($result['id'])) {
|
|
$fileId = (int) $result['id'];
|
|
}
|
|
elseif (!empty($result['values']) && is_array($result['values'])) {
|
|
$first = reset($result['values']);
|
|
if (!empty($first['id'])) {
|
|
$fileId = (int) $first['id'];
|
|
}
|
|
}
|
|
if (!$fileId) {
|
|
throw new Exception('Attachment.create returned no id');
|
|
}
|
|
$this->jsonOk(['id' => $fileId, 'name' => $name]);
|
|
}
|
|
catch (Throwable $e) {
|
|
$this->jsonError('Attachment.create failed: ' . $e->getMessage(), 500);
|
|
}
|
|
}
|
|
|
|
private function jsonOk(array $payload): void {
|
|
header('Content-Type: application/json', TRUE, 200);
|
|
echo json_encode($payload);
|
|
CRM_Utils_System::civiExit();
|
|
}
|
|
|
|
private function jsonError(string $message, int $status): void {
|
|
header('Content-Type: application/json', TRUE, $status);
|
|
echo json_encode(['error' => $message]);
|
|
CRM_Utils_System::civiExit();
|
|
}
|
|
|
|
}
|