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:
parent
40b7472fef
commit
d1efb45fec
@ -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
|
||||
--
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user