Step-by-Step Guide — Follow each step exactly as written. All values are provided — do not improvise.
TC-EDGE-003: Upload Sanitization & Storage (Detailed)
Module: Edge Cases — Upload Sanitization & Storage
Primary Test User: admin@rcme.membervu.com / Admin123!; testmember@rcme.membervu.com / Member123!; treasurer@demo.membervu.com / Treasurer123!
Priority: P1
URLs for this test:
Frontend:
Org profile (logo):
Member profile (avatar):
Admin payments (proof):
Frontend:
https://stg-rcme.membervu.com/loginOrg profile (logo):
https://stg-rcme.membervu.com/admin/organizationMember profile (avatar):
https://stg-rcme.membervu.com/profileAdmin payments (proof):
https://stg-rcme.membervu.com/admin/finance/payments
What "sanitized" means here.
sanitizeUploadFilename() (auth-service/src/services/storage/sanitizeUploadFilename.ts) strips directory components and .. traversal (basename only), replaces every character outside [A-Za-z0-9._-] with _, and always returns a non-empty ASCII name with an extension (falls back to .bin if none survives). It governs the display/metadata name — the actual S3 object key is a separate generated value (e.g. avatar-<timestamp>-<random>.jpg) and never touches the raw filename at all. Confirmed exact outputs used below were derived by running the real function against each payload.
Step 1: Upload a path-traversal filename (EDGE3-01)
| Step | Action (EXACT clicks/typing) | Expected Result (EXACT text/behavior) | Test Value |
|---|---|---|---|
| 1 | Go to https://stg-rcme.membervu.com/login; sign in as member. | Dashboard loads. | testmember@rcme.membervu.com / Member123! |
| 2 | Go to My Profile → change avatar. In the file picker, rename a small JPG/PNG on disk to ../../etc/passwd.png before selecting it (or use a browser devtools file-input override if the OS blocks the rename — the browser sends whatever string is in file.originalname, the OS filename restriction is not the control under test). | Upload completes with 200 — no 500, no crash. The UI shows the new avatar thumbnail. | Filename: ../../etc/passwd.png |
| 3 | Reasoning check (no UI surface exists for this): per sanitizeUploadFilename(), this input's basename is passwd.png — NOT extension-less like the raw /etc/passwd case, because a .png suffix survives basename extraction. Confirm the upload did NOT fail and did NOT write any path outside the member's own avatar key. | No path-traversal side effect (no file written outside avatars/<tenantId>/<memberId>/…). The stored S3 key is always a generated avatar-<timestamp>-<random>.png, unrelated to the traversal payload. | — |
Data assertion (reference, not directly visible in UI): feeding the literal
../../etc/passwd (no extension) through sanitizeUploadFilename() yields exactly passwd.bin — no /, no .., a safe fallback extension appended. This is the value written to the Upload.originalFilename audit row and the S3 object metadata (x-amz-meta-originalname), never the raw string.
Step 2: Control-character / whitespace filename (EDGE3-02)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | As Finance Manager, submit a manual bank-transfer payment proof (Finance → Payments → Record Payment, channel = Bank Transfer) for any outstanding invoice, attaching a file whose name contains a tab and a newline before the extension. | Upload completes with 200; the proof attaches to the payment (status PENDING). | Filename (conceptually): re ce<TAB>ipt<LF>.pdf |
| 2 | Open the payment detail / proof viewer for this payment (Finance → Payments → open the row → View Proof, hits GET /api/billing/admin/payments/:paymentId/proof). | Proof file opens/downloads correctly — content-type and bytes intact. The response's Content-Disposition filename is the generated storage key basename (e.g. proof-<timestamp>-<random>.pdf), which is inherently ASCII-safe since it's never derived from user input. | — |
Data assertion (reference):
sanitizeUploadFilename("re ce\tipt\n.pdf") returns re_ce_ipt.pdf — every control/whitespace character collapses to _ (runs of underscores collapse to one), extension preserved, fully ASCII. This is what lands in the Upload.originalFilename audit row (category payment-proofs) — it is what makes this upload NOT break the S3 PutObject metadata header (root cause of the pre-fix #440 bug this hardening also closes).
Step 3: Formula-injection-style filename (EDGE3-03)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | As admin, go to Admin → Organization → Logo and upload a logo file renamed to a formula-injection-style string. | Upload completes with 200; the new logo appears in the org profile preview immediately. | Filename: =cmd|'/c calc'!A0.png |
| 2 | No UI surfaces the raw audit name directly — confirm instead that the upload did not error and the tenant's OrgProfile.logoUrl updated (new logo renders). | Logo updates cleanly; no server error; no injected/odd behavior. | — |
Data assertion (reference):
sanitizeUploadFilename("=cmd|'/c calc'!A0.png") returns c_calc_A0.png — the leading = (CSV/Excel formula-injection trigger) and every |/'/!/space character are stripped or folded to _, so a spreadsheet export of the audit table can never carry a live formula. Extension .png preserved.
Step 4: Avatar/logo/proof stored as bare S3 key + presigned on read (EDGE3-04, EDGE3-05)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Open browser DevTools → Network tab. As member, reload My Profile (the avatar uploaded in Step 1). | The avatar <img> request URL is either (a) an /secure-images/members/<memberId>/<file> redirect-service path, or (b) a direct S3 URL carrying a presigned query string (X-Amz-Signature=…) — never a bare unsigned S3 URL that would 403. | — |
| 2 | Hard-refresh the page again (Ctrl/Cmd+Shift+R) 2+ minutes later. | The avatar still loads. If it's a direct presigned URL, the query string/signature on this load is different from Step 1's (freshly generated per getSignedUrl call) — confirms it is generated on every read, not a stored stale value. | — |
| 3 | Repeat for the org logo uploaded in Step 3 (reload Admin → Organization) and the payment proof from Step 2 (reopen View Proof). | Both resolve the same way — no broken image icon, no 403/AccessDenied. | — |
Data assertion: the DB column itself (
Member.avatarUrl / OrgProfile.logoUrl / Payment.proofUrl) is expected to hold a bare key like avatars/<tenantId>/<memberId>/avatar-<ts>-<rand>.png — never a full URL. uploadOrgLogoHandler (tenantHandlers.ts) explicitly stores multerS3File.key, not .location (#406). Every read path wraps the stored value in presignStoredAsset() (avatars/logos) so a fresh signed URL is issued per request — this is what makes Step 4.2's re-signed query string the pass criterion, not just "an image shows up."
Step 5: Legacy full-URL logo still renders — no backfill (EDGE3-06)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Find (or ask an admin/db-check for) a tenant whose OrgProfile.logoUrl was set before the #406/#407 fix — i.e. still stored as a full S3 URL rather than a bare key. If none exists in staging seed data, this step is a code-reasoning check instead of a live click-through — do not fabricate a URL value. | — | — |
| 2 | Load that tenant's org profile / invoice header where the logo renders. | The logo still renders. extractStorageKeyFromUrl() recognizes S3 path-style, virtual-host, and local-provider URL shapes and strips them down to a key before presignStoredAsset() signs it — so a legacy full-URL value degrades gracefully instead of 403ing. | — |
| 3 | Re-save that tenant's org profile (any no-op edit + Save) via Admin → Organization → Save. | On the NEXT read, logoUrl is now the canonicalized bare key (per the write-time canonicalization in updateTenantOrgProfileHandler, #407) — confirms writes self-heal legacy values without a dedicated backfill migration. | — |
Step 6: External avatar URL passed through untouched (EDGE3-07)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | As admin, open a member record whose avatarUrl is (or can be set to, via any admin member-edit avatar-URL field if exposed) an external absolute URL on a non-S3 host. | Member record saves without error. | Example external host: https://robohash.org/… (any host that is NOT *.amazonaws.com, not s3.membervu.local, not the configured S3_ENDPOINT/S3_PUBLIC_ENDPOINT) |
| 2 | View this member in the admin member list / member detail page, and (if the tenant has a public-attendee-list event) view them in that event's public attendee list as an anonymous visitor. | The avatar renders using the ORIGINAL external URL exactly — inspect the <img src> in DevTools. | — |
| 3 | Confirm the rendered src is NOT rewritten to /images/members/… or /secure-images/members/…. | URL is unchanged from what was set in Step 1 — no basename-stripping, no redirect-service wrapping. | — |
Data assertion:
isInlineOrAbsoluteAvatar() (auth-service/src/services/imageUrl.ts) returns true for any http(s):// URL whose host is NOT recognized as "own S3" (own-S3 = *.amazonaws.com, s3.membervu.local, localhost LocalStack, or the S3_ENDPOINT/S3_PUBLIC_ENDPOINT hosts) — both memberAvatarPublicUrl/memberAvatarPublicUrlAsync and memberAvatarPublicAttendeeUrl return such URLs unchanged, never path.basename-stripped and re-wrapped. This is the #410 fix: an own-S3 URL is deliberately still rewritten (it needs a presign), but a genuinely external URL is not — an external avatar must not 403 or 404 through a bogus redirect.
CLEANUP
- Restore the member's original avatar (Step 1) and org logo (Step 3) if the hostile-filename uploads should not remain as the tester's live profile image.
- If Step 5 re-saved a shared/seeded tenant's org profile, note that its
logoUrlis now canonicalized — this is expected and should NOT be reverted. - If Step 6 set a real member's
avatarUrlto a test external URL, restore it (or clear it) so later regression passes aren't confused by a robohash placeholder.