Drop Pipeline Security Audit

Comprehensive security audit of the Phenom Drop media submission pipeline — findings, fail-safe scenarios, and remediation status.

Executive Summary

A comprehensive security audit of the Phenom Drop media submission pipeline was conducted on 2026-03-09 by 5 specialized audit agents covering infrastructure, video processing, forensics/integrity, legal/compliance, and exploit scenario validation.

Overall Pipeline Status: GREEN — All 4 fail-safe scenarios pass. All critical, high, and medium findings have been remediated and deployed.

ScenarioVerdictKey Finding
Reverse shell in disguised .mp4GREENFiles land inert in S3 — no execution surface. Magic byte detection rejects non-media types.
Unauthenticated raw file accessGREENBoth S3 buckets block public access. API Gateway requires Secrets Manager password.
Chain of custody (hash log)GREENSHA-256 hash embedded in S3 metadata at URL generation, verified against uploaded bytes by file-validator.
GPS leak via public galleryGREENmetadata-stripper Lambda creates EXIF/GPS-free copy at public/ prefix. Original preserved for archival.

Findings Summary

SeverityCountStatusExamples
CRITICAL7All resolvedUpload bypass, ClamAV fail-closed, SVG blocked, hash binding, GDPR consent, API auth, metadata stripping
HIGH6All resolvedBody size limit, CORS restriction, API logging, Content-Length enforcement, privacy policy, data retention
MEDIUM6All resolvedPresigned URL expiry, email validation, rate limiting, security headers, consent checkbox, log retention

Architecture

The following diagram shows the remediated pipeline:

graph TD
    Browser(Browser - drop.html) --> Nginx(nginx Reverse Proxy)
    Nginx --> Backend("drop-hash-log.py<br/>Hash registry + Email OTP + Upload proxy")
    Backend --> APIGW(AWS API Gateway<br/>Rate limiting + Password auth + Access logging)
    APIGW --> Staging("S3 Staging Bucket<br/>24h auto-delete + AES-256")
    Staging -- "S3 Event" --> Validator("File Validator Lambda<br/>Magic bytes + ClamAV + Hash verify")
    Validator --> Final("S3 Final Bucket<br/>Versioned + Encrypted + No public access")
    Final -- "S3 Event" --> Stripper("metadata-stripper Lambda<br/>Strip EXIF/GPS/IPTC/XMP")
    Stripper --> Public("public/ prefix<br/>Clean copy for gallery")

    style Browser fill:#339af0,color:#fff
    style Validator fill:#f59f00,color:#fff
    style Final fill:#51cf66,color:#fff
    style Stripper fill:#845ef7,color:#fff

End-to-End Upload Flow

sequenceDiagram
    actor User as Browser (drop.html)
    participant NAS as NAS Backend<br/>drop-hash-log.py
    participant Lambda1 as presigned-url-gen<br/>Lambda
    participant S3S as S3 Staging Bucket
    participant Lambda2 as file-validator<br/>Lambda
    participant S3F as S3 Final Bucket
    participant Strip as metadata-stripper<br/>Lambda

    rect rgb(240, 248, 255)
        Note over User: Step 1: Client-Side Verification (browser-only)
        User->>User: C2PA verify (WASM, local)
        User->>User: AI detection (40+ patterns)
        User->>User: SHA-256 hash (WebCrypto)
    end

    rect rgb(245, 255, 245)
        Note over User,NAS: Step 2: Hash Registry + Email OTP
        User->>NAS: POST /hash {fileHash, email}
        NAS->>NAS: Store in SQLite
        User->>NAS: POST /send-pw
        NAS-->>User: SES email with OTP
        User->>NAS: POST /verify-pw
        NAS-->>User: Verified
    end

    rect rgb(255, 248, 240)
        Note over User,Lambda1: Step 3: Presigned URL Generation
        User->>NAS: POST /upload
        NAS->>Lambda1: POST /generate-url<br/>{fileHash, fileSize, fileName, password}
        Lambda1->>Lambda1: Validate hash (64-char hex)<br/>Validate size + password
        Lambda1->>Lambda1: Store expected-hash<br/>in S3 object metadata
        Lambda1-->>NAS: Presigned URL
        NAS-->>User: Upload URL
    end

    rect rgb(255, 240, 245)
        Note over User,S3F: Step 4: Upload + Multi-Layer Validation
        User->>S3S: PUT (presigned URL)
        S3S->>Lambda2: S3 Event trigger
        Lambda2->>Lambda2: 1. Size check
        Lambda2->>Lambda2: 2. Magic byte detection
        Lambda2->>Lambda2: 3. SHA-256 hash vs expected
        Lambda2->>Lambda2: 4. ClamAV virus scan
        Lambda2->>S3F: 5. Move to final bucket
        S3F->>Strip: S3 Event trigger
        Strip->>Strip: Strip EXIF/GPS/IPTC/XMP
        Strip->>S3F: Upload clean copy to public/ prefix
    end

