Allow binary files to be downloaded from changes for local diff

If a change modifies a binary file, a user may need to download
that file and use a local application in order to review what
has been modified.  Image files or common office file formats
are two examples where this is very useful, as text level diff
is just not available, or practical.

Due to "features" in Microsoft Internet Explorer 6.0 that permit
a user to open a downloaded file in the same security zone as
the website that is serving the file, we wrap the download in
a ZIP archive.  This forces IE to open the ZIP archive rather
than the file itself, tricking it into treating the content as
a virtual folder and not the original file type.  When the file
is eventually opened from that virtual folder, it gets opened
with the local security zone, rather than the server's, and the
user's cookies and session are protected.

To reduce risks associated with an attacker trying to use the
ZIP archive as a container (e.g. for a Java applet), the file
name within the ZIP archive is randomly generated, including
request-specific information.  This makes it very difficult to
predict in advance what file name will appear in the archive,
so an attacker cannot reference a Java class file through an
applet tag (for example).  As the ZIP archive contains exactly
one file stream, it is also less likely to match with other
common uses of the ZIP format, like a *.odf file, as only one
data stream can appear.

In the future we would like to support site-specific blessed
content rules.  For example, a Gerrit server on an internal
corporate intranet might want to permit *.doc to be sent as
an inline content (rather than wrapped in a ZIP) to make the
review of Microsoft Word formatted files easier.  This change
does not permit that, but leaves the guessContentType() and
the isSafeInline() methods available for future enhancement.

Bug: GERRIT-132
Signed-off-by: Shawn O. Pearce <sop@google.com>
This commit is contained in:
Shawn O. Pearce
2009-04-15 09:03:52 -07:00
parent 409ef9b840
commit c66b78165a
7 changed files with 440 additions and 19 deletions

View File

@@ -34,6 +34,8 @@
class='com.google.gerrit.server.OpenIdLoginServlet'/>
<servlet path='/ssh_info'
class='com.google.gerrit.server.ssh.SshServlet'/>
<servlet path='/cat/*'
class='com.google.gerrit.server.CatServlet'/>
<servlet path='/rpc/AccountService'
class='com.google.gerrit.server.AccountServiceSrv'/>

View File

