Skip to content

fix(identity): reject stale profile updates with ETag/If-Match instead of losing the write - #1366

Open
marcelo-maciel wants to merge 6 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/identity-profile-concurrency
Open

fix(identity): reject stale profile updates with ETag/If-Match instead of losing the write#1366
marcelo-maciel wants to merge 6 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/identity-profile-concurrency

Conversation

@marcelo-maciel

@marcelo-maciel marcelo-maciel commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

What

PUT /identity/profile is a full-representation update with no concurrency token, so two
overlapping self-updates silently lose one another's changes (#1359). This adds an optimistic
precondition: GET /identity/profile returns a strong ETag, PUT honours If-Match, and a
stale token is answered with 412 Precondition Failed instead of overwriting the newer write.

Closes #1359.

The token is already there — no migration

The issue's own suggested fix proposed a new RowVersion/xmin column. That isn't needed:
AspNetUsers.ConcurrencyStamp is already mapped IsConcurrencyToken() in the snapshot, and
ASP.NET Identity's UserStore.UpdateAsync rotates it on every write. It can serve as the
validator directly, which keeps this change at zero schema impact and avoids carrying two
concurrency tokens on one entity.

The stamp is exposed as the ETag and never as a body field ([JsonIgnore] on
UserDto.ConcurrencyStamp), so it stays out of the OpenAPI contract and cannot be spoofed
through the request body.

Behaviour

Request Result
No If-Match Accepted, as before. Backward compatible.
If-Match matching the stored stamp Accepted; the stamp rotates.
If-Match: * Accepted (RFC 9110: matches any current representation).
Stale If-Match 412, nothing written.
Weak validator (W/"...") 412If-Match mandates the strong comparison function.
Malformed header 400, not 412: a 412 would send a well-behaved client into a refetch-and-retry loop it can never win, since the malformed header is its own bug.

Two smaller fixes fell out of this:

  • UserManager.UpdateAsync answers a lost race with IdentityResult.Failed(ConcurrencyFailure())
    rather than throwing, so it used to surface as a generic 500. It now maps to the same 412.
  • RefreshSignInAsync ran before the success check, refreshing the sign-in even when the update
    had failed. It now runs only on success.

Ordering matters

The precondition is checked immediately after FindByIdAsync, ahead of the storage calls and
ahead of SetPhoneNumberAsync. Anywhere later and a 412 would already have orphaned an upload,
or — on the deleteCurrentImage path — deleted the avatar with no database change to show for it.
UpdateProfile_Should_KeepAvatar_When_IfMatchIsStaleAndDeleteCurrentImageRequested covers exactly
that; I confirmed it goes red if the guard is moved down.

Worth naming for reviewers: SetPhoneNumberAsync is a second database write and its
IdentityResult is discarded (pre-existing, untouched here). The handler is therefore not atomic
across the two writes. A phone-number race is still reported, because the subsequent UpdateAsync
also fails and that failure now maps to 412 — but the mechanism is that mapping, not atomicity.

Client

clients/dashboard reads the ETag from the profile fetch, echoes it as If-Match on save, and
retries once on 412 against a fresh read. The retry is deliberate: the stamp also rotates on
writes the user never perceives as a profile edit (a password change, a failed sign-in, a new
avatar), so a single 412 should resolve itself rather than surface as a failed save.

clients/admin has no PUT /identity/profile caller on main, so nothing to change there. (The
issue text claims both apps write the profile — that part of my own report was wrong.)

The CORS piece — why this PR touches BuildingBlocks

An ETag/If-Match contract is a no-op in the browser unless CORS cooperates, and it did not:

  • ETag is not a CORS-safelisted response header. FSH.Framework.Web.Cors never called
    WithExposedHeaders, so a browser hid the tag from JS on every cross-origin call — which is
    every dev run, since both React apps point apiBase at the API's own origin. The client then
    read null, stopped sending If-Match, and the endpoint degraded straight back to the lost
    update it now prevents. One WithExposedHeaders call (plus a four-line comment), placed after the
    branch so it covers both policies — neither AllowAnyHeader nor WithHeaders implies exposure.
  • if-match is not a safelisted request header either. It joins AllowedHeaders in both
    shipped appsettings, otherwise AllowAll: false strips the precondition before the endpoint
    sees it.

