Skip to content

fix(realunit): bound the tax-residence payload and stop writing user_data.tin on a rejected request#4210

Open
TaprootFreak wants to merge 3 commits into
developfrom
fix/realunit-tin-persistence-hardening
Open

fix(realunit): bound the tax-residence payload and stop writing user_data.tin on a rejected request#4210
TaprootFreak wants to merge 3 commits into
developfrom
fix/realunit-tin-persistence-hardening

Conversation

@TaprootFreak

Copy link
Copy Markdown
Collaborator

Follow-up to #4198. A full review of #4198 together with its client-side counterpart surfaced three defects; this PR fixes all three plus one hardening item.

1. user_data.tin overflows its column (blocker)

user_data.tin is character varying(256), but neither CountryAndTin.tin (no @MaxLength) nor countryAndTINs (no @ArrayMaxSize) was bounded, and the client allows an unbounded number of tax residences. Seven entries with an 11-digit TIN already serialize to 260 characters. Verified against a real Postgres 16:

UPDATE user_data SET "tin" = repeat('x', 260)
ERROR:  value too long for type character varying(256)

Postgres errors rather than truncating, and the write happens after forwardRegistration has already POSTed to Aktionariat and persisted the registration row — so a completed registration answered with a 500, and every retry hit the idempotent path, re-ran the same write and reproduced it.

Fixed on three levels:

  • Bound the input: @MaxLength(64) on CountryAndTin.tin, @ArrayMaxSize(10) on countryAndTINs, mirrored by service-level guards.
  • Fit the column: widen user_data.tin to varchar(1024) (migration) — the bounded worst case serializes to ~890 characters. Widening a varchar length in Postgres is a catalog-only change, no table rewrite.
  • Fail closed up front: validateRegistrationDto now rejects an oversized payload with a 400 before anything is forwarded, so the write can never fail on a registration that is already durable.

@Transform(Util.trim) on tin also closes a smaller gap: the TIN was validated as non-empty after trimming but stored and forwarded untrimmed.

2. A rejected request had already mutated the database (blocker)

In the idempotent branch of completeRegistration, persistUserDataTinAfterRegistration ran one line before idempotentRegistrationResult — which rejects a mismatching signature with a 400. countryAndTINs is not part of the EIP-712 envelope, so the request body cannot describe what was actually registered either: an already-registered user could sign a fresh payload carrying arbitrary TINs, have user_data.tin overwritten, and then receive the 400. The snapshot drifted away from the Aktionariat record of truth with no new registration event — which is exactly what the "Event before snapshot" rule added to CONTRIBUTING.md in #4198 forbids.

Now the signature is checked first, and the snapshot is synced from the durable registration row (toRegistrationDto), never from the request body.

3. @ValidateIf disabled @IsArray → 500 instead of 400 (major)

@ValidateIf((o) => !o.swissTaxResidence) skipped every validator on countryAndTINs — including @IsArray — as soon as swissTaxResidence was true. Verified against the real ValidationPipe: { swissTaxResidence: true, countryAndTINs: "pwned" } passed validation, and entries.some(...) then threw a TypeError → HTTP 500. The service comment claimed to enforce the nested shape, but only checked the elements, never that entries was an array at all.

The condition now also fires whenever the field is present, and the service keeps a defensive Array.isArray guard.

4. Unguarded JSON.parse(user_data.tin) in the prefill (minor)

A malformed legacy value took down GET registration-info with a 500 — the only channel through which the user could submit a corrected declaration. It now degrades loudly (PII-free error log) to "no known tax residences", and also drops contract-violating entries (e.g. a legacy CH entry) so the prefill can never hand back a payload the validator would reject.

Test plan

  • format:check, lint, type-check
  • Full suite: 3008 passed
  • Column overflow reproduced and the fix verified against a real Postgres 16
  • @ValidateIf gap reproduced against the real NestJS ValidationPipe

…data.tin on a rejected request

Three defects in the tax-residence contract:

- user_data.tin is varchar(256), but neither CountryAndTin.tin nor countryAndTINs was
  length-bounded and the client allows an unbounded number of tax residences. Seven
  entries with an 11-digit TIN already serialize to 260 characters, so the UPDATE threw
  'value too long for type character varying(256)' — AFTER forwardRegistration had already
  persisted the registration, turning a completed registration into a 500 that every retry
  reproduced. Bound the input (10 entries, 64-character TIN), widen the column to
  varchar(1024) to cover that bounded maximum, and reject an oversized payload up front in
  validateRegistrationDto so it can never fail once the registration is durable.

- The idempotent branch of completeRegistration wrote user_data.tin BEFORE
  idempotentRegistrationResult rejects a mismatching signature, so a request answered with a
  400 had already mutated the column. countryAndTINs is not part of the EIP-712 envelope, so
  the body cannot describe what was actually registered either. Check the signature first and
  sync the snapshot from the durable registration row instead of the request body.

- @ValidateIf(!swissTaxResidence) disabled every validator on countryAndTINs — including
  @isarray — whenever swissTaxResidence was true, so a non-array body reached the service and
  crashed it with a TypeError (500 instead of 400). Validate whenever the field is required or
  present, and keep a defensive Array.isArray guard in the service.

Also replace the unguarded JSON.parse(user_data.tin) in the registration prefill: a malformed
legacy value took down the only endpoint through which the user could submit a corrected
declaration. It now degrades loudly (PII-free error log) to 'no known tax residences'.
The @Transform(Util.trim) added to CountryAndTin.tin runs in class-transformer
before class-validator, so a non-string tin (e.g. a number) made value.trim()
throw a TypeError => HTTP 500 before @IsString could reject it as a 400 — the
exact 500-instead-of-400 regression this change set exists to remove. Trim only
actual strings and let any other type fall through to @IsString.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Ran the mandatory pre-review process: two full review passes (conformance + logic). The first pass surfaced one issue (the newly added @Transform(Util.trim) on CountryAndTin.tin threw a TypeError on a non-string tin, turning a 400 into a 500); it was fixed and the second pass came back clean — 0 issues.

Verified on a build host: format:check, lint, type-check and the full test suite (3009 passed) are green.

@TaprootFreak TaprootFreak marked this pull request as ready for review July 15, 2026 06:23
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.

1 participant