Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: secure Lassie server using authorization #273

Merged
merged 4 commits into from
Jun 28, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion cli/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ use zinnia_runtime::anyhow::{Context, Error, Result};
use zinnia_runtime::deno_core::error::JsError;
use zinnia_runtime::fmt_errors::format_js_error;
use zinnia_runtime::{
colors, lassie, resolve_path, run_js_module, BootstrapOptions, ConsoleReporter,
colors, generate_lassie_access_token, lassie, resolve_path, run_js_module, BootstrapOptions,
ConsoleReporter,
};

#[tokio::main(flavor = "current_thread")]
Expand Down Expand Up @@ -57,6 +58,7 @@ async fn main_impl() -> Result<()> {
temp_dir: None,
// Listen on an ephemeral port selected by the operating system
port: 0,
access_token: Some(generate_lassie_access_token()),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested this change manually by adding console.log to runtime/js/fetch.js, running zinnia-dev run runtime/tests/js/ipfs_retrieval_tests.js and observing output.

// Use the default Lassie configuration for everything else
..lassie::DaemonConfig::default()
})
Expand Down
6 changes: 5 additions & 1 deletion daemon/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ use args::CliArgs;
use clap::Parser;

use zinnia_runtime::anyhow::{anyhow, Context, Error, Result};
use zinnia_runtime::{get_module_root, lassie, resolve_path, run_js_module, BootstrapOptions};
use zinnia_runtime::{
generate_lassie_access_token, get_module_root, lassie, resolve_path, run_js_module,
BootstrapOptions,
};

use crate::station_reporter::{log_info_activity, StationReporter};

Expand Down Expand Up @@ -49,6 +52,7 @@ async fn run(config: CliArgs) -> Result<()> {
temp_dir: Some(lassie_temp_dir),
// Listen on an ephemeral port selected by the operating system
port: 0,
access_token: Some(generate_lassie_access_token()),
bajtos marked this conversation as resolved.
Show resolved Hide resolved
// Use the default Lassie configuration for everything else
..lassie::DaemonConfig::default()
};
Expand Down
2 changes: 1 addition & 1 deletion runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ deno_fetch = "0.132.0"
deno_url = "0.108.0"
deno_web = "0.139.0"
deno_webidl = "0.108.0"
lassie = "0.4.0"
lassie = "0.5.1"
# lassie = { git = "https://github.com/filecoin-station/rusty-lassie.git" }
log.workspace = true
once_cell = "1.18.0"
Expand Down
4 changes: 2 additions & 2 deletions runtime/js/99_main.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
mainRuntimeGlobalProperties,
windowOrWorkerGlobalScope,
} from "ext:zinnia_runtime/98_global_scope.js";
import { setLassieUrl } from "ext:zinnia_runtime/fetch.js";
import { setLassieConfig } from "ext:zinnia_runtime/fetch.js";
import { setVersions } from "ext:zinnia_runtime/90_zinnia_apis.js";

