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.

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

Findings Summary

Severity Count Status Examples
CRITICAL 7 All resolved Upload bypass, ClamAV fail-closed, SVG blocked, hash binding, GDPR consent, API auth, metadata stripping
HIGH 6 All resolved Body size limit, CORS restriction, API logging, Content-Length enforcement, privacy policy, data retention
MEDIUM 6 All resolved Presigned 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

# Guardrail Severity Layer Status
C1 ClamAV fail-closed CRITICAL Lambda (file-validator) Implemented
C2 SVG upload blocked CRITICAL Terraform (variables.tf) Implemented
C3 SHA-256 hash binding CRITICAL Lambda (both) Implemented
C4 Upload validation required CRITICAL Backend (drop-hash-log.py) Implemented
C5 GDPR marketing consent CRITICAL Backend (drop-hash-log.py) Implemented
C6 Admin endpoint auth CRITICAL Backend (drop-hash-log.py) Implemented
C7 Metadata stripping CRITICAL Lambda (metadata-stripper) Implemented
H1 Content-Length enforcement HIGH Lambda (presigned-url-gen) Implemented
H2 CORS origin restriction HIGH Terraform + Backend Implemented
H3 API Gateway access logging HIGH Terraform (api-gateway.tf) Implemented
H4 Request body size limit HIGH Backend (drop-hash-log.py) Implemented
H5 Privacy Policy updated HIGH Frontend (privacy-policy.html) Implemented
H6 Data retention policy HIGH Backend (drop-hash-log.py) Implemented
M1 Presigned URL expiry (10 min) MEDIUM Terraform (variables.tf) Implemented
M2 Email regex validation MEDIUM Backend (drop-hash-log.py) Implemented
M3 Rate limiting MEDIUM Backend (drop-hash-log.py) Implemented
M4 Security headers (nginx) MEDIUM Backend (nginx.dev.conf) Implemented
M5 Consent checkbox (UI) MEDIUM Frontend (drop.html) Implemented
M6 Log retention (90 days) MEDIUM Terraform (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: Active: ClamAV enabled with fail-closed mode
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: Active: SVG absent from allowed_image_types; magic byte check enforced
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: Active: hash binding operational across presigned-url-gen and file-validator
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: Active: validation enforced in drop-hash-log.py
  • 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: Active: consent gate enforced in drop-hash-log.py

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: Active: admin auth enforced in drop-hash-log.py

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: Active: metadata-stripper Lambda operational; GPS/EXIF stripping confirmed
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: Active: ContentLength enforced in presigned-url-gen Lambda
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: Active: per-environment allowlist in variables.tf and drop-hash-log.py
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: Active: logging configured in api-gateway.tf
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: Active: body size limit enforced in drop-hash-log.py

H5: Privacy Policy Updated

  • Location: privacy-policy.html
  • Fix: Added “Phenom Drop Media Submissions” section covering data collection, retention, and processing.
  • Status: Active: section live in privacy-policy.html

H6: Data Retention Policy

  • Location: drop-hash-log.py
  • Fix: 90-day retention with automated cleanup of expired submissions.
  • Status: Active: retention policy enforced in drop-hash-log.py

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)

Variable Default Description
enable_virus_scanning true ClamAV scanning (fail-closed mode)
cors_allowed_origins [] Explicit CORS allowlist (no wildcard!)
upload_expiry_seconds 600 Presigned URL lifetime (10 min)
max_file_size_mb 500 Maximum upload size
log_retention_days 90 CloudWatch log retention
api_quota_limit 10000 API Gateway daily quota
api_rate_limit 10 Requests per second
api_burst_limit 20 Burst limit
allowed_image_types JPEG, PNG, GIF, WebP, TIFF, BMP No SVG!
allowed_video_types MP4, MPEG, MOV, AVI, WMV, WebM Standard video formats
  • /docs/security/drop-remediation-tracker/: Current security control state for all findings
  • Phenom Drop Overview: Pipeline architecture and feature documentation
  • Phenom Infrastructure: Terraform modules and AWS service configuration