S3 Bucket Architecture

graph TD
    Upload(Presigned URL Upload) --> Staging

    subgraph Staging["S3 Staging Bucket (phenom-media-staging)"]
        S1(PUT-only presigned URLs)
        S2(24-hour auto-expiry lifecycle)
        S3(Versioning enabled)
        S4(AES-256 encryption)
        S5(Block ALL public access)
        S6(S3 Event triggers file-validator)
    end

    Staging -- "validated files" --> Final

    subgraph Final["S3 Final Bucket (phenom-media-storage)"]
        F1(GET/HEAD only - no direct PUT)
        F2(Versioning enabled)
        F3(AES-256 encryption)
        F4(Block ALL public access)
        F5("Organized: images/ and videos/")
        F6("Metadata: original-filename,<br/>expected-hash, detected-type,<br/>validation-date")
    end

    Final -- "S3 Event" --> Stripper(metadata-stripper Lambda)
    Stripper -- "clean copy" --> Public("public/ prefix<br/>EXIF/GPS stripped")

Security Guardrails

Overview

#GuardrailSeverityLayerStatus
C1ClamAV fail-closedCRITICALLambda (file-validator)Implemented
C2SVG upload blockedCRITICALTerraform (variables.tf)Implemented
C3SHA-256 hash bindingCRITICALLambda (both)Implemented
C4Upload validation requiredCRITICALBackend (drop-hash-log.py)Implemented
C5GDPR marketing consentCRITICALBackend (drop-hash-log.py)Implemented
C6Admin endpoint authCRITICALBackend (drop-hash-log.py)Implemented
C7Metadata strippingCRITICALLambda (metadata-stripper)Implemented
H1Content-Length enforcementHIGHLambda (presigned-url-gen)Implemented
H2CORS origin restrictionHIGHTerraform + BackendImplemented
H3API Gateway access loggingHIGHTerraform (api-gateway.tf)Implemented
H4Request body size limitHIGHBackend (drop-hash-log.py)Implemented
H5Privacy Policy updatedHIGHFrontend (privacy-policy.html)Implemented
H6Data retention policyHIGHBackend (drop-hash-log.py)Implemented
M1Presigned URL expiry (10 min)MEDIUMTerraform (variables.tf)Implemented
M2Email regex validationMEDIUMBackend (drop-hash-log.py)Implemented
M3Rate limitingMEDIUMBackend (drop-hash-log.py)Implemented
M4Security headers (nginx)MEDIUMBackend (nginx.dev.conf)Implemented
M5Consent checkbox (UI)MEDIUMFrontend (drop.html)Implemented
M6Log retention (90 days)MEDIUMTerraform (variables.tf)Implemented

Critical Findings Detail

C1: ClamAV Virus Scanning — Fail-Closed

  • Location: variables.tf:41, file-validator/index.js
  • Impact: Malicious files pass through unscanned
  • Fix: Enabled ClamAV with fail-closed mode — Lambda throws on init failure, rejects unscannable files
  • Status: DONE — PR #8 (phenom-infra)
