HTTP Server with POST and SSL support
Last time, I walked through the development of a simple HTTP server in Java. Two major missing features in that server were the lack of support for HTTP POST as well as support for HTTPS. I'll rectify both in this post.
Post Bodies
The earliest use case for HTTP was to retrieve static documents from remote servers, hence the
prominent support for the GET
verb. GET
specifies the document the client
is looking for, but the transfer is considered one-way: the server isn't expected to change its own
internal state based on the GET request. Although web application implementers found ways around this
using URL parameters and cookies, the more complete approach is the POST
verb which
differs from GET
in that it includes arbitrary data after the HTTP headers, just like an
HTTP response does. This means that the client has to indicate to the server how much data it's
sending so that the server knows when to stop reading. HTTP supports two ways to implement this:
first, by explicitly declaring up-front the number of bytes in the POST body via the Content-Length
header and second by prepending each "chunk" of data with its length (which is referred to as
Chunked
transfer encoding). The easier of the two to support is Content-Length, which
I'll implement first.
Content-Length header
Recall from the last post that the class Request
was responsible for parsing the command
line and the headers. This doesn't vary between GET
and POST
; the difference
between the two here is that the input can be effectively considered exhausted once the last header
has been received in a GET
request. I'll leave it up to the request handler to figure
out what to do with the body rather than try to handle it in the request parser and instead provide
a getBody
invocation for the handler to get the "rest" of the request when it's ready:
public class Request {
...
public InputStream getBody() throws IOException {
return new HttpInputStream(in, headers);
}
getBody
, in turn, refers to an instance of a new class HttpInputStream
which wraps the logic of parsing the headers to find the content length and indicate to the caller
when the data has been completely consumed.
class HttpInputStream extends InputStream {
private Reader source;
private int bytesRemaining;
public HttpInputStream(Reader source, Map<String, String> headers) throws IOException {
this.source = source;
try {
bytesRemaining = Integer.parseInt(headers.get("Content-Length"));
} catch (NumberFormatException e) {
throw new IOException("Malformed or missing Content-Length header");
}
}
public int read() throws IOException {
if (bytesRemaining == 0) {
return -1;
} else {
bytesRemaining -= 1;
return source.read();
}
}
}
Class HttpInputStream
looks for the Content-Length
header, keeps track
of how many bytes have been read, and returns -1 when all have been consumed (per the
java.io.InputStream
specification). Note that just returning source.read()
would not work here; source
refers to an instance of Socket InputStream, which
will never return -1 unless the underlying socket itself is closed which isn't what we want to do
here.
The earliest version of HTTP did work that way, indicating the end of a request by closing the input side of the socket. This was changed to support HTTP KeepAlive semantics, where a single socket could be used to service multiple requests.
And that's it; the server now supports POST, at least when the POST body is explicitly stated in a request header. An example of how you might use it is shown in listing 3:
server.addHandler("POST", "/login", new Handler() {
public void handle(Request request, Response response) throws IOException {
StringBuffer buf = new StringBuffer();
InputStream in = request.getBody();
int c;
while ((c = in.read()) != -1) {
buf.append((char) c);
}
String[] components = buf.toString().split("&");
Map<String, String> urlParameters = new HashMap<String, String>();
for (String component : components) {
String[] pieces = component.split("=");
urlParameters.put(pieces[0], pieces[1]);
}
String html = "<body>Welcome, " + urlParameters.get("username") + "</body>";
response.setResponseCode(200, "OK");
response.addHeader("Content-Type", "text/html");
response.addBody(html);
}
});
You could test this out with a curl command like:
$ curl -d "username=jdavies&password=secret" http://localhost:8080/login
Chunked transfer encoding
That's the easy case, of course - HTTP supports a more complex case where the client is streaming data of unknown size in which case the sender is responsible for breaking the data into chunks of known size and prepending each chunk with its length. The size of the pending chunk is provided as CRLF-delimited ASCII-formatted hexadecimal. So, for example, if the next chunk is 16,372 bytes long (0x3ff4), the chunk will be prepended by the byte sequence:
\r\n3ff4\r\n
Each of these 8 bytes must be stored and then discarded by the HttpInputStream, and 16,372 bytes provided to the caller. The 16,373rd byte must be \r, starting another chunk. The sender indicates completion by marking a chunk size of 0 (\r\n0\r\n, specifically). One minor irritant is that the first chunk size isn't preceded by CRLF — or rather, it is, but that CRLF is the indicator that the headers list is complete. Since it was already consumed by the header parser, I have to do a little bit of special handling to treat the very first chunk length indicator differently than the remaining ones. Listing 4 shows the changes to HttpInputStream needed to support chunked behavior: notice that the changes are entirely local to this class, and are completely transparent to the caller.
class HttpInputStream extends InputStream {
private Reader source;
private int bytesRemaining;
private boolean chunked = false;
public HttpInputStream(Reader source, Map<String, String> headers) throws IOException {
this.source = source;
String declaredContentLength = headers.get("Content-Length");
if (declaredContentLength != null) {
try {
bytesRemaining = Integer.parseInt(declaredContentLength);
} catch (NumberFormatException e) {
throw new IOException("Malformed or missing Content-Length header");
}
} else if ("chunked".equals(headers.get("Transfer-Encoding"))) {
chunked = true;
bytesRemaining = parseChunkSize();
}
}
private int parseChunkSize() throws IOException {
int b;
int chunkSize = 0;
while ((b = source.read()) != '\r') {
chunkSize = (chunkSize << 4) |
((b > '9') ?
(b > 'F') ?
(b - 'a' + 10) :
(b - 'A' + 10) :
(b - '0'));
}
// Consume the trailing '\n'
if (source.read() != '\n') {
throw new IOException("Malformed chunked encoding");
}
return chunkSize;
}
public int read() throws IOException {
if (bytesRemaining == 0) {
if (!chunked) {
return -1;
} else {
// Read next chunk size; return -1 if 0 indicating end of stream
// Read and discard extraneous \r\n
if (source.read() != '\r') {
throw new IOException("Malformed chunked encoding");
}
if (source.read() != '\n') {
throw new IOException("Malformed chunked encoding");
}
bytesRemaining = parseChunkSize();
if (bytesRemaining == 0) {
return -1;
}
}
}
bytesRemaining -= 1;
return source.read();
}
}
The only potentially confusing part here is how I parse the chunk sizes. Suppose I get chunk
size 3FF4
: I'll first read in the byte '3', ascii code 51. I'll subtract that from
'0' (ascii code 48) to get the numeric value 3, and store that in the chunkSize variable. So, as
of now, the chunk size is 3. The next byte is 'F', ascii code 70. I'll subtract that from ascii
code 'A' (65) and then add back 10 to get the correct value 15 for that character code. I'll then
shift the bytes remaining over four bits (i.e. multiply by 16) and insert the new value 15 into the
new low-order nybble. Table 1 summarizes what happens here, byte-by-byte.
Byte read | Hex Coding | Cumulative value | |
---|---|---|---|
3 | 3 | 0 << 4 = 0 | 3 = 3 | |
F | 15 | 3 << 4 = 48 | 15 = 63 | |
F | 15 | 63 << 4 = 1008 | 15 = 1023 | |
4 | 15 | 1023 << 4 = 16368 | 4 = 16372 |
Note that x << m | n is exactly equivalent to x * 2m + n, but this implementation is just a bit faster.
parseChunkSize
is effectively equivalent to Integer.parseInt(s, 16)
, but
there's no real benefit in using the Java library here because I still have to gather up the
characters in a StringBuffer
to use it.
Otherwise, I take a peek at the headers in the constructor to figure out what sort of encoding I'm dealing with and then handle it in the reader. Note that I include a nod to exception handling by ensuring that the bytes \r and \n are included when they're expected, but a broken or malicious client could easily crash or at least hang this implementation.
It does pain me a bit to convert an InputStream
to a BufferedReader
and
then back into an InputStream
again — but I can't really see any compelling reason
to take the time to implement the Reader
interface in this case so I'll leave it as is.
SSL Support
One last change I'll make in this post is support for SSL. SSL was originally created to support HTTP, so unlike most other secure versions of protocols that use the same port for secure and non-secure connections, HTTP "cheats" by listening on a different port for secure connections. If a client connects on the "main" port (80, by default), the server expects the next byte to be part of an HTTP message. If the client connects on the secure port (443, by default), the server expects an SSL connections negotiation to take place before the HTTP message starts — otherwise, the presence of SSL is transparent to the client and the server, as it was designed to be.
Java includes support for secure sockets by default through the javax.net.ssl.SSLServerSocket
class. You can modify the start code in the HTTP server to look like Listing 5:
public void start() throws IOException {
SSLServerSocketFactory factory = (SSLServerSocketFactory) SSLServerSocketFactory.getDefault();
ServerSocket socket = factory.createServerSocket(securePort);
Socket client;
while ((client = socket.accept()) != null) {
And it will compile and run - however, if you try to connect to it, you'll get an error message:
$ curl -v https://localhost:8443/hello
* STATE: INIT => CONNECT handle 0x6000579e0; line 1404 (connection #-5000)
* Added connection 0. The cache now contains 1 members
* STATE: CONNECT => WAITRESOLVE handle 0x6000579e0; line 1440 (connection #0)
* Trying ::1...
* TCP_NODELAY set
* STATE: WAITRESOLVE => WAITCONNECT handle 0x6000579e0; line 1521 (connection #0)
* Connected to localhost (::1) port 8443 (#0)
* STATE: WAITCONNECT => SENDPROTOCONNECT handle 0x6000579e0; line 1573 (connection #0)
* Marked for [keep alive]: HTTP default
* ALPN, offering h2
* ALPN, offering http/1.1
* Cipher selection: ALL:!EXPORT:!EXPORT40:!EXPORT56:!aNULL:!LOW:!RC4:@STRENGTH
* successfully set certificate verify locations:
CAfile: /etc/pki/tls/certs/ca-bundle.crt
CApath: none
* TLSv1.2 (OUT), TLS header, Certificate Status (22):
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* STATE: SENDPROTOCONNECT => PROTOCONNECT handle 0x6000579e0; line 1587 (connection #0)
* TLSv1.2 (IN), TLS header, Unknown (21):
* TLSv1.2 (IN), TLS alert, Server hello (2):
* error:14077410:SSL routines:SSL23_GET_SERVER_HELLO:sslv3 alert handshake failure
* Marked for [closure]: Failed HTTPS connection
* multi_done
* stopped the pause stream!
* Closing connection 0
* The cache now contains 0 members
* Expire cleared
curl: (35) error:14077410:SSL routines:SSL23_GET_SERVER_HELLO:sslv3 alert handshake failure
The Java library puts a lot of work into making secure socket support as transparent as possible, but the SSL handshake itself requires that a server certificate be presented to the client, so most of the work in adding SSL support to an HTTP server in Java is centered around managing this certificate.
Before you can include it in the server code, then, you have to have the certificate to begin with.
This certificate has to be "signed" by a certificate authority trusted by the client — certificate
authorities like Verisign and Thawte are used for this purpose in commercial web sites, and can charge
at least a few hundred dollars in exchange for their seal of approval. For testing purposes, though,
you can generate a "self-signed" certificate and import it into your client. Java's
keytool
utility makes it easy to create a self-signed certificate and use it to protect
a server socket.
$ keytool -genkey -keyalg RSA -alias httpserver -keystore httpserver.jks -storepass password
What is your first and last name?
[Unknown]: localhost
What is the name of your organizational unit?
[Unknown]:
What is the name of your organization?
[Unknown]:
What is the name of your City or Locality?
[Unknown]:
What is the name of your State or Province?
[Unknown]:
What is the two-letter country code for this unit?
[Unknown]:
Is CN=localhost, OU=Unknown, O=Unknown, L=Unknown, ST=Unknown, C=Unknown correct?
[no]: yes
Enter key password for
(RETURN if same as keystore password):
Warning:
The JKS keystore uses a proprietary format. It is recommended to migrate to PKCS12 which
is an industry standard format using "keytool -importkeystore -srckeystore httpserver.jks
-destkeystore httpserver.jks -deststoretype pkcs12".
This creates a new file named httpserver.jks
which contains an encrypted keypair (for
the purposes of this sample, you can ignore the warning about the format).
You can start up the server as shown in example 3 to recognize this new key store/self-signed
certificate (the Java runtime is "smart" enough to recognize that there's a single certificate in
here and use it):
java \
-Djavax.net.ssl.keyStore=./httpserver.jks \
-Djavax.net.ssl.keyStorePass=password \
-classpath . \
com.jdavies.http.HttpServer
But you're still not quite out of the woods yet if you want to use it:
$ curl https://localhost:8443/hello
curl: (60) SSL certificate problem: self signed certificate
More details here: https://curl.haxx.se/docs/sslcerts.html
curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.
If you try this in a browser, you'll get a security warning urging you not to trust this page, but you can click through it and see the page. However, since my main use case here is mocking out REST APIs for testing purposes, I have to be able to trust the certificate. You can export the self- signed certificate and then instruct curl to trust it as shown in example 5.
$ keytool -export -keystore ./httpserver.jks -alias httpserver -file httpserver.cer -rfc
Enter keystore password: password
Certificate stored in file <httpserver.cer>
$ curl --cacert ./httpserver.cer https://localhost:8443/hello?name=josh
<body>It works, josh</body>
Take note of the -rfc
parameter at the end of the export command - if you leave this off,
you'll get a certificate in DER (binary) format, which curl doesn't appear to accept (even though the
documentation says it should, with the --cert-type
parameter).
Next Steps
At this point, this HTTP server is pretty useful for its original intended purpose, which is to mock out external dependencies for testing purposes. Still, there are a couple of remaining deficiencies that are worth addressing: support for cookies, and support for the HTTP keep alive extension. I'll address both in my next post.
Add a comment:
.....
urlParameters.get("username") returns null.
headers.put(headerLine.substring(0, separator), headerLine.substring(separator + 1));
But it should be:
headers.put(headerLine.substring(0, separator), headerLine.substring(separator + 2));
Or else the content length header will have a space in the front (i.e. ' 13'), which Integer.parseInt rejects (or, conversely, I could use .strip() to remove any leading or trailing spaces).
public boolean parse() throws IOException {
String initialLine = in.readLine(); log(initialLine); StringTokenizer tok = new StringTokenizer(initialLine); String[] components = new String[3]; for (int i = 0; i < components.length; i++) { // TODO support HTTP/1.0? if (tok.hasMoreTokens()) { components[i] = tok.nextToken(); } else { return false; } }
method = components[0]; fullUrl = components[1];
// Consume headers while (true) { String headerLine = in.readLine(); log(headerLine); if (headerLine.length() == 0) { break; }
int separator = headerLine.indexOf(":"); if (separator == -1) { return false; } headers.put(headerLine.substring(0, separator), headerLine.substring(separator + 1)); }
// TODO should look for host header, Connection: Keep-Alive header, // Content-Transfer-Encoding: chunked
if (method.equals("GET")) {
if (components[1].indexOf("?") == -1) { path = components[1]; } else { path = components[1].substring(0, components[1].indexOf("?")); parseQueryParameters(components[1].substring( components[1].indexOf("?") + 1)); }}
else
if (method.equals("POST")) {
path = components[1];
try {
contentLength =Integer.parseInt(headers.get("Content-Length").trim());
} catch (NumberFormatException e) {
throw new IOException("Malformed or missing Content-Length header");
}
String body = null;
if (0 < contentLength) {
char[] c = new char[contentLength];
in.read(c);
body = new String(c);
}
parseQueryParameters(body);
}
if ("/".e
.....
urlParameters.get("username") returns null.