I know src/BuildingBlocks is protected, so this is deliberately the smallest possible change and
kept in its own commit (feat(cors): expose ETag and allow If-Match…) — easy to drop or rework
without touching the rest. Happy to split it into its own PR if you'd rather review it separately.

Both directions are gated rather than described in a comment: CorsPolicyTests asserts the exposure
at the policy level for both branches, and
GetProfile_Should_ExposeETagToCrossOriginCallers_When_ProfileIsRead asserts it end to end against
a cross-origin request. The front-end specs mock the header, so without a server-side gate nothing
would notice the policy dropping it. Verified by mutation: removing the argument turns all three
red.

Separately and not fixed here: the tenant header both front-ends send on every request is
also missing from AllowedHeaders, so anyone enabling the restricted policy today is already
broken. Filed as #1367 rather than folded into this one.

Also in this PR

Directory.Packages.props pins SSH.NET to 2026.0.0 (cherry-picked byte-identical from #1333):
NU1903 breaks restore on main too, so nothing builds without it. Drop that commit once #1333
merges.

Verification

  • Integration suite (Testcontainers): 756 passed / 1 skipped (pre-existing) / 0 failed
  • All 13 other test projects green, aggregate exit 0 (~1057 tests)
  • clients/dashboard Playwright: 152 passed; tsc -b clean and eslint . exit 0 (12 pre-existing
    react-refresh warnings, none in touched files)
  • Mutation-checked at three points: reverting the precondition guard to a no-op turns 4 tests red;
    moving it below the storage calls turns the avatar test red; dropping the CORS exposure turns
    the three new CORS gates red.

Docs + changelog in fullstackhero/docs follow in a companion PR.

…1333 is open

`NU1903` / `GHSA-q939-rpr3-3284` on `SSH.NET` 2025.1.0, pulled transitively by
Testcontainers, fails `restore` for the whole solution under
`TreatWarningsAsErrors` — on `main` too. It is not introduced here and the fix
belongs to fullstackhero#1333, which is still open.

Carried byte-identical to fullstackhero#1333's version of the file, comment included, so both
stay mergeable in either order and this copy can simply be dropped once fullstackhero#1333
lands.
`PUT /identity/profile` is a full-representation update: every field is assigned
from the request, so a caller working from a stale read blanks whatever changed
in between. Nothing on the request said which version the caller had edited, so
the server could not tell a deliberate overwrite from a lost update and accepted
both.

`AspNetUsers.ConcurrencyStamp` is already mapped as an EF concurrency token and
Identity's store rotates it on every `UserManager.UpdateAsync`, so the version
marker exists — it just was not on the wire. `GET /identity/profile` now
publishes it as a strong `ETag`, and `PUT /identity/profile` honours `If-Match`:
a token that no longer matches gets `412 Precondition Failed` instead of
silently winning. No migration and no schema change.

The header stays optional — absent means today's behaviour, so existing clients
keep working. A `ponytail:` comment marks the future path where it becomes
required and a missing header answers `428`.

Details worth calling out:

- The precondition is checked immediately after the user is loaded, before the
  storage calls. Any later and a rejected update would already have uploaded an
  orphan blob or, on the `deleteCurrentImage` path, deleted the avatar for a
  request that then fails and changes nothing in the database.
- `IdentityResult`'s `ConcurrencyFailure` is mapped to the same 412. Identity's
  store returns it rather than throwing, so a race lost one layer down used to
  surface as a generic 500.
- `RefreshSignInAsync` now runs after the success guard. It used to refresh the
  sign-in even when the update had failed.
- `*` in `If-Match` asks only that the resource exist. Weak validators can never
  satisfy the strong comparison the header mandates, so they answer 412. A
  malformed header answers 400: 412 would send a client into a refetch-and-retry
  loop it can never win, since the broken header is its own bug.

Tests: integration coverage for the ETag shape, matching/stale/list/`*`/weak/
malformed preconditions, token rotation and the avatar-survives-412 case, plus a
handler unit test that the tokens reach the service.
The avatar case only checked the image URL. `SetPhoneNumberAsync` persists on its
own, ahead of the final `UserManager.UpdateAsync`, so a precondition checked too
late would let a field through on a request that then answers 412. Asserting the
name as well pins that down, and the comment now says what the test proves rather
than claiming the storage call itself is observed.
`updateMyProfile` reads the profile, merges the edited fields and PUTs the whole
representation back. Nothing tied that write to the version it was built from, so a
concurrent change — another tab, a phone, a slow save racing a fast one — was
silently overwritten.

The read now also picks up the profile's `ETag` and the PUT echoes it in `If-Match`,
so the server can answer 412 instead of accepting a stale representation. A 412 is
retried once from a fresh read: the token rotates on writes the user never thinks of
as profile edits (a password change, a failed sign-in, a new avatar), and turning
those into a failed save would be noise. A second 412 propagates.

`apiFetch` grew an `onResponse` hook, because it returns the parsed body and there
was no way to reach a response header from a caller.

Note for anyone running the API on a separate origin (the dev setup does — the page
is on 5174 and the API on 7030): `ETag` is not a CORS-safelisted response header, so
the browser hides it from JS unless the API also sends
`Access-Control-Expose-Headers: ETag`, and `If-Match` has to be an allowed request
header. The framework's CORS policy does neither today, which is a separate change
in protected code. Until it lands this path degrades to the old behaviour — the
client reads no tag and sends no precondition. Same-origin deployments (the shipped
`apiBase: ""` default) are unaffected.
The dashboard specs mock `Access-Control-Expose-Headers: ETag`, which the API does
not send: `FSH.Framework.Web.Cors` never calls `WithExposedHeaders`. A browser
therefore hides the tag from JS on any cross-origin call, the client stops sending
`If-Match`, and the endpoint silently falls back to the lost-update behaviour this
branch set out to fix -- with every test still green.

Assert it instead of describing it in a comment. The test is skipped so the suite
stays green until the framework change lands (protected code, needs approval);
the skip reason names exactly what has to change to un-skip it.

Verified: un-skipped it fails on the missing header; with `WithExposedHeaders("ETag")`
added locally to the AllowAll branch it passes. That temporary edit was reverted --
`src/BuildingBlocks` is untouched by this branch.

Refs fullstackhero#1359
…itions

`ETag` is not a CORS-safelisted response header, so a browser hid it from JS on every
cross-origin call -- which is every dev run, since both React apps point `apiBase` at
the API's own origin. A front-end that cannot read the validator cannot send `If-Match`,
so the optimistic-concurrency precondition on `PUT /identity/profile` degraded straight
back to the lost update it exists to prevent, with the whole suite still green.

Exposed for both policy branches: neither `AllowAnyHeader` nor `WithHeaders` implies
exposure, and the header carries no data of its own, only a validator.

`if-match` joins `AllowedHeaders` in both shipped appsettings for the mirror-image reason:
with `AllowAll: false` the request header is stripped before it reaches the endpoint.

Gates: `CorsPolicyTests` covers both branches at the policy level and
`GetProfile_Should_ExposeETagToCrossOriginCallers_When_ProfileIsRead` covers it end to end,
so the front-end mocks can no longer hide a server that stops sending the header. Verified by
mutation -- dropping the argument turns all three red; restored and re-run green.

Refs fullstackhero#1359
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@marcelo-maciel

Copy link
Copy Markdown
Contributor Author

CI note: the analyze (CodeQL) check is red for a platform reason, not a finding. GitHub is in a
partial outage right now (incident opened 2026-08-17 13:40 UTC) and the job died uploading its
results, after every query had already been interpreted:

##[error]No server is currently available to service your request.
Uploading failed SARIF file ../codeql-failed-run.sarif
CodeQL job status was failure.

Everything else is green: Backend CI, Frontend CI, Unit Tests, Integration Tests, E2E (admin and
dashboard), both scaffold builds and the DbMigrator smoke — 14 success, 2 skipped (publish jobs).
A re-run of analyze once the outage clears should be enough; I don't have permission to trigger
one here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Lost update on PUT /identity/profile: no concurrency token on a full-representation update

1 participant