Compare commits

..

1 Commits

Author SHA1 Message Date
Savannah Savings 21c678f72c fix(portal): drop writable photoKey from PATCH /portal/pets to close S3 key-hijack (GRO-2187 / GRO-2198)
CI / Test (pull_request) Successful in 25s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Images (pull_request) Successful in 58s
Security gate FAIL (Barkley, CTO-ratified): the portal pet PATCH mapped
`body.photoUrl -> updateData.photoKey` with no validation. photoKey is a
trusted S3 object key consumed server-side by getPresignedGetUrl (read) and
deleteObject (destructive); the upload path (pets.ts) already guards keys with
a pets/{petId}/ prefix to prevent hijacking. The portal PATCH bypassed that
guard, letting an authenticated customer point their pet's photoKey at any
object key (cross-tenant disclosure/destruction on a later staff action).

Fix (per CTO directive — smallest safe surface):
- Remove `photoUrl` from portalPetUpdateSchema and delete the
  `updateData.photoKey = body.photoUrl` mapping. Photo changes already have a
  dedicated, key-validated flow (upload + /photo/confirm). The web form's
  round-trip of the GET-shaped photoUrl is a no-op (Zod strips the unknown key).
- LOW hardening: cap preferredCuts (string max 2000, array max 50),
  medicalAlerts (array max 50) and medicalAlert type/description (max 2000),
  mirroring the existing free-text max(2000) caps.

Tests:
- New regression: foreign photoUrl on portal PATCH returns 200 but leaves
  photoKey unchanged (gate evidence).
- New: over-long medicalAlert description rejected (400, zValidator).
- Updated the existing "persists mapped columns" test: photoKey is no longer
  written by the portal PATCH.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-08 12:36:56 +00:00
3 changed files with 0 additions and 13 deletions
-11
View File
@@ -1,11 +0,0 @@
{
"mcpServers": {
"gitea": {
"type": "http",
"url": "https://git-mcp.farh.net/mcp",
"headers": {
"Authorization": "Bearer ${GITEA_TOKEN}"
}
}
}
}
-2
View File
@@ -165,8 +165,6 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode
| TC-API-3.19b | Get pet profile summary — customer cross-tenant blocked (GRO-2013) | Sign in as `uat-customer@groombook.dev`; reuse the customer's sessionId from TC-API-3.19a; `GET /api/pets/{otherClientPetId}/profile-summary` for a pet owned by a different client (`c0000002-...` or any non-customer pet) | 403 Forbidden (owner-bypass requires session.clientId === pet.clientId) |
| TC-API-3.19c | Get pet profile summary — customer without portal session header | Same as TC-API-3.19a but omit the `X-Impersonation-Session-Id` header | 403 Forbidden (no owner-bypass without valid portal session) |
| TC-API-3.19d | Get pet profile summary — owner-bypass writes audit row (GRO-2063) | Same setup as TC-API-3.19a (sign in as `uat-customer@groombook.dev`, establish a portal session for the customer's own clientId, call `GET /api/pets/{ownPetId}/profile-summary` with `X-Impersonation-Session-Id: {sessionId}` and a 200 OK response). Then call `GET /api/impersonation/sessions/{sessionId}/audit-log` and confirm there is exactly one entry with `action === "read_profile_summary"`, `pageVisited` matching the profile-summary path, and `metadata` containing `petId` and `actorStaffId` for the customer. Repeat TC-API-3.19b (cross-tenant attempt) and confirm NO new `read_profile_summary` row was written for the cross-tenant attempt. | 200 OK on the profile-summary call AND an audit log entry is present with the correct shape (defense-in-depth audit row; bypass attempts against other clients must NOT log) |
| TC-UAT-2 | Groomer accesses linked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000002/profile-summary` (UAT Pup Alpha — linked via deterministic completed appointment `a0000001-0000-0000-0000-000000000001`, service `b0000001-…-0001` "Bath & Brush", `startTime` ~7 days ago) | 200 OK, `recentGroomingHistory[]` non-empty (>=1 entry), `visitCount >= 1`, `upcomingAppointment` null (the seeded appointment is in the past) |
| TC-UAT-3 | Groomer blocked from unlinked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000003/profile-summary` (UAT Pup Beta — intentionally UNLINKED; no appointment row references this pet's clientId+groomerId combo) | 403 Forbidden (RBAC `groomer` role lacks the appointment-linkage grant for this pet). NOTE: if 404 is returned instead of 403, file a separate RBAC defect (not against the seed) — see GRO-2100 verification note |
| TC-API-3.29 | Get pet profile summary — unknown UUID returns 404 (GRO-2014) | GET /api/pets/00000000-0000-0000-0000-000000000001/profile-summary while authenticated (any role) | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014) |
| TC-API-3.30 | Get pet profile summary — malformed UUID returns 404 (GRO-2014) | GET /api/pets/not-a-uuid/profile-summary while authenticated | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014 — Postgres uuid cast failure) |
| TC-API-3.31 | Get pet profile summary — never empty-body 500 (GRO-2014) | GET /api/pets/{anyId}/profile-summary across the test sweep | No response has status 500 with an empty body. Any 500 must include a JSON body `{"error":"Internal Server Error"}` |
View File