CVE-2021-21318 (published 2021-02-18T18:15Z)

Opencast is a free, open-source platform to support the management of educational audio and video content. In Opencast before version 9.2 there is a vulnerability in which publishing an episode with strict access rules will overwrite the currently set series access. This allows for an easy denial of access for all users without superuser privileges, effectively hiding the series. Access to series and series metadata on the search service (shown in media module and player) depends on the events published which are part of the series. Publishing an event will automatically publish a series and update access to it. Removing an event or republishing the event should do the same. Affected versions of Opencast may not update the series access or remove a published series if an event is being removed. On removal of an episode, this may lead to an access control list for series metadata with broader access rules than the merged access rules of all remaining events, or the series metadata still being available although all episodes of that series have been removed. This problem is fixed in Opencast 9.2.

Severity: MEDIUM
CVSS v2 base score: 5.5 (AV:N/AC:L/Au:S/C:N/I:P/A:P)
CVSS v3 base score: 5.4, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L)

Weakness information:
cwe_id: CWE-863
cwe_name: Incorrect Authorization
description: The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. This allows attackers to bypass intended access restrictions.
extended_description: 
            Assuming a user with a given identity, authorization is the process of determining whether that user can access a given resource, based on the user's privileges and any permissions or other access-control specifications that apply to the resource.
            When access control checks are incorrectly applied, users are able to access data or perform actions that they should not be allowed to perform. This can lead to a wide range of problems, including information exposures, denial of service, and arbitrary code execution.
         
         

Fix information:
repo_url: https://github.com/opencast/opencast
commit_details: repo_url: https://github.com/opencast/opencast
author: Lars Kiesow
author_date: 2021-01-17 16:23:25+01:00
msg: Fix Engage Series Publication and Access

Access to series and series metadata on the search service (shown in
media module and player) depends on the events published which are part
of the series. Publishing an event will automatically publish a series
and update access to it. Removing an event or republishing the event
should do the same.

Incorrectly Hiding Public Series
--------------------------------

This patch fixes the access control update to the series when a new
episode is being published. Until now, a new episode publication would
always update the series access with the episode access.

While this is no security issue since it can only cause the access to be
stricter, it may cause public series to become private. This would
happen, for example, if a users sets one episode of a series to private
and republishes the episode.

Now, the search service will merge the access control lists of all
episodes to grant access based on their combined access rules.

Update Series on Removal
------------------------

This patch fixes Opencast not updating the series access or remove a
published series if an event is being removed.

This means that access to a series is re-calculated when an episode is
being deleted based on the remaining published episodes in the series.
For example, removing the last episode with public access will now make
the series private which it would have stayed public before.

It also means that if the last episode of a series is being removed, the
series itself will be unpublished as well, so no empty series will
continue to show up any longer.
repository: repo_url: https://github.com/opencast/opencast
repo_name: opencast/opencast
description: The free and open source solution for automated video capture and distribution at scale.
repo_language: Java
owner: opencast
file_changes: filename: SearchServiceImpl.java
change_type: ModificationType.MODIFY
diff: @@ -373,28 +373,32 @@ public final class SearchServiceImpl extends AbstractJobProducer implements Sear
    *          the media package
    * @throws SearchException
    *           if the media package cannot be added to the search index
-   * @throws MediaPackageException
-   *           if the mediapckage is invalid
    * @throws IllegalArgumentException
    *           if the mediapackage is <code>null</code>
    * @throws UnauthorizedException
    *           if the user does not have the rights to add the mediapackage
    */