function formatException(error) {
Expand Down Expand Up @@ -69,7 +69,7 @@ function runtimeStart(runtimeOptions) {
// deno-lint-ignore prefer-primordials
Error.prepareStackTrace = core.prepareStackTrace;

setLassieUrl(runtimeOptions.lassieUrl);
setLassieConfig(runtimeOptions.lassieUrl, runtimeOptions.lassieAuth);
setVersions(runtimeOptions.zinniaVersion, runtimeOptions.v8Version);
}

Expand Down
16 changes: 14 additions & 2 deletions runtime/js/fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ import { fetch as fetchImpl } from "ext:deno_fetch/26_fetch.js";
import { fromInnerResponse, toInnerResponse } from "ext:deno_fetch/23_response.js";
import { toInnerRequest, fromInnerRequest, Request } from "ext:deno_fetch/23_request.js";
import { guardFromHeaders } from "ext:deno_fetch/20_headers.js";
import { byteLowerCase } from "ext:deno_web/00_infra.js";

const ipfsScheme = "ipfs://";
let ipfsBaseUrl = undefined;

export function setLassieUrl(/** @type {string} */ value) {
ipfsBaseUrl = value + "ipfs/";
/** @type {string|null} */
let lassieAuth = null;

export function setLassieConfig(/** @type {string} */ url, /** @type {string|null} */ auth) {
ipfsBaseUrl = url + "ipfs/";
lassieAuth = auth;
}

export function fetch(resource, options) {
Expand Down Expand Up @@ -52,6 +57,13 @@ function buildIpfsRequest(request) {
url.startsWith(ipfsScheme) ? ipfsBaseUrl + url.slice(ipfsScheme.length) : url,
);

if (inner.headerList.some(([name, _value]) => byteLowerCase(name) == "authorization")) {
throw new Error("IPFS retrieval requests don't support Authorization header yet");
}
if (lassieAuth) {
inner.headerList.push(["authorization", lassieAuth]);
}

return fromInnerRequest(inner, request.signal, guardFromHeaders(request.headers));
}

Expand Down
15 changes: 15 additions & 0 deletions runtime/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ impl BootstrapOptions {
"isTty": self.is_tty,
"walletAddress": self.wallet_address,
"lassieUrl": format!("http://127.0.0.1:{}/", self.lassie_daemon.port()),
"lassieAuth": match self.lassie_daemon.access_token() {
Some(token) => serde_json::Value::String(format!("Bearer {token}")),
None => serde_json::Value::Null,
},
"zinniaVersion": self.zinnia_version,
"v8Version": deno_core::v8_version(),
});
Expand Down Expand Up @@ -132,3 +136,14 @@ pub async fn run_js_module(

Ok(())
}

use deno_crypto::rand::{self, distributions::Alphanumeric, Rng};

/// A helper function to generate an access token for protecting Lassie's HTTP API
pub fn generate_lassie_access_token() -> String {
rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(24)
.map(char::from)
.collect()
}
11 changes: 9 additions & 2 deletions runtime/tests/helpers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
use std::sync::{Arc, OnceLock};

use zinnia_runtime::generate_lassie_access_token;

pub fn lassie_daemon() -> Arc<lassie::Daemon> {
static LASSIE_DAEMON: OnceLock<Result<Arc<lassie::Daemon>, lassie::StartError>> =
OnceLock::new();

let result = LASSIE_DAEMON
.get_or_init(|| lassie::Daemon::start(lassie::DaemonConfig::default()).map(Arc::new));
let result = LASSIE_DAEMON.get_or_init(|| {
lassie::Daemon::start(lassie::DaemonConfig {
access_token: Some(generate_lassie_access_token()),
..lassie::DaemonConfig::default()
})
.map(Arc::new)
});

match result {
Ok(ptr) => Arc::clone(ptr),
Expand Down
51 changes: 47 additions & 4 deletions runtime/tests/js/ipfs_retrieval_tests.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { test } from "zinnia:test";
import { assertEquals, AssertionError } from "zinnia:assert";
import { assertEquals, assertMatch, assertRejects, AssertionError } from "zinnia:assert";

const EXPECTED_CAR_BASE64 =
"OqJlcm9vdHOB2CpYJQABcBIgO/KicpaH2Kj0sXyJNWLdY4kGpEe2mjY5zovBGRJ+6mpndmVyc2lvbgFrAXASIDvyonKWh9io9LF8iTVi3WOJBqRHtpo2Oc6LwRkSfupqCkUIAhI/TXkgbW9zdCBmYW1vdXMgZHJhd2luZywgYW5kIG9uZSBvZiB0aGUgZmlyc3QgSSBkaWQgZm9yIHRoZSBzaXRlGD8=";

test("can retrieve CID content as a CAR file", async () => {
const requestUrl = "ipfs://bafybeib36krhffuh3cupjml4re2wfxldredkir5wti3dttulyemre7xkni";
const response = await fetch(requestUrl);
assertResponseIsOk(response);
await assertResponseIsOk(response);

const payload = await response.arrayBuffer();
assertEquals(payload.byteLength, 167, "CAR size in bytes");
Expand All @@ -21,7 +21,7 @@ test("can retrieve CID content as a CAR file", async () => {
test("can retrieve IPFS content using URL", async () => {
const requestUrl = new URL("ipfs://bafybeib36krhffuh3cupjml4re2wfxldredkir5wti3dttulyemre7xkni");
const response = await fetch(requestUrl);
assertResponseIsOk(response);
await assertResponseIsOk(response);

const payload = await response.arrayBuffer();
assertEquals(payload.byteLength, 167, "CAR size in bytes");
Expand All @@ -32,14 +32,57 @@ test("can retrieve IPFS content using URL", async () => {
test("can retrieve IPFS content using Fetch Request object", async () => {
const request = new Request("ipfs://bafybeib36krhffuh3cupjml4re2wfxldredkir5wti3dttulyemre7xkni");
const response = await fetch(request);
assertResponseIsOk(response);
await assertResponseIsOk(response);

const payload = await response.arrayBuffer();
assertEquals(payload.byteLength, 167, "CAR size in bytes");

assertEquals(response.url, request.url);
});

test("does not modify original request headers - headers initialized as array", async () => {
const request = new Request(
"ipfs://bafybeib36krhffuh3cupjml4re2wfxldredkir5wti3dttulyemre7xkni",
{ headers: [["X-Test", "true"]] },
);
const response = await fetch(request);
await assertResponseIsOk(response);

assertEquals(Array.from(request.headers.keys()), ["x-test"]);
});

test("does not modify original request headers - headers initialized as object", async () => {
const request = new Request(
"ipfs://bafybeib36krhffuh3cupjml4re2wfxldredkir5wti3dttulyemre7xkni",
{ headers: { "X-Test": "true" } },
);
const response = await fetch(request);
await assertResponseIsOk(response);

assertEquals(Array.from(request.headers.keys()), ["x-test"]);
});

test("does not modify original request headers - headers initialized as Headers", async () => {
const request = new Request(
"ipfs://bafybeib36krhffuh3cupjml4re2wfxldredkir5wti3dttulyemre7xkni",
{ headers: new Headers({ "X-Test": "true" }) },
);
const response = await fetch(request);
await assertResponseIsOk(response);

assertEquals(Array.from(request.headers.keys()), ["x-test"]);
});

test("rejects user-provided Authorization header", async () => {
const request = new Request(
"ipfs://bafybeib36krhffuh3cupjml4re2wfxldredkir5wti3dttulyemre7xkni",
{ headers: { Authorization: "invalid" } },
);

let error = await assertRejects(() => fetch(request));
assertMatch(error.message, /authorization/i);
});

/**
* @param {Response} response Fetch API response
*/
Expand Down