November 3, 202612 min read

Tracing an Automated Attack Through Next.js Error Logs

Next.jsTypeScriptFull-Stack DevelopmentTesting

Last week, production logs began showing a pair of errors on KittenPlein’s messaging route:

TypeError: b.get is not a function
TypeError: Cannot read properties of undefined (reading 'get')

Both came from POST requests to /account/berichten/[id]. Because the route uses Next.js Server Actions, an error around .get() initially looked like a malformed form submission.

The request context told a more interesting story. The user agent was Python-urllib/3.11; the route was an action; and the render source was a React Server Components payload. A Python client calling a browser-oriented Server Action is not proof of an attack, but repeated malformed requests were enough to investigate.

That investigation grew from two JavaScript errors into a review of input validation, authentication, authorization, logging and operational controls. It also reinforced a lesson worth keeping: an error log is evidence, not a verdict.

Start With What The Records Actually Prove

The two error groups shared a stored IP address and accounted for 25 recorded occurrences. The same address was associated with an account signup and a later login shortly before the errors began.

That gave me an account to review, but not complete attribution. The error records did not contain an authenticated user ID. An IP match connects activity, yet it cannot establish who controlled every request from that address.

The same source also appeared in registration errors. The timing, scripted client and malformed payloads strongly suggested automated probing. They did not establish successful SQL injection, code execution or a full account compromise.

That distinction shaped the rest of the response. I fixed the behaviors I could reproduce, reviewed adjacent attack surfaces, and kept the uncertainty explicit where the available records ended.

Scanner Traffic Is Often Opportunistic

The original requests were only part of the traffic. Another client, libredtail-http, requested paths such as:

/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php

KittenPlein does not run PHP. Other requests looked for environment files, Git directories and WordPress routes. These were recognizable probes for software that was not installed.

They came from different sources, and I could not prove they were operated by the same person. That was fine: the priority was reducing exposed behavior, not constructing a narrative around every IP address.

The listing pages were probed too:

/kittens?min=100%27
/kittens?min=100%27+AND+%271%27%3D%272
/kittens?min=999999999999999999999999

In one fifteen-minute window, a single source made 1,624 requests and accounted for all 58 HTTP 500 responses. The price filter converted a URL parameter with Number(value) * 100 before passing it to the database. Invalid values became NaN; oversized values exceeded the integer range used for prices.

The queries already used bound parameters. The observed failures showed invalid input causing query errors, not an attacker changing SQL structure. Still, a public URL should never be able to create avoidable database work or a stream of 500s.

Validation Should Describe The Field, Not The Attacker

The fix was a shared price parser that accepts non-negative decimal values and verifies the database integer range. Saved searches received the same validation. At the request boundary, malformed price bounds are rejected before rendering. I also bounded listing IDs before database queries and tightened repeated scalar filters such as q and provincie.

The important design choice was what I did not block. I did not add a generic rejection rule for quotes or SQL keywords. Free-text search legitimately contains punctuation. Each field needs validation based on the values the product supports:

  • Price fields accept a bounded decimal representation.
  • IDs accept bounded identifiers.
  • Enumerated filters accept known values.
  • Free text gets length, shape and request-budget limits without pretending punctuation is inherently hostile.

This makes application behavior clearer and avoids breaking normal search while still removing the failure paths that probes found.

A Ban Is Not A Ban Until Login And Session Resolution Enforce It

For the account connected to the original activity, I revoked existing sessions and blocked the observed source address. A later check showed the account could sign in again from a new IP.

The gap was straightforward: session revocation invalidated old tokens but did not prevent a new login. The ban needed to be part of both authentication and session resolution. After adding account-status checks to both paths, a blocked account could not create a fresh session or continue with an existing one.

This is a useful reminder that security state has to be enforced at every entry point it is meant to govern. A database flag alone is just data until the authentication flow consistently applies it.

A Redirect Does Not Prove Data Stayed Private

The most serious finding appeared during review of adjacent admin routes. An unauthorized request received a redirect, but its response body could still include private conversation data.

The layout checked access, yet a child page could load and render data before that check completed. The access log made the request appear rejected because it recorded the redirect. It did not prove that no sensitive data had been present in the response.

I reproduced the exposure and added authorization checks before data loading on every admin page. The available records could confirm the exposure but could not determine the full amount of data retrieved by the probing source. That boundary matters: it is more honest and more useful than turning an incomplete log trail into a certainty.

The broader rule is simple: authorization must happen before sensitive data is fetched, not merely before the final page is displayed.

Server Action Types Are Not Runtime Validation

The Server Action errors needed their own fix. TypeScript can describe an argument as FormData, but it cannot guarantee that a network request actually supplied valid FormData at runtime.

I added guards before actions read fields and reviewed the other action entry points for the same assumption. The guards validate the decoded argument, bound field counts and text lengths, and allow files only in declared upload fields. Missing or malformed input now reaches deliberate validation rather than failing on .get() or .trim().

