Skip to content

fix(server): reject a NUL or a lone surrogate in a text value with a 422 - #122

Merged
pdevito3 merged 3 commits into
mainfrom
fix/issue-104-text-control-characters
Oct 25, 2026
Merged

pdevito3 merged 3 commits into
mainfrom
fix/issue-104-text-control-characters

Conversation

@pdevito3

Copy link
Copy Markdown
Owner

Why

A text value with U+0000 or a lone UTF-16 surrogate passed the domain checks, then Postgres refused it, and the API gave 500 in place of 422. Postgres text cannot hold U+0000, and UTF-8 cannot encode a lone surrogate. The domain checked only the length. Also, AddTenant, AddUser, and InitiateUserAndTenant sent the raw request value to a unique-value query before the entity validated it, so that query failed first.

Closes #104

What changed

  • ValidationException.ThrowWhenHasCharacterPostgresCannotStore is the one shared check. Each text value that a request sets calls it, next to its length check, in User, Tenant, Agent, and EmailAddress. The message is "A first name has a character that is not allowed.", with the name of the field.
  • AddTenant, AddUser, and InitiateUserAndTenant create the entity first and query with its value, as UpdateTenant and AddAgent already did.
  • Rule 10 in docs/backend/domain-entities.md and the validation rules in docs/backend/features-and-cqrs.md state the check and the order.

Character rule

The domain refuses U+0000 and each unpaired surrogate (U+D800 to U+DFFF without its pair). These are the only characters that Postgres cannot store. A valid surrogate pair, such as an emoji, is allowed.

Other control characters stay allowed. Postgres stores U+0001 to U+001F, U+007F, and U+0085, and the repro gave 201 for each of them in a first name. None of them gives a 500. Whether a name can hold a tab or a line feed is a product rule for each field, and a later multi-line field needs them. This PR does not make that rule.

Scope

  • Covers each write path of a domain text value, on HTTP, gRPC, and MCP.
  • Also refuses a requested tenantId that is not a valid tenant ID before the query in AddUser and AddAgent, with the same "The tenant does not exist." message.
  • Leaves out the read inputs. A NUL in a filter, in a gRPC or MCP ID, or in the entityId of the audit page still gives 500. See fix(server): a NUL in a filter, an ID, or the audit entity ID gives a 500 in place of a 400 or a 404 #121.
  • A lone surrogate cannot get to the Server today. System.Text.Json refuses it with 400, and the gRPC UTF-8 decoder changes it to U+FFFD. The domain check covers in-process callers, such as a MediatR command.

Blast Radius

Only values that failed with 500 now fail with 422. In the three reordered handlers, a request with a taken value and a field that is not valid now gets 422, not 409.

Verification

  • Before: a FunctionalTests probe on real Postgres gave 500 for "firstName":"a\u0000b" on POST /api/v1/users (22P05: unsupported Unicode escape sequence) and for a tenant name on PUT /api/v1/tenants/{id} (22021: invalid byte sequence for encoding "UTF8": 0x00). gRPC AddTenant gave Unknown, and MCP update_tenant gave "An error occurred invoking 'update_tenant'."
  • After: the same probe gives 422 with errors.firstName and errors.name, gRPC gives InvalidArgument with a field violation on name, and MCP gives invalid_argument.
  • The guard test in StringColumnLengthTests sends U+0000, U+D800, and U+DC00 to each of the 8 caller columns (24 failed before the fix, 0 after). CharacterPostgresCannotStoreTests sends a NUL to each of the 19 text fields of the 6 write endpoints (20 of 20 failed before with 500, all pass after), plus the two tenantId cases (500 before, 422 after). dotnet test --solution FullstackTemplate.slnx: 1767 passed.

The domain checks only the length of a text value. Postgres text cannot
hold U+0000, and UTF-8 cannot encode a lone surrogate, so the save fails
and the API gives 500. These tests fail until the domain refuses both:

- StringColumnLengthTests writes U+0000, U+D800, and U+DC00 to each
  caller column and expects a ValidationException on the request field.
- CharacterPostgresCannotStoreTests sends a NUL in each text field of
  each write endpoint over HTTP and expects 422 on the field.
- The gRPC AddTenant and MCP update_tenant tests expect an invalid
  argument on the name.
Postgres text cannot hold U+0000, and UTF-8 cannot encode a lone
surrogate. The domain checked only the length of a text value, so such
a value got to Postgres and the API gave 500.

ValidationException.ThrowWhenHasCharacterPostgresCannotStore refuses
both. Each text value that a request sets calls it: the first name,
last name, identifier, and username of a user, the email, the tenant
name, and the agent name and client ID. Other control characters stay
allowed, because Postgres stores them.

AddTenant, AddUser, and InitiateUserAndTenant ran their unique-value
queries with the raw request value before the entity validated it, so
the query failed first. They now create the entity first and query with
its value. UpdateTenant now also queries with the value of the entity.

AddUser and AddAgent sent the requested tenant ID to a query before any
check. A caller that manages all tenants could send a NUL there. An ID
that is not a valid tenant ID now gets "The tenant does not exist."
without a query.
@pdevito3

Copy link
Copy Markdown
Owner Author

Review gate: two independent review passes found no high or medium findings. The first pass found three low ones (the tenant ID query in AddUser and AddAgent, the query value in UpdateTenant, and a JSON body built by string interpolation in a test). The second pass found one low gap in features-and-cqrs.md. All four are fixed in this PR. One note stays open, out of scope: GetById sends a request ID with a NUL to Postgres. It is tracked in #121.

@pdevito3
pdevito3 merged commit f226e4e into main Oct 25, 2026
9 checks passed
@pdevito3
pdevito3 deleted the fix/issue-104-text-control-characters branch October 25, 2026 11:18
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.

fix(server): a NUL or a lone surrogate in a text field gives a 500 in place of a 422

1 participant