graph TD
    Init(ClamAV Initialization) --> Check{Init Success?}
    Check -- Yes --> Scan(Scan uploaded file)
    Check -- "FAILURE" --> Throw("throw Error — fail-closed")
    Throw --> Reject1("ALL FILES REJECTED")

    Scan --> Result{Scan Result?}
    Result -- Clean --> Accept(Accept file)
    Result -- Infected --> Reject2("REJECT + delete from S3")
    Result -- "Cannot scan" --> Reject3("REJECT")

    style Throw fill:#ff6b6b,color:#fff
    style Reject1 fill:#ff6b6b,color:#fff
    style Reject2 fill:#ff6b6b,color:#fff
    style Reject3 fill:#ff6b6b,color:#fff
    style Accept fill:#51cf66,color:#fff

Terraform variable:

variable "enable_virus_scanning" {
  default = true  # SECURITY: Always enabled. Fail-closed mode.
}

C2: SVG Uploads Blocked (XSS/XXE)

  • Location: variables.tf:89
  • Impact: Stored XSS and XML External Entity attacks via malicious SVG payloads
  • Fix: Removed image/svg+xml from allowed MIME types. Magic byte detection enforces true type.
  • Status: DONE — PR #8 (phenom-infra)
variable "allowed_image_types" {
  default = [
    "image/jpeg",
    "image/png",
    "image/gif",
    "image/webp",
    "image/tiff",
    "image/bmp"
    # "image/svg+xml" — REMOVED: XSS/XXE attack vector
  ]
}

C3: SHA-256 Hash-to-Upload Binding

  • Location: presigned-url-generator/index.js, file-validator/index.js
  • Impact: Attacker could register hash of file A then upload file B
  • Fix: Hash embedded in S3 object metadata at URL generation, verified by file-validator Lambda
  • Status: DONE — PR #8 (phenom-infra)
sequenceDiagram
    actor Browser
    participant Gen as presigned-url-gen
    participant S3 as S3 Staging
    participant Val as file-validator

    Browser->>Gen: fileHash=abc123...
    Gen->>Gen: Validate: 64-char hex
    Gen->>S3: Create presigned URL<br/>with metadata: expected-hash=abc123...
    Gen-->>Browser: Presigned URL

    Browser->>S3: PUT file bytes
    S3->>Val: S3 Event trigger

    Val->>Val: Compute SHA-256 of uploaded bytes
    Val->>Val: Read expected-hash from S3 metadata

    alt Hash matches
        Val->>Val: Continue validation
    else Hash mismatch
        Val->>S3: DELETE file
        Val-->>Val: REJECT
    end

Presigned URL generator validates the hash format and embeds it as S3 object metadata:

if (!fileHash || !/^[a-f0-9]{64}$/.test(fileHash)) {
    return { statusCode: 400, body: 'Missing or invalid fileHash' };
}
// Stored as S3 metadata on the presigned URL
Metadata: { 'expected-hash': fileHash }

File validator recomputes the hash from the uploaded bytes and compares:

const expectedHash = metadata['expected-hash'];
const actualHash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
if (actualHash !== expectedHash) {
    await handleInvalidFile(bucket, key, 'File hash mismatch');
}

C4: Upload Endpoint Validation

  • Location: drop-hash-log.py:278,289
  • Impact: Omitting fileHash or email bypassed all submission validation
  • Fix: Both fields required with server-side enforcement. Returns HTTP 400 if either is absent.
  • Status: DONE — PR #7 (www)
  • Location: drop-hash-log.py:227-231
  • Impact: Submitter email added to Brevo mailing list without explicit consent
  • Fix: Brevo enrollment gated behind explicit opt-in checkbox. Backend enforces marketing_consent field.
  • Status: DONE — PR #7 (www)

