` and rely on us to mint the `fcs` JWT that * Civi's `/civicrm/file` handler requires. The JWT is signed with the * site's crypto key, which we have here because we're running inside * CiviCRM; the Next.js side doesn't. * * URL shape: /civicrm/webform-mw/file?id= * * Authorization: minting an fcs JWT is effectively "issuing a bearer * credential for this file" — once issued, /civicrm/file will serve the * bytes against any session. To avoid becoming a credential-laundering * IDOR, we look up the file's linked entity and run the entity-type's * native permission check before signing. The base `access CiviCRM` * permission only gates reaching this endpoint at all; per-record ACLs * happen here. Unknown entity types are denied by default. */ class CRM_WebformMw_Page_File extends CRM_Core_Page { public function run() { $fileId = (int) CRM_Utils_Request::retrieve('id', 'Positive', $this, TRUE); // Look up the file's linked entity. We need both entity_table and // entity_id — entity_table drives which permission API to call. $dao = CRM_Core_DAO::executeQuery( "SELECT entity_table, entity_id FROM civicrm_entity_file WHERE file_id = %1 LIMIT 1", [1 => [$fileId, 'Positive']] ); if (!$dao->fetch()) { CRM_Utils_System::permissionDenied(); return; } $entityTable = (string) $dao->entity_table; $entityId = (int) $dao->entity_id; if (!$this->canViewEntity($entityTable, $entityId)) { CRM_Utils_System::permissionDenied(); return; } // Mint a short-lived fcs JWT. The token is minted at click time (not // at report-render time), so 10 minutes covers normal redirect-and-fetch // latency without making the URL a long-lived bearer credential. $payload = [ 'exp' => time() + 60 * 10, 'civi.file' => (string) $fileId, ]; $fcs = \Civi::service('crypto.jwt')->encode($payload); $url = CRM_Utils_System::url( 'civicrm/file', "reset=1&id={$fileId}&eid={$entityId}&fcs=" . urlencode($fcs), FALSE, NULL, FALSE, TRUE ); CRM_Utils_System::redirect($url); } /** * Check whether the current Civi user can view the entity a file is * linked to. Restricted to the entity types the WebForm-mw staff report * actually surfaces (activity custom-field files and contact custom-field * files); everything else denies. New entity types should be added here * deliberately so we don't accidentally widen the surface. */ private function canViewEntity(string $entityTable, int $entityId): bool { if ($entityId <= 0) { return FALSE; } switch ($entityTable) { case 'civicrm_activity': return CRM_Activity_BAO_Activity::checkPermission( $entityId, CRM_Core_Permission::VIEW ); case 'civicrm_contact': return CRM_Contact_BAO_Contact_Permission::allow( $entityId, CRM_Core_Permission::VIEW ); default: return FALSE; } } }