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: https://stg-rcme.membervu.com/login
Org profile (logo): https://stg-rcme.membervu.com/admin/organization
Member profile (avatar): https://stg-rcme.membervu.com/profile
Admin 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)

StepAction (EXACT clicks/typing)Expected Result (EXACT text/behavior)Test Value
1Go to https://stg-rcme.membervu.com/login; sign in as member.Dashboard loads.testmember@rcme.membervu.com / Member123!
2Go 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
3Reasoning check (no UI surface exists for this): per sanitizeUploadFilename(), this input's basename is passwd.pngNOT 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)

StepActionExpected ResultTest Value
1As 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
2Open 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)

StepActionExpected ResultTest Value
1As 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
2No 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)

StepActionExpected ResultTest Value
1Open 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.
2Hard-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.
3Repeat 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)

StepActionExpected ResultTest Value
1Find (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.
2Load 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.
3Re-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)

StepActionExpected ResultTest Value
1As 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)
2View 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.
3Confirm 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