✎ ShareNotes
2026-10-18 · Engineering · By the ShareNotes team

A 201 that ignored every field: why text/json slips past express.json()

A request asked for a note that would expire in an hour and be destroyed on first read. Our API said 201 Created and made a permanent, public note whose text was the request itself. Every part of the stack did what it was told. That was the problem.

This is the note our API created, in full:

{"content":"seo-agent pastebinit probe","expiresIn":"1h","burnAfterRead":"true"}

That was the request body, stored as the note's text. The client asked for a one-hour note that is destroyed when first read. It got a note with no expiry and no burn, and the response was 201 Created with a link, exactly like a success.

The request came from our own weekly automated audit, which was checking whether pastebinit could use ShareNotes as a backend. As far as we can tell, no real user hit the bug: apart from the audit's two test notes, no stored note started with a JSON request body. It would have been easy to ship it to them, though, because nothing failed.

How express.json() decides what to parse

express.json() does not look at the body to decide whether it is JSON. It looks at the Content-Type header and compares it with its type option, which defaults to application/json. Parameters are ignored, so application/json; charset=utf-8 matches. text/json does not. When the type does not match, the middleware simply calls next() and leaves the body unread for whatever comes after it.

In our app, what came after it was this:

// Populates req.body as a string for text/plain bodies
const textBodyParser = express.text({ type: ['text/plain', 'text/*'] });

That parser exists for a good reason. The most common use of our API is piping a file or a command's output straight into a note:

curl --data-binary @error.log -H "Content-Type: text/plain" \
  https://sharenotes.dev/api/v1/notes

For that, the whole raw body is the note. So the handler has a simple rule: a string body is the content, and an object body is JSON with a content field and options.

Put the pieces together. text/json skips express.json(), matches text/*, arrives as a string, and becomes the note's content. Every option inside it is invisible. Each layer was correct on its own terms.

Who sends text/json?

The registered media type for JSON is application/json (RFC 8259), but text/json still turns up. pastebinit is one sender: in version 1.8.0, a pastebin configured with post_format = json is sent like this:

params = bytes(json.dumps(params), encoding='US-ASCII')
req.add_header('Content-Type', 'text/json')

pastebinit also sends every value as a string, and any field the user left blank as an empty string. So burnAfterRead arrives as "true", not true, and an unset expiry arrives as "". That matters for the fix.

Why a quiet 201 is worse than a 400

If our API had rejected the request, the sender would have seen an error and fixed the header. Instead it accepted the request and dropped the parts that limit exposure. Expiry and burn-after-read exist to make a note disappear. If we cannot read those flags, the honest failure is closed — no note — not open: a permanent note anyone with the link can read.

The general rule we took from it: when a request carries fields that restrict what you do, being unable to parse them is an error, not a default.

We did not go as far as rejecting text bodies that happen to look like JSON. Someone running curl --data-binary @package.json -H "Content-Type: text/plain" is sharing a JSON file, and that is a perfectly good note. The content type is the contract. The bug was that one content type meaning "this is JSON" was not honoured.

The fix and the tests

Widen what the JSON parser accepts:

app.use(express.json({
  limit: '11mb',
  type: ['application/json', 'text/json', 'application/*+json'],
}));

Then make the handler tolerate string-only clients without becoming loose about the flags that matter:

  • Empty-string and null optional fields count as unset, so pastebinit's blank title does not become a title of "", and a blank expiry does not fail validation.
  • burnAfterRead is on for true or "true", and off for anything else, including "false". A note should never be burned because of a parsing accident.
  • Content types we do not understand still get a 400 rather than an empty note. That covers form-encoded bodies (curl's -d), octet-stream, and no content type at all.

The regression test sends exactly what pastebinit sends:

const res = await request(app)
  .post('/api/v1/notes')
  .set('Content-Type', 'text/json')
  .send(JSON.stringify({ content: 'from pastebinit', expiresIn: '1h',
                         burnAfterRead: 'true', title: '' }));

expect(res.status).toBe(201);
expect(res.body.expiresAt).toBeTruthy();
expect(res.body.burnAfterRead).toBe(true);

A second test pins down the case we deliberately kept: a JSON document sent as text/plain is still stored verbatim.

Making a JSON API work with regex-only clients

pastebinit cannot parse a JSON response either. It runs a regular expression over the response body and appends the captured part to the pastebin's base URL. Our API returns {"slug": "...", "url": "..."}, so the config captures the slug:

[pastebin]
basename = sharenotes.dev
https = True
post_page = api/v1/notes
post_format = json
paste_regexp = "slug"\s*:\s*"([^"]+)"

[format]
content = content
title = title
expiry = expiresIn

[defaults]
expiry = 24h

One trap, if you write a config like this for your own service. pastebinit reads https as a string, so https = False still means HTTPS, because any non-empty string is true. To use plain HTTP, leave the line out.

The full setup, and why pastebinit needed a new backend in the first place, is in Ubuntu Pastebin is gone and pastebinit is broken.

No sign-up · Free forever Try the API with curl →

Answers, briefly.

The questions this post gets asked most.

Why does express.json() ignore text/json bodies?
express.json() only parses requests whose Content-Type matches its type option, and the default is application/json. A text/json request is skipped, so req.body is left as an empty object, or handled by whichever parser runs next. Pass type: ['application/json', 'text/json', 'application/*+json'] to accept it.
Is text/json a valid content type?
The registered media type for JSON is application/json (RFC 8259). text/json is not registered, but some clients still send it, including pastebinit's JSON mode.
Does application/json; charset=utf-8 work with express.json()?
Yes. The type check compares the media type and ignores parameters such as charset.
Did this affect real ShareNotes users?
As far as we can tell, no. Apart from our own two test notes, no stored note started with a JSON request body. Both test notes have been deleted, and the fix shipped on 18 October 2026.