Merge changes from topic 'cors'
* changes: Support faster cross-domain XHR calls Allow CORS to use modifying REST API
This commit is contained in:
commit
c6aa1c2774
@ -3920,9 +3920,12 @@ Defaults to an empty list, meaning no additional TLDs are allowed.
|
||||
[[site.allowOriginRegex]]site.allowOriginRegex::
|
||||
+
|
||||
List of regular expressions matching origins that should be permitted
|
||||
to use the Gerrit REST API to read content. These should be trusted
|
||||
applications as the sites may be able to use the user's credentials.
|
||||
Only applies to GET and HEAD requests.
|
||||
to use the full Gerrit REST API. These should be trusted applications,
|
||||
as the sites may be able to use the user's credentials. Applies to
|
||||
all requests, including state changing methods (PUT, DELETE, POST).
|
||||
+
|
||||
Expressions should not require trailing slash. For example a valid
|
||||
pattern might be `https://build-status[.]example[.]com`.
|
||||
+
|
||||
By default, unset, denying all cross-origin requests.
|
||||
|
||||
|
@ -32,12 +32,41 @@ By default all REST endpoints assume anonymous access and filter
|
||||
results to correspond to what anonymous users can read (which may
|
||||
be nothing at all).
|
||||
|
||||
Users (and programs) may authenticate by prefixing the endpoint URL with
|
||||
`/a/`. For example to authenticate to `/projects/`, request the URL
|
||||
`/a/projects/`.
|
||||
Users (and programs) can authenticate with HTTP passwords by prefixing
|
||||
the endpoint URL with `/a/`. For example to authenticate to
|
||||
`/projects/`, request the URL `/a/projects/`. Gerrit will use HTTP basic
|
||||
authentication with the HTTP password from the user's account settings
|
||||
page. This form of authentication bypasses the need for XSRF tokens.
|
||||
|
||||
Gerrit uses HTTP basic authentication with the HTTP password from the
|
||||
user's account settings page.
|
||||
An authorization cookie may be presented in the request URL inside the
|
||||
`access_token` query parameter. XSRF tokens are not required when a
|
||||
valid `access_token` is used in the URL.
|
||||
|
||||
[[cors]]
|
||||
=== CORS
|
||||
|
||||
Cross-site scripting may be supported if the administrator has configured
|
||||
link:config-gerrit.html#site.allowOriginRegex[site.allowOriginRegex].
|
||||
|
||||
Approved web applications running from an allowed origin can rely on
|
||||
CORS preflight to authorize requests requiring cookie based
|
||||
authentication, or mutations (POST, PUT, DELETE). Mutations require a
|
||||
valid XSRF token in the `X-Gerrit-Auth` request header.
|
||||
|
||||
Alternatively applications can use `access_token` in the URL (see
|
||||
above) to authorize requests. Mutations sent as POST with a request
|
||||
content type of `text/plain` can skip CORS preflight. Gerrit accepts
|
||||
additional query parameters `$m` to override the correct method (PUT,
|
||||
POST, DELETE) and `$ct` to specify the actual content type, such as
|
||||
`application/json; charset=UTF-8`. Example:
|
||||
|
||||
----
|
||||
POST /changes/42/topic?$m=PUT&$ct=application/json%3B%20charset%3DUTF-8&access_token=secret HTTP/1.1
|
||||
Content-Type: text/plain
|
||||
Content-Length: 23
|
||||
|
||||
{"topic": "new-topic"}
|
||||
----
|
||||
|
||||
[[preconditions]]
|
||||
=== Preconditions
|
||||
|
@ -18,18 +18,33 @@ import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS
|
||||
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
|
||||
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
|
||||
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
|
||||
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_MAX_AGE;
|
||||
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
|
||||
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
|
||||
import static com.google.common.net.HttpHeaders.AUTHORIZATION;
|
||||
import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
|
||||
import static com.google.common.net.HttpHeaders.ORIGIN;
|
||||
import static com.google.common.net.HttpHeaders.VARY;
|
||||
import static com.google.common.truth.Truth.assertThat;
|
||||
|
||||
import com.google.common.base.Splitter;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.gerrit.acceptance.AbstractDaemonTest;
|
||||
import com.google.gerrit.acceptance.PushOneCommit.Result;
|
||||
import com.google.gerrit.acceptance.RestResponse;
|
||||
import com.google.gerrit.extensions.common.ChangeInfo;
|
||||
import com.google.gerrit.server.UrlEncoded;
|
||||
import com.google.gerrit.testutil.ConfigSuite;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Stream;
|
||||
import org.apache.http.Header;
|
||||
import org.apache.http.HttpResponse;
|
||||
import org.apache.http.client.fluent.Executor;
|
||||
import org.apache.http.client.fluent.Request;
|
||||
import org.apache.http.cookie.Cookie;
|
||||
import org.apache.http.impl.client.BasicCookieStore;
|
||||
import org.apache.http.message.BasicHeader;
|
||||
import org.eclipse.jgit.lib.Config;
|
||||
import org.junit.Test;
|
||||
@ -38,6 +53,7 @@ public class CorsIT extends AbstractDaemonTest {
|
||||
@ConfigSuite.Default
|
||||
public static Config allowExampleDotCom() {
|
||||
Config cfg = new Config();
|
||||
cfg.setString("auth", null, "type", "DEVELOPMENT_BECOME_ANY_ACCOUNT");
|
||||
cfg.setStringList(
|
||||
"site",
|
||||
null,
|
||||
@ -65,14 +81,14 @@ public class CorsIT extends AbstractDaemonTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void putWithOriginRefused() throws Exception {
|
||||
public void putWithOriginAccepted() throws Exception {
|
||||
Result change = createChange();
|
||||
String origin = "http://example.com";
|
||||
RestResponse r =
|
||||
adminRestSession.putWithHeader(
|
||||
"/changes/" + change.getChangeId() + "/topic", new BasicHeader(ORIGIN, origin), "A");
|
||||
r.assertOK();
|
||||
checkCors(r, false, origin);
|
||||
checkCors(r, true, origin);
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -88,71 +104,134 @@ public class CorsIT extends AbstractDaemonTest {
|
||||
|
||||
RestResponse res = adminRestSession.execute(req);
|
||||
res.assertOK();
|
||||
|
||||
String vary = res.getHeader(VARY);
|
||||
assertThat(vary).named(VARY).isNotNull();
|
||||
assertThat(Splitter.on(", ").splitToList(vary))
|
||||
.containsExactly(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS);
|
||||
checkCors(res, true, origin);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preflightBadOrigin() throws Exception {
|
||||
Result change = createChange();
|
||||
|
||||
Request req =
|
||||
Request.Options(adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
|
||||
req.addHeader(ORIGIN, "http://evil.attacker");
|
||||
req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "GET");
|
||||
|
||||
adminRestSession.execute(req).assertBadRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preflightBadMethod() throws Exception {
|
||||
Result change = createChange();
|
||||
|
||||
for (String method : new String[] {"POST", "PUT", "DELETE", "PATCH"}) {
|
||||
Request req =
|
||||
Request.Options(
|
||||
adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
|
||||
req.addHeader(ORIGIN, "http://example.com");
|
||||
req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, method);
|
||||
adminRestSession.execute(req).assertBadRequest();
|
||||
}
|
||||
Request req =
|
||||
Request.Options(adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
|
||||
req.addHeader(ORIGIN, "http://example.com");
|
||||
req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "CALL");
|
||||
adminRestSession.execute(req).assertBadRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preflightBadHeader() throws Exception {
|
||||
Result change = createChange();
|
||||
|
||||
Request req =
|
||||
Request.Options(adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
|
||||
req.addHeader(ORIGIN, "http://example.com");
|
||||
req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "GET");
|
||||
req.addHeader(ACCESS_CONTROL_REQUEST_HEADERS, "X-Gerrit-Auth");
|
||||
|
||||
req.addHeader(ACCESS_CONTROL_REQUEST_HEADERS, "X-Secret-Auth-Token");
|
||||
adminRestSession.execute(req).assertBadRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void crossDomainPutTopic() throws Exception {
|
||||
Result change = createChange();
|
||||
BasicCookieStore cookies = new BasicCookieStore();
|
||||
Executor http = Executor.newInstance().cookieStore(cookies);
|
||||
|
||||
Request req = Request.Get(canonicalWebUrl.get() + "/login/?account_id=" + admin.id.get());
|
||||
HttpResponse r = http.execute(req).returnResponse();
|
||||
String auth = null;
|
||||
for (Cookie c : cookies.getCookies()) {
|
||||
if ("GerritAccount".equals(c.getName())) {
|
||||
auth = c.getValue();
|
||||
}
|
||||
}
|
||||
assertThat(auth).named("GerritAccount cookie").isNotNull();
|
||||
cookies.clear();
|
||||
|
||||
UrlEncoded url =
|
||||
new UrlEncoded(canonicalWebUrl.get() + "/changes/" + change.getChangeId() + "/topic");
|
||||
url.put("$m", "PUT");
|
||||
url.put("$ct", "application/json; charset=US-ASCII");
|
||||
url.put("access_token", auth);
|
||||
|
||||
String origin = "http://example.com";
|
||||
req = Request.Post(url.toString());
|
||||
req.setHeader(CONTENT_TYPE, "text/plain");
|
||||
req.setHeader(ORIGIN, origin);
|
||||
req.bodyByteArray("{\"topic\":\"test-xd\"}".getBytes(StandardCharsets.US_ASCII));
|
||||
|
||||
r = http.execute(req).returnResponse();
|
||||
assertThat(r.getStatusLine().getStatusCode()).isEqualTo(200);
|
||||
|
||||
Header vary = r.getFirstHeader(VARY);
|
||||
assertThat(vary).named(VARY).isNotNull();
|
||||
assertThat(Splitter.on(", ").splitToList(vary.getValue())).named(VARY).contains(ORIGIN);
|
||||
|
||||
Header allowOrigin = r.getFirstHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
|
||||
assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNotNull();
|
||||
assertThat(allowOrigin.getValue()).named(ACCESS_CONTROL_ALLOW_ORIGIN).isEqualTo(origin);
|
||||
|
||||
ChangeInfo info = gApi.changes().id(change.getChangeId()).get();
|
||||
assertThat(info.topic).named("topic").isEqualTo("test-xd");
|
||||
}
|
||||
|
||||
private RestResponse check(String url, boolean accept, String origin) throws Exception {
|
||||
Header hdr = new BasicHeader(ORIGIN, origin);
|
||||
RestResponse r = adminRestSession.getWithHeader(url, hdr);
|
||||
r.assertOK();
|
||||
if (accept) {
|
||||
r.assertOK();
|
||||
} else {
|
||||
r.assertBadRequest();
|
||||
}
|
||||
checkCors(r, accept, origin);
|
||||
return r;
|
||||
}
|
||||
|
||||
private void checkCors(RestResponse r, boolean accept, String origin) {
|
||||
String vary = r.getHeader(VARY);
|
||||
assertThat(vary).named(VARY).isNotNull();
|
||||
assertThat(Splitter.on(", ").splitToList(vary)).named(VARY).contains(ORIGIN);
|
||||
|
||||
String allowOrigin = r.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
|
||||
String allowCred = r.getHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
|
||||
String maxAge = r.getHeader(ACCESS_CONTROL_MAX_AGE);
|
||||
String allowMethods = r.getHeader(ACCESS_CONTROL_ALLOW_METHODS);
|
||||
String allowHeaders = r.getHeader(ACCESS_CONTROL_ALLOW_HEADERS);
|
||||
if (accept) {
|
||||
assertThat(allowOrigin).isEqualTo(origin);
|
||||
assertThat(allowCred).isEqualTo("true");
|
||||
assertThat(allowMethods).isEqualTo("GET, OPTIONS");
|
||||
assertThat(allowHeaders).isEqualTo("X-Requested-With");
|
||||
assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isEqualTo(origin);
|
||||
assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isEqualTo("true");
|
||||
assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isEqualTo("600");
|
||||
|
||||
assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNotNull();
|
||||
assertThat(Splitter.on(", ").splitToList(allowMethods))
|
||||
.named(ACCESS_CONTROL_ALLOW_METHODS)
|
||||
.containsExactly("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS");
|
||||
|
||||
assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNotNull();
|
||||
assertThat(Splitter.on(", ").splitToList(allowHeaders))
|
||||
.named(ACCESS_CONTROL_ALLOW_HEADERS)
|
||||
.containsExactlyElementsIn(
|
||||
Stream.of(AUTHORIZATION, CONTENT_TYPE, "X-Gerrit-Auth", "X-Requested-With")
|
||||
.map(s -> s.toLowerCase(Locale.US))
|
||||
.collect(ImmutableSet.toImmutableSet()));
|
||||
} else {
|
||||
assertThat(allowOrigin).isNull();
|
||||
assertThat(allowCred).isNull();
|
||||
assertThat(allowMethods).isNull();
|
||||
assertThat(allowHeaders).isNull();
|
||||
assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNull();
|
||||
assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNull();
|
||||
assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isNull();
|
||||
assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNull();
|
||||
assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -19,8 +19,10 @@ import static java.util.concurrent.TimeUnit.HOURS;
|
||||
import com.google.common.base.Strings;
|
||||
import com.google.gerrit.common.Nullable;
|
||||
import com.google.gerrit.common.data.HostPageData;
|
||||
import com.google.gerrit.extensions.restapi.BadRequestException;
|
||||
import com.google.gerrit.httpd.WebSessionManager.Key;
|
||||
import com.google.gerrit.httpd.WebSessionManager.Val;
|
||||
import com.google.gerrit.httpd.restapi.ParameterParser;
|
||||
import com.google.gerrit.reviewdb.client.Account;
|
||||
import com.google.gerrit.server.AccessPath;
|
||||
import com.google.gerrit.server.AnonymousUser;
|
||||
@ -70,31 +72,50 @@ public abstract class CacheBasedWebSession implements WebSession {
|
||||
this.identified = identified;
|
||||
|
||||
if (request.getRequestURI() == null || !GitSmartHttpTools.isGitClient(request)) {
|
||||
String cookie = readCookie();
|
||||
String cookie = readCookie(request);
|
||||
if (cookie != null) {
|
||||
key = new Key(cookie);
|
||||
val = manager.get(key);
|
||||
if (val != null && val.needsCookieRefresh()) {
|
||||
// Cookie is more than half old. Send the cookie again to the
|
||||
// client with an updated expiration date.
|
||||
val = manager.createVal(key, val);
|
||||
authFromCookie(cookie);
|
||||
} else {
|
||||
String token;
|
||||
try {
|
||||
token = ParameterParser.getQueryParams(request).accessToken();
|
||||
} catch (BadRequestException e) {
|
||||
token = null;
|
||||
}
|
||||
|
||||
String token = request.getHeader(HostPageData.XSRF_HEADER_NAME);
|
||||
if (val != null && token != null && token.equals(val.getAuth())) {
|
||||
okPaths.add(AccessPath.REST_API);
|
||||
if (token != null) {
|
||||
authFromQueryParameter(token);
|
||||
}
|
||||
}
|
||||
if (val != null && val.needsCookieRefresh()) {
|
||||
// Session is more than half old; update cache entry with new expiration date.
|
||||
val = manager.createVal(key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String readCookie() {
|
||||
final Cookie[] all = request.getCookies();
|
||||
private void authFromCookie(String cookie) {
|
||||
key = new Key(cookie);
|
||||
val = manager.get(key);
|
||||
String token = request.getHeader(HostPageData.XSRF_HEADER_NAME);
|
||||
if (val != null && token != null && token.equals(val.getAuth())) {
|
||||
okPaths.add(AccessPath.REST_API);
|
||||
}
|
||||
}
|
||||
|
||||
private void authFromQueryParameter(String accessToken) {
|
||||
key = new Key(accessToken);
|
||||
val = manager.get(key);
|
||||
if (val != null) {
|
||||
okPaths.add(AccessPath.REST_API);
|
||||
}
|
||||
}
|
||||
|
||||
private static String readCookie(HttpServletRequest request) {
|
||||
Cookie[] all = request.getCookies();
|
||||
if (all != null) {
|
||||
for (Cookie c : all) {
|
||||
if (ACCOUNT_COOKIE.equals(c.getName())) {
|
||||
final String v = c.getValue();
|
||||
return v != null && !"".equals(v) ? v : null;
|
||||
return Strings.emptyToNull(c.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -14,16 +14,24 @@
|
||||
|
||||
package com.google.gerrit.httpd.restapi;
|
||||
|
||||
import static com.google.gerrit.httpd.restapi.RestApiServlet.ALLOWED_CORS_METHODS;
|
||||
import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_AUTHORIZATION;
|
||||
import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_CONTENT_TYPE;
|
||||
import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_METHOD;
|
||||
import static com.google.gerrit.httpd.restapi.RestApiServlet.replyBinaryResult;
|
||||
import static com.google.gerrit.httpd.restapi.RestApiServlet.replyError;
|
||||
import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
|
||||
|
||||
import com.google.auto.value.AutoValue;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.base.Splitter;
|
||||
import com.google.common.base.Strings;
|
||||
import com.google.common.collect.ImmutableListMultimap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.common.collect.Iterables;
|
||||
import com.google.common.collect.ListMultimap;
|
||||
import com.google.common.collect.MultimapBuilder;
|
||||
import com.google.gerrit.common.Nullable;
|
||||
import com.google.gerrit.extensions.registration.DynamicMap;
|
||||
import com.google.gerrit.extensions.restapi.BadRequestException;
|
||||
import com.google.gerrit.extensions.restapi.BinaryResult;
|
||||
@ -47,10 +55,97 @@ import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import org.kohsuke.args4j.CmdLineException;
|
||||
|
||||
class ParameterParser {
|
||||
public class ParameterParser {
|
||||
private static final ImmutableSet<String> RESERVED_KEYS =
|
||||
ImmutableSet.of("pp", "prettyPrint", "strict", "callback", "alt", "fields");
|
||||
|
||||
@AutoValue
|
||||
public abstract static class QueryParams {
|
||||
static final String I = QueryParams.class.getName();
|
||||
|
||||
static QueryParams create(
|
||||
@Nullable String accessToken,
|
||||
@Nullable String xdMethod,
|
||||
@Nullable String xdContentType,
|
||||
ImmutableListMultimap<String, String> config,
|
||||
ImmutableListMultimap<String, String> params) {
|
||||
return new AutoValue_ParameterParser_QueryParams(
|
||||
accessToken, xdMethod, xdContentType, config, params);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public abstract String accessToken();
|
||||
|
||||
@Nullable
|
||||
abstract String xdMethod();
|
||||
|
||||
@Nullable
|
||||
abstract String xdContentType();
|
||||
|
||||
abstract ImmutableListMultimap<String, String> config();
|
||||
|
||||
abstract ImmutableListMultimap<String, String> params();
|
||||
|
||||
boolean hasXdOverride() {
|
||||
return xdMethod() != null || xdContentType() != null;
|
||||
}
|
||||
}
|
||||
|
||||
public static QueryParams getQueryParams(HttpServletRequest req) throws BadRequestException {
|
||||
QueryParams qp = (QueryParams) req.getAttribute(QueryParams.I);
|
||||
if (qp != null) {
|
||||
return qp;
|
||||
}
|
||||
|
||||
String accessToken = null;
|
||||
String xdMethod = null;
|
||||
String xdContentType = null;
|
||||
ListMultimap<String, String> config = MultimapBuilder.hashKeys(4).arrayListValues().build();
|
||||
ListMultimap<String, String> params = MultimapBuilder.hashKeys().arrayListValues().build();
|
||||
|
||||
String queryString = req.getQueryString();
|
||||
if (!Strings.isNullOrEmpty(queryString)) {
|
||||
for (String kvPair : Splitter.on('&').split(queryString)) {
|
||||
Iterator<String> i = Splitter.on('=').limit(2).split(kvPair).iterator();
|
||||
String key = Url.decode(i.next());
|
||||
String val = i.hasNext() ? Url.decode(i.next()) : "";
|
||||
|
||||
if (XD_AUTHORIZATION.equals(key)) {
|
||||
if (accessToken != null) {
|
||||
throw new BadRequestException("duplicate " + XD_AUTHORIZATION);
|
||||
}
|
||||
accessToken = val;
|
||||
} else if (XD_METHOD.equals(key)) {
|
||||
if (xdMethod != null) {
|
||||
throw new BadRequestException("duplicate " + XD_METHOD);
|
||||
} else if (!ALLOWED_CORS_METHODS.contains(val)) {
|
||||
throw new BadRequestException("invalid " + XD_METHOD);
|
||||
}
|
||||
xdMethod = val;
|
||||
} else if (XD_CONTENT_TYPE.equals(key)) {
|
||||
if (xdContentType != null) {
|
||||
throw new BadRequestException("duplicate " + XD_CONTENT_TYPE);
|
||||
}
|
||||
xdContentType = val;
|
||||
} else if (RESERVED_KEYS.contains(key)) {
|
||||
config.put(key, val);
|
||||
} else {
|
||||
params.put(key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
qp =
|
||||
QueryParams.create(
|
||||
accessToken,
|
||||
xdMethod,
|
||||
xdContentType,
|
||||
ImmutableListMultimap.copyOf(config),
|
||||
ImmutableListMultimap.copyOf(params));
|
||||
req.setAttribute(QueryParams.I, qp);
|
||||
return qp;
|
||||
}
|
||||
|
||||
private final CmdLineParser.Factory parserFactory;
|
||||
private final Injector injector;
|
||||
private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
|
||||
@ -98,24 +193,6 @@ class ParameterParser {
|
||||
return true;
|
||||
}
|
||||
|
||||
static void splitQueryString(
|
||||
String queryString,
|
||||
ListMultimap<String, String> config,
|
||||
ListMultimap<String, String> params) {
|
||||
if (!Strings.isNullOrEmpty(queryString)) {
|
||||
for (String kvPair : Splitter.on('&').split(queryString)) {
|
||||
Iterator<String> i = Splitter.on('=').limit(2).split(kvPair).iterator();
|
||||
String key = Url.decode(i.next());
|
||||
String val = i.hasNext() ? Url.decode(i.next()) : "";
|
||||
if (RESERVED_KEYS.contains(key)) {
|
||||
config.put(key, val);
|
||||
} else {
|
||||
params.put(key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> query(HttpServletRequest req) {
|
||||
Set<String> params = new HashSet<>();
|
||||
if (!Strings.isNullOrEmpty(req.getQueryString())) {
|
||||
|
@ -20,8 +20,11 @@ import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS
|
||||
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
|
||||
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
|
||||
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
|
||||
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_MAX_AGE;
|
||||
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
|
||||
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
|
||||
import static com.google.common.net.HttpHeaders.AUTHORIZATION;
|
||||
import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
|
||||
import static com.google.common.net.HttpHeaders.ORIGIN;
|
||||
import static com.google.common.net.HttpHeaders.VARY;
|
||||
import static java.math.RoundingMode.CEILING;
|
||||
@ -52,8 +55,6 @@ import com.google.common.collect.ImmutableSet;
|
||||
import com.google.common.collect.Iterables;
|
||||
import com.google.common.collect.ListMultimap;
|
||||
import com.google.common.collect.Lists;
|
||||
import com.google.common.collect.MultimapBuilder;
|
||||
import com.google.common.collect.Streams;
|
||||
import com.google.common.io.BaseEncoding;
|
||||
import com.google.common.io.CountingOutputStream;
|
||||
import com.google.common.math.IntMath;
|
||||
@ -92,6 +93,7 @@ import com.google.gerrit.extensions.restapi.RestView;
|
||||
import com.google.gerrit.extensions.restapi.TopLevelResource;
|
||||
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
|
||||
import com.google.gerrit.httpd.WebSession;
|
||||
import com.google.gerrit.httpd.restapi.ParameterParser.QueryParams;
|
||||
import com.google.gerrit.server.AccessPath;
|
||||
import com.google.gerrit.server.AnonymousUser;
|
||||
import com.google.gerrit.server.CurrentUser;
|
||||
@ -139,15 +141,18 @@ import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServlet;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletRequestWrapper;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import org.eclipse.jgit.http.server.ServletUtils;
|
||||
import org.eclipse.jgit.lib.Config;
|
||||
@ -169,8 +174,17 @@ public class RestApiServlet extends HttpServlet {
|
||||
// TODO: Remove when HttpServletResponse.SC_UNPROCESSABLE_ENTITY is available
|
||||
private static final int SC_UNPROCESSABLE_ENTITY = 422;
|
||||
private static final String X_REQUESTED_WITH = "X-Requested-With";
|
||||
private static final String X_GERRIT_AUTH = "X-Gerrit-Auth";
|
||||
static final ImmutableSet<String> ALLOWED_CORS_METHODS =
|
||||
ImmutableSet.of("GET", "HEAD", "POST", "PUT", "DELETE");
|
||||
private static final ImmutableSet<String> ALLOWED_CORS_REQUEST_HEADERS =
|
||||
ImmutableSet.of(X_REQUESTED_WITH);
|
||||
Stream.of(AUTHORIZATION, CONTENT_TYPE, X_GERRIT_AUTH, X_REQUESTED_WITH)
|
||||
.map(s -> s.toLowerCase(Locale.US))
|
||||
.collect(ImmutableSet.toImmutableSet());
|
||||
|
||||
public static final String XD_AUTHORIZATION = "access_token";
|
||||
public static final String XD_CONTENT_TYPE = "$ct";
|
||||
public static final String XD_METHOD = "$m";
|
||||
|
||||
private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks.
|
||||
|
||||
@ -252,8 +266,7 @@ public class RestApiServlet extends HttpServlet {
|
||||
int status = SC_OK;
|
||||
long responseBytes = -1;
|
||||
Object result = null;
|
||||
ListMultimap<String, String> params = MultimapBuilder.hashKeys().arrayListValues().build();
|
||||
ListMultimap<String, String> config = MultimapBuilder.hashKeys().arrayListValues().build();
|
||||
QueryParams qp = null;
|
||||
Object inputRequestBody = null;
|
||||
RestResource rsrc = TopLevelResource.INSTANCE;
|
||||
ViewData viewData = null;
|
||||
@ -263,10 +276,13 @@ public class RestApiServlet extends HttpServlet {
|
||||
doCorsPreflight(req, res);
|
||||
return;
|
||||
}
|
||||
checkCors(req, res);
|
||||
checkUserSession(req);
|
||||
|
||||
ParameterParser.splitQueryString(req.getQueryString(), config, params);
|
||||
qp = ParameterParser.getQueryParams(req);
|
||||
checkCors(req, res, qp.hasXdOverride());
|
||||
if (qp.hasXdOverride()) {
|
||||
req = applyXdOverrides(req, qp);
|
||||
}
|
||||
checkUserSession(req);
|
||||
|
||||
List<IdString> path = splitPath(req);
|
||||
RestCollection<RestResource, RestResource> rc = members.get();
|
||||
@ -279,7 +295,7 @@ public class RestApiServlet extends HttpServlet {
|
||||
|
||||
if (path.isEmpty()) {
|
||||
if (rc instanceof NeedsParams) {
|
||||
((NeedsParams) rc).setParams(params);
|
||||
((NeedsParams) rc).setParams(qp.params());
|
||||
}
|
||||
|
||||
if (isRead(req)) {
|
||||
@ -372,7 +388,7 @@ public class RestApiServlet extends HttpServlet {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!globals.paramParser.get().parse(viewData.view, params, req, res)) {
|
||||
if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -415,7 +431,7 @@ public class RestApiServlet extends HttpServlet {
|
||||
if (result instanceof BinaryResult) {
|
||||
responseBytes = replyBinaryResult(req, res, (BinaryResult) result);
|
||||
} else {
|
||||
responseBytes = replyJson(req, res, config, result);
|
||||
responseBytes = replyJson(req, res, qp.config(), result);
|
||||
}
|
||||
}
|
||||
} catch (MalformedJsonException e) {
|
||||
@ -490,7 +506,7 @@ public class RestApiServlet extends HttpServlet {
|
||||
globals.currentUser.get(),
|
||||
req,
|
||||
auditStartTs,
|
||||
params,
|
||||
qp != null ? qp.params() : ImmutableListMultimap.of(),
|
||||
inputRequestBody,
|
||||
status,
|
||||
result,
|
||||
@ -499,11 +515,50 @@ public class RestApiServlet extends HttpServlet {
|
||||
}
|
||||
}
|
||||
|
||||
private void checkCors(HttpServletRequest req, HttpServletResponse res) {
|
||||
private static HttpServletRequest applyXdOverrides(HttpServletRequest req, QueryParams qp)
|
||||
throws BadRequestException {
|
||||
if (!"POST".equals(req.getMethod())) {
|
||||
throw new BadRequestException("POST required");
|
||||
}
|
||||
|
||||
String method = qp.xdMethod();
|
||||
String contentType = qp.xdContentType();
|
||||
if (method.equals("POST") || method.equals("PUT")) {
|
||||
if (!"text/plain".equals(req.getContentType())) {
|
||||
throw new BadRequestException("invalid " + CONTENT_TYPE);
|
||||
} else if (Strings.isNullOrEmpty(contentType)) {
|
||||
throw new BadRequestException(XD_CONTENT_TYPE + " required");
|
||||
}
|
||||
}
|
||||
|
||||
return new HttpServletRequestWrapper(req) {
|
||||
@Override
|
||||
public String getMethod() {
|
||||
return method;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void checkCors(HttpServletRequest req, HttpServletResponse res, boolean isXd)
|
||||
throws BadRequestException {
|
||||
String origin = req.getHeader(ORIGIN);
|
||||
if (isRead(req) && !Strings.isNullOrEmpty(origin) && isOriginAllowed(origin)) {
|
||||
if (!Strings.isNullOrEmpty(origin)) {
|
||||
res.addHeader(VARY, ORIGIN);
|
||||
setCorsHeaders(res, origin);
|
||||
if (!isOriginAllowed(origin)) {
|
||||
throw new BadRequestException("origin not allowed");
|
||||
}
|
||||
if (isXd) {
|
||||
res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
|
||||
} else {
|
||||
setCorsHeaders(res, origin);
|
||||
}
|
||||
} else if (isXd) {
|
||||
throw new BadRequestException("expected " + ORIGIN);
|
||||
}
|
||||
}
|
||||
|
||||
@ -516,8 +571,10 @@ public class RestApiServlet extends HttpServlet {
|
||||
private void doCorsPreflight(HttpServletRequest req, HttpServletResponse res)
|
||||
throws BadRequestException {
|
||||
CacheHeaders.setNotCacheable(res);
|
||||
res.setHeader(
|
||||
VARY, Joiner.on(", ").join(ImmutableList.of(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD)));
|
||||
setHeaderList(
|
||||
res,
|
||||
VARY,
|
||||
ImmutableList.of(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS));
|
||||
|
||||
String origin = req.getHeader(ORIGIN);
|
||||
if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
|
||||
@ -525,20 +582,17 @@ public class RestApiServlet extends HttpServlet {
|
||||
}
|
||||
|
||||
String method = req.getHeader(ACCESS_CONTROL_REQUEST_METHOD);
|
||||
if (!"GET".equals(method) && !"HEAD".equals(method)) {
|
||||
if (!ALLOWED_CORS_METHODS.contains(method)) {
|
||||
throw new BadRequestException(method + " not allowed in CORS");
|
||||
}
|
||||
|
||||
String headers = req.getHeader(ACCESS_CONTROL_REQUEST_HEADERS);
|
||||
if (headers != null) {
|
||||
res.addHeader(VARY, ACCESS_CONTROL_REQUEST_HEADERS);
|
||||
String badHeader =
|
||||
Streams.stream(Splitter.on(',').trimResults().split(headers))
|
||||
.filter(h -> !ALLOWED_CORS_REQUEST_HEADERS.contains(h))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (badHeader != null) {
|
||||
throw new BadRequestException(badHeader + " not allowed in CORS");
|
||||
for (String reqHdr : Splitter.on(',').trimResults().split(headers)) {
|
||||
if (!ALLOWED_CORS_REQUEST_HEADERS.contains(reqHdr.toLowerCase(Locale.US))) {
|
||||
throw new BadRequestException(reqHdr + " not allowed in CORS");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -548,11 +602,19 @@ public class RestApiServlet extends HttpServlet {
|
||||
res.setContentLength(0);
|
||||
}
|
||||
|
||||
private void setCorsHeaders(HttpServletResponse res, String origin) {
|
||||
private static void setCorsHeaders(HttpServletResponse res, String origin) {
|
||||
res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
|
||||
res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
|
||||
res.setHeader(ACCESS_CONTROL_ALLOW_METHODS, "GET, OPTIONS");
|
||||
res.setHeader(ACCESS_CONTROL_ALLOW_HEADERS, Joiner.on(", ").join(ALLOWED_CORS_REQUEST_HEADERS));
|
||||
res.setHeader(ACCESS_CONTROL_MAX_AGE, "600");
|
||||
setHeaderList(
|
||||
res,
|
||||
ACCESS_CONTROL_ALLOW_METHODS,
|
||||
Iterables.concat(ALLOWED_CORS_METHODS, ImmutableList.of("OPTIONS")));
|
||||
setHeaderList(res, ACCESS_CONTROL_ALLOW_HEADERS, ALLOWED_CORS_REQUEST_HEADERS);
|
||||
}
|
||||
|
||||
private static void setHeaderList(HttpServletResponse res, String name, Iterable<String> values) {
|
||||
res.setHeader(name, Joiner.on(", ").join(values));
|
||||
}
|
||||
|
||||
private boolean isOriginAllowed(String origin) {
|
||||
@ -1054,7 +1116,8 @@ public class RestApiServlet extends HttpServlet {
|
||||
throw new AmbiguousViewException(
|
||||
String.format(
|
||||
"Projection %s is ambiguous: %s",
|
||||
name, r.keySet().stream().map(in -> in + "~" + projection).collect(joining(", "))));
|
||||
name,
|
||||
r.keySet().stream().map(in -> in + "~" + projection).collect(joining(", "))));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -14,11 +14,15 @@
|
||||
|
||||
package com.google.gerrit.httpd.restapi;
|
||||
|
||||
import static com.google.common.truth.Truth.assertThat;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.gerrit.extensions.restapi.BadRequestException;
|
||||
import com.google.gerrit.httpd.restapi.ParameterParser.QueryParams;
|
||||
import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
@ -49,4 +53,91 @@ public class ParameterParserTest {
|
||||
|
||||
assertEquals(exp, obj);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseQuery() throws BadRequestException {
|
||||
FakeHttpServletRequest req = new FakeHttpServletRequest();
|
||||
req.setQueryString("query=status%3aopen");
|
||||
QueryParams qp = ParameterParser.getQueryParams(req);
|
||||
assertThat(qp.accessToken()).isNull();
|
||||
assertThat(qp.xdMethod()).isNull();
|
||||
assertThat(qp.xdContentType()).isNull();
|
||||
assertThat(qp.hasXdOverride()).isFalse();
|
||||
assertThat(qp.config()).isEmpty();
|
||||
assertThat(qp.params()).containsKey("query");
|
||||
assertThat(qp.params().get("query")).containsExactly("status:open");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseAccessToken() throws BadRequestException {
|
||||
FakeHttpServletRequest req = new FakeHttpServletRequest();
|
||||
req.setQueryString("query=status%3aopen&access_token=secr%65t");
|
||||
QueryParams qp = ParameterParser.getQueryParams(req);
|
||||
assertThat(qp.accessToken()).isEqualTo("secret");
|
||||
assertThat(qp.xdMethod()).isNull();
|
||||
assertThat(qp.xdContentType()).isNull();
|
||||
assertThat(qp.hasXdOverride()).isFalse();
|
||||
assertThat(qp.config()).isEmpty();
|
||||
assertThat(qp.params()).containsKey("query");
|
||||
assertThat(qp.params().get("query")).containsExactly("status:open");
|
||||
|
||||
req = new FakeHttpServletRequest();
|
||||
req.setQueryString("access_token=secret");
|
||||
qp = ParameterParser.getQueryParams(req);
|
||||
assertThat(qp.accessToken()).isEqualTo("secret");
|
||||
assertThat(qp.xdMethod()).isNull();
|
||||
assertThat(qp.xdContentType()).isNull();
|
||||
assertThat(qp.hasXdOverride()).isFalse();
|
||||
assertThat(qp.config()).isEmpty();
|
||||
assertThat(qp.params()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseXdOverride() throws BadRequestException {
|
||||
FakeHttpServletRequest req = new FakeHttpServletRequest();
|
||||
req.setQueryString("$m=PUT&$ct=json&access_token=secret");
|
||||
QueryParams qp = ParameterParser.getQueryParams(req);
|
||||
assertThat(qp.accessToken()).isEqualTo("secret");
|
||||
assertThat(qp.xdMethod()).isEqualTo("PUT");
|
||||
assertThat(qp.xdContentType()).isEqualTo("json");
|
||||
assertThat(qp.hasXdOverride()).isTrue();
|
||||
assertThat(qp.config()).isEmpty();
|
||||
assertThat(qp.params()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectDuplicateMethod() {
|
||||
FakeHttpServletRequest req = new FakeHttpServletRequest();
|
||||
req.setQueryString("$m=PUT&$m=DELETE");
|
||||
try {
|
||||
ParameterParser.getQueryParams(req);
|
||||
fail("expected BadRequestException");
|
||||
} catch (BadRequestException bad) {
|
||||
assertThat(bad).hasMessageThat().isEqualTo("duplicate $m");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectDuplicateContentType() {
|
||||
FakeHttpServletRequest req = new FakeHttpServletRequest();
|
||||
req.setQueryString("$ct=json&$ct=string");
|
||||
try {
|
||||
ParameterParser.getQueryParams(req);
|
||||
fail("expected BadRequestException");
|
||||
} catch (BadRequestException bad) {
|
||||
assertThat(bad).hasMessageThat().isEqualTo("duplicate $ct");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectInvalidMethod() {
|
||||
FakeHttpServletRequest req = new FakeHttpServletRequest();
|
||||
req.setQueryString("$m=CONNECT");
|
||||
try {
|
||||
ParameterParser.getQueryParams(req);
|
||||
fail("expected BadRequestException");
|
||||
} catch (BadRequestException bad) {
|
||||
assertThat(bad).hasMessageThat().isEqualTo("invalid $m");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -156,6 +156,7 @@ junit_tests(
|
||||
name = "pgm_tests",
|
||||
srcs = glob(["src/test/java/**/*.java"]),
|
||||
deps = [
|
||||
":http-jetty",
|
||||
":init",
|
||||
":init-api",
|
||||
":pgm",
|
||||
@ -163,6 +164,7 @@ junit_tests(
|
||||
"//gerrit-server:server",
|
||||
"//lib:guava",
|
||||
"//lib:junit",
|
||||
"//lib:truth",
|
||||
"//lib/easymock",
|
||||
"//lib/guice",
|
||||
"//lib/jgit/org.eclipse.jgit:jgit",
|
||||
|
@ -14,10 +14,18 @@
|
||||
|
||||
package com.google.gerrit.pgm.http.jetty;
|
||||
|
||||
import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_AUTHORIZATION;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.base.Splitter;
|
||||
import com.google.common.base.Strings;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.gerrit.common.TimeUtil;
|
||||
import com.google.gerrit.extensions.restapi.Url;
|
||||
import com.google.gerrit.httpd.GetUserFilter;
|
||||
import com.google.gerrit.server.util.SystemLog;
|
||||
import com.google.inject.Inject;
|
||||
import java.util.Iterator;
|
||||
import org.apache.log4j.AsyncAppender;
|
||||
import org.apache.log4j.Level;
|
||||
import org.apache.log4j.Logger;
|
||||
@ -31,6 +39,7 @@ import org.eclipse.jetty.util.component.AbstractLifeCycle;
|
||||
class HttpLog extends AbstractLifeCycle implements RequestLog {
|
||||
private static final Logger log = Logger.getLogger(HttpLog.class);
|
||||
private static final String LOG_NAME = "httpd_log";
|
||||
private static final ImmutableSet<String> REDACT_PARAM = ImmutableSet.of(XD_AUTHORIZATION);
|
||||
|
||||
interface HttpLogFactory {
|
||||
HttpLog get();
|
||||
@ -78,10 +87,7 @@ class HttpLog extends AbstractLifeCycle implements RequestLog {
|
||||
);
|
||||
|
||||
String uri = req.getRequestURI();
|
||||
String qs = req.getQueryString();
|
||||
if (qs != null) {
|
||||
uri = uri + "?" + qs;
|
||||
}
|
||||
uri = redactQueryString(uri, req.getQueryString());
|
||||
|
||||
String user = (String) req.getAttribute(GetUserFilter.REQ_ATTR_KEY);
|
||||
if (user != null) {
|
||||
@ -100,6 +106,31 @@ class HttpLog extends AbstractLifeCycle implements RequestLog {
|
||||
async.append(event);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static String redactQueryString(String uri, String qs) {
|
||||
if (Strings.isNullOrEmpty(qs)) {
|
||||
return uri;
|
||||
}
|
||||
|
||||
StringBuilder b = new StringBuilder(uri);
|
||||
boolean first = true;
|
||||
for (String kvPair : Splitter.on('&').split(qs)) {
|
||||
Iterator<String> i = Splitter.on('=').limit(2).split(kvPair).iterator();
|
||||
String key = i.next();
|
||||
b.append(first ? '?' : '&').append(key);
|
||||
first = false;
|
||||
if (i.hasNext()) {
|
||||
b.append('=');
|
||||
if (REDACT_PARAM.contains(Url.decode(key))) {
|
||||
b.append('*');
|
||||
} else {
|
||||
b.append(i.next());
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
private static void set(LoggingEvent event, String key, String val) {
|
||||
if (val != null && !val.isEmpty()) {
|
||||
event.setProperty(key, val);
|
||||
|
@ -0,0 +1,46 @@
|
||||
// Copyright (C) 2017 The Android Open Source Project
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package com.google.gerrit.pgm.http.jetty;
|
||||
|
||||
import static com.google.common.truth.Truth.assertThat;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class HttpLogRedactTest {
|
||||
@Test
|
||||
public void includeQueryString() {
|
||||
assertThat(HttpLog.redactQueryString("/changes/", null)).isEqualTo("/changes/");
|
||||
assertThat(HttpLog.redactQueryString("/changes/", "")).isEqualTo("/changes/");
|
||||
assertThat(HttpLog.redactQueryString("/changes/", "x")).isEqualTo("/changes/?x");
|
||||
assertThat(HttpLog.redactQueryString("/changes/", "x=y")).isEqualTo("/changes/?x=y");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void redactAuth() {
|
||||
assertThat(HttpLog.redactQueryString("/changes/", "query=status:open"))
|
||||
.isEqualTo("/changes/?query=status:open");
|
||||
|
||||
assertThat(HttpLog.redactQueryString("/changes/", "query=status:open&access_token=foo"))
|
||||
.isEqualTo("/changes/?query=status:open&access_token=*");
|
||||
|
||||
assertThat(HttpLog.redactQueryString("/changes/", "access_token=foo"))
|
||||
.isEqualTo("/changes/?access_token=*");
|
||||
|
||||
assertThat(
|
||||
HttpLog.redactQueryString(
|
||||
"/changes/", "query=status:open&access_token=foo&access_token=bar"))
|
||||
.isEqualTo("/changes/?query=status:open&access_token=*&access_token=*");
|
||||
}
|
||||
}
|
@ -19,11 +19,11 @@ import static com.google.common.base.Preconditions.checkNotNull;
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
|
||||
import com.google.common.base.Splitter;
|
||||
import com.google.common.base.Strings;
|
||||
import com.google.common.collect.Iterables;
|
||||
import com.google.common.collect.LinkedListMultimap;
|
||||
import com.google.common.collect.ListMultimap;
|
||||
import com.google.common.collect.Maps;
|
||||
import com.google.gerrit.extensions.restapi.Url;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLDecoder;
|
||||
@ -60,6 +60,7 @@ public class FakeHttpServletRequest implements HttpServletRequest {
|
||||
private final ListMultimap<String, String> headers;
|
||||
|
||||
private ListMultimap<String, String> parameters;
|
||||
private String queryString;
|
||||
private String hostName;
|
||||
private int port;
|
||||
private String contextPath;
|
||||
@ -158,6 +159,7 @@ public class FakeHttpServletRequest implements HttpServletRequest {
|
||||
}
|
||||
|
||||
public void setQueryString(String qs) {
|
||||
this.queryString = qs;
|
||||
ListMultimap<String, String> params = LinkedListMultimap.create();
|
||||
for (String entry : Splitter.on('&').split(qs)) {
|
||||
List<String> kv = Splitter.on('=').limit(2).splitToList(entry);
|
||||
@ -306,7 +308,7 @@ public class FakeHttpServletRequest implements HttpServletRequest {
|
||||
|
||||
@Override
|
||||
public String getQueryString() {
|
||||
return paramsToString(parameters);
|
||||
return queryString;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -317,8 +319,8 @@ public class FakeHttpServletRequest implements HttpServletRequest {
|
||||
@Override
|
||||
public String getRequestURI() {
|
||||
String uri = contextPath + servletPath + path;
|
||||
if (!parameters.isEmpty()) {
|
||||
uri += "?" + paramsToString(parameters);
|
||||
if (!Strings.isNullOrEmpty(queryString)) {
|
||||
uri += '?' + queryString;
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
@ -379,23 +381,6 @@ public class FakeHttpServletRequest implements HttpServletRequest {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
private static String paramsToString(ListMultimap<String, String> params) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, String> e : params.entries()) {
|
||||
if (!first) {
|
||||
sb.append('&');
|
||||
} else {
|
||||
first = false;
|
||||
}
|
||||
sb.append(Url.encode(e.getKey()));
|
||||
if (!"".equals(e.getValue())) {
|
||||
sb.append('=').append(Url.encode(e.getValue()));
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AsyncContext getAsyncContext() {
|
||||
throw new UnsupportedOperationException();
|
||||
|
Loading…
Reference in New Issue
Block a user