@@ -43,6 +43,8 @@ public interface ChangeConstants extends Constants {
String patchTableColumnDiff();
String patchTableDiffSideBySide();
String patchTableDiffUnified();
String patchTableDownloadPreImage();
String patchTableDownloadPostImage();
String changeScreenDescription();
String changeScreenDependencies();

View File

@@ -23,6 +23,8 @@ patchTableColumnComments = Comments
patchTableColumnDiff = Diff
patchTableDiffSideBySide = Side-by-Side
patchTableDiffUnified = Unified
patchTableDownloadPreImage = old
patchTableDownloadPostImage = new
changeScreenDescription = Description
changeScreenDependencies = Dependencies

View File

@@ -18,6 +18,7 @@ import com.google.gerrit.client.Link;
import com.google.gerrit.client.reviewdb.Patch;
import com.google.gerrit.client.reviewdb.PatchSet;
import com.google.gerrit.client.ui.FancyFlexTable;
import com.google.gwt.core.client.GWT;
import com.google.gwt.user.client.DeferredCommand;
import com.google.gwt.user.client.History;
import com.google.gwt.user.client.IncrementalCommand;
@@ -28,6 +29,7 @@ import com.google.gwt.user.client.ui.TableListener;
import com.google.gwtexpui.progress.client.ProgressBar;
import com.google.gwtexpui.safehtml.client.SafeHtml;
import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
import com.google.gwtorm.client.KeyUtil;
import java.util.List;
@@ -104,7 +106,7 @@ public class PatchTable extends Composite {
m.openTd();
m.setStyleName(S_DATA_HEADER);
m.setAttribute("colspan", 2);
m.setAttribute("colspan", 3);
m.append(Util.C.patchTableColumnDiff());
m.closeTd();
@@ -174,31 +176,79 @@ public class PatchTable extends Composite {
}
m.closeTd();
m.openTd();
m.addStyleName(S_DATA_CELL);
m.addStyleName("DiffLinkCell");
if (p.getPatchType() == Patch.PatchType.UNIFIED) {
m.openAnchor();
m.setAttribute("href", "#" + Link.toPatchSideBySide(p.getKey()));
m.append(Util.C.patchTableDiffSideBySide());
m.closeAnchor();
} else {
m.nbsp();
}
m.closeTd();
switch (p.getPatchType()) {
case UNIFIED:
openlink(m, 2);
m.setAttribute("href", "#" + Link.toPatchSideBySide(p.getKey()));
m.append(Util.C.patchTableDiffSideBySide());
closelink(m);
break;
m.openTd();
m.addStyleName(S_DATA_CELL);
m.addStyleName("DiffLinkCell");
m.openAnchor();
case BINARY: {
String base = GWT.getModuleBaseURL();
base += "cat/" + KeyUtil.encode(p.getKey().toString());
switch (p.getChangeType()) {
case DELETED:
case MODIFIED:
openlink(m, 1);
m.setAttribute("href", base + "^1");
m.append(Util.C.patchTableDownloadPreImage());
closelink(m);
break;
default:
emptycell(m, 1);
break;
}
switch (p.getChangeType()) {
case MODIFIED:
case ADDED:
openlink(m, 1);
m.setAttribute("href", base + "^0");
m.append(Util.C.patchTableDownloadPostImage());
closelink(m);
break;
default:
emptycell(m, 1);
break;
}
break;
}
default:
emptycell(m, 2);
break;
}
openlink(m, 1);
m.setAttribute("href", "#" + Link.toPatchUnified(p.getKey()));
m.append(Util.C.patchTableDiffUnified());
m.closeAnchor();
m.closeTd();
closelink(m);
m.closeTr();
}
private void openlink(final SafeHtmlBuilder m, final int colspan) {
m.openTd();
m.addStyleName(S_DATA_CELL);
m.addStyleName("DiffLinkCell");
m.setAttribute("colspan", colspan);
m.openAnchor();
}
private void closelink(final SafeHtmlBuilder m) {
m.closeAnchor();
m.closeTd();
}
private void emptycell(final SafeHtmlBuilder m, final int colspan) {
m.openTd();
m.addStyleName(S_DATA_CELL);
m.addStyleName("DiffLinkCell");
m.setAttribute("colspan", colspan);
m.nbsp();
m.closeTd();
}
@Override
protected Object getRowItemKey(final Patch item) {
return item.getKey();

View File

@@ -101,6 +101,10 @@ public class BaseServiceImplementation {
public static boolean canRead(final Account.Id who,
final Project.NameKey projectKey) {
final ProjectCache.Entry e = Common.getProjectCache().get(projectKey);
return canRead(who, e);
}
public static boolean canRead(final Account.Id who, final ProjectCache.Entry e) {
if (e == null) {
// Unexpected, a project disappearing. But claim its not available.
//

View File

@@ -0,0 +1,351 @@
// Copyright (C) 2009 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;
import static com.google.gerrit.client.rpc.BaseServiceImplementation.canRead;
import com.google.gerrit.client.data.ProjectCache;
import com.google.gerrit.client.reviewdb.Account;
import com.google.gerrit.client.reviewdb.Change;
import com.google.gerrit.client.reviewdb.Patch;
import com.google.gerrit.client.reviewdb.PatchSet;
import com.google.gerrit.client.reviewdb.Project;
import com.google.gerrit.client.reviewdb.ReviewDb;
import com.google.gerrit.client.rpc.Common;
import com.google.gerrit.git.InvalidRepositoryException;
import com.google.gwtjsonrpc.server.XsrfException;
import com.google.gwtorm.client.OrmException;
import org.spearce.jgit.lib.Constants;
import org.spearce.jgit.lib.ObjectId;
import org.spearce.jgit.lib.Repository;
import org.spearce.jgit.revwalk.RevCommit;
import org.spearce.jgit.revwalk.RevWalk;
import org.spearce.jgit.treewalk.TreeWalk;
import org.spearce.jgit.util.NB;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* Exports a single version of a patch as a normal file download.
* <p>
* This can be relatively unsafe with Microsoft Internet Explorer 6.0 as the
* browser will (rather incorrectly) treat an HTML or JavaScript file its
* supposed to download as though it was served by this site, and will execute
* it with the site's own protection domain. This opens a massive security hole
* so we package the content into a zip file.
*/
public class CatServlet extends HttpServlet {
private static final String APPLICATION_OCTET_STREAM =
"application/octet-stream";
private GerritServer server;
private SecureRandom rng;
@Override
public void init(final ServletConfig config) throws ServletException {
super.init(config);
try {
server = GerritServer.getInstance();
} catch (OrmException e) {
throw new ServletException("Cannot load GerritServer", e);
} catch (XsrfException e) {
throw new ServletException("Cannot load GerritServer", e);
}
rng = new SecureRandom();
}
@Override
protected void doGet(final HttpServletRequest req,
final HttpServletResponse rsp) throws IOException {
String keyStr = req.getPathInfo();
if (!keyStr.startsWith("/")) {
rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
keyStr = keyStr.substring(1);
final Patch.Key patchKey;
final int side;
{
final int c = keyStr.lastIndexOf('^');
if (c == 0) {
rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
if (c < 0) {
side = 0;
} else {
try {
side = Integer.parseInt(keyStr.substring(c + 1));
keyStr = keyStr.substring(0, c);
} catch (NumberFormatException e) {
rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
}
try {
patchKey = Patch.Key.parse(keyStr);
} catch (NumberFormatException e) {
rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
}
final Account.Id me = new GerritCall(server, req, rsp).getAccountId();
final Change.Id changeId = patchKey.getParentKey().getParentKey();
final Project project;
final Change change;
final PatchSet patchSet;
final Patch patch;
try {
final ReviewDb db = Common.getSchemaFactory().open();
try {
change = db.changes().get(changeId);
if (change == null) {
rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
final ProjectCache.Entry e =
Common.getProjectCache().get(change.getDest().getParentKey());
if (e == null || !canRead(me, e)) {
rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
project = e.getProject();
patchSet = db.patchSets().get(patchKey.getParentKey());
patch = db.patches().get(patchKey);
if (patchSet == null || patch == null) {
rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
} finally {
db.close();
}
} catch (OrmException e) {
getServletContext().log("Cannot query database", e);
rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
return;
}
final Repository repo;
try {
repo =
server.getRepositoryCache()
.get(change.getDest().getParentKey().get());
} catch (InvalidRepositoryException e) {
getServletContext().log("Cannot open repository", e);
rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
return;
}
final byte[] blobData;
final RevCommit fromCommit;
final String suffix;
final String path = patch.getFileName();
try {
final RevWalk rw = new RevWalk(repo);
final RevCommit c;
final TreeWalk tw;
c = rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
if (side == 0) {
fromCommit = c;
suffix = "new";
} else if (1 <= side && side - 1 < c.getParentCount()) {
fromCommit = rw.parseCommit(c.getParent(side - 1));
if (c.getParentCount() == 1) {
suffix = "old";
} else {
suffix = "old" + side;
}
} else {
rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
tw = TreeWalk.forPath(repo, path, fromCommit.getTree());
if (tw == null) {
rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
if (tw.getFileMode(0).getObjectType() == Constants.OBJ_BLOB) {
blobData = repo.openBlob(tw.getObjectId(0)).getCachedBytes();
} else {
rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
return;
}
} catch (IOException e) {
getServletContext().log("Cannot read repository", e);
rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
return;
} catch (RuntimeException e) {
getServletContext().log("Cannot read repository", e);
rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
return;
}
final long when = fromCommit.getCommitTime() * 1000L;
String contentType = guessContentType(project, path, blobData);
final String fn;
final byte[] outData;
if (isSafeInline(contentType)) {
fn = safeFileName(path, suffix);
outData = blobData;
} else {
// The content may not be safe to transmit inline, as a browser might
// interpret it as HTML or JavaScript hosted by this site. Such code
// might then run in the site's security domain, and may be able to use
// the user's cookies to perform unauthorized actions.
//
// Usually, wrapping the content into a ZIP file forces the browser to
// save the content to the local system instead.
//
final ByteArrayOutputStream zip = new ByteArrayOutputStream();
final ZipOutputStream zo = new ZipOutputStream(zip);
final ZipEntry e = new ZipEntry(safeFileName(path, rand(req, suffix)));
e.setComment(fromCommit.name() + ":" + path);
e.setSize(blobData.length);
e.setTime(when);
zo.putNextEntry(e);
zo.write(blobData);
zo.closeEntry();
zo.close();
outData = zip.toByteArray();
contentType = "application/zip";
fn = safeFileName(path, suffix) + ".zip";
}
rsp.setContentType(contentType);
rsp.setContentLength(outData.length);
rsp.setDateHeader("Last-Modified", when);
rsp.setHeader("Content-Disposition", "attachment; filename=\"" + fn + "\"");
rsp.setDateHeader("Expires", 0L);
rsp.setHeader("Pragma", "no-cache");
rsp.setHeader("Cache-Control", "no-cache, must-revalidate");
rsp.getOutputStream().write(outData);
}
private String guessContentType(final Project project, final String path,
final byte[] content) {
// When in doubt, call it a generic binary stream.
//
return APPLICATION_OCTET_STREAM;
}
private boolean isSafeInline(final String contentType) {
if (APPLICATION_OCTET_STREAM.equals(contentType)) {
// Most browsers perform content type sniffing when they get told
// a generic content type. This is bad, so assume we cannot send
// the file inline.
//
return false;
}
// Assume we cannot send the content inline.
//
return false;
}
private static String safeFileName(String fileName, final 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.
//
final int slash = fileName.lastIndexOf('/');
if (slash >= 0) {
fileName = fileName.substring(slash + 1);
}
final 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();
final int ext = fileName.lastIndexOf('.');
if (ext <= 0) {
return fileName + "_" + suffix;
} else {
return fileName.substring(0, ext) + "_" + suffix
+ fileName.substring(ext);
}
}
private String rand(final HttpServletRequest req, final String suffix)
throws UnsupportedEncodingException {
// 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.
//
final MessageDigest md = Constants.newMessageDigest();
final byte[] buf = new byte[8];
NB.encodeInt32(buf, 0, req.getRemotePort());
md.update(req.getRemoteAddr().getBytes("UTF-8"));
md.update(buf, 0, 4);
NB.encodeInt64(buf, 0, System.currentTimeMillis());
md.update(buf, 0, 8);
rng.nextBytes(buf);
md.update(buf, 0, 8);
return suffix + "-" + ObjectId.fromRaw(md.digest()).name();
}
}

View File

@@ -64,6 +64,16 @@
<url-pattern>/ssh_info</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>cat</servlet-name>
<servlet-class>com.google.gerrit.server.CatServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>cat</servlet-name>
<url-pattern>/cat/*</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>Static</servlet-name>
<servlet-class>com.google.gerrit.server.StaticServlet</servlet-class>