The review also found an exported listing helper that accepted caller-selected ownership and approval options. I moved its implementation into a server-only module and left only narrowly scoped user and admin actions callable behind session and permission checks.

Framework Parsing Failures Need Their Own Category

Some errors continued after the action guards were added:

Connection closed.
Unexpected end of JSON input
No number after minus sign in JSON

I reproduced these with the installed React Server Components decoder without invoking an application action. Empty decoder input could produce the JSON error; missing model fields could produce Connection closed. In this context, that message referred to an incomplete decoder model, not proof that PostgreSQL disconnected or that the server crashed.

Application guards protect values after decoding. A framework can reject a request before an action begins. I therefore classified narrowly identified framework parsing failures as warnings while retaining them for investigation. Ordinary application JSON and connection errors remained errors.

The classifier could not rely on message text alone. The installed framework did not consistently attach the expected error codes, so the fallback also used request context and the originating stack frame. This reduced alert noise without hiding unexpected failures behind a broad message match.

Logs Need Fresh Context And Explicit Trust Boundaries

The investigation also exposed a logging bug. Grouped rows retained the first occurrence’s IP and browser details while updating the latest timestamp and total count. A new curl error could therefore look as though it belonged to an earlier Safari request.

I changed the logger to store the latest occurrence context and increment counts atomically. Bounded diagnostics now include content type, declared body length and build identifier. Request bodies, cookies and authentication headers are excluded.

The browser error-report endpoint received similar scrutiny. Its request body comes from a client, so it is not trustworthy merely because it appears in an admin log. I added a bounded schema and stream-enforced body-size limit; shared database counters constrain report attempts across workers and restarts; accepted reports are marked unverified; and alert emails share send limits so a report flood cannot become an email flood.

Block Carefully: A 429 Is Not A Reason To Ban A Visitor

At nginx, I added rules for observed scanner signatures including sqlmap, Nikto, Nuclei and WPScan. Matching requests receive a 403 before Next.js processes them, and known nonexistent-service paths get a separate probe classification.

User-agent blocking remains a limited control because the caller chooses the header. curl remains useful for monitoring and debugging. Some Python clients are blocked under the site policy, but a rejected user agent is not enough evidence to ban a person from the entire site.

The first version of automated blocking made that mistake. Fail2ban rules escalated some request failures and traffic volumes into temporary whole-site bans, and I blocked my own address during normal use. An exemption restored access, but it did not answer the more important question: could ordinary visitors be caught too?

The audit found one address that loaded a knowledge-centre article and its assets, then repeatedly revalidated site icons. All 90 HTTP 429 responses from that source were for favicon and Android icon requests. Those rejections had contributed to a fifteen-minute firewall ban.

The traffic could justify throttling. It did not establish that the visitor should lose access to the whole website.

I disabled whole-site bans based solely on ordinary statuses and volume. nginx and application rate limits remain active, so individual requests can still be refused without converting retries into a site-wide lockout. The remaining automatic website ban requires repeated explicit probes for sensitive paths such as .env, .git or nonexistent WordPress installations.

I also separated icon traffic into its own nginx budget, served known icon files directly, and added caching plus conditional responses. Listing-search budgets now recognize actual filter parameters and exclude framework navigation parameters such as ?_rsc=..., as well as campaign parameters.

Test The Protection And The Normal Path

Security controls are only useful if they protect the application without disrupting legitimate flows. Verification covered both sides:

  • nginx configuration was tested in disposable containers against a dummy application;
  • scanner requests were refused while browser, crawler, monitoring and payment-callback checks passed;
  • tests covered encoded paths, shared budgets, forged forwarding headers and token redaction;
  • Fail2ban parser tests covered IPv4, IPv6 and misleading user-agent or referrer values;
  • database tests covered registration refusal, authentication and session behavior;
  • live checks verified response statuses and the deployed protection configuration.

Registration also received stronger validation, a honeypot and server-verified Turnstile checks. Those controls cover password, email-link and social-account signup paths so one alternate route cannot bypass them. Authentication and recovery flows now use shared limits with additional challenges after suspicious activity.

The Lesson: Preserve The Difference Between Signal And Proof

Two vague JavaScript errors revealed application bugs that scanners could trigger, a confirmed authorization exposure, and blocking rules that could harm legitimate visitors. They did not, by themselves, prove a successful injection or identify every operator behind the traffic.

The request context made that distinction possible. Method, source address, user agent, timestamps and response body each answer different questions. A redirect is not proof that data stayed private. A 500 is not proof of compromise. A burst of rejected requests is not proof that a visitor deserves a firewall ban.

Good incident response is not only about finding suspicious traffic. It is about making the application fail safely, enforcing access before loading sensitive data, retaining evidence that can be trusted, and testing protections against the normal paths the product depends on.