blob: d5b80374cb2bd1f5cd27c50e0451afa581f13235 [file] [log] [blame]
/*
* Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Distribution License v. 1.0 which is available at
* https://www.eclipse.org/org/documents/edl-v10.php.
*
* SPDX-License-Identifier: BSD-3-Clause
*/
package org.eclipse.jgit.internal.transport.sshd.proxy;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
/**
* A basic parser for HTTP response headers. Handles status lines and
* authentication headers (WWW-Authenticate, Proxy-Authenticate).
*
* @see <a href="https://tools.ietf.org/html/rfc7230">RFC 7230</a>
* @see <a href="https://tools.ietf.org/html/rfc7235">RFC 7235</a>
*/
public final class HttpParser {
/**
* An exception indicating some problem parsing HTPP headers.
*/
public static class ParseException extends Exception {
private static final long serialVersionUID = -1634090143702048640L;
}
private HttpParser() {
// No instantiation
}
/**
* Parse a HTTP response status line.
*
* @param line
* to parse
* @return the {@link StatusLine}
* @throws ParseException
* if the line cannot be parsed or has the wrong HTTP version
*/
public static StatusLine parseStatusLine(String line)
throws ParseException {
// Format is HTTP/<version> Code Reason
int firstBlank = line.indexOf(' ');
if (firstBlank < 0) {
throw new ParseException();
}
int secondBlank = line.indexOf(' ', firstBlank + 1);
if (secondBlank < 0) {
// Accept the line even if the (according to RFC 2616 mandatory)
// reason is missing.
secondBlank = line.length();
}
int resultCode;
try {
resultCode = Integer.parseUnsignedInt(
line.substring(firstBlank + 1, secondBlank));
} catch (NumberFormatException e) {
throw new ParseException();
}
// Again, accept even if the reason is missing
String reason = ""; //$NON-NLS-1$
if (secondBlank < line.length()) {
reason = line.substring(secondBlank + 1);
}
return new StatusLine(line.substring(0, firstBlank), resultCode,
reason);
}
/**
* Extract the authentication headers from the header lines. It is assumed
* that the first element in {@code reply} is the raw status line as
* received from the server. It is skipped. Line processing stops on the
* first empty line thereafter.
*
* @param reply
* The complete (header) lines of the HTTP response
* @param authenticationHeader
* to look for (including the terminating ':'!)
* @return a list of {@link AuthenticationChallenge}s found.
*/
public static List<AuthenticationChallenge> getAuthenticationHeaders(
List<String> reply, String authenticationHeader) {
List<AuthenticationChallenge> challenges = new ArrayList<>();
Iterator<String> lines = reply.iterator();
// We know we have at least one line. Skip the response line.
lines.next();
StringBuilder value = null;
while (lines.hasNext()) {
String line = lines.next();
if (line.isEmpty()) {
break;
}
if (Character.isWhitespace(line.charAt(0))) {
// Continuation line.
if (value == null) {
// Skip if we have no current value
continue;
}
// Skip leading whitespace
int i = skipWhiteSpace(line, 1);
value.append(' ').append(line, i, line.length());
continue;
}
if (value != null) {
parseChallenges(challenges, value.toString());
value = null;
}
int firstColon = line.indexOf(':');
if (firstColon > 0 && authenticationHeader
.equalsIgnoreCase(line.substring(0, firstColon + 1))) {
value = new StringBuilder(line.substring(firstColon + 1));
}
}
if (value != null) {
parseChallenges(challenges, value.toString());
}
return challenges;
}
private static void parseChallenges(
List<AuthenticationChallenge> challenges,
String header) {
// Comma-separated list of challenges, each itself a scheme name
// followed optionally by either: a comma-separated list of key=value
// pairs, where the value may be a quoted string with backslash escapes,
// or a single token value, which itself may end in zero or more '='
// characters. Ugh.
int length = header.length();
for (int i = 0; i < length;) {
int start = skipWhiteSpace(header, i);
int end = scanToken(header, start);
if (end <= start) {
break;
}
AuthenticationChallenge challenge = new AuthenticationChallenge(
header.substring(start, end));
challenges.add(challenge);
i = parseChallenge(challenge, header, end);
}
}
private static int parseChallenge(AuthenticationChallenge challenge,
String header, int from) {
int length = header.length();
boolean first = true;
for (int start = from; start <= length; first = false) {
// Now we have either a single token, which may end in zero or more
// equal signs, or a comma-separated list of key=value pairs (with
// optional legacy whitespace around the equals sign), where the
// value can be either a token or a quoted string.
start = skipWhiteSpace(header, start);
int end = scanToken(header, start);
if (end == start) {
// Nothing found. Either at end or on a comma.
if (start < header.length() && header.charAt(start) == ',') {
return start + 1;
}
return start;
}
int next = skipWhiteSpace(header, end);
// Comma, or equals sign, or end of string
if (next >= length || header.charAt(next) != '=') {
if (first) {
// It must be a token
challenge.setToken(header.substring(start, end));
if (next < length && header.charAt(next) == ',') {
next++;
}
return next;
}
// This token must be the name of the next authentication
// scheme.
return start;
}
int nextStart = skipWhiteSpace(header, next + 1);
if (nextStart >= length) {
if (next == end) {
// '=' immediately after the key, no value: key must be the
// token, and the equals sign is part of the token
challenge.setToken(header.substring(start, end + 1));
} else {
// Key without value...
challenge.addArgument(header.substring(start, end), null);
}
return nextStart;
}
if (nextStart == end + 1 && header.charAt(nextStart) == '=') {
// More than one equals sign: must be the single token.
end = nextStart + 1;
while (end < length && header.charAt(end) == '=') {
end++;
}
challenge.setToken(header.substring(start, end));
end = skipWhiteSpace(header, end);
if (end < length && header.charAt(end) == ',') {
end++;
}
return end;
}
if (header.charAt(nextStart) == ',') {
if (next == end) {
// '=' immediately after the key, no value: key must be the
// token, and the equals sign is part of the token
challenge.setToken(header.substring(start, end + 1));
return nextStart + 1;
}
// Key without value...
challenge.addArgument(header.substring(start, end), null);
start = nextStart + 1;
} else {
if (header.charAt(nextStart) == '"') {
int[] nextEnd = { nextStart + 1 };
String value = scanQuotedString(header, nextStart + 1,
nextEnd);
challenge.addArgument(header.substring(start, end), value);
start = nextEnd[0];
} else {
int nextEnd = scanToken(header, nextStart);
challenge.addArgument(header.substring(start, end),
header.substring(nextStart, nextEnd));
start = nextEnd;
}
start = skipWhiteSpace(header, start);
if (start < length && header.charAt(start) == ',') {
start++;
}
}
}
return length;
}
private static int skipWhiteSpace(String header, int i) {
int length = header.length();
while (i < length && Character.isWhitespace(header.charAt(i))) {
i++;
}
return i;
}
private static int scanToken(String header, int from) {
int length = header.length();
int i = from;
while (i < length) {
char c = header.charAt(i);
switch (c) {
case '!':
case '#':
case '$':
case '%':
case '&':
case '\'':
case '*':
case '+':
case '-':
case '.':
case '^':
case '_':
case '`':
case '|':
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
i++;
break;
default:
if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') {
i++;
break;
}
return i;
}
}
return i;
}
private static String scanQuotedString(String header, int from, int[] to) {
StringBuilder result = new StringBuilder();
int length = header.length();
boolean quoted = false;
int i = from;
while (i < length) {
char c = header.charAt(i++);
if (quoted) {
result.append(c);
quoted = false;
} else if (c == '\\') {
quoted = true;
} else if (c == '"') {
break;
} else {
result.append(c);
}
}
to[0] = i;
return result.toString();
}
}