Merge branch 'master' of github.com:fraction/oasis into add-i18n

This commit is contained in:
Christian Bundy 2020-02-03 21:51:59 -08:00
commit 1099395dfa
8 changed files with 259 additions and 180 deletions

View File

@ -14,6 +14,7 @@
"LGPL",
"Machinekit",
"manyverse",
"minlength",
"monokai",
"msgs",
"multiserver",
@ -31,10 +32,10 @@
"roadmap",
"sameorigin",
"shortname",
"systemctl",
"socio",
"ssbc",
"summerfruit",
"systemctl",
"systemd",
"unfollow",
"unikitty",

View File

@ -26,7 +26,7 @@ Options:
## Installation
Most people should install Oasis with [npm](https://npmjs.org/).
Most people should install stable releases with [npm](https://npmjs.org/).
```shell
npm --global install @fraction/oasis@latest
@ -34,13 +34,19 @@ npm --global install @fraction/oasis@latest
Please make sure that your Node.js version is the [**current** or **active LTS** release](https://nodejs.org/en/about/releases/).
For faster updates and less stability, install from GitHub and upgrade often.
```shell
npm --global install github:fraction/oasis
```
Want more? Check out [`install.md`](https://github.com/fraction/oasis/blob/master/docs/install.md).
## Resources
- [Contributing](https://github.com/fraction/oasis/blob/master/docs/contributing.md)
- [Architecture](https://github.com/fraction/oasis/blob/master/docs/architecture.md)
- [Help](https://github.com/fraction/oasis/issues/new/choose)
- [Help](https://github.com/fraction/oasis/issues/new)
- [Roadmap](https://github.com/fraction/oasis/blob/master/docs/roadmap.md)
- [Security Policy](https://github.com/fraction/oasis/blob/master/docs/security.md)
- [Source Code](https://github.com/fraction/oasis.git)

35
docs/blob-security.md Normal file
View File

@ -0,0 +1,35 @@
# Blob security
**This is how we secure blob pages from interacting with Oasis. If you notice
any errors or omissions, please follow the steps in the security policy.**
One of the problems we have when hosting content from other people in a P2P
network is avoiding
[arbitrary code execution](https://en.wikipedia.org/wiki/Arbitrary_code_execution).
In the context of Oasis, we need to be very sure that we aren't letting any code
other than Oasis run in the browser. Markdown is a security concern, but it's
got lots of eyeballs on the problem, whereas blob security is a security
concern without any common best practices. The problem we need to solve isn't
super common: hosting arbitrary data, especially HTML, in a safe way that doesn't
open security vulnerabilities.
The way we currently deal with this is a [content security policy (CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP),
which gives Oasis a way to tell the web browser which features can be safely
disabled. Since Oasis doesn't use any front-end JavaScript, we can disable all
JavaScript being run by the web browser. This is _huge_ and massively reduces
the surface area that might be vulnerable to attack. You can find all of the
CSP code in [`http.js`](https://github.com/fraction/oasis/blob/master/src/http.js).
With JavaScript out of the way, the only attack vector that we should worry
about is an [HTML form](https://developer.mozilla.org/en-US/docs/Learn/Forms#See_also).
If one of these were embedded in a blob, they would be able to send HTTP POST
requests to our API endpoints, impersonating the user. A button called "click
me", could publish posts, change follow status, make changes to our settings
page, or other bad behavior that we want to avoid.
The mitigation for this is a referrer check on all POST endpoints, which helps
us guarantee that all form submissions came from a non-blob page. If we receive
an HTTP POST without a referrer, we throw an error. If we receive a referrer from
a blob page, we throw an error. If a form submission passes these two checks,
we can safely assume that the POST request came from a legitimate person using
Oasis.

View File

@ -393,7 +393,7 @@ label {
nav > ul {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
justify-content: space-around;
margin: 0;
padding: 0;
}

View File

@ -17,6 +17,7 @@ module.exports = ({ host, port, middleware }) => {
// Output full error objects
err.message = err.stack;
err.expose = true;
console.error(err);
return null;
});

View File

@ -270,11 +270,15 @@ router
})
.get("/image/:imageSize/:blobId", async ctx => {
const { blobId, imageSize } = ctx.params;
ctx.type = "image/png";
if (sharp) {
ctx.type = "image/png";
}
const fakePixel = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64"
);
const fakeImage = imageSize =>
sharp
? sharp({
@ -290,7 +294,10 @@ router
}
}
})
.png()
.toBuffer()
: new Promise(resolve => resolve(fakePixel));
const image = async ({ blobId, imageSize }) => {
const bufferSource = await blob.get({ blobId });
const fakeId = "&0000000000000000000000000000000000000000000=.sha256";

View File

@ -643,6 +643,15 @@ module.exports = cooler => {
},
latestFollowing: async () => {
const ssb = await cooler.connect();
const { id } = ssb;
const relationshipObject = await cooler.get(ssb.friends.get, {
source: id
});
const followingList = Object.entries(relationshipObject)
.filter(([, val]) => val === true)
.map(([key]) => key);
const myFeedId = ssb.id;
@ -656,14 +665,11 @@ module.exports = cooler => {
const messages = await new Promise((resolve, reject) => {
pull(
source,
pull.asyncMap((message, cb) => {
models.friend.isFollowing(message.value.author).then(following => {
cb(null, following ? message : null);
});
}),
pull.filter(
message =>
message !== null && typeof message.value.content !== "string"
followingList.includes(message.value.author) &&
message !== null &&
typeof message.value.content !== "string"
),
pull.take(maxMessages),
pull.collect((err, collectedMessages) => {
@ -805,187 +811,195 @@ module.exports = cooler => {
const myFeedId = ssb.id;
const options = configure({ id: msgId }, customOptions);
const rawMsg = await cooler.get(ssb.get, options);
return cooler
.get(ssb.get, options)
.then(async rawMsg => {
debug("got raw message");
debug("got raw message");
const parents = [];
const parents = [];
const getRootAncestor = msg =>
new Promise((resolve, reject) => {
if (msg.key == null) {
debug("something is very wrong, we used `{ meta: true }`");
resolve(parents);
} else {
debug("getting root ancestor of %s", msg.key);
const getRootAncestor = msg =>
new Promise((resolve, reject) => {
if (msg.key == null) {
debug("something is very wrong, we used `{ meta: true }`");
resolve(parents);
} else {
debug("getting root ancestor of %s", msg.key);
if (typeof msg.value.content === "string") {
debug("private message");
// Private message we can't decrypt, stop looking for parents.
resolve(parents);
}
if (typeof msg.value.content === "string") {
debug("private message");
// Private message we can't decrypt, stop looking for parents.
resolve(parents);
}
if (msg.value.content.type !== "post") {
debug("not a post");
resolve(msg);
}
if (msg.value.content.type !== "post") {
debug("not a post");
resolve(msg);
}
if (isLooseReply(msg)) {
debug("reply, get the parent");
try {
// It's a message reply, get the parent!
cooler
.get(ssb.get, {
id: msg.value.content.fork,
meta: true,
private: true
})
.then(fork => {
resolve(getRootAncestor(fork));
})
.catch(reject);
} catch (e) {
debug(e);
resolve(msg);
if (isLooseReply(msg)) {
debug("reply, get the parent");
try {
// It's a message reply, get the parent!
cooler
.get(ssb.get, {
id: msg.value.content.fork,
meta: true,
private: true
})
.then(fork => {
resolve(getRootAncestor(fork));
})
.catch(reject);
} catch (e) {
debug(e);
resolve(msg);
}
} else if (isLooseComment(msg)) {
debug("comment: %s", msg.value.content.root);
try {
// It's a thread reply, get the parent!
cooler
.get(ssb.get, {
id: msg.value.content.root,
meta: true,
private: true
})
.then(root => {
resolve(getRootAncestor(root));
})
.catch(reject);
} catch (e) {
debug(e);
resolve(msg);
}
} else if (isLooseRoot(msg)) {
debug("got root ancestor");
resolve(msg);
} else {
// type !== "post", probably
// this should show up as JSON
debug(
"got mysterious root ancestor that fails all known schemas"
);
debug("%O", msg);
resolve(msg);
}
}
} else if (isLooseComment(msg)) {
debug("comment: %s", msg.value.content.root);
try {
// It's a thread reply, get the parent!
cooler
.get(ssb.get, {
id: msg.value.content.root,
meta: true,
private: true
})
.then(root => {
resolve(getRootAncestor(root));
})
.catch(reject);
} catch (e) {
debug(e);
resolve(msg);
}
} else if (isLooseRoot(msg)) {
debug("got root ancestor");
resolve(msg);
} else {
// type !== "post", probably
// this should show up as JSON
debug(
"got mysterious root ancestor that fails all known schemas"
);
debug("%O", msg);
resolve(msg);
}
}
});
});
const getReplies = key =>
new Promise((resolve, reject) => {
const filterQuery = {
$filter: {
dest: key
}
};
const getReplies = key =>
new Promise((resolve, reject) => {
const filterQuery = {
$filter: {
dest: key
}
};
cooler
.read(ssb.backlinks.read, {
query: [filterQuery],
index: "DTA" // use asserted timestamps
})
.then(referenceStream => {
pull(
referenceStream,
pull.filter(msg => {
const isPost =
lodash.get(msg, "value.content.type") === "post";
if (isPost === false) {
return false;
}
const root = lodash.get(msg, "value.content.root");
const fork = lodash.get(msg, "value.content.fork");
if (root !== key && fork !== key) {
// mention
return false;
}
if (fork === key) {
// not a reply to this post
// it's a reply *to a reply* of this post
return false;
}
return true;
}),
pull.collect((err, messages) => {
if (err) {
reject(err);
} else {
resolve(messages || undefined);
}
cooler
.read(ssb.backlinks.read, {
query: [filterQuery],
index: "DTA" // use asserted timestamps
})
);
})
.catch(reject);
});
.then(referenceStream => {
pull(
referenceStream,
pull.filter(msg => {
const isPost =
lodash.get(msg, "value.content.type") === "post";
if (isPost === false) {
return false;
}
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flat
const flattenDeep = arr1 =>
arr1.reduce(
(acc, val) =>
Array.isArray(val) ? acc.concat(flattenDeep(val)) : acc.concat(val),
[]
);
const root = lodash.get(msg, "value.content.root");
const fork = lodash.get(msg, "value.content.fork");
const getDeepReplies = key =>
new Promise((resolve, reject) => {
const oneDeeper = async (replyKey, depth) => {
const replies = await getReplies(replyKey);
debug(
"replies",
replies.map(m => m.key)
if (root !== key && fork !== key) {
// mention
return false;
}
if (fork === key) {
// not a reply to this post
// it's a reply *to a reply* of this post
return false;
}
return true;
}),
pull.collect((err, messages) => {
if (err) {
reject(err);
} else {
resolve(messages || undefined);
}
})
);
})
.catch(reject);
});
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flat
const flattenDeep = arr1 =>
arr1.reduce(
(acc, val) =>
Array.isArray(val)
? acc.concat(flattenDeep(val))
: acc.concat(val),
[]
);
debug("found %s replies for %s", replies.length, replyKey);
const getDeepReplies = key =>
new Promise((resolve, reject) => {
const oneDeeper = async (replyKey, depth) => {
const replies = await getReplies(replyKey);
debug(
"replies",
replies.map(m => m.key)
);
if (replies.length === 0) {
return replies;
}
return Promise.all(
replies.map(async reply => {
const deeperReplies = await oneDeeper(reply.key, depth + 1);
lodash.set(reply, "value.meta.thread.depth", depth);
lodash.set(reply, "value.meta.thread.reply", true);
return [reply, deeperReplies];
})
);
};
oneDeeper(key, 0)
.then(nested => {
const nestedReplies = [...nested];
const deepReplies = flattenDeep(nestedReplies);
resolve(deepReplies);
})
.catch(reject);
debug("found %s replies for %s", replies.length, replyKey);
if (replies.length === 0) {
return replies;
}
return Promise.all(
replies.map(async reply => {
const deeperReplies = await oneDeeper(reply.key, depth + 1);
lodash.set(reply, "value.meta.thread.depth", depth);
lodash.set(reply, "value.meta.thread.reply", true);
return [reply, deeperReplies];
})
);
};
oneDeeper(key, 0)
.then(nested => {
const nestedReplies = [...nested];
const deepReplies = flattenDeep(nestedReplies);
resolve(deepReplies);
})
.catch(reject);
});
debug("about to get root ancestor");
const rootAncestor = await getRootAncestor(rawMsg);
debug("got root ancestors");
const deepReplies = await getDeepReplies(rootAncestor.key);
debug("got deep replies");
const allMessages = [rootAncestor, ...deepReplies].map(message => {
const isThreadTarget = message.key === msgId;
lodash.set(message, "value.meta.thread.target", isThreadTarget);
return message;
});
return await transform(ssb, allMessages, myFeedId);
})
.catch(() => {
throw new Error(
"Message not found in the database. You've done nothing wrong. Maybe try again later?"
);
});
debug("about to get root ancestor");
const rootAncestor = await getRootAncestor(rawMsg);
debug("got root ancestors");
const deepReplies = await getDeepReplies(rootAncestor.key);
debug("got deep replies");
const allMessages = [rootAncestor, ...deepReplies].map(message => {
const isThreadTarget = message.key === msgId;
lodash.set(message, "value.meta.thread.target", isThreadTarget);
return message;
});
const transformed = await transform(ssb, allMessages, myFeedId);
return transformed;
},
get: async (msgId, customOptions) => {
debug("get: %s", msgId);

View File

@ -580,14 +580,28 @@ exports.replyView = async ({ messages, myFeedId }) => {
);
};
exports.searchView = ({ messages, query }) =>
template(
exports.searchView = ({ messages, query }) => {
const searchInput = input({
name: "query",
required: false,
type: "search",
value: query
});
// - Minimum length of 3 because otherwise SSB-Search hangs forever. :)
// https://github.com/ssbc/ssb-search/issues/8
// - Using `setAttribute()` because HyperScript (the HyperAxe dependency has
// a bug where the `minlength` property is being ignored. No idea why.
// https://github.com/hyperhype/hyperscript/issues/91
searchInput.setAttribute("minlength", 3);
return template(
section(
form(
{ action: "/search", method: "get" },
header(strong(i18n.search)),
label({ for: "query" }, i18n.searchLabel),
input({ required: true, type: "search", name: "query", value: query }),
searchInput,
button(
{
type: "submit"
@ -598,3 +612,4 @@ exports.searchView = ({ messages, query }) =>
),
messages.map(msg => post({ msg }))
);
};