6d934dbd6b
This reverts my previous approach and applies a better approach. My previous approach expected URLs to change to have ".gz" extensions. The new approach keeps the URLs the same but checks if a file exists on-disk that has the same name with the ".gz" extension.
445 lines
16 KiB
C++
445 lines
16 KiB
C++
// Copyright (c) 2014 Sandstorm Development Group, Inc.
|
|
// Licensed under the MIT License:
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
// THE SOFTWARE.
|
|
|
|
// Hack around stdlib bug with C++14.
|
|
#include <initializer_list> // force libstdc++ to include its config
|
|
#undef _GLIBCXX_HAVE_GETS // correct broken config
|
|
// End hack.
|
|
|
|
#include <kj/main.h>
|
|
#include <kj/debug.h>
|
|
#include <kj/io.h>
|
|
#include <kj/async-io.h>
|
|
#include <capnp/rpc-twoparty.h>
|
|
#include <capnp/serialize.h>
|
|
#include <unistd.h>
|
|
#include <stdlib.h>
|
|
#include <stdio.h>
|
|
#include <fcntl.h>
|
|
#include <sys/types.h>
|
|
#include <sys/stat.h>
|
|
#include <dirent.h>
|
|
#include <errno.h>
|
|
|
|
#include <sandstorm/grain.capnp.h>
|
|
#include <sandstorm/web-session.capnp.h>
|
|
#include <sandstorm/hack-session.capnp.h>
|
|
|
|
namespace {
|
|
|
|
#if __QTCREATOR
|
|
#define KJ_MVCAP(var) var
|
|
// QtCreator dosen't understand C++14 syntax yet.
|
|
#else
|
|
#define KJ_MVCAP(var) var = ::kj::mv(var)
|
|
// Capture the given variable by move. Place this in a lambda capture list. Requires C++14.
|
|
//
|
|
// TODO(cleanup): Move to libkj.
|
|
#endif
|
|
|
|
typedef unsigned int uint;
|
|
typedef unsigned char byte;
|
|
|
|
// =======================================================================================
|
|
// Utility functions
|
|
//
|
|
// Most of these should be moved to the KJ library someday.
|
|
|
|
kj::AutoCloseFd createFile(kj::StringPtr name, int flags, mode_t mode = 0666) {
|
|
// Create a file, returning an RAII wrapper around the file descriptor. Errors throw exceptinos.
|
|
|
|
int fd;
|
|
KJ_SYSCALL(fd = open(name.cStr(), O_CREAT | flags, mode), name);
|
|
return kj::AutoCloseFd(fd);
|
|
}
|
|
|
|
size_t getFileSize(int fd, kj::StringPtr filename) {
|
|
struct stat stats;
|
|
KJ_SYSCALL(fstat(fd, &stats));
|
|
KJ_REQUIRE(S_ISREG(stats.st_mode), "Not a regular file.", filename);
|
|
return stats.st_size;
|
|
}
|
|
|
|
kj::Maybe<kj::AutoCloseFd> tryOpen(kj::StringPtr name, int flags, mode_t mode = 0666) {
|
|
// Try to open a file, returning an RAII wrapper around the file descriptor, or null if the
|
|
// file doesn't exist. All other errors throw exceptions.
|
|
|
|
int fd;
|
|
|
|
while ((fd = open(name.cStr(), flags, mode)) < 0) {
|
|
int error = errno;
|
|
if (error == ENOENT) {
|
|
return nullptr;
|
|
} else if (error != EINTR) {
|
|
KJ_FAIL_SYSCALL("open(name)", error, name);
|
|
}
|
|
}
|
|
|
|
return kj::AutoCloseFd(fd);
|
|
}
|
|
|
|
bool isDirectory(kj::StringPtr filename) {
|
|
// Return true if the parameter names a directory, false if it's any other kind of node or
|
|
// doesn't exist.
|
|
|
|
struct stat stats;
|
|
while (stat(filename.cStr(), &stats) < 0) {
|
|
if (errno != EINTR) {
|
|
return false;
|
|
}
|
|
}
|
|
return S_ISDIR(stats.st_mode);
|
|
}
|
|
|
|
kj::Vector<kj::String> listDirectory(kj::StringPtr dirname) {
|
|
// Return a list of all filenames in the given directory, except "." and "..".
|
|
|
|
kj::Vector<kj::String> entries;
|
|
|
|
DIR* dir = opendir(dirname.cStr());
|
|
if (dir == nullptr) {
|
|
KJ_FAIL_SYSCALL("opendir", errno, dirname);
|
|
}
|
|
KJ_DEFER(closedir(dir));
|
|
|
|
for (;;) {
|
|
errno = 0;
|
|
struct dirent* entry = readdir(dir);
|
|
if (entry == nullptr) {
|
|
int error = errno;
|
|
if (error == 0) {
|
|
break;
|
|
} else {
|
|
KJ_FAIL_SYSCALL("readdir", error, dirname);
|
|
}
|
|
}
|
|
|
|
kj::StringPtr name = entry->d_name;
|
|
if (name != "." && name != "..") {
|
|
entries.add(kj::heapString(entry->d_name));
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
// =======================================================================================
|
|
// WebSession implementation (interface declared in sandstorm/web-session.capnp)
|
|
|
|
class WebSessionImpl final: public sandstorm::WebSession::Server {
|
|
public:
|
|
WebSessionImpl(sandstorm::UserInfo::Reader userInfo,
|
|
sandstorm::SessionContext::Client context,
|
|
sandstorm::WebSession::Params::Reader params) {
|
|
// Permission #0 is "write". Check if bit 0 in the PermissionSet is set.
|
|
auto permissions = userInfo.getPermissions();
|
|
canWrite = permissions.size() > 0 && (permissions[0] & 1);
|
|
|
|
// `UserInfo` is defined in `sandstorm/grain.capnp` and contains info like:
|
|
// - A stable ID for the user, so you can correlate sessions from the same user.
|
|
// - The user's display name, e.g. "Mark Miller", useful for identifying the user to other
|
|
// users.
|
|
// - The user's permissions (seen above).
|
|
|
|
// `WebSession::Params` is defined in `sandstorm/web-session.capnp` and contains info like:
|
|
// - The hostname where the grain was mapped for this user. Every time a user opens a grain,
|
|
// it is mapped at a new random hostname for security reasons.
|
|
// - The user's User-Agent and Accept-Languages headers.
|
|
|
|
// `SessionContext` is defined in `sandstorm/grain.capnp` and implements callbacks for
|
|
// sharing/access control and service publishing/discovery.
|
|
}
|
|
|
|
kj::Promise<void> get(GetContext context) override {
|
|
// HTTP GET request.
|
|
|
|
auto path = context.getParams().getPath();
|
|
requireCanonicalPath(path);
|
|
|
|
if (path == "var" || path == "var/") {
|
|
// Return a listing of the directory contents, one per line.
|
|
auto text = kj::strArray(listDirectory("var"), "\n");
|
|
auto response = context.getResults().initContent();
|
|
response.setMimeType("text/plain");
|
|
response.getBody().setBytes(
|
|
kj::arrayPtr(reinterpret_cast<byte*>(text.begin()), text.size()));
|
|
return kj::READY_NOW;
|
|
} else if (path.startsWith("var/")) {
|
|
// Serve all files under /var with type application/octet-stream since it comes from the
|
|
// user. E.g. serving as "text/html" here would allow someone to trivially XSS other users
|
|
// of the grain by PUTing malicious HTML content. (Such an attack wouldn't be a huge deal:
|
|
// it would only allow the attacker to hijack another user's access to this grain, not to
|
|
// Sandstorm in general, and if they attacker already has write access to upload the
|
|
// malicious content, they have little to gain from hijacking another session.)
|
|
return readFile(path, context, "application/octet-stream");
|
|
} else if (path == ".can-write") {
|
|
// Fetch "/.can-write" to determine if the user has write permission, so you can show them
|
|
// a different UI if not.
|
|
auto response = context.getResults().initContent();
|
|
response.setMimeType("text/plain");
|
|
response.getBody().setBytes(kj::str(canWrite).asBytes());
|
|
return kj::READY_NOW;
|
|
} else if (path == "" || path.endsWith("/")) {
|
|
// A directory. Serve "index.html".
|
|
return readFile(kj::str("client/", path, "ssindex.html"), context, "text/html; charset=UTF-8");
|
|
} else {
|
|
// Request for a static file. Look for it under "client/".
|
|
auto filename = kj::str("client/", path);
|
|
|
|
// Check if it's a directory.
|
|
if (isDirectory(filename)) {
|
|
// It is. Return redirect to add '/'.
|
|
auto redirect = context.getResults().initRedirect();
|
|
redirect.setIsPermanent(true);
|
|
redirect.setSwitchToGet(true);
|
|
redirect.setLocation(kj::str(path, '/'));
|
|
return kj::READY_NOW;
|
|
}
|
|
|
|
// Regular file (or non-existent).
|
|
return readFile(kj::mv(filename), context, inferContentType(path));
|
|
}
|
|
}
|
|
|
|
kj::Promise<void> put(PutContext context) override {
|
|
// HTTP PUT request.
|
|
|
|
auto params = context.getParams();
|
|
auto path = params.getPath();
|
|
requireCanonicalPath(path);
|
|
|
|
KJ_REQUIRE(path.startsWith("var/"), "PUT only supported under /var.");
|
|
|
|
if (!canWrite) {
|
|
context.getResults().initClientError()
|
|
.setStatusCode(sandstorm::WebSession::Response::ClientErrorCode::FORBIDDEN);
|
|
} else {
|
|
auto tempPath = kj::str(path, ".uploading");
|
|
auto data = params.getContent().getContent();
|
|
|
|
kj::FdOutputStream(createFile(tempPath, O_WRONLY | O_TRUNC))
|
|
.write(data.begin(), data.size());
|
|
|
|
KJ_SYSCALL(rename(tempPath.cStr(), path.cStr()));
|
|
context.getResults().initNoContent();
|
|
}
|
|
|
|
return kj::READY_NOW;
|
|
}
|
|
|
|
kj::Promise<void> delete_(DeleteContext context) override {
|
|
// HTTP DELETE request.
|
|
|
|
auto path = context.getParams().getPath();
|
|
requireCanonicalPath(path);
|
|
|
|
KJ_REQUIRE(path.startsWith("var/"), "DELETE only supported under /var.");
|
|
|
|
if (!canWrite) {
|
|
context.getResults().initClientError()
|
|
.setStatusCode(sandstorm::WebSession::Response::ClientErrorCode::FORBIDDEN);
|
|
} else {
|
|
while (unlink(path.cStr()) != 0) {
|
|
int error = errno;
|
|
if (error == ENOENT) {
|
|
// Ignore file-not-found for idempotency.
|
|
break;
|
|
} else if (error != EINTR) {
|
|
KJ_FAIL_SYSCALL("unlink", error);
|
|
}
|
|
}
|
|
}
|
|
|
|
return kj::READY_NOW;
|
|
}
|
|
|
|
private:
|
|
bool canWrite;
|
|
// True if the user has write permission.
|
|
|
|
void requireCanonicalPath(kj::StringPtr path) {
|
|
// Require that the path doesn't contain "." or ".." or consecutive slashes, to prevent path
|
|
// injection attacks.
|
|
//
|
|
// Note that such attacks wouldn't actually accomplish much since everything outside /var
|
|
// is a read-only filesystem anyway, containing the app package contents which are non-secret.
|
|
|
|
KJ_REQUIRE(!path.startsWith("/"));
|
|
KJ_REQUIRE(!path.startsWith("./") && path != ".");
|
|
KJ_REQUIRE(!path.startsWith("../") && path != "..");
|
|
|
|
KJ_IF_MAYBE(slashPos, path.findFirst('/')) {
|
|
requireCanonicalPath(path.slice(*slashPos + 1));
|
|
}
|
|
}
|
|
|
|
kj::StringPtr inferContentType(kj::StringPtr filename) {
|
|
if (filename.endsWith(".html")) {
|
|
return "text/html; charset=UTF-8";
|
|
} else if (filename.endsWith(".js")) {
|
|
return "text/javascript; charset=UTF-8";
|
|
} else if (filename.endsWith(".css")) {
|
|
return "text/css; charset=UTF-8";
|
|
} else if (filename.endsWith(".png")) {
|
|
return "image/png";
|
|
} else if (filename.endsWith(".gif")) {
|
|
return "image/gif";
|
|
} else if (filename.endsWith(".jpg") || filename.endsWith(".jpeg")) {
|
|
return "image/jpeg";
|
|
} else if (filename.endsWith(".svg")) {
|
|
return "image/svg+xml; charset=UTF-8";
|
|
} else if (filename.endsWith(".txt")) {
|
|
return "text/plain; charset=UTF-8";
|
|
} else {
|
|
return "application/octet-stream";
|
|
}
|
|
}
|
|
|
|
kj::Promise<void> readFile(
|
|
kj::StringPtr filename, GetContext context, kj::StringPtr contentType) {
|
|
// Do we support compression?
|
|
bool canGzip = false;
|
|
for (auto accept: context.getParams().getContext().getAcceptEncoding()) {
|
|
if (accept.getContentCoding() == "gzip") {
|
|
canGzip = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If compression is supported, look for file with .gz extension.
|
|
kj::Maybe<kj::AutoCloseFd> maybeFd;
|
|
bool isGzipped = false;
|
|
if (canGzip) {
|
|
maybeFd = tryOpen(kj::str(filename, ".gz"), O_RDONLY);
|
|
isGzipped = maybeFd != nullptr;
|
|
}
|
|
|
|
// If we haven't found a suitable file yet, look for the uncompressed version.
|
|
if (maybeFd == nullptr) {
|
|
maybeFd = tryOpen(filename, O_RDONLY);
|
|
}
|
|
|
|
// Serve it.
|
|
KJ_IF_MAYBE(fd, kj::mv(maybeFd)) {
|
|
auto size = getFileSize(*fd, filename);
|
|
kj::FdInputStream stream(kj::mv(*fd));
|
|
auto response = context.getResults(capnp::MessageSize { size / sizeof(capnp::word) + 32, 0 });
|
|
auto content = response.initContent();
|
|
content.setStatusCode(sandstorm::WebSession::Response::SuccessCode::OK);
|
|
content.setMimeType(contentType);
|
|
if (isGzipped) {
|
|
content.setEncoding("gzip");
|
|
}
|
|
stream.read(content.getBody().initBytes(size).begin(), size);
|
|
return kj::READY_NOW;
|
|
} else {
|
|
auto error = context.getResults().initClientError();
|
|
error.setStatusCode(sandstorm::WebSession::Response::ClientErrorCode::NOT_FOUND);
|
|
return kj::READY_NOW;
|
|
}
|
|
}
|
|
};
|
|
|
|
// =======================================================================================
|
|
// UiView implementation (interface declared in sandstorm/grain.capnp)
|
|
|
|
class UiViewImpl final: public sandstorm::UiView::Server {
|
|
public:
|
|
kj::Promise<void> getViewInfo(GetViewInfoContext context) override {
|
|
auto viewInfo = context.initResults();
|
|
|
|
// Define a "write" permission, and then define roles "editor" and "viewer" where only "editor"
|
|
// has the "write" permission. This will allow people to share read-only.
|
|
auto perms = viewInfo.initPermissions(1);
|
|
perms[0].setName("write");
|
|
auto write = perms[0];
|
|
write.setName("write");
|
|
write.initTitle().setDefaultText("write");
|
|
|
|
auto roles = viewInfo.initRoles(2);
|
|
auto editor = roles[0];
|
|
editor.initTitle().setDefaultText("editor");
|
|
editor.initVerbPhrase().setDefaultText("can edit");
|
|
editor.initPermissions(1).set(0, true); // has "write" permission
|
|
auto viewer = roles[1];
|
|
viewer.initTitle().setDefaultText("viewer");
|
|
viewer.initVerbPhrase().setDefaultText("can view");
|
|
viewer.initPermissions(1).set(0, false); // does not have "write" permission
|
|
|
|
return kj::READY_NOW;
|
|
}
|
|
|
|
kj::Promise<void> newSession(NewSessionContext context) override {
|
|
auto params = context.getParams();
|
|
|
|
KJ_REQUIRE(params.getSessionType() == capnp::typeId<sandstorm::WebSession>(),
|
|
"Unsupported session type.");
|
|
|
|
context.getResults().setSession(
|
|
kj::heap<WebSessionImpl>(params.getUserInfo(), params.getContext(),
|
|
params.getSessionParams().getAs<sandstorm::WebSession::Params>()));
|
|
|
|
return kj::READY_NOW;
|
|
}
|
|
};
|
|
|
|
// =======================================================================================
|
|
// Program main
|
|
|
|
class ServerMain {
|
|
public:
|
|
ServerMain(kj::ProcessContext& context): context(context), ioContext(kj::setupAsyncIo()) {}
|
|
|
|
kj::MainFunc getMain() {
|
|
return kj::MainBuilder(context, "Sandstorm Thin Server",
|
|
"Intended to be run as the root process of a Sandstorm app.")
|
|
.callAfterParsing(KJ_BIND_METHOD(*this, run))
|
|
.build();
|
|
}
|
|
|
|
kj::MainBuilder::Validity run() {
|
|
// Set up RPC on file descriptor 3.
|
|
auto stream = ioContext.lowLevelProvider->wrapSocketFd(3);
|
|
capnp::TwoPartyVatNetwork network(*stream, capnp::rpc::twoparty::Side::CLIENT);
|
|
auto rpcSystem = capnp::makeRpcServer(network, kj::heap<UiViewImpl>());
|
|
|
|
// Get the SandstormApi default capability from the supervisor.
|
|
// TODO(soon): We don't use this, but for some reason the connection doesn't come up if we
|
|
// don't do this restore. Cap'n Proto bug? v8capnp bug? Shell bug?
|
|
{
|
|
capnp::MallocMessageBuilder message;
|
|
auto vatId = message.getRoot<capnp::rpc::twoparty::VatId>();
|
|
vatId.setSide(capnp::rpc::twoparty::Side::SERVER);
|
|
sandstorm::SandstormApi<>::Client api =
|
|
rpcSystem.bootstrap(vatId).castAs<sandstorm::SandstormApi<>>();
|
|
}
|
|
|
|
kj::NEVER_DONE.wait(ioContext.waitScope);
|
|
}
|
|
|
|
private:
|
|
kj::ProcessContext& context;
|
|
kj::AsyncIoContext ioContext;
|
|
};
|
|
|
|
} // anonymous namespace
|
|
|
|
KJ_MAIN(ServerMain)
|