Lesson 2.2

One error and one early response

The reject-don't-repair rule, the single HttpError type the whole series uses, and the socket-level function which turns a rejection into wire bytes.

25 minsrc/errors.js, src/server.js
  • Explain the reject-don't-repair posture and why repairing ambiguous framing opens the door to request smuggling.
  • Build one HttpError type differentiated by statusCode and code, with a fixed property layout.
  • Write the below-framework early-error response by hand and read its bytes on the wire.

Before the parser reads any bytes, it needs an error type and a socket-level error response. This lesson builds both of them. The next two lessons will throw these errors from the parser.

Reject, don't repair

Malformed input is ambiguous when two parsers can assign different meanings to the same bytes. HTTP request smuggling exploits exactly this kind of disagreement. A front-end proxy receives a request and forwards it to an origin server. The origin is the server which produces the response. Now if the proxy finds one request in the bytes but the origin finds two, then the bytes which the proxy accepted as part of the first request become a second request at the origin. And that second request bypasses a security check which was performed only on the first one.

Whitespace before a field colon, conflicting content-length fields, and a conflict between length and transfer coding - all of these can create such a disagreement. This group is called the smuggling class.

The defense is a single posture, and the whole series runs on it - reject, don't repair. When the input is ambiguous, you refuse it with a 400 and close the connection. You never silently "fix" it by picking one interpretation, because the interpretation you pick might not be the same one the proxy in front of you picked, and that gap is exactly what the attacker exploits. A server which refuses the ambiguity cannot be smuggled through it, because there is no second reading left to exploit.

This chapter rejects in three ways. Each one is a 400, and each one gets a stable code string which you'll branch and log on.

  • ERR_MALFORMED_REQUEST_LINE - a request line which doesn't fit method SP target SP version CRLF. A missing space, an empty token, a bad version.
  • ERR_HEADER_WS_BEFORE_COLON - whitespace between a field name and its colon. This is the smuggling-class reject, the one this chapter demonstrates end to end.
  • ERR_INVALID_HEADER - a broken field line. An empty name, a bare line feed, a carriage return which is not followed by a line feed.

You'll apply each one in the lesson that parses the field it guards. Here you just build the type which carries them.

One error class, two fields that matter

The series has only one error class. Two properties distinguish the failures - the numeric statusCode selects the HTTP response status, and the string code identifies the failure to framework code and logs.

js
export class HttpError extends Error {
  constructor(statusCode, code, message = reasonPhrase(statusCode)) { /* ... */ }
}

Declare the own properties statusCode, code, and expose in that order. An own property is stored directly on the instance, it is not inherited from some other object. So every HttpError has the same property layout. expose is statusCode < 500. HTTP status codes from 400 to 499 report a problem in the client's request. Codes from 500 to 599 report a server problem. A later chapter uses expose to prevent internal server messages from getting sent to clients.

The default message is the reason phrase for the status code, so new HttpError(400, "ERR_HEADER_WS_BEFORE_COLON") receives "Bad Request". A reason phrase is the text after a status code in the status line.

The class name is stored on the prototype. A prototype is an object from which instances inherit properties. Storing name there avoids adding one more own property. A stack trace records the active function calls when an error was created or thrown, and its first line will read HttpError: Bad Request.

js
HttpError.prototype.name = "HttpError";

A later chapter introduces a complete status-text table and exports reasonPhrase. For now, errors.js keeps a small non-exported lookup for the codes it emits. Module-private means the name is used inside its own file only and is left out of the file's exports. Keeping this helper private makes sure a later public export won't conflict with it.

writeEarlyError

When the parser throws, no Request or Response object exists yet. writeEarlyError writes the minimal response directly to the socket.

js
export function writeEarlyError(socket, statusCode) { /* ... */ }

The bytes it writes are frozen for the rest of the series.

http
HTTP/1.1 400 Bad Request
content-length: 0
connection: close

content-length: 0 frames the empty body. connection: close tells the client that this connection won't carry any more responses. socket.end() queues a TCP FIN after the previously written bytes. FIN tells the peer that this side will send no more bytes. Node emits 'close' after the socket finishes closing. The server never reuses a connection after losing its position in the request stream.

