Uploaded image for project: 'Project Quay'
  1. Project Quay
  2. PROJQUAY-10499

Immutable tags can have expiration dates set via API (logical contradiction and data loss risk)

XMLWordPrintable

    • Icon: Bug Bug
    • Resolution: Unresolved
    • Icon: Critical Critical
    • None
    • quay-v3.17.0
    • quay

      Problem Statement

      Immutable tags (marked with lock icon) can have expiration dates, creating a logical contradiction:

      • Immutable = Tag cannot be deleted or modified (permanent)
      • Expiration = Tag will be auto-deleted after expiration time

      These properties are mutually exclusive but the system currently allows both to be set simultaneously.

      Quay Build: quay.io/redhat-user-workloads/quay-eng-tenant/stable-3-17-v4-20@sha256:ebd38d2e196a047d662be9e5c053b92a2606ffea1e16edde4d8c23ef111fd32dĀ 

      Visual Evidence

      Tag stable in repository quayqe/demo shows:

      • šŸ”’ Lock icon (immutable)
      • ā° "9 hours" expiration warning


      Root Cause Analysis

      Frontend (UI) - āœ… CORRECT

      The new React UI correctly disables the "Change expiration" dropdown action for immutable tags.

      File: web/src/routes/RepositoryDetails/Tags/TagsActions.tsx:59-64

      Backend API - āŒ MISSING VALIDATION

      File: endpoints/api/tag.py:217-252

      The PUT /api/v1/repository/{namespace}/{repository}/tag/{tag} endpoint processes "expiration" field without checking if tag is immutable.

      Database Layer - āŒ MISSING VALIDATION

      File: data/model/oci/tag.py:651-679

      The change_tag_expiration() function does not validate immutability.


      How This Bug Occurs

      Scenario 1: Expiration Set Before Immutability

      1. Tag created with expiration (e.g., 30 days)
      2. Immutability policy applied or tag manually made immutable
      3. Tag now has BOTH properties (contradictory state)

      Scenario 2: Direct API Bypass

      1. Tag made immutable via UI or policy
      2. User calls API directly with "expiration": <timestamp>
      3. Backend accepts request without validation
      4. Tag now has contradictory state

      Impact Assessment

      Severity: MAJOR (Potentially CRITICAL)

      Data Loss Risk

      • If garbage collection worker honors expiration over immutability, production images may be auto-deleted
      • Violates immutability guarantee

      Compliance Risk

      • Immutable tags often used for compliance (audit trails, signed releases, regulatory requirements)
      • Unexpected deletion could violate SOC2, HIPAA, or other compliance standards

      Security Risk

      • Immutability is a security feature (prevents tampering)
      • Circumventing it via expiration could enable supply chain attacks

      Steps to Reproduce

      Method 1: UI Workflow

      1. Create repository and push tag with expiration (e.g., 7 days)
      2. Create immutability policy for tag pattern
      3. Tag now shows lock icon AND expiration date (contradiction!)

      Method 2: API Direct Call

      1. Make tag immutable
      2. Call API: PUT /api/v1/repository/test/demo/tag/v1.0.0 with "expiration": 1739000000
      3. Tag now has both immutable=true AND lifetime_end_ms set

      Expected Behavior

      • API should return HTTP 400 with error: "Cannot change expiration on immutable tag"
      • Database should enforce mutual exclusivity

      Actual Behavior

      • API accepts expiration change on immutable tags (bug)
      • Database allows contradictory state (bug)

      Affected Code Files

      Backend API:

      • endpoints/api/tag.py - Lines 217-252 (expiration handling)

      Data Layer:

      • data/model/oci/tag.py - Lines 651-679 (change_tag_expiration)
      • data/model/oci/tag.py - set_tag_immutable function

      Frontend (Already Correct):

      • web/src/routes/RepositoryDetails/Tags/TagsActions.tsx - Lines 59-64

      Recommended Fixes

      Fix 1: Backend API Validation (CRITICAL)

      Add validation in endpoints/api/tag.py:217:

      if "expiration" in request.get_json():
          tag_ref = registry_model.get_repo_tag(repo_ref, tag)
      
          # āœ… ADD VALIDATION
          if features.IMMUTABLE_TAGS and tag_ref.immutable:
              raise InvalidRequest(
                  "Cannot change expiration on immutable tag."
              )
      

      Fix 2: Database Layer Validation (CRITICAL)

      Add validation in data/model/oci/tag.py:change_tag_expiration:

      def change_tag_expiration(tag_id, expiration_datetime):
          tag = Tag.get(id=tag_id)
      
          # āœ… ADD VALIDATION
          if features.IMMUTABLE_TAGS and tag.immutable:
              raise ImmutableTagException(tag.name, "change_expiration", tag.repository_id)
      

      Add reverse validation in set_tag_immutable:

      def set_tag_immutable(repository_id, tag_name, immutable):
          tag = get_tag(repository_id, tag_name)
      
          # āœ… ADD VALIDATION
          if immutable and tag.lifetime_end_ms is not None:
              raise InvalidRequest(
                  "Cannot make tag immutable while it has expiration."
              )
      

      Fix 3: Data Migration (HIGH)

      Clean up existing conflicting tags:

      def fix_immutable_tags_with_expiration():
          conflicting_tags = Tag.select().where(
              Tag.immutable == True,
              Tag.lifetime_end_ms.is_null(False)
          )
      
          for tag in conflicting_tags:
              # Remove expiration (immutability takes precedence)
              Tag.update(lifetime_end_ms=None).where(Tag.id == tag.id).execute()
      

      Testing Requirements

      Unit Tests

      def test_cannot_set_expiration_on_immutable_tag():
          tag = create_tag(immutable=True)
          with pytest.raises(ImmutableTagException):
              change_tag_expiration(tag.id, expiration_date)
      
      def test_cannot_make_tag_with_expiration_immutable():
          tag = create_tag_with_expiration()
          with pytest.raises(InvalidRequest):
              set_tag_immutable(repo.id, tag.name, immutable=True)
      

      API Tests

      def test_api_rejects_expiration_on_immutable_tag():
          response = put_tag_expiration(immutable_tag, expiration)
          assert response.status_code == 400
      

      Database Query to Find Affected Tags

      SELECT n.username, r.name, t.name, t.immutable,
             to_timestamp(t.lifetime_end_ms / 1000) as expires_at
      FROM tag t
      JOIN repository r ON t.repository = r.id
      JOIN namespace n ON r.namespace_user = n.id
      WHERE t.immutable = true
        AND t.lifetime_end_ms IS NOT NULL
        AND t.lifetime_end_ms > (EXTRACT(EPOCH FROM NOW()) * 1000);
      

      Workaround

      Until fixed, manually remove expiration from immutable tags:

      # Remove immutability
      curl -X PUT ".../tag/stable" -d '{"immutable": false}'
      
      # Remove expiration
      curl -X PUT ".../tag/stable" -d '{"expiration": null}'
      
      # Re-apply immutability
      curl -X PUT ".../tag/stable" -d '{"immutable": true}'
      

      Acceptance Criteria

      • API returns HTTP 400 when setting expiration on immutable tag
      • API returns HTTP 400 when making tag with expiration immutable
      • Database raises ImmutableTagException for invalid operations
      • Existing conflicting tags cleaned up via migration
      • Unit tests and API tests added
      • No tags have contradictory state after deployment

      Generated by: Claude Code Automated Bug Analysis
      Discovery: Manual testing + Screenshot analysis

              rhn-support-bpratt Brady Pratt
              lzha1981 luffy zhang
              Votes:
              0 Vote for this issue
              Watchers:
              1 Start watching this issue

                Created:
                Updated: