Implement Safe Content REST API method

The Safe Content REST method has similar behavior to the legacy
CatServlet servlet. It retrieves the requested content directly,
for safe file types, or as a ZIP file containing an entry with
an unpredictable filename based on the resource filename.

There are minor differences. RestApiServlet always specifies
attachment, with or without a filename, for the Content-Disposition,
whereas CatServlet would not declare direct downloads as attachments.
Given that the attachment disposition is obligatory, this REST API
method always sets a meaningful attachment filename, for ZIP files
and direct file alike.

RestApiServlet specifies and parses resources differently than
CatServlet. The GetSafeContent view includes an optional parameter
for naming the side, which becomes a suffix decorating the filenames.
CatServlet will use this parameter to designate whether the file
comes from a child or parent in a diff.

A forthcoming patch will modify CatServlet to consolidate its
implementation, taking advantage of this new code.

Change-Id: I2ecdbd0bd6706066026000de813244f909d48cba
This commit is contained in:
David Pletcher 2015-09-01 17:45:55 -07:00
parent 40b7472fef
commit d1efb45fec
4 changed files with 251 additions and 1 deletions

View File

@ -3326,6 +3326,60 @@ Alternatively, if the only value of the Accept request header is
`application/json` the content is returned as JSON string and
`X-FYI-Content-Encoding` is set to `json`.
[[get-safe-content]]
=== Download Content
--
'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/files/link:#file-id[\{file-id\}]/download'
--
Downloads the content of a file from a certain revision, in a safe format
that poses no risk for inadvertent execution of untrusted code.
If the content type is defined as safe, the binary file content is returned
verbatim. If the content type is not safe, the file is stored inside a ZIP
file, containing a single entry with a random, unpredictable name having the
same base and suffix as the true filename. The ZIP file is returned in
verbatim binary form.
See link:config-gerrit.html#mimetype.name.safe[Gerrit config documentation]
for information about safe file type configuration.
The HTTP resource Content-Type is dependent on the file type: the
applicable type for safe files, or "application/zip" for unsafe files.
The `suffix` parameter can be specified to decorate the names of the files.
The suffix is inserted between the base filename and the random component or
extension, or appended to the filename if neither such component is present.
Only the lowercase Roman letters a-z are permitted; other characters are ignored.
.Request
----
GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/website%2Freleases%2Flogo.png/safe_content HTTP/1.0
----
.Response
----
HTTP/1.1 200 OK
Content-Disposition: attachment; filename="logo.png"
Content-Type: image/png
`[binary data for logo.png]`
----
.Request
----
GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/gerrit-server%2Fsrc%2Fmain%2Fjava%2Fcom%2Fgoogle%2Fgerrit%2Fserver%2Fproject%2FRefControl.java/safe_content?suffix=new HTTP/1.0
----
.Response
----
HTTP/1.1 200 OK
Content-Disposition: Content-Disposition:attachment; filename="RefControl_new-931cdb73ae9d97eb500a3533455b055d90b99944.java.zip"
Content-Type:application/zip
`[binary ZIP archive containing a single file, "RefControl_new-cb218df1337df48a0e7ab30a49a8067ac7321881.java"]`
----
[[get-diff]]
=== Get Diff
--

View File

@ -0,0 +1,52 @@
// Copyright (C) 2015 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.server.change;
import com.google.gerrit.extensions.restapi.BinaryResult;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.RestReadView;
import com.google.gerrit.server.project.NoSuchChangeException;
import com.google.gerrit.server.project.ProjectState;
import com.google.gwtorm.server.OrmException;
import com.google.inject.Inject;
import org.eclipse.jgit.lib.ObjectId;
import org.kohsuke.args4j.Option;
import java.io.IOException;
public class DownloadContent implements RestReadView<FileResource> {
private final FileContentUtil fileContentUtil;
@Option(name = "--suffix")
private String suffix;
@Inject
DownloadContent(FileContentUtil fileContentUtil) {
this.fileContentUtil = fileContentUtil;
}
@Override
public BinaryResult apply(FileResource rsrc)
throws ResourceNotFoundException, IOException, NoSuchChangeException,
OrmException {
String path = rsrc.getPatchKey().get();
ProjectState projectState =
rsrc.getRevision().getControl().getProjectControl().getProjectState();
ObjectId revstr = ObjectId.fromString(
rsrc.getRevision().getPatchSet().getRevision().get());
return fileContentUtil.downloadContent(projectState, revstr, path, suffix);
}
}

View File

@ -16,6 +16,12 @@ package com.google.gerrit.server.change;
import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
import com.google.common.base.CharMatcher;
import com.google.common.base.Strings;
import com.google.common.hash.Hasher;
import com.google.common.hash.Hashing;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.TimeUtil;
import com.google.gerrit.common.data.PatchScript.FileMode;
import com.google.gerrit.extensions.restapi.BinaryResult;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@ -26,8 +32,11 @@ import com.google.gerrit.server.project.ProjectState;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import eu.medsea.mimeutil.MimeType;
import org.eclipse.jgit.errors.LargeObjectException;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectLoader;
import org.eclipse.jgit.lib.ObjectReader;
@ -35,9 +44,14 @@ import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.eclipse.jgit.util.NB;
import java.io.IOException;
import java.io.OutputStream;
import java.security.MessageDigest;
import java.util.Random;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@Singleton
public class FileContentUtil {
@ -45,6 +59,8 @@ public class FileContentUtil {
private static final String X_GIT_SYMLINK = "x-git/symlink";
private static final String X_GIT_GITLINK = "x-git/gitlink";
private static final int MAX_SIZE = 5 << 20;
private static final String ZIP_TYPE = "application/zip";
private static final Random rng = new Random();
private final GitRepositoryManager repoManager;
private final FileTypeRegistry registry;
@ -75,7 +91,7 @@ public class FileContentUtil {
.base64();
}
final ObjectLoader obj = repo.open(id, OBJ_BLOB);
ObjectLoader obj = repo.open(id, OBJ_BLOB);
byte[] raw;
try {
raw = obj.getCachedBytes(MAX_SIZE);
@ -110,6 +126,133 @@ public class FileContentUtil {
return result;
}
public BinaryResult downloadContent(ProjectState project, ObjectId revstr,
String path, @Nullable String suffix)
throws ResourceNotFoundException, IOException {
suffix = Strings.emptyToNull(CharMatcher.inRange('a', 'z')
.retainFrom(Strings.nullToEmpty(suffix)));
try (Repository repo = openRepository(project);
RevWalk rw = new RevWalk(repo)) {
RevCommit commit = rw.parseCommit(revstr);
ObjectReader reader = rw.getObjectReader();
TreeWalk tw = TreeWalk.forPath(reader, path, commit.getTree());
if (tw == null) {
throw new ResourceNotFoundException();
}
int mode = tw.getFileMode(0).getObjectType();
if (mode != Constants.OBJ_BLOB) {
throw new ResourceNotFoundException();
}
ObjectId id = tw.getObjectId(0);
ObjectLoader obj = repo.open(id, OBJ_BLOB);
byte[] raw;
try {
raw = obj.getCachedBytes(MAX_SIZE);
} catch (LargeObjectException e) {
raw = null;
}
MimeType contentType = registry.getMimeType(path, raw);
return registry.isSafeInline(contentType)
? wrapBlob(project, path, obj, raw, contentType, suffix)
: zipBlob(path, obj, commit, suffix);
}
}
private BinaryResult wrapBlob(ProjectState project, String path,
final ObjectLoader obj, byte[] raw, MimeType contentType,
@Nullable String suffix) {
return asBinaryResult(raw, obj)
.setContentType(contentType.toString())
.setAttachmentName(safeFileName(path, suffix));
}
private BinaryResult zipBlob(final String path, final ObjectLoader obj,
RevCommit commit, final @Nullable String suffix) {
final String commitName = commit.getName();
final long when = commit.getCommitTime() * 1000L;
return new BinaryResult() {
@Override
public void writeTo(OutputStream os) throws IOException {
try (ZipOutputStream zipOut = new ZipOutputStream(os)) {
String decoration = randSuffix();
if (!Strings.isNullOrEmpty(suffix)) {
decoration = suffix + '-' + decoration;
}
ZipEntry e = new ZipEntry(safeFileName(path, decoration));
e.setComment(commitName + ":" + path);
e.setSize(obj.getSize());
e.setTime(when);
zipOut.putNextEntry(e);
obj.copyTo(zipOut);
zipOut.closeEntry();
}
}
}.setContentType(ZIP_TYPE)
.setAttachmentName(safeFileName(path, suffix) + ".zip")
.disableGzip();
}
private static String safeFileName(String fileName, @Nullable String suffix) {
// Convert a file path (e.g. "src/Init.c") to a safe file name with
// no meta-characters that might be unsafe on any given platform.
//
int slash = fileName.lastIndexOf('/');
if (slash >= 0) {
fileName = fileName.substring(slash + 1);
}
StringBuilder r = new StringBuilder(fileName.length());
for (int i = 0; i < fileName.length(); i++) {
final char c = fileName.charAt(i);
if (c == '_' || c == '-' || c == '.' || c == '@') {
r.append(c);
} else if ('0' <= c && c <= '9') {
r.append(c);
} else if ('A' <= c && c <= 'Z') {
r.append(c);
} else if ('a' <= c && c <= 'z') {
r.append(c);
} else if (c == ' ' || c == '\n' || c == '\r' || c == '\t') {
r.append('-');
} else {
r.append('_');
}
}
fileName = r.toString();
int ext = fileName.lastIndexOf('.');
if (suffix == null) {
return fileName;
} else if (ext <= 0) {
return fileName + "_" + suffix;
} else {
return fileName.substring(0, ext) + "_" + suffix
+ fileName.substring(ext);
}
}
private static String randSuffix() {
// Produce a random suffix that is difficult (or nearly impossible)
// for an attacker to guess in advance. This reduces the risk that
// an attacker could upload a *.class file and have us send a ZIP
// that can be invoked through an applet tag in the victim's browser.
//
Hasher h = Hashing.md5().newHasher();
byte[] buf = new byte[8];
NB.encodeInt64(buf, 0, TimeUtil.nowMs());
h.putBytes(buf);
rng.nextBytes(buf);
h.putBytes(buf);
return h.hash().toString();
}
public static String resolveContentType(ProjectState project, String path,
FileMode fileMode, String mimeType) {
switch (fileMode) {

View File

@ -105,6 +105,7 @@ public class Module extends RestApiModule {
put(FILE_KIND, "reviewed").to(PutReviewed.class);
delete(FILE_KIND, "reviewed").to(DeleteReviewed.class);
get(FILE_KIND, "content").to(GetContent.class);
get(FILE_KIND, "download").to(DownloadContent.class);
get(FILE_KIND, "diff").to(GetDiff.class);
child(CHANGE_KIND, "edit").to(ChangeEdits.class);