Systems + network security

What building a reverse proxy in C taught me

CinderProxy began as a networking project. It became an exercise in distrust: distrust malformed requests, overloaded backends, slow clients, and every assumption made on the happy path.

← ALL WRITEUPS

A reverse proxy sits between a client and the service that client wants to reach. The basic diagram is simple: accept a connection, read the request, choose a backend, forward the bytes, then send the response back.

That description is correct in the same way that “read a file” describes a parser. The difficult parts start as soon as the traffic is incomplete, malformed, concurrent, encrypted, or intentionally abusive.

1. Treat the network as untrusted input

HTTP arrives over a byte stream. A complete request is not guaranteed to arrive in one read, and a buffer does not automatically contain a valid method, header block, or body. CinderProxy uses defensive parsing so it can reject malformed input instead of passing ambiguous traffic deeper into the system.

client bytes → validate request → select backend → forward safely

This changed how I thought about parsing. It is not only about extracting fields. It is also a security boundary. Every length, delimiter, and state transition needs to be handled deliberately.

The parser’s job is not to be generous. It is to be predictable.

2. Concurrency changes the failure modes

A proxy that handles one request at a time is straightforward, but not very useful. CinderProxy is multithreaded so multiple connections can move through the proxy concurrently.

That introduces shared state: rate-limit counters, backend status, connection data, and shutdown behavior. The question stops being “does this function work?” and becomes “does it still work when several threads reach related state at nearly the same time?”

The project made race conditions feel concrete. A small synchronization mistake may not fail consistently, which makes testing and simple control flow more important than cleverness.

3. Protect the backend, not just the proxy

A reverse proxy should do more than relay traffic. CinderProxy includes rate limiting and backend health checks so it can control how traffic reaches the services behind it.

Rate limiting puts a boundary around how much work a client can create. Health checks keep the proxy from treating every configured backend as healthy forever. Together, those features turn the proxy into a control point rather than another hop.

request → rate check → healthy backend → upstream connection

too many requests or no healthy backend → controlled failure

4. TLS termination moves trust into the proxy

Supporting TLS termination means the proxy handles the encrypted client connection before forwarding traffic. That keeps encryption concerns at the edge, but it also increases the proxy’s responsibility. Certificate handling, connection errors, and plaintext data after decryption all become part of its security model.

It reinforced a broader lesson: adding a security feature also adds new code and new failure paths. The feature only helps if those paths fail safely.

5. Streaming matters for correctness and memory

Buffering an entire response before sending it sounds convenient, but it scales poorly and creates unnecessary memory pressure. Streaming lets data move through the proxy as it arrives instead of requiring the whole payload at once.

That made performance feel less like one benchmark number and more like resource behavior: how much work is being held in memory, how long a connection stays blocked, and what happens when clients and backends move at different speeds.

The benchmark—and what it does not prove

In my benchmark, CinderProxy handled roughly 5,193 requests per second with a 6.5 ms median p95 and zero failures. That was useful because it showed the implementation could sustain concurrent traffic under that test setup.

It does not prove the proxy is production-ready. A benchmark represents a particular workload, machine, configuration, and moment. The useful part was having a repeatable way to catch regressions and compare changes instead of relying on “it feels fast.”

What I took away

CinderProxy taught me that reliable systems software is mostly about handling the cases that the clean architecture diagram leaves out. The core request path is only the beginning. The real project is deciding what should happen when input is strange, traffic spikes, a backend stops responding, or two threads touch the same state.

That mindset carries directly into security work: assume the edge cases will happen, keep the behavior explainable, and make failure controlled.

View CinderProxy on GitHub ↗