Drop Pipeline Security Audit
Categories:
Corrected 2026-08-18 after a ground-truth verification pass. The original version of this page reported the pipeline as GREEN with every finding remediated. Three of its central claims did not survive checking against the code and the live AWS account. They are corrected below and marked CORRECTED 2026-08-18. Read What changed and why before you rely on any status in this page.
Executive Summary
A security audit of the Phenom Drop media submission pipeline was carried out on 2026-03-09 across infrastructure, video processing, forensics and integrity, legal and compliance, and exploit scenario validation.
On 2026-08-18 every claim in this page was re-checked against phenom-infra at origin/main and against the live AWS account. Most held. Three did not, and one of those describes a control that has never existed.
Overall pipeline status: AMBER. The upload path itself is sound. The storage and publication path is not, and the audit’s original GREEN verdict rested partly on a Lambda function that is not in the repository.
| Scenario | Original verdict | Verified verdict 2026-08-18 | Basis |
|---|---|---|---|
| Reverse shell in disguised .mp4 | GREEN | GREEN | Magic byte detection and fail-closed ClamAV both confirmed in file-validator/index.js. |
| Unauthenticated raw file access | GREEN | RED | phenom-dev-media-storage returns HTTP 200 to an anonymous GET. See Scenario 2. |
| Chain of custody (hash log) | GREEN | GREEN | Hash binding confirmed end to end in both Lambdas. |
| GPS leak via public gallery | GREEN | UNPROVEN | The metadata-stripper Lambda credited with preventing this does not exist. See Scenario 4. |
What changed and why
Three corrections, in order of how much they matter.
1. The metadata-stripper Lambda has never existed
The original page credited a metadata-stripper Lambda, written in Python using Pillow, with stripping EXIF, GPS, IPTC and XMP from a public copy of every uploaded image. It was the sole basis for finding C7 and for fail-safe Scenario 4.
It is not in the repository, and it never has been:
modules/video-upload/lambda-functions/contains exactly two functions,file-validatorandpresigned-url-generator.git log --all -- '*metadata-stripper*'returns nothing across all branches and all history.- No file in the module mentions EXIF, GPS, IPTC, XMP, Pillow or piexif.
C7 and Scenario 4 are therefore unproven, not remediated.
2. The two-bucket architecture was replaced by one bucket
The original page diagrammed a staging bucket feeding a separate final bucket, with the validator moving files between them. That design was deliberately abandoned. The module header in modules/video-upload/main.tf records the replacement and the reason:
One bucket, one key, never deleted. Validator tags objects as validated or rejected, does not move them. Public GET works directly from CloudFront on
uploads/*.
The move existed because the two-bucket design broke media retrieval: the presigned URL pointed at uploads/<key> in staging, the validator moved the object to images/<key> in the final bucket, and the key recorded in Firestore no longer resolved.
3. One storage bucket is genuinely world-readable
This is the finding that changes the risk picture, and it was discovered by probing the live account rather than by reading code.
phenom-dev-media-storage carries a bucket policy statement PublicReadGetObject allowing s3:GetObject to Principal * on every object, with no condition, and its public access block is fully disabled (all four settings false). An unauthenticated HTTP GET against an object in that bucket returned HTTP 200 with real image bytes.
phenom-drop-staging-uploads carries the same unconditional public-read statement with BlockPublicPolicy and RestrictPublicBuckets both false. That bucket is currently empty, so there is no live exposure from it today, but the posture is the same.
For contrast, the two buckets that are configured correctly are phenom-prod-media-storage (public access fully blocked, GET allowed only to the CloudFront service principal) and phenom-dev-media-storage’s sibling phenom-dev-media-staging (public access fully blocked, no bucket policy).
Is GPS actually leaking today? Not in the sample taken. Twelve objects over 10 KB were pulled from phenom-dev-media-storage and inspected with exiftool: zero carried an EXIF block, zero carried a GPS block. The media is re-encoded to WebP on the way in, and that transcode discards EXIF as a side effect. So the leak is not currently materialising, but it is prevented by an accident of the encoding pipeline rather than by the control the audit credits. Twelve objects is a sample of a bucket holding over a thousand, not proof of absence.
Findings Summary
| Severity | Count | Verified status 2026-08-18 |
|---|---|---|
| CRITICAL | 7 | 6 confirmed remediated, 1 unproven (C7) |
| HIGH | 6 | 6 confirmed remediated, 1 with incorrect supporting evidence (H1) |
| MEDIUM | 6 | Spot-checked; M1 and M6 confirmed |
Note that this page’s counts describe the March 2026 drop-pipeline audit only. The separate Platform Security Assessment 2026-08 holds 22 open findings against phenom-drop, including one Critical. A GREEN verdict here has never meant the service is clear.
Architecture
The pipeline as it actually stands today.
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 --> Canonical("S3 canonical bucket<br/><project>-media-staging<br/>uploads/ prefix")
Canonical -- "S3 Event" --> Validator("file-validator Lambda<br/>Magic bytes + ClamAV + Hash verify")
Validator -- "tags object validated / rejected<br/>does NOT move or delete" --> Canonical
Canonical -- "public GET" --> CDN("CloudFront<br/>serves uploads/* directly")
style Browser fill:#339af0,color:#fff
style Validator fill:#f59f00,color:#fff
style Canonical fill:#51cf66,color:#fff
style CDN fill:#845ef7,color:#fff
There is no second bucket and no metadata-stripping stage. Objects keep the key they were uploaded under for their whole life.
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 S3 as S3 canonical bucket
participant Lambda2 as file-validator<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 POST 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: Build POST policy:<br/>content-length-range + Content-Type
Lambda1->>Lambda1: Store expected-hash<br/>in S3 object metadata
Lambda1-->>NAS: Presigned POST (URL + fields)
NAS-->>User: Upload target
end
rect rgb(255, 240, 245)
Note over User,Lambda2: Step 4: Upload + Multi-Layer Validation
User->>S3: POST (presigned policy)
S3->>S3: Enforce content-length-range<br/>and Content-Type server-side
S3->>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->>S3: 5. Tag object validated or rejected<br/>(object is not moved)
end
S3 Bucket Architecture
graph TD
Upload("Presigned POST upload") --> Canonical
subgraph Canonical["S3 canonical bucket (<project>-media-staging)"]
S1("Presigned POST with server-enforced policy")
S2("uploads/ prefix, key never changes")
S3("AES-256 encryption")
S4("Block ALL public access")
S5("S3 Event triggers file-validator")
S6("Validator tags validated / rejected in place")
end
Canonical -- "public GET" --> CDN("CloudFront serves uploads/*")
Storage("<project>-media-storage") --> Posture{"Public access posture"}
Posture -- "phenom-prod-media-storage" --> Good("Blocked. GET only via<br/>CloudFront service principal")
Posture -- "phenom-dev-media-storage" --> Bad("PUBLIC. Anonymous GET returns 200")
style Bad fill:#ff6b6b,color:#fff
style Good fill:#51cf66,color:#fff
style CDN fill:#845ef7,color:#fff
Security Guardrails
Overview
| # | Guardrail | Severity | Layer | Verified status 2026-08-18 |
|---|---|---|---|---|
| C1 | ClamAV fail-closed | CRITICAL | Lambda (file-validator) | Confirmed |
| C2 | SVG upload blocked | CRITICAL | Terraform (variables.tf) | Confirmed |
| C3 | SHA-256 hash binding | CRITICAL | Lambda (both) | Confirmed |
| C4 | Upload validation required | CRITICAL | Backend (drop-hash-log.py) | Not re-checked |
| C5 | GDPR marketing consent | CRITICAL | Backend (drop-hash-log.py) | Not re-checked |
| C6 | Admin endpoint auth | CRITICAL | Backend (drop-hash-log.py) | Not re-checked |
| C7 | Metadata stripping | CRITICAL | Lambda (metadata-stripper) | Unproven. Control does not exist. |
| H1 | Upload size enforcement | HIGH | Lambda (presigned-url-gen) | Confirmed, stronger than originally described |
| H2 | CORS origin restriction | HIGH | Terraform + Backend | Confirmed, default corrected |
| H3 | API Gateway access logging | HIGH | Terraform (api-gateway.tf) | Not re-checked |
| H4 | Request body size limit | HIGH | Backend (drop-hash-log.py) | Not re-checked |
| H5 | Privacy Policy updated | HIGH | Frontend (privacy-policy.html) | Not re-checked |
| H6 | Data retention policy | HIGH | Backend (drop-hash-log.py) | Not re-checked |
| M1 | Presigned URL expiry (10 min) | MEDIUM | Terraform (variables.tf) | Confirmed |
| M2 | Email regex validation | MEDIUM | Backend (drop-hash-log.py) | Not re-checked |
| M3 | Rate limiting | MEDIUM | Backend (drop-hash-log.py) | Not re-checked |
| M4 | Security headers (nginx) | MEDIUM | Backend (nginx.dev.conf) | Not re-checked |
| M5 | Consent checkbox (UI) | MEDIUM | Frontend (drop.html) | Not re-checked |
| M6 | Log retention (90 days) | MEDIUM | Terraform (variables.tf) | Confirmed |
“Not re-checked” means the 2026-08-18 pass covered the Terraform module and the two Lambdas but did not re-read the drop-hash-log.py backend or the frontend. Those rows carry the March 2026 verdict unchanged and unverified.
Critical Findings Detail
C1: ClamAV Virus Scanning: Fail-Closed
- Location:
variables.tf:41,file-validator/index.js - Impact: Malicious files pass through unscanned
- Fix: ClamAV enabled with fail-closed mode. The Lambda throws on init failure and rejects unscannable files.
- Verified 2026-08-18: Confirmed.
variables.tf:38-42sets the documented default, andfile-validator/index.jsreturns'scan-error'with an explicit fail-closed comment on both error paths.
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, quoted verbatim:
variable "enable_virus_scanning" {
default = true # SECURITY: Always enabled. Fail-closed mode.
}
C2: SVG Uploads Blocked (XSS/XXE)
- Location:
variables.tf:81-92 - Impact: Stored XSS and XML External Entity attacks via malicious SVG payloads
- Fix:
image/svg+xmlabsent from allowed MIME types. Magic byte detection enforces the true type. - Verified 2026-08-18: Confirmed in substance. SVG is genuinely absent from
allowed_image_types. Two details in the original page were wrong: the variable is at lines 81 to 92, not line 89, and the explanatory comment quoted below as# "image/svg+xml": REMOVEDdoes not appear in the file. The absence is real; the annotation was not.
variable "allowed_image_types" {
default = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/tiff",
"image/bmp"
]
}
C3: SHA-256 Hash-to-Upload Binding
- Location:
presigned-url-generator/index.js,file-validator/index.js - Impact: An attacker could register the hash of file A then upload file B
- Fix: Hash embedded in S3 object metadata at URL generation, verified by the file-validator Lambda
- Verified 2026-08-18: Confirmed on both sides. Format check at
presigned-url-generator/index.js:111, metadata write at:197, recompute and compare atfile-validator/index.js:381-384.
sequenceDiagram
actor Browser
participant Gen as presigned-url-gen
participant S3 as S3 canonical bucket
participant Val as file-validator
Browser->>Gen: fileHash=abc123...
Gen->>Gen: Validate: 64-char hex
Gen->>S3: Create presigned POST<br/>with metadata: expected-hash=abc123...
Gen-->>Browser: Presigned POST
Browser->>S3: POST 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: Reject
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 POST
const 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: March 2026 verdict, not re-checked on 2026-08-18.
C5: GDPR Marketing Consent
- Location:
drop-hash-log.py:227-231 - Impact: Submitter email added to the Brevo mailing list without explicit consent
- Fix: Brevo enrollment gated behind an explicit opt-in checkbox. Backend enforces
marketing_consent. - Status: March 2026 verdict, not re-checked on 2026-08-18.
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. - Status: March 2026 verdict, not re-checked on 2026-08-18.
C7: GPS/EXIF Metadata Stripping
CORRECTED 2026-08-18: this control does not exist
The original page recorded C7 as Active, implemented by ametadata-stripper Lambda at lambda-functions/metadata-stripper/index.py using Pillow. There is no such file, there is no such Lambda, and there never has been in any branch of phenom-infra. C7 is unproven, not remediated.
- Impact: GPS and EXIF metadata preserved in publicly accessible copies
- Constraint: The original file must be preserved for archival
- Claimed fix: A
metadata-stripperLambda using Pillow, preserving the original and writing a stripped copy at apublic/prefix - Verified 2026-08-18: The claimed fix does not exist. Evidence:
lambda-functions/holds onlyfile-validatorandpresigned-url-generator;git log --all -- '*metadata-stripper*'returns no commits; no file in the module references EXIF, GPS, IPTC, XMP, Pillow or piexif; there is nopublic/prefix anywhere in the module. - Current real-world position: A twelve-object sample from
phenom-dev-media-storagefound no EXIF and no GPS blocks. Uploads are re-encoded to WebP and the transcode discards EXIF. The outcome is currently acceptable; the mechanism is incidental and undocumented, and nothing enforces it.
What needs to happen: either build the stripping stage this finding assumed, or write down the transcode as the deliberate control, pin it with a test that uploads a geotagged image and asserts the stored object carries no GPS block, and re-scope C7 accordingly. Until one of those is done, treat GPS handling as unverified.
High Findings Detail
H1: Upload Size Enforcement
CORRECTED 2026-08-18: right conclusion, wrong evidence
The original page quoted aPutObjectCommand carrying a ContentLength field. That code is not in the Lambda; grep -c ContentLength returns 0. The real implementation uses createPresignedPost with a content-length-range policy condition, which is a stronger control than the one described, because S3 enforces the range server-side before accepting the object rather than trusting a client-declared length.
- Location:
presigned-url-generator/index.js:178-215 - Fix:
fileSizerequired. The presigned POST policy pins acontent-length-rangeand an exactContent-Type, both enforced by S3. - Verified 2026-08-18: Confirmed. Lower bound of 1 also rejects zero-byte uploads.
// Generate presigned POST (createPresignedPost). S3 enforces the
// content-length-range, Content-Type, and x-amz-meta-* fields
// server-side before accepting the object. This eliminates the
// client-controlled fileSize bypass. Lower bound is 1 so zero-byte
// uploads are rejected.
const conditions = [
['content-length-range', 1, MAX_FILE_SIZE],
['eq', '$Content-Type', fileType]
];
H2: CORS Origin Restriction
CORRECTED 2026-08-18: the quoted default was wrong
The original page quoteddefault = [] with the comment “No default: must be explicitly set”. The real default is a two-entry allowlist. The security outcome is the same, no wildcard, but the quoted code was not the code.
- Location:
variables.tf:20-24,environments/development/main.tf,drop-hash-log.py - Before:
cors_allowed_origins = ["*"] - After: Explicit allowlist, applied to the S3 CORS rules, the API Gateway integration responses and the Lambda CORS headers.
- Verified 2026-08-18: Confirmed. No wildcard anywhere in the chain.
variable "cors_allowed_origins" {
type = list(string)
default = ["https://thephenom.app", "https://dev-nest.thephenom.app"]
}
H3: API Gateway Access Logging
- Location:
api-gateway.tf - Fix: Structured JSON access logging to CloudWatch, 90-day retention.
- Status: March 2026 verdict, not re-checked on 2026-08-18.
H4: Request Body Size Limit
- Location:
drop-hash-log.py - Fix: Server-side body size ceiling on the backend endpoints.
- Status: March 2026 verdict, not re-checked on 2026-08-18.
H5: Privacy Policy Updated
- Location:
privacy-policy.html - Status: March 2026 verdict, not re-checked on 2026-08-18.
H6: Data Retention Policy
- Location:
drop-hash-log.py - Status: March 2026 verdict, not re-checked on 2026-08-18.
Fail-Safe Scenarios
Scenario 1: Reverse Shell in Disguised .mp4: GREEN
graph TD
Attack("Attacker uploads ELF binary<br/>renamed to .mp4") --> S3("S3 canonical bucket")
S3 -- "S3 Event" --> Validator("file-validator Lambda")
Validator --> Magic{"Magic byte check:<br/>ELF detected"}
Magic -- "Not in allowed MIME types" --> Reject("REJECTED")
Magic -- "Even if it passed..." --> Clam{"ClamAV scan"}
Clam -- "Detected" --> Reject
Result("Object tagged rejected.<br/>No code execution surface in S3.")
Reject --> Result
style Attack fill:#ff6b6b,color:#fff
style Reject fill:#ff6b6b,color:#fff
style Result fill:#51cf66,color:#fff
Verified 2026-08-18. Note one wording change from the original: a rejected object is tagged rejected in place, it is not deleted, because the current single-bucket design never deletes objects.
Scenario 2: Unauthenticated Raw File Access: RED
CORRECTED 2026-08-18: this scenario fails
The original page asserted “Both S3 buckets block public access” and concluded ACCESS DENIED. An anonymous HTTP GET against an object inphenom-dev-media-storage returned HTTP 200 with image bytes. The scenario does not pass.
graph TD
Attacker("Unauthenticated attacker") --> Try{"Which bucket?"}
Try --> Prod("phenom-prod-media-storage<br/>PAB fully on<br/>GET only via CloudFront principal")
Try --> DevStg("phenom-dev-media-staging<br/>PAB fully on, no bucket policy")
Try --> DevSto("phenom-dev-media-storage<br/>PAB fully OFF<br/>Allow * s3:GetObject")
Try --> DropUp("phenom-drop-staging-uploads<br/>Allow * s3:GetObject<br/>currently empty")
Prod --> Denied("ACCESS DENIED")
DevStg --> Denied
DevSto --> Open("HTTP 200<br/>object bytes returned")
DropUp --> Latent("Open by policy,<br/>no objects today")
style Attacker fill:#ff6b6b,color:#fff
style Open fill:#ff6b6b,color:#fff
style Latent fill:#ffd43b,color:#333
style Denied fill:#51cf66,color:#fff
Live evidence, 2026-08-18.
| Bucket | Public access block | Bucket policy | Anonymous GET |
|---|---|---|---|
phenom-prod-media-storage |
all four true |
GET allowed to cloudfront.amazonaws.com only |
denied |
phenom-dev-media-staging |
all four true |
none | denied |
phenom-dev-media-storage |
all four false |
Allow * s3:GetObject, unconditional |
HTTP 200 |
phenom-drop-staging-uploads |
BlockPublicPolicy and RestrictPublicBuckets false |
Allow * s3:GetObject, unconditional |
bucket empty |
The production bucket is configured correctly. The development bucket that holds real uploaded media is not. Anyone who learns or guesses an object key can fetch the object without credentials.
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 canonical bucket")
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")
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
Verified 2026-08-18 against both Lambdas. This one holds exactly as described.
Scenario 4: GPS Leak via Public Gallery: UNPROVEN
CORRECTED 2026-08-18: the control credited here does not exist
The original diagram showed ametadata-stripper Lambda producing a stripped copy at a public/ prefix. No such Lambda, and no such prefix, exists anywhere in phenom-infra. The scenario cannot be called GREEN on that basis.
graph TD
Upload("User uploads geotagged photo") --> Canonical("S3 canonical bucket<br/>uploads/ prefix")
Canonical --> Claimed("CLAIMED: metadata-stripper Lambda<br/>writes stripped copy to public/")
Canonical --> Actual("ACTUAL: no stripping stage exists")
Actual --> Transcode("Media re-encoded to WebP<br/>upstream of storage")
Transcode --> Sample("Sample of 12 objects:<br/>0 EXIF blocks, 0 GPS blocks")
Sample --> Verdict("Outcome currently acceptable.<br/>Mechanism incidental, untested, unenforced.")
style Claimed fill:#ff6b6b,color:#fff
style Actual fill:#ffd43b,color:#333
style Verdict fill:#ffd43b,color:#333
The practical risk today is low, because the transcode to WebP happens to discard EXIF. The governance risk is not low: nothing tests for this, nothing documents it as a control, and a future change that stores an original alongside the transcode would reintroduce the leak silently. Combined with Scenario 2, where one media bucket is world-readable, that is a combination worth closing deliberately rather than by luck.
Configuration Reference
Security Variables (modules/video-upload/variables.tf)
Every row below was read from the file on 2026-08-18.
| Variable | Default | Line | Description |
|---|---|---|---|
cors_allowed_origins |
["https://thephenom.app", "https://dev-nest.thephenom.app"] |
20-24 | Explicit CORS allowlist, no wildcard |
upload_expiry_seconds |
600 |
26 | Presigned URL lifetime (10 min) |
enable_virus_scanning |
true |
38-42 | ClamAV scanning, fail-closed mode |
log_retention_days |
90 |
44-48 | CloudWatch log retention |
allowed_image_types |
JPEG, PNG, GIF, WebP, TIFF, BMP | 81-92 | No SVG |
max_file_size_mb |
500 |
Maximum upload size | |
allowed_video_types |
MP4, MPEG, MOV, AVI, WMV, WebM | Standard video formats | |
api_quota_limit |
10000 |
API Gateway daily quota | |
api_rate_limit |
10 |
Requests per second | |
api_burst_limit |
20 |
Burst limit |
How to act on this
The corrections above are written up as proposed work in Security remediation planning, which groups them into epics and issues in plain language.
Verification method
So the next reader can repeat it rather than trust it.
| Claim class | How it was checked |
|---|---|
| Code exists / does not exist | git log --all -- '<path>' on a freshly fetched origin/main, plus directory listing and full-text grep across the module |
| Quoted code accuracy | Read the cited file and line range directly, compared character by character |
| Terraform defaults | Read variables.tf, recorded the real line numbers |
| Bucket public-access posture | aws s3api get-public-access-block and get-bucket-policy against the live account |
| Actual reachability | Unauthenticated curl against the object URL, status code and content type recorded, body discarded |
| EXIF / GPS presence | exiftool -s -G over a twelve-object sample, presence of [GPS] and [EXIF] blocks counted; no values read or recorded |
No credential value, key, token or password appears in this page. Personal data is described by class and count only.
Related Documentation
- /docs/security/remediation-planning/: plain-language epics and issues derived from these corrections
- /docs/security/drop-remediation-tracker/: current security control state for all findings
- /docs/security/platform-assessment-2026-08/: the August 2026 platform-wide assessment, which holds 22 open findings against phenom-drop
- 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.