Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3e3e7e7
Introduce `CourseContextRoleListener` to dynamically assign contextua…
AngelFQC May 25, 2026
842806e
Refactor `Voter` classes to remove dynamic role assignments from user…
AngelFQC May 25, 2026
f08b577
Security: Restrict document access to course-specific roles (`ROLE_CU…
AngelFQC May 25, 2026
5ac25ea
Security: Add regression tests for CDocument API to prevent unauthori…
AngelFQC May 26, 2026
867481a
Security: Enforce course-teacher validation for `resourceLinkList` in…
AngelFQC May 26, 2026
a48a955
Introduce `OptionalCourseLinkFilter` to allow optional `cid` handling…
AngelFQC May 26, 2026
06aade9
Replace manual `cid`, `sid`, and `gid` handling with `CidReqHelper` f…
AngelFQC May 26, 2026
d423ad3
Enforce contextual course security roles and improve attendance API h…
AngelFQC May 26, 2026
47873e5
Security: Enforce contextual validation of `resourceLinkList` in `Cre…
AngelFQC May 26, 2026
6c45d8b
Security: Glossary: Enforce contextual course roles on API operations
AngelFQC May 30, 2026
ab9be5b
Security: Link: Enforce contextual course roles on API operations
AngelFQC May 30, 2026
5b101b7
Security: LinkCategory: Enforce contextual course roles on API operat…
AngelFQC May 30, 2026
55b76ae
Security: LearningPathCategory: Enforce contextual course roles on AP…
AngelFQC May 30, 2026
2f51513
Security: LearningPath: Enforce contextual course roles on API operat…
AngelFQC May 30, 2026
11d3803
Security: StudentPublication: Enforce contextual course roles on API …
AngelFQC May 30, 2026
c793ff1
Security: StudentPublicationComment: Enforce contextual course roles
AngelFQC May 30, 2026
2a4bffc
Security: ToolIntro: Enforce contextual read role on GetCollection
AngelFQC May 30, 2026
1aa7ff8
Security: BlogPost: Enforce contextual read role on GetCollection
AngelFQC May 30, 2026
c513651
Security: BlogComment: Enforce contextual read role on GetCollection
AngelFQC May 30, 2026
04728a1
Security: BlogTask: Enforce contextual/object-level roles on API oper…
AngelFQC May 30, 2026
2693889
Security: BlogRelUser: Enforce contextual read role on GetCollection
AngelFQC May 30, 2026
9a2b19d
Security: BlogAttachment: Enforce contextual/object-level roles
AngelFQC May 30, 2026
1b34769
Security: BlogRating: Enforce contextual read role at resource level
AngelFQC May 30, 2026
1ac0c9f
Security: BlogTaskRelUser: Enforce contextual/object-level roles
AngelFQC May 30, 2026
bb2d81e
Security: StudentPublicationCorrection: Enforce contextual teacher roles
AngelFQC May 30, 2026
6b0aa1d
Security: StudentPublicationRelDocument: Enforce contextual/object-le…
AngelFQC May 30, 2026
2b74dea
Security: StudentPublicationRelUser: Enforce contextual/object-level …
AngelFQC May 30, 2026
9e71c25
Security: Tool: Enforce contextual read role on GetCollection
AngelFQC May 30, 2026
7819071
Security: Group: Enforce contextual read role on GetCollection
AngelFQC May 30, 2026
90884ae
Security: DropboxFile: Enforce contextual roles on upload
AngelFQC May 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions assets/vue/services/attendanceService.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,12 @@ export default {
},

/**
* Creates a new attendance list.
* @param {Object} data - Data for the new attendance list.
* Creates a new attendance list. The course context is mirrored from the
* request body to the query string so CidReqListener can resolve it and
* CourseContextRoleListener can publish the contextual TEACHER role that
* the operation requires.
*
* @param {Object} data - Data for the new attendance list (includes cid, sid).
* @returns {Promise<Object>} - Data of the created attendance list.
*/
createAttendance: async (data) => {
Expand All @@ -66,8 +70,13 @@ export default {
},

/**
* Deletes an attendance list.
* Deletes an attendance list. The course context (cid, optional sid/gid)
* must be supplied as query parameters so CidReqListener can resolve the
* course and CourseContextRoleListener can publish the contextual TEACHER
* role that the operation requires.
*
* @param {Number|String} attendanceId - ID of the attendance list.
* @param {{cid: Number|String, sid?: Number|String, gid?: Number|String}} context
* @returns {Promise<Object>} - Result of the deletion.
*/
deleteAttendance: async (attendanceId) => {
Expand Down
50 changes: 50 additions & 0 deletions src/CoreBundle/Controller/Api/CreateCBlogAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

#[AsController]
class CreateCBlogAction extends BaseResourceFileAction
Expand All @@ -42,6 +43,12 @@ public function __invoke(
$resourceLinkList = \is_array($resourceLinkListRaw) ? $resourceLinkListRaw : [];
}

// The `cid` (and optional `sid`/`gid`) query parameter establishes the
// course context that gated the security expression. Any
// resourceLinkList entry that points to a different context would
// bypass that gate, so we reject the request outright.
$this->assertResourceLinkListMatchesQueryContext($request, $resourceLinkList, $security);

$blog = (new CBlog())
->setTitle($title)
->setBlogSubtitle($subtitle)
Expand Down Expand Up @@ -89,4 +96,47 @@ private function handleShortcutCreation(
$shortcutRepository->addShortCut($blog, $currentUser, $course, $session);
}
}

/**
* @param array<int, mixed> $resourceLinkList
*/
private function assertResourceLinkListMatchesQueryContext(
Request $request,
array $resourceLinkList,
Security $security,
): void {
if ($security->isGranted('ROLE_ADMIN')) {
return;
}

if ([] === $resourceLinkList) {
return;
}

$queryCid = (int) $request->query->get('cid');
$querySid = (int) $request->query->get('sid');
$queryGid = (int) $request->query->get('gid');

foreach ($resourceLinkList as $entry) {
if (!\is_array($entry)) {
continue;
}

$entryCid = (int) ($entry['cid'] ?? 0);
$entrySid = (int) ($entry['sid'] ?? 0);
$entryGid = (int) ($entry['gid'] ?? 0);

if ($entryCid > 0 && $entryCid !== $queryCid) {
throw new AccessDeniedHttpException('resourceLinkList course does not match the request context.');
}

if ($entrySid > 0 && $entrySid !== $querySid) {
throw new AccessDeniedHttpException('resourceLinkList session does not match the request context.');
}

if ($entryGid > 0 && $entryGid !== $queryGid) {
throw new AccessDeniedHttpException('resourceLinkList group does not match the request context.');
}
}
}
}
71 changes: 71 additions & 0 deletions src/CoreBundle/Controller/Api/CreateDocumentFileAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@

namespace Chamilo\CoreBundle\Controller\Api;

use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Helpers\AiDisclosureHelper;
use Chamilo\CoreBundle\Helpers\CourseHelper;
use Chamilo\CoreBundle\Repository\Node\CourseRepository;
use Chamilo\CourseBundle\Entity\CDocument;
use Chamilo\CourseBundle\Repository\CDocumentRepository;
use Doctrine\ORM\EntityManager;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
Expand Down Expand Up @@ -48,7 +51,14 @@ public function __invoke(
CourseRepository $courseRepository,
CourseHelper $courseHelper,
AiDisclosureHelper $aiDisclosureHelper,
Security $security,
): CDocument {
// Reject any resourceLinkList entry pointing to a course where the
// caller is not a teacher (admins bypass). The operation-level
// security expression only validates the role for the query `cid`,
// so the body could otherwise target a foreign course (IDOR).
$this->assertUserCanLinkToCourses($request, $security, $courseRepository);

$isUncompressZipEnabled = (string) $request->get('isUncompressZipEnabled', 'false');
$fileExistsOption = (string) $request->get('fileExistsOption', 'rename');
$aiAssistedRaw = strtolower(trim((string) $request->get('ai_assisted', '')));
Expand Down Expand Up @@ -153,4 +163,65 @@ private function isAllowedCloudLinkHost(string $host): bool

return false;
}

private function assertUserCanLinkToCourses(
Request $request,
Security $security,
CourseRepository $courseRepository,
): void {
$user = $security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}

if ($security->isGranted('ROLE_ADMIN')) {
return;
}

$resourceLinkList = $this->extractResourceLinkList($request);
foreach ($resourceLinkList as $entry) {
if (!\is_array($entry)) {
continue;
}

$cid = (int) ($entry['cid'] ?? 0);
if ($cid <= 0) {
continue;
}

$course = $courseRepository->find($cid);
if (null === $course || !$course->hasUserAsTeacher($user)) {
throw new AccessDeniedHttpException('You are not a teacher of one of the referenced courses.');
}
}
}

