[PP-1] S3 Access Denied Request has expired - getExternalUrl() cached beyond presign timeout

Created on 11 January 2021, over 3 years ago
Updated 2 September 2024, 15 days ago

Problem/Motivation

Using S3:// schema, files are stored as private on AWS requiring a signed URL to access (avoid bucket discovery), the intent is to keep the timeout short in case permission to access the file is revoked.

Page cache of the getExternalURL() response causes the URL not to be regenerated after the presign timeout causing the S3 request to be denied as expired.

Drupal 9.0.8 for testing. 8.x-3.0-alpha16 with the validScheme patch.

Steps to reproduce

$settings['s3fs.upload_as_private'] = TRUE;
Presigned URLs "60|.*"

Assign a file upload to S3 on a content type.
Upload a new file and publish the content
Visit the node and click the link. This should work in most cases (unless slow to click the link)
Wait 60 seconds for the presign to expire and reload the node,
Node will generate using the same URL and presign as previously which will now return "Access Denied Request has expired" from S3.

If the dynamic_page_cache is disabled S3FS can generate a new presign each page load.

Proposed resolution

1) Publish an expire time for the getExternalURL() function (if possible) that populates up to cause the cache to invalidate.
(Not sure this can be done, I know this can be set at the render level but from this deep I'm not aware of a method)

2) Route all requests for presigned URL's through a route/controller callback similar to how Image Style generation is first done so that the call can be redirected with updated presign signature.

Remaining tasks

User interface changes

API changes

Data model changes

πŸ› Bug report
Status

Postponed

Version

3.0

Component

Code

Created by

πŸ‡ΊπŸ‡ΈUnited States cmlara

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

Not all content is available!

It's likely this issue predates Contrib.social: some issue and comment data are missing.

  • πŸ‡ΊπŸ‡ΈUnited States cmlara

    RE:CCO

    I do not believe so.

    Firstly because there is currently no ability to even provide metadata for caching from a StreamWrapper, meaning there is nothing to bubble up to CCO.

    Secondly:
    IIRC CCO has known problems that can only be solved by Core fixing its metadata handling problems

  • πŸ‡ΊπŸ‡ΈUnited States grasmash

    I found a workaround for this that is good enough for my own needs. Basically, you need to user alter hooks to add cachebility metadata at the entity level. In my case, I wanted to add a max-age to a node when it had a file field with a presigned url that was set to expire.

    This could be simplified. I had two different fields I had to take into account. Posting for posterity:

    /**
     * Implements hook_node_load().
     */
    function mymodule_node_load($entities) {
      $map = [
        // node-type => s3 key.
        'song' => [
          's3_key' => 'songs',
          // We only override the max-age if one of the file fields in actually populated.
          'required_fields' => ['field_song_audio', 'field_song_archive'],
        ],
        'firmware_release' => [
          's3_key' => 'firmware-releases',
          'required_fields' => ['field_firmware_archive'],
        ]
      ];
    
      foreach ($entities as $entity) {
        if ($entity->getEntityTypeId() == 'node' && in_array($entity->getType(), array_keys($map))) {
          if (!shouldOverrideCacheHeaders($map, $entity)) {
            continue;
          }
    
          $config = \Drupal::config('s3fs.settings');
          if ($config->get('presigned_urls')) {
            $presigned_urls = getPresignedUrls($config->get('presigned_urls'));
            $s3_key = $map[$entity->getType()]['s3_key'];
            foreach ($presigned_urls as $blob => $timeout) {
              if (preg_match("^$blob^", $s3_key)) {
                $cachebility_metadata = new CacheableMetadata();
                $cachebility_metadata->setCacheMaxAge((int) $timeout);
                $entity->addCacheableDependency($cachebility_metadata);
                break;
              }
            }
          }
        }
      }
    }
    
    /**
     * @param $map
     * @param $entity
     *
     * @return bool
     */
    function shouldOverrideCacheHeaders($map, $entity): bool {
      foreach ($map[$entity->getType()]['required_fields'] as $field_name) {
        if (!$entity->{$field_name}->isEmpty()) {
          return TRUE;
        }
      }
    
      return FALSE;
    }
    
    /**
     * @param string $presigned_urls_config
     *
     * @return array
     * @see \Drupal\s3fs\StreamWrapper\S3fsStream->__construct())
     */
    function getPresignedUrls(string $presigned_urls_config): array {
      $presigned_urls = [];
      foreach (explode(PHP_EOL, $presigned_urls_config) as $line) {
        $blob = trim($line);
        if ($blob) {
          if (preg_match('/(.*)\|(.*)/', $blob, $matches)) {
            $blob = $matches[2];
            $timeout = $matches[1];
            $presigned_urls[$blob] = $timeout;
          }
          else {
            $presigned_urls[$blob] = 60;
          }
        }
      }
      return $presigned_urls;
    }
    
Production build 0.71.5 2024