writeEarlyError lives in src/server.js, next to the other socket-level concerns, and it needs its own small reason lookup for the codes it can emit - 400 today, plus 408, 413, 414, and 431 for a later hardening chapter which reuses this exact path. It is one of only two places in the entire series which are allowed to write a status line. The Response object, coming a couple of chapters later, is the other one.

Build it

Two files this time. First, create src/errors.js.

  • Export class HttpError extends Error. Its constructor is (statusCode, code, message = reasonPhrase(statusCode)). In the body, call super(message), then assign exactly three own fields in this order - statusCode, code, and expose (set it to statusCode < 500).
  • Set the class name on the prototype - HttpError.prototype.name = "HttpError".
  • Add a module-private, null-prototype REASON table and a non-exported reasonPhrase(statusCode) helper which returns REASON[statusCode] or a fallback. Cover at least 400, 408, 413, 414, 431, and 500. Don't export reasonPhrase.

Then add these to src/server.js, above the unchanged Server class from Chapter 1.

  • A module-private, null-prototype EARLY_REASON table covering 400, 408, 413, 414, and 431.
  • export function writeEarlyError(socket, statusCode). It looks up the reason (falling back to "Bad Request"), then it socket.write(...)s the four-line response above with CRLF between every line and a bare CRLF terminator, and then it calls socket.end(). Header names are lowercase, exactly like they go on the wire.

Don't touch the Server class itself - only add the two exports above it.

Hints

Reveal these one at a time, and only when you are genuinely stuck. Each one tells you a little more than the last - none of them is the answer.

    Verify it

    First, prove the error's property layout and the expose logic against your own module.

    bash
    node -e '
      import("./src/errors.js").then(({ HttpError }) => {
        const e = new HttpError(400, "ERR_HEADER_WS_BEFORE_COLON");
        console.log(e instanceof Error, e.name, e.statusCode, e.code, e.expose, JSON.stringify(e.message));
        console.log("5xx expose:", new HttpError(500, "ERR_X").expose);
      })'
    text
    true HttpError 400 ERR_HEADER_WS_BEFORE_COLON true "Bad Request"
    5xx expose: false

    Then prove that writeEarlyError writes valid framed bytes. Start a throwaway server which rejects every connection, and connect to it.

    bash
    node -e '
      import("node:net").then(async ({ default: net }) => {
        const { writeEarlyError } = await import("./src/server.js");
        net.createServer((s) => {
          s.on("error", () => {});
          writeEarlyError(s, 400);
        }).listen(3202, "127.0.0.1", () => console.log("up on 3202"));
      })'

    The temporary socket error listener is there so that a client which connects and resets without reading doesn't kill this demonstration process. Your Server class already installs the equivalent listener for accepted sockets.

    bash
    printf 'GET / HTTP/1.1\r\nhost: x\r\n\r\n' | nc -w1 127.0.0.1 3202 | od -c
    text
    0000000    H   T   T   P   /   1   .   1       4   0   0       B   a   d
    0000020        R   e   q   u   e   s   t  \r  \n   c   o   n   t   e   n
    0000040    t   -   l   e   n   g   t   h   :       0  \r  \n   c   o   n
    0000060    n   e   c   t   i   o   n   :       c   l   o   s   e  \r  \n
    0000100   \r  \n
    0000102

    You can see the two lowercase field lines, each ending in \r \n, and after them the bare \r \n header block terminator with nothing following it - a zero-length, correctly framed body. The socket closes immediately because you called socket.end(). This is the exact response the parser will fire the moment it meets input it must refuse.

    Common mistakes
    • node -e complains that reasonPhrase is not exported when you try to import it. Good, it should not be. It is deliberately module-private so that it never collides with the public reasonPhrase which a later chapter freezes. Only HttpError leaves errors.js.
    • Stack traces read Error: Bad Request instead of HttpError: Bad Request. Means you forgot to set name on the prototype, or you set it as another instance property.
    • The client hangs after your early error instead of finishing. You omitted content-length: 0, so the client is still waiting for a body. Every response needs a frame, even an empty one.
    • The connection stays open and the client sends another request on it. You dropped connection: close. After a parse error you have lost your place in the byte stream, so you must not invite a second message onto that socket.

    The parser now has one HttpError type and the writeEarlyError socket response. Ambiguous input gets a framed 400 response and a connection close. The next lesson builds the Request object which is returned after successful parsing.