-  public void addSynchronously(MediaPackage mediaPackage) throws SearchException, MediaPackageException,
-          IllegalArgumentException, UnauthorizedException {
+  public void addSynchronously(MediaPackage mediaPackage)
+      throws SearchException, IllegalArgumentException, UnauthorizedException, NotFoundException,
+      SearchServiceDatabaseException {
     if (mediaPackage == null) {
       throw new IllegalArgumentException("Unable to add a null mediapackage");
     }
-    logger.debug("Attempting to add mediapackage {} to search index", mediaPackage.getIdentifier());
+    final String mediaPackageId = mediaPackage.getIdentifier().toString();
+    logger.debug("Attempting to add media package {} to search index", mediaPackageId);
     AccessControlList acl = authorizationService.getActiveAcl(mediaPackage).getA();
 
+    AccessControlList seriesAcl = persistence.getAccessControlLists(mediaPackage.getSeries(), mediaPackageId).stream()
+        .reduce(new AccessControlList(acl.getEntries()), AccessControlList::mergeActions);
+    logger.debug("Updating series with merged access control list: {}", seriesAcl);
+
     Date now = new Date();
 
     try {
-      if (indexManager.add(mediaPackage, acl, now)) {
-        logger.info("Added mediapackage `{}` to the search index, using ACL `{}`", mediaPackage, acl);
+      if (indexManager.add(mediaPackage, acl, seriesAcl, now)) {
+        logger.info("Added media package `{}` to the search index, using ACL `{}`", mediaPackageId, acl);
       } else {
-        logger.warn("Failed to add mediapackage {} to the search index", mediaPackage.getIdentifier());
+        logger.warn("Failed to add media package {} to the search index", mediaPackageId);
       }
     } catch (SolrServerException e) {
       throw new SearchException(e);
@@ -403,8 +407,8 @@ public final class SearchServiceImpl extends AbstractJobProducer implements Sear
     try {
       persistence.storeMediaPackage(mediaPackage, acl, now);
     } catch (SearchServiceDatabaseException e) {
-      logger.error("Could not store media package to search database {}: {}", mediaPackage.getIdentifier(), e);
-      throw new SearchException(e);
+      throw new SearchException(
+          String.format("Could not store media package to search database %s", mediaPackageId), e);
     }
   }
 
@@ -429,13 +433,8 @@ public final class SearchServiceImpl extends AbstractJobProducer implements Sear
    * @return <code>true</code> if the mediapackage was deleted
    * @throws SearchException
    *           if deletion failed
-   * @throws UnauthorizedException
-   *           if the user did not have access to the media package
-   * @throws NotFoundException
-   *           if the mediapackage did not exist
    */
-  public boolean deleteSynchronously(String mediaPackageId) throws SearchException, UnauthorizedException,
-          NotFoundException {
+  public boolean deleteSynchronously(final String mediaPackageId) throws SearchException {
     SearchResult result;
     try {
       result = solrRequester.getForWrite(new SearchQuery().withId(mediaPackageId));
@@ -445,7 +444,8 @@ public final class SearchServiceImpl extends AbstractJobProducer implements Sear
                 mediaPackageId);
         return false;
       }
-      logger.info("Removing mediapackage {} from search index", mediaPackageId);
+      final String seriesId = result.getItems()[0].getDcIsPartOf();
+      logger.info("Removing media package {} from search index", mediaPackageId);
 
       Date now = new Date();
       try {
@@ -460,8 +460,24 @@ public final class SearchServiceImpl extends AbstractJobProducer implements Sear
         throw new SearchException(e);
       }
 
-      return indexManager.delete(mediaPackageId, now);
-    } catch (SolrServerException e) {
+      final boolean success = indexManager.delete(mediaPackageId, now);
+
+      // Update series
+      if (seriesId != null) {
+        if (persistence.getMediaPackages(seriesId).size() > 0) {
+          // Update series acl if there are still episodes in the series
+          final AccessControlList seriesAcl = persistence.getAccessControlLists(seriesId).stream()
+              .reduce(new AccessControlList(), AccessControlList::mergeActions);
+          indexManager.addSeries(seriesId, seriesAcl);
+
+        } else {
+          // Remove series if there are no episodes in the series any longer
+          indexManager.delete(seriesId, now);
+        }
+      }
+
+      return success;
+    } catch (SolrServerException | SearchServiceDatabaseException e) {
       logger.info("Could not delete media package with id {} from search index", mediaPackageId);
       throw new SearchException(e);
     }

programming_language: Java
filename: SearchEntity.java
change_type: ModificationType.MODIFY
diff: @@ -50,7 +50,8 @@ import javax.persistence.TemporalType;
     @NamedQuery(name = "Search.findAll", query = "SELECT s FROM SearchEntity s"),
     @NamedQuery(name = "Search.getCount", query = "SELECT COUNT(s) FROM SearchEntity s"),
     @NamedQuery(name = "Search.findById", query = "SELECT s FROM SearchEntity s WHERE s.mediaPackageId=:mediaPackageId"),
-    @NamedQuery(name = "Search.findBySeriesId", query = "SELECT s FROM SearchEntity s WHERE s.seriesId=:seriesId"),
+    @NamedQuery(name = "Search.findBySeriesId", query = "SELECT s FROM SearchEntity s WHERE s.seriesId=:seriesId and "
+        + "s.deletionDate is null"),
     @NamedQuery(name = "Search.getNoSeries", query = "SELECT s FROM SearchEntity s WHERE s.seriesId IS NULL")})
 public class SearchEntity {
 

programming_language: Java
filename: SearchServiceDatabase.java
change_type: ModificationType.MODIFY
diff: @@ -27,6 +27,7 @@ import org.opencastproject.security.api.UnauthorizedException;
 import org.opencastproject.util.NotFoundException;
 import org.opencastproject.util.data.Tuple;
 
+import java.util.Collection;
 import java.util.Date;
 import java.util.Iterator;
 
@@ -81,7 +82,18 @@ public interface SearchServiceDatabase {
   MediaPackage getMediaPackage(String mediaPackageId) throws NotFoundException, SearchServiceDatabaseException;
 
   /**
-   * Retrieves ACL for series with given ID.
+   * Gets media packages from a specific series
+   *
+   * @param seriesId
+   *          the series identifier
+   * @return collection of media packages
+   * @throws SearchServiceDatabaseException
+   *           if there is a problem communicating with the underlying data store
+   */
+  Collection<MediaPackage> getMediaPackages(String seriesId) throws SearchServiceDatabaseException;
+
+  /**
+   * Retrieves ACL for episode with given ID.
    *
    * @param mediaPackageId
    *          media package for which ACL will be retrieved
@@ -92,7 +104,21 @@ public interface SearchServiceDatabase {
    *           if exception occurred
    */
   AccessControlList getAccessControlList(String mediaPackageId) throws NotFoundException,
-          SearchServiceDatabaseException;
+      SearchServiceDatabaseException;
+
+  /**
+   * Retrieves ACLs for series with given ID.
+   *
+   * @param seriesId
+   *          series identifier for which ACL will be retrieved
+   * @param excludeIds
+   *          list of media package identifier to exclude from the list
+   * @return Collection of {@link AccessControlList} of media packages from the series
+   * @throws SearchServiceDatabaseException
+   *           if exception occurred
+   */
+  Collection<AccessControlList> getAccessControlLists(String seriesId, String ... excludeIds)
+      throws SearchServiceDatabaseException;
 
   /**
    * Returns the modification date from the selected media package.

programming_language: Java
filename: SearchServiceDatabaseImpl.java
change_type: ModificationType.MODIFY
diff: @@ -26,9 +26,11 @@ import static org.opencastproject.security.api.Permissions.Action.READ;
 import static org.opencastproject.security.api.Permissions.Action.WRITE;
 
 import org.opencastproject.mediapackage.MediaPackage;
+import org.opencastproject.mediapackage.MediaPackageException;
 import org.opencastproject.mediapackage.MediaPackageParser;
 import org.opencastproject.security.api.AccessControlList;
 import org.opencastproject.security.api.AccessControlParser;
+import org.opencastproject.security.api.AccessControlParsingException;
 import org.opencastproject.security.api.AccessControlUtil;
 import org.opencastproject.security.api.Organization;
 import org.opencastproject.security.api.SecurityService;
@@ -42,6 +44,10 @@ import org.osgi.service.component.ComponentContext;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
 import java.util.Date;
 import java.util.Iterator;
 import java.util.LinkedList;
@@ -250,13 +256,66 @@ public class SearchServiceDatabaseImpl implements SearchServiceDatabase {
     } catch (NotFoundException e) {
       throw e;
     } catch (Exception e) {
-      logger.error("Could not retrieve ACL {}: {}", mediaPackageId, e.getMessage());
+      logger.error("Could not retrieve ACL {}", mediaPackageId, e);
       throw new SearchServiceDatabaseException(e);
     } finally {
       em.close();
     }
   }
 
+  /**
+   * {@inheritDoc}
+   *
+   * @see org.opencastproject.search.impl.persistence.SearchServiceDatabase#getAccessControlLists(String, String...)
+   */
+  @Override
+  public Collection<AccessControlList> getAccessControlLists(final String seriesId, String ... excludeIds)
+      throws SearchServiceDatabaseException {
+    List<String> excludes = Arrays.asList(excludeIds);
+    List<AccessControlList> accessControlLists = new ArrayList<>();
+    EntityManager em = emf.createEntityManager();
+    TypedQuery<SearchEntity> q = em.createNamedQuery("Search.findBySeriesId", SearchEntity.class)
+        .setParameter("seriesId", seriesId);
+    try {
+      for (SearchEntity entity: q.getResultList()) {
+        if (entity.getAccessControl() != null && !excludes.contains(entity.getMediaPackageId())) {
+          accessControlLists.add(AccessControlParser.parseAcl(entity.getAccessControl()));
+        }
+      }
+    } catch (IOException | AccessControlParsingException e) {
+      throw new SearchServiceDatabaseException(e);
+    } finally {
+      em.close();
+    }
+    return accessControlLists;
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * @see org.opencastproject.search.impl.persistence.SearchServiceDatabase#getMediaPackages(String)
+   */
+  @Override
+  public Collection<MediaPackage> getMediaPackages(final String seriesId)
+      throws SearchServiceDatabaseException {
+    List<MediaPackage> episodes = new ArrayList<>();
+    EntityManager em = emf.createEntityManager();
+    TypedQuery<SearchEntity> q = em.createNamedQuery("Search.findBySeriesId", SearchEntity.class)
+        .setParameter("seriesId", seriesId);
+    try {
+      for (SearchEntity entity: q.getResultList()) {
+        if (entity.getMediaPackageXML() != null) {
+          episodes.add(MediaPackageParser.getFromXml(entity.getMediaPackageXML()));
+        }
+      }
+    } catch (MediaPackageException e) {
+      throw new SearchServiceDatabaseException(e);
+    } finally {
+      em.close();
+    }
+    return episodes;
+  }
+
   /**
    * {@inheritDoc}
    *

programming_language: Java
filename: SolrIndexManager.java
change_type: ModificationType.MODIFY
diff: @@ -273,32 +273,47 @@ public class SolrIndexManager {
    * @throws SolrServerException
    *           if an errors occurs while talking to solr
    */
-  public boolean add(MediaPackage sourceMediaPackage, AccessControlList acl, Date now) throws SolrServerException,
-          UnauthorizedException {
+  public boolean add(MediaPackage sourceMediaPackage, AccessControlList acl, AccessControlList seriesAcl, Date now)
+      throws SolrServerException, UnauthorizedException {
     try {
       SolrInputDocument episodeDocument = createEpisodeInputDocument(sourceMediaPackage, acl);
       Schema.setOcModified(episodeDocument, now);
 
-      SolrInputDocument seriesDocument = createSeriesInputDocument(sourceMediaPackage.getSeries(), acl);
+      SolrInputDocument seriesDocument = createSeriesInputDocument(sourceMediaPackage.getSeries(), seriesAcl);
       if (seriesDocument != null)
         Schema.enrich(episodeDocument, seriesDocument);
 
-      // If neither an episode nor a series was contained, there is no point in trying to update
-      if (episodeDocument == null && seriesDocument == null) {
-        logger.warn("Neither episode nor series metadata found");
-        return false;
-      }
-
       // Post everything to the search index
-      if (episodeDocument != null)
-        solrServer.add(episodeDocument);
+      solrServer.add(episodeDocument);
       if (seriesDocument != null)
         solrServer.add(seriesDocument);
       solrServer.commit();
       return true;
     } catch (Exception e) {
-      logger.error("Unable to add mediapackage {} to index", sourceMediaPackage.getIdentifier());
-      throw new SolrServerException(e);
+      throw new SolrServerException(
+          String.format("Unable to add media package %s to index", sourceMediaPackage.getIdentifier()), e);
+    }
+  }
+
+  /**
+   * Posts a series to Solr. If the entry already exists, this will update the series.
+   *
+   * @param seriesId
+   *          the series to post
+   * @param acl
+   *          the access control list for this series
+   * @throws SolrServerException
+   *           if an errors occurs while talking to solr
+   */
+  public void addSeries(final String seriesId, final AccessControlList acl) throws SolrServerException {
+    try {
+      SolrInputDocument seriesDocument = createSeriesInputDocument(seriesId, acl);
+      if (seriesDocument != null) {
+        solrServer.add(seriesDocument);
+        solrServer.commit();
+      }
+    } catch (Exception e) {
+      throw new SolrServerException(String.format("Unable to add series %s to index", seriesId), e);
     }
   }
 

programming_language: Java