Memra

Parsing the request line and mapping a path — safely

◈ 5 cards

Read exactly one line, split it on whitespace, tolerate the two-token form — then turn the target into a file with a canonical-containment check that GET /../../etc/passwd cannot beat.

Read one line, then stop

A server that serves many files has to look at the request, and almost all of what it needs is on the first line. Read bytes until the first CR or LF, keep what came before, and go no further — you are not obliged to consume the rest, and a client whose headers you ignore is not an error.

Read that line as US-ASCII, not as platform-default text. A request line is protocol, and protocol is ASCII by definition; decoding it with a multi-byte charset lets a malformed byte sequence from a hostile client change how your parser sees the line.

Two tokens or three

The request line is METHOD target version, single-space separated. Trim, split on runs of whitespace, and you get either three tokens (HTTP/1.0 and later) or two (the HTTP/0.9 form, no version). Fewer than two is not a request line at all and earns a 400.

The middle token is the request target, and it is not yet a path. It may carry a query string (/search?q=socket) that has nothing to do with any file, and it is percent-encoded%20 for a space, %2e for a dot. new URI(token).getPath() does both jobs: it drops the query and returns the decoded path component. Resist URLDecoder.decode, which is the form decoder and turns + into a space — wrong for a path, where + is a literal plus.

The obvious mapping is a filesystem hole

The naive mapping is one line: strip the leading slash, append the rest to your document root, open it. It works for every honest request and hands your whole disk to a dishonest one. GET /../../etc/passwd maps to root + "/../../etc/passwd", and the filesystem is delighted to resolve that. This is directory traversal, the single most-exploited bug in hand-written web servers.

The defence has to be about the resolved path, because that is what the filesystem acts on. Resolve the target against the root, normalise it so every .. and . collapses away, then refuse unless the result is still inside the root. Path.normalize() collapses lexically, without touching the disk, so it works for files that do not exist — which matters, because you must reject the request before opening anything.

Containment, checked element by element

The test itself is file.startsWith(root) — on Path, not on String. Path.startsWith compares whole name elements, so /srv/site-backup/secrets does not start with /srv/site. The string version says it does, and that off-by-one-directory is a real disclosure bug in servers that reached for String.startsWith because the paths were already strings.

Two rules make it airtight. Canonicalise the root once, at startup (Paths.get(dir).toRealPath()), so every later comparison is against a form with no symlinks and no relative segments. And decode once, before the check, then open exactly what you checked — if you validate the raw target and open the decoded one, an attacker just sends %2e%2e and walks straight through your test.

Worked example — /docs/ and /../../etc/passwd against one root

Take the root as /srv/site, resolved at startup, and walk two requests through the same four steps.

// startup, once
Path root = Paths.get("/srv/site").toRealPath();

// per request
String[] tokens = requestLine.trim().split("\\s+");
if (tokens.length < 2) { sendError(out, "400 Bad Request"); return; }
String method  = tokens[0];
String version = tokens.length > 2 ? tokens[2] : "";

String target = new URI(tokens[1]).getPath();      // decoded, query dropped
if (target == null || !target.startsWith("/")) { sendError(out, "400 Bad Request"); return; }
if (target.endsWith("/")) target += "index.html";  // a directory means its index

Path file = root.resolve(target.substring(1)).normalize();
if (!file.startsWith(root) || !Files.isReadable(file)) {
    sendError(out, "404 Not Found");
    return;
}
serveFile(file, version, out, raw);

GET /docs/ HTTP/1.1 splits into three tokens. getPath() returns /docs/, the trailing slash appends index.html, and root.resolve("docs/index.html") normalises to /srv/site/docs/index.html, which starts with /srv/site. Served.

GET /../../etc/passwd HTTP/1.1 splits the same way. getPath() returns /../../etc/passwd, resolve produces /srv/site/../../etc/passwd, and normalize() collapses that to /etc/passwd. startsWith(root) is false, so the request never reaches the filesystem and the client gets a 404 — not a 403, because a probe that can tell "forbidden" from "absent" is a map of your disk.

The percent-encoded form GET /%2e%2e/%2e%2e/etc/passwd decodes to the same string at step two and dies at exactly the same test — because the decode happened before the check, and the Path you tested is the Path you would have opened.

after resolve() + normalize()GET /GET /docs/GET /../../srv/siteroot, resolved onceindex.htmlserveddocs/index.htmlserved/etc/passwdoutsidestartsWith(root) decides, and it compares name elements.
Both good requests normalise to a path whose first two name elements are still srv and site. The traversal request normalises to /etc/passwd, which shares no leading element with the root, so Path.startsWith is false and the handler answers 404 without ever opening a file.
NORMAL ~/memra/learn/comp-348/http-server-path-mapping utf-8 LF