View Javadoc
1   /*
2    * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> and others
3    *
4    * This program and the accompanying materials are made available under the
5    * terms of the Eclipse Distribution License v. 1.0 which is available at
6    * https://www.eclipse.org/org/documents/edl-v10.php.
7    *
8    * SPDX-License-Identifier: BSD-3-Clause
9    */
10  package org.eclipse.jgit.internal.transport.sshd.proxy;
11  
12  import java.util.ArrayList;
13  import java.util.Iterator;
14  import java.util.List;
15  
16  /**
17   * A basic parser for HTTP response headers. Handles status lines and
18   * authentication headers (WWW-Authenticate, Proxy-Authenticate).
19   *
20   * @see <a href="https://tools.ietf.org/html/rfc7230">RFC 7230</a>
21   * @see <a href="https://tools.ietf.org/html/rfc7235">RFC 7235</a>
22   */
23  public final class HttpParser {
24  
25  	/**
26  	 * An exception indicating some problem parsing HTPP headers.
27  	 */
28  	public static class ParseException extends Exception {
29  
30  		private static final long serialVersionUID = -1634090143702048640L;
31  
32  	}
33  
34  	private HttpParser() {
35  		// No instantiation
36  	}
37  
38  	/**
39  	 * Parse a HTTP response status line.
40  	 *
41  	 * @param line
42  	 *            to parse
43  	 * @return the {@link StatusLine}
44  	 * @throws ParseException
45  	 *             if the line cannot be parsed or has the wrong HTTP version
46  	 */
47  	public static StatusLine parseStatusLine(String line)
48  			throws ParseException {
49  		// Format is HTTP/<version> Code Reason
50  		int firstBlank = line.indexOf(' ');
51  		if (firstBlank < 0) {
52  			throw new ParseException();
53  		}
54  		int secondBlank = line.indexOf(' ', firstBlank + 1);
55  		if (secondBlank < 0) {
56  			// Accept the line even if the (according to RFC 2616 mandatory)
57  			// reason is missing.
58  			secondBlank = line.length();
59  		}
60  		int resultCode;
61  		try {
62  			resultCode = Integer.parseUnsignedInt(
63  					line.substring(firstBlank + 1, secondBlank));
64  		} catch (NumberFormatException e) {
65  			throw new ParseException();
66  		}
67  		// Again, accept even if the reason is missing
68  		String reason = ""; //$NON-NLS-1$
69  		if (secondBlank < line.length()) {
70  			reason = line.substring(secondBlank + 1);
71  		}
72  		return new StatusLine(line.substring(0, firstBlank), resultCode,
73  				reason);
74  	}
75  
76  	/**
77  	 * Extract the authentication headers from the header lines. It is assumed
78  	 * that the first element in {@code reply} is the raw status line as
79  	 * received from the server. It is skipped. Line processing stops on the
80  	 * first empty line thereafter.
81  	 *
82  	 * @param reply
83  	 *            The complete (header) lines of the HTTP response
84  	 * @param authenticationHeader
85  	 *            to look for (including the terminating ':'!)
86  	 * @return a list of {@link AuthenticationChallenge}s found.
87  	 */
88  	public static List<AuthenticationChallenge> getAuthenticationHeaders(
89  			List<String> reply, String authenticationHeader) {
90  		List<AuthenticationChallenge> challenges = new ArrayList<>();
91  		Iterator<String> lines = reply.iterator();
92  		// We know we have at least one line. Skip the response line.
93  		lines.next();
94  		StringBuilder value = null;
95  		while (lines.hasNext()) {
96  			String line = lines.next();
97  			if (line.isEmpty()) {
98  				break;
99  			}
100 			if (Character.isWhitespace(line.charAt(0))) {
101 				// Continuation line.
102 				if (value == null) {
103 					// Skip if we have no current value
104 					continue;
105 				}
106 				// Skip leading whitespace
107 				int i = skipWhiteSpace(line, 1);
108 				value.append(' ').append(line, i, line.length());
109 				continue;
110 			}
111 			if (value != null) {
112 				parseChallenges(challenges, value.toString());
113 				value = null;
114 			}
115 			int firstColon = line.indexOf(':');
116 			if (firstColon > 0 && authenticationHeader
117 					.equalsIgnoreCase(line.substring(0, firstColon + 1))) {
118 				value = new StringBuilder(line.substring(firstColon + 1));
119 			}
120 		}
121 		if (value != null) {
122 			parseChallenges(challenges, value.toString());
123 		}
124 		return challenges;
125 	}
126 
127 	private static void parseChallenges(
128 			List<AuthenticationChallenge> challenges,
129 			String header) {
130 		// Comma-separated list of challenges, each itself a scheme name
131 		// followed optionally by either: a comma-separated list of key=value
132 		// pairs, where the value may be a quoted string with backslash escapes,
133 		// or a single token value, which itself may end in zero or more '='
134 		// characters. Ugh.
135 		int length = header.length();
136 		for (int i = 0; i < length;) {
137 			int start = skipWhiteSpace(header, i);
138 			int end = scanToken(header, start);
139 			if (end <= start) {
140 				break;
141 			}
142 			AuthenticationChallenge challenge = new AuthenticationChallenge(
143 					header.substring(start, end));
144 			challenges.add(challenge);
145 			i = parseChallenge(challenge, header, end);
146 		}
147 	}
148 
149 	private static int parseChallenge(AuthenticationChallenge challenge,
150 			String header, int from) {
151 		int length = header.length();
152 		boolean first = true;
153 		for (int start = from; start <= length; first = false) {
154 			// Now we have either a single token, which may end in zero or more
155 			// equal signs, or a comma-separated list of key=value pairs (with
156 			// optional legacy whitespace around the equals sign), where the
157 			// value can be either a token or a quoted string.
158 			start = skipWhiteSpace(header, start);
159 			int end = scanToken(header, start);
160 			if (end == start) {
161 				// Nothing found. Either at end or on a comma.
162 				if (start < header.length() && header.charAt(start) == ',') {
163 					return start + 1;
164 				}
165 				return start;
166 			}
167 			int next = skipWhiteSpace(header, end);
168 			// Comma, or equals sign, or end of string
169 			if (next >= length || header.charAt(next) != '=') {
170 				if (first) {
171 					// It must be a token
172 					challenge.setToken(header.substring(start, end));
173 					if (next < length && header.charAt(next) == ',') {
174 						next++;
175 					}
176 					return next;
177 				}
178 				// This token must be the name of the next authentication
179 				// scheme.
180 				return start;
181 			}
182 			int nextStart = skipWhiteSpace(header, next + 1);
183 			if (nextStart >= length) {
184 				if (next == end) {
185 					// '=' immediately after the key, no value: key must be the
186 					// token, and the equals sign is part of the token
187 					challenge.setToken(header.substring(start, end + 1));
188 				} else {
189 					// Key without value...
190 					challenge.addArgument(header.substring(start, end), null);
191 				}
192 				return nextStart;
193 			}
194 			if (nextStart == end + 1 && header.charAt(nextStart) == '=') {
195 				// More than one equals sign: must be the single token.
196 				end = nextStart + 1;
197 				while (end < length && header.charAt(end) == '=') {
198 					end++;
199 				}
200 				challenge.setToken(header.substring(start, end));
201 				end = skipWhiteSpace(header, end);
202 				if (end < length && header.charAt(end) == ',') {
203 					end++;
204 				}
205 				return end;
206 			}
207 			if (header.charAt(nextStart) == ',') {
208 				if (next == end) {
209 					// '=' immediately after the key, no value: key must be the
210 					// token, and the equals sign is part of the token
211 					challenge.setToken(header.substring(start, end + 1));
212 					return nextStart + 1;
213 				}
214 				// Key without value...
215 				challenge.addArgument(header.substring(start, end), null);
216 				start = nextStart + 1;
217 			} else {
218 				if (header.charAt(nextStart) == '"') {
219 					int[] nextEnd = { nextStart + 1 };
220 					String value = scanQuotedString(header, nextStart + 1,
221 							nextEnd);
222 					challenge.addArgument(header.substring(start, end), value);
223 					start = nextEnd[0];
224 				} else {
225 					int nextEnd = scanToken(header, nextStart);
226 					challenge.addArgument(header.substring(start, end),
227 							header.substring(nextStart, nextEnd));
228 					start = nextEnd;
229 				}
230 				start = skipWhiteSpace(header, start);
231 				if (start < length && header.charAt(start) == ',') {
232 					start++;
233 				}
234 			}
235 		}
236 		return length;
237 	}
238 
239 	private static int skipWhiteSpace(String header, int i) {
240 		int length = header.length();
241 		while (i < length && Character.isWhitespace(header.charAt(i))) {
242 			i++;
243 		}
244 		return i;
245 	}
246 
247 	private static int scanToken(String header, int from) {
248 		int length = header.length();
249 		int i = from;
250 		while (i < length) {
251 			char c = header.charAt(i);
252 			switch (c) {
253 			case '!':
254 			case '#':
255 			case '$':
256 			case '%':
257 			case '&':
258 			case '\'':
259 			case '*':
260 			case '+':
261 			case '-':
262 			case '.':
263 			case '^':
264 			case '_':
265 			case '`':
266 			case '|':
267 			case '0':
268 			case '1':
269 			case '2':
270 			case '3':
271 			case '4':
272 			case '5':
273 			case '6':
274 			case '7':
275 			case '8':
276 			case '9':
277 				i++;
278 				break;
279 			default:
280 				if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') {
281 					i++;
282 					break;
283 				}
284 				return i;
285 			}
286 		}
287 		return i;
288 	}
289 
290 	private static String scanQuotedString(String header, int from, int[] to) {
291 		StringBuilder result = new StringBuilder();
292 		int length = header.length();
293 		boolean quoted = false;
294 		int i = from;
295 		while (i < length) {
296 			char c = header.charAt(i++);
297 			if (quoted) {
298 				result.append(c);
299 				quoted = false;
300 			} else if (c == '\\') {
301 				quoted = true;
302 			} else if (c == '"') {
303 				break;
304 			} else {
305 				result.append(c);
306 			}
307 		}
308 		to[0] = i;
309 		return result.toString();
310 	}
311 }