/**
* @return array<int, mixed>
*/
private function extractResourceLinkList(Request $request): array
{
$raw = (string) $request->getContent();
if ('' !== $raw) {
$decoded = json_decode($raw, true);
if (\is_array($decoded) && isset($decoded['resourceLinkList']) && \is_array($decoded['resourceLinkList'])) {
return $decoded['resourceLinkList'];
}
}

$fromForm = $request->get('resourceLinkList', []);
if (\is_array($fromForm)) {
return $fromForm;
}

if (\is_string($fromForm) && '' !== $fromForm) {
$normalized = str_contains($fromForm, '[') ? $fromForm : '['.$fromForm.']';
$decoded = json_decode($normalized, true);
if (\is_array($decoded)) {
return $decoded;
}
}

return [];
}
}
111 changes: 111 additions & 0 deletions src/CoreBundle/EventListener/CourseContextRoleListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

/* For licensing terms, see /license.txt */

declare(strict_types=1);

namespace Chamilo\CoreBundle\EventListener;

use Chamilo\CoreBundle\Entity\Course;
use Chamilo\CoreBundle\Entity\Session;
use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Security\CourseAccessResolver;
use Chamilo\CourseBundle\Entity\CGroup;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken;

/**
* Publishes the contextual ROLE_CURRENT_COURSE_* roles for the authenticated
* user once CidReqListener has resolved the course/session/group context.
*
* The roles are exposed in two places so that every consumer keeps working:
* - User::$temporaryRoles, so ResourceNodeVoter::hasContextRole() and any
* code reading $user->getRoles() continues to see them.
* - The security token's roleNames, so expressions such as
* `security: "is_granted('ROLE_CURRENT_COURSE_STUDENT')"` on
* API Platform operations resolve correctly (AbstractToken::getRoleNames()
* freezes the role list at token-creation time).
*
* Must run with a kernel.request priority lower than CidReqListener (priority 6)
* so the course/session/group is already in the session by the time we look it up.
*/
final class CourseContextRoleListener
{
public function __construct(
private readonly TokenStorageInterface $tokenStorage,
private readonly CourseAccessResolver $resolver,
) {}

public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}

$request = $event->getRequest();
if (!$request->hasSession()) {
return;
}

$token = $this->tokenStorage->getToken();
if (null === $token) {
return;
}

$user = $token->getUser();
if (!$user instanceof User) {
return;
}

$sessionHandler = $request->getSession();
$course = $sessionHandler->get('course');
$courseSession = $sessionHandler->get('session');
$group = $sessionHandler->get('group');

$contextRoles = [];
if ($course instanceof Course) {
$contextRoles = $this->resolver->resolveCourseRoles(
$user,
$course,
$courseSession instanceof Session ? $courseSession : null,
);

if ($group instanceof CGroup) {
$contextRoles = array_merge(
$contextRoles,
$this->resolver->resolveGroupRoles($user, $course, $group),
);
}
}

$contextRoles = array_values(array_unique($contextRoles));

$existingRoleNames = $token->getRoleNames();
$hasStaleContextRoles = [] !== array_intersect($existingRoleNames, User::CONTEXT_ROLES);

// Nothing to sync: no current context and no stale roles to strip.
if ([] === $contextRoles && !$hasStaleContextRoles) {
return;
}

// Mirror the context roles into the User's temporary roles so that any
// legacy code reading $user->getRoles() observes the same state.
foreach ($contextRoles as $role) {
$user->addTemporaryRole($role);
}

$persistedRoleNames = array_values(array_diff($existingRoleNames, User::CONTEXT_ROLES));
$desiredRoleNames = array_values(array_unique(array_merge($persistedRoleNames, $contextRoles)));

$firewallName = method_exists($token, 'getFirewallName')
? (string) $token->getFirewallName()
: 'main';

if ('' === $firewallName) {
$firewallName = 'main';
}

$this->tokenStorage->setToken(new PostAuthenticationToken($user, $firewallName, $desiredRoleNames));
}
}
9 changes: 7 additions & 2 deletions src/CoreBundle/Filter/CidFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ public function getDescription(string $resourceClass): array
return [
'cid' => [
'property' => null,
'type' => 'int',
'required' => false,
'type' => 'string',
'required' => $this->isRequired(),
'description' => 'Course identifier',
],
];
Expand Down Expand Up @@ -76,4 +76,9 @@ protected function filterProperty(
->setParameter('course', $course->getId())
;
}

public function isRequired(): bool
{
return true;
}
}
15 changes: 15 additions & 0 deletions src/CoreBundle/Filter/OptionalCourseLinkFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

/* For licensing terms, see /license.txt */

declare(strict_types=1);

namespace Chamilo\CoreBundle\Filter;

class OptionalCourseLinkFilter extends CidFilter
{
public function isRequired(): bool
{
return false;
}
}
7 changes: 7 additions & 0 deletions src/CoreBundle/Resources/config/listeners.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ services:
- {name: kernel.event_listener, event: kernel.request, method: onKernelRequest, priority: 6}
- {name: kernel.event_listener, event: kernel.controller, method: onKernelController}

# Publishes ROLE_CURRENT_COURSE_* on the token after CidReqListener (priority 6)
# has resolved the cid/sid/gid context. Must run with a lower priority so the
# course, session and group are already available in the request session.
Chamilo\CoreBundle\EventListener\CourseContextRoleListener:
tags:
- {name: kernel.event_listener, event: kernel.request, method: onKernelRequest, priority: 5}

# Sets the user access in a course listener
Chamilo\CoreBundle\EventListener\CourseAccessListener:
tags:
Expand Down
Loading
Loading