C6: Admin Endpoint Authentication

  • Location: drop-hash-log.py:371-424
  • Impact: Full submission enumeration via unauthenticated /api/drop/hashes
  • Fix: API key authentication required for all admin-facing endpoints. Key loaded from .drop-admin-key file.
  • Status: DONE — PR #7 (www)

C7: GPS/EXIF Metadata Stripping

  • Location: lambda-functions/metadata-stripper/index.py
  • Impact: GPS/EXIF metadata preserved in publicly accessible copies
  • Constraint: Original file must be preserved in archive bucket
  • Fix: metadata-stripper Lambda using Pillow — preserves original, creates stripped copy at public/ prefix
  • Status: DONE — PR #8 (phenom-infra)
graph TD
    Event(S3 Event: new file in final bucket) --> Download(Download original file)
    Download --> Strip("Strip EXIF, GPS, IPTC, XMP<br/>using Pillow")
    Strip --> Upload("Upload clean copy to public/ prefix")
    Upload --> Done("Original preserved intact at original key")

    style Event fill:#339af0,color:#fff
    style Strip fill:#f59f00,color:#fff
    style Done fill:#51cf66,color:#fff

High Findings Detail

H1: Content-Length Enforcement

  • Location: presigned-url-generator/index.js
  • Fix: fileSize required. Presigned URL includes ContentLength constraint — S3 rejects mismatched uploads.
  • Status: DONE — PR #8 (phenom-infra)
const putCommand = new PutObjectCommand({
    Bucket: STAGING_BUCKET,
    Key: key,
    ContentType: fileType,
    ContentLength: fileSize,  // S3 enforces exact match
    Metadata: { 'expected-hash': fileHash }
});

H2: CORS Origin Restriction

  • Location: variables.tf, environments/development/main.tf, drop-hash-log.py
  • Before: cors_allowed_origins = ["*"]
  • After: Explicit allowlist per environment, applied to both S3 CORS rules and backend.
  • Status: DONE — PR #7 (www) + PR #8 (phenom-infra)
variable "cors_allowed_origins" {
  default = []  # SECURITY: No default — must be explicitly set
}

H3: API Gateway Access Logging

  • Location: api-gateway.tf
  • Fix: Structured JSON access logging to CloudWatch. 90-day retention.
  • Status: DONE — PR #8 (phenom-infra)
access_log_settings {
  destination_arn = aws_cloudwatch_log_group.api_gateway.arn
  format = jsonencode({
    requestId        = "$context.requestId"
    requestTime      = "$context.requestTime"
    path             = "$context.path"
    method           = "$context.httpMethod"
    status           = "$context.status"
    responseLength   = "$context.responseLength"
    error            = "$context.error.message"
  })
}

H4: Request Body Size Limit

  • Location: drop-hash-log.py
  • Fix: 10 MB maximum request body enforced server-side.
  • Status: DONE — PR #7 (www)

H5: Privacy Policy Updated

  • Location: privacy-policy.html
  • Fix: Added “Phenom Drop Media Submissions” section covering data collection, retention, and processing.
  • Status: DONE — PR #7 (www)

H6: Data Retention Policy

  • Location: drop-hash-log.py
  • Fix: 90-day retention with automated cleanup of expired submissions.
  • Status: DONE — PR #7 (www)

Fail-Safe Scenarios

Scenario 1: Reverse Shell in Disguised .mp4 — GREEN

graph TD
    Attack("Attacker uploads ELF binary<br/>renamed to .mp4") --> S3(S3 Staging Bucket)
    S3 -- "S3 Event" --> Validator(file-validator Lambda)

    Validator --> Magic{"Magic byte check:<br/>ELF detected"}
    Magic -- "Not in allowed MIME types" --> Reject("REJECTED + DELETED")
    Magic -- "Even if it passed..." --> Clam{"ClamAV scan"}
    Clam -- "Detected" --> Reject

    Result("File never reaches final bucket.<br/>No code execution possible.")
    Reject --> Result

    style Attack fill:#ff6b6b,color:#fff
    style Reject fill:#ff6b6b,color:#fff
    style Result fill:#51cf66,color:#fff

Scenario 2: Unauthenticated Raw File Access — GREEN

graph TD
    Attacker("Attacker tries direct access") --> Try

    Try --> S3S("S3 Staging<br/>Block ALL public access")
    Try --> S3F("S3 Final<br/>Block ALL public access")
    Try --> API("API Gateway<br/>Password via Secrets Manager")

    S3S --> Denied("ACCESS DENIED")
    S3F --> Denied
    API --> Denied

    Result("No public access to any storage.<br/>API requires password.")
    Denied --> Result

    style Attacker fill:#ff6b6b,color:#fff
    style Denied fill:#ff6b6b,color:#fff
    style Result fill:#51cf66,color:#fff

Scenario 3: Chain of Custody — GREEN (was RED)

graph TD
    Attack("Attacker registers hash of clean.jpg<br/>then uploads malware.exe") --> Gen(presigned-url-gen Lambda)
    Gen -- "Stores expected-hash<br/>as S3 object metadata" --> S3(S3 Staging)
    S3 -- "S3 Event" --> Val(file-validator Lambda)

    Val --> Compute("Compute SHA-256<br/>of uploaded bytes")
    Compute --> Compare{"actual hash<br/>== expected hash?"}
    Compare -- "MISMATCH" --> Reject("REJECTED + DELETED")
    Compare -- "Match" --> Accept("Continue validation")

    Result("Hash binding prevents<br/>bait-and-switch attacks.")
    Reject --> Result

    style Attack fill:#ff6b6b,color:#fff
    style Reject fill:#ff6b6b,color:#fff
    style Result fill:#51cf66,color:#fff
graph TD
    Upload("User uploads geotagged photo") --> Final(S3 Final Bucket)

    Final --> Original("Original preserved at original key<br/>ALL metadata intact<br/>(archival copy)")
    Final -- "S3 Event" --> Stripper(metadata-stripper Lambda)

    Stripper --> Process("Download original<br/>Strip EXIF/GPS/IPTC/XMP via Pillow<br/>Upload clean copy")
    Process --> Public("public/ prefix<br/>No location metadata")

    Constraint("CONSTRAINT: Original is NEVER modified.<br/>Only the public/ copy has metadata stripped.")

    style Upload fill:#339af0,color:#fff
    style Public fill:#51cf66,color:#fff
    style Constraint fill:#ffd43b,color:#333

Configuration Reference

Security Variables (modules/video-upload/variables.tf)

VariableDefaultDescription
enable_virus_scanningtrueClamAV scanning (fail-closed mode)
cors_allowed_origins[]Explicit CORS allowlist (no wildcard!)
upload_expiry_seconds600Presigned URL lifetime (10 min)
max_file_size_mb500Maximum upload size
log_retention_days90CloudWatch log retention
api_quota_limit10000API Gateway daily quota
api_rate_limit10Requests per second
api_burst_limit20Burst limit
allowed_image_typesJPEG, PNG, GIF, WebP, TIFF, BMPNo SVG!
allowed_video_typesMP4, MPEG, MOV, AVI, WMV, WebMStandard video formats

Remediation Timeline

All remediations were completed on 2026-03-09:

PriorityItemsStatus
ImmediateC4 upload bypass, C2 SVG removal, C5 Brevo consent, C6 API auth, H2 CORSDONE
Week 1C3 hash binding, C1 ClamAV, H1 Content-Length, H4 body limit, M3 rate limitDONE
Week 1C7 metadata stripping, H5 Privacy Policy, M5 consent checkboxDONE
Week 1H3 API logging, M4 security headers, M6 log retentionDONE
  • /docs/security/drop-remediation-tracker/ — Remediation completion tracker with PR references
  • Phenom Drop Overview — Pipeline architecture and feature documentation
  • Phenom Infrastructure — Terraform modules and AWS service configuration