Drop Pipeline Security Audit
Categories:
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:#fffEnd-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
endS3 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: 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:#fffTerraform 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+xmlfrom 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
endPresigned 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
fileHashoremailbypassed all submission validation - Fix: Both fields required with server-side enforcement. Returns HTTP 400 if either is absent.
- Status: DONE — PR #7 (www)
C5: GDPR Marketing Consent
- 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_consentfield. - 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-keyfile. - 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:#fffHigh Findings Detail
H1: Content-Length Enforcement
- Location:
presigned-url-generator/index.js - Fix:
fileSizerequired. Presigned URL includesContentLengthconstraint — 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:#fffScenario 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:#fffScenario 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:#fffScenario 4: GPS Leak via Public Gallery — GREEN (was RED)
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:#333Configuration 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 |
Remediation Timeline
All remediations were completed on 2026-03-09:
| Priority | Items | Status |
|---|---|---|
| Immediate | C4 upload bypass, C2 SVG removal, C5 Brevo consent, C6 API auth, H2 CORS | DONE |
| Week 1 | C3 hash binding, C1 ClamAV, H1 Content-Length, H4 body limit, M3 rate limit | DONE |
| Week 1 | C7 metadata stripping, H5 Privacy Policy, M5 consent checkbox | DONE |
| Week 1 | H3 API logging, M4 security headers, M6 log retention | DONE |
Related Documentation
- /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
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.