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

Implement an UnsafeEval binding #1338

Merged
merged 1 commit into from
Oct 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
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
34 changes: 34 additions & 0 deletions src/workerd/api/tests/unsafe-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {
strictEqual,
ok,
throws
} from 'node:assert';

export const basics = {
test(ctx, env) {
strictEqual(env.unsafe.eval('1'), 1);

// eval does not capture outer scope.
let m = 1;
throws(() => env.unsafe.eval('m'));

throws(() => env.unsafe.eval(' throw new Error("boom"); ', 'foo'), {
message: 'boom',
stack: /at foo/
});

// Regular dynamic eval is still not allowed
throws(() => eval(''));
}
};

export const newFunction = {
test(ctx, env) {
const fn = env.unsafe.newFunction('return m', 'bar', 'm');
strictEqual(fn.length, 1);
strictEqual(fn.name, 'bar');
strictEqual(fn(), undefined);
strictEqual(fn(1), 1);
strictEqual(fn(fn), fn);
}
};
18 changes: 18 additions & 0 deletions src/workerd/api/tests/unsafe-test.wd-test
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Workerd = import "/workerd/workerd.capnp";

const unitTests :Workerd.Config = (
services = [
( name = "unsafe-test",
worker = (
modules = [
(name = "worker", esModule = embed "unsafe-test.js")
],
compatibilityDate = "2023-01-15",
compatibilityFlags = ["nodejs_compat", "experimental"],
bindings = [
(name = "unsafe", unsafeEval = void )
]
)
),
],
);
46 changes: 46 additions & 0 deletions src/workerd/api/unsafe.c++
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#include "unsafe.h"

namespace workerd::api {

namespace {
static constexpr auto EVAL_STR = "eval"_kjc;
inline kj::StringPtr getName(jsg::Optional<kj::String>& name, kj::StringPtr def) {
return name.map([](kj::String& str) {
return str.asPtr();
}).orDefault(def);
}
} // namespace

jsg::JsValue UnsafeEval::eval(jsg::Lock& js, kj::String script,
jsg::Optional<kj::String> name) {
js.setAllowEval(true);
Copy link
Member

Choose a reason for hiding this comment

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

@jasnell Is this call actually necessary? Since we're not actually invoking the JS eval API in this code -- we're calling the C++ APIs directly -- I suspect there's no need to enable eval here?

Copy link
Member Author

@jasnell jasnell Oct 31, 2023

Choose a reason for hiding this comment

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

This is specifically to allow for cases where the script being eval'd itself contains nested regular eval() expressions, e.g.

env.unsafe.eval('eval(1)');

While this specific example is a bit silly, it's not unlikely that nested evals could be included in the script passed into the unsafe.eval(...)

Copy link
Collaborator

Choose a reason for hiding this comment

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

I didn't realize that that was the case @jasnell. I think it's undesirable to recursively allow eval to be used by unprivileged code.

We use dynamic execution in a specialty/privileged code that powers local development workflows/ Outside of this specialty code, we don't want people to use eval as that could user provided code to run locally but not in production.

I propose that we remove this recursive feature. If the privileged code has a need to expose eval to userland code, it can do so via globalThis.eval = env.unsafe.eval before evaling user's code.

Copy link
Member Author

@jasnell jasnell Oct 31, 2023

Choose a reason for hiding this comment

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

globalThis.eval = env.unsafe.eval wouldn't actually work directly, that would end up with a "Illegal invocation" error, but you can get close with globalThis.eval = function(code) { return env.unsafe.eval(code); }. But this is not a pattern that we should ever recommend either way.

We use dynamic execution in a specialty/privileged code that powers local development workflows/ Outside of this specialty code, we don't want people to use eval as that could user provided code to run locally but not in production.

I'm not sure how this would allow use of eval outside of this scope? The regular eval() would only be allowed within the script passed into env.unsafe.eval(...), which is only enabled with the binding that only exists in the local runtime. There's nothing here that would allow that to be used in production and nothing that would allow eval() to be used outside of this specific binding.

For instance, even with the binding enabled, the following still throws an error:

eval('1+1');  // throws!

But

env.unsafe.eval('eval("1+1")');  // works

Even if I tried something silly like the following, trying to break out of the restriction we still get an appropriate error.

const fn = env.unsafe.eval(`
function foo() { eval('1+1'); }
foo;
`);

fn();  // throws!

Copy link
Contributor

Choose a reason for hiding this comment

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

I think I agree with @IgorMinar here. Users could feasibly try to use eval()/new Function() in the top-level. Passing this user code to env.unsafe.eval would succeed in local development/testing, where it would fail when deployed.

As a concrete example, imagine a user wanted to use Ajv for JSON-schema validation. They build the validator in the top-level so they can reuse it across functions. This won't work when deployed, as new Function() is disabled, but would work if the user code was passed directly to env.unsafe.eval().

// ...bundled Ajv somehow
const ajv = new Ajv();

const schema = {
  type: "object",
  properties: {
    key: { type: "string" },
  },
  required: ["key"]
};
const validate = ajv.compile(schema); // fails when deployed because `new Function()` disallowed

addEventListener("fetch", (event) => {
  const valid = validate({ key: "thing" });
  if (valid) { ... } else { ... }
});

...whereas the following would pass...

env.unsafe.eval(`
// ...
const validate = ajv.compile(schema);
// ...
`)

Admittedly, the user code would probably be wrapped in a function() { ... } like your last code snippet, so this wouldn't be an issue. Even so, I'm not sure this should be the default behaviour.

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'm not sure I understand why this is a problem. Using Ajv like this at the top level, the user would still need to call unsafe.eval(...) to use it, and we would only allow for eval(...) and new Function(...) within the synchronous execution of that call. ANY worker code that is using unsafe.eval(...), whether it contains a nested eval or not, will not work when deployed to production because we cannot deploy a worker with the unsafe binding at all -- it simply doesn't and won't exist outside of the local workerd environment.

KJ_DEFER(js.setAllowEval(false));
auto compiled = jsg::NonModuleScript::compile(script, js, getName(name, EVAL_STR));
return jsg::JsValue(compiled.runAndReturn(js.v8Context()));
}

UnsafeEval::UnsafeEvalFunction UnsafeEval::newFunction(
jsg::Lock& js,
jsg::JsString script,
jsg::Optional<kj::String> name,
jsg::Arguments<jsg::JsRef<jsg::JsString>> args,
const jsg::TypeHandler<UnsafeEvalFunction>& handler) {
js.setAllowEval(true);
KJ_DEFER(js.setAllowEval(false));

auto nameStr = js.str(getName(name, EVAL_STR));
v8::ScriptOrigin origin(js.v8Isolate, nameStr);
v8::ScriptCompiler::Source source(script, origin);

auto argNames = KJ_MAP(arg, args) {
return v8::Local<v8::String>(arg.getHandle(js));
};

auto fn = jsg::check(v8::ScriptCompiler::CompileFunction(
js.v8Context(), &source, argNames.size(), argNames.begin(), 0, nullptr));
fn->SetName(nameStr);

return KJ_ASSERT_NONNULL(handler.tryUnwrap(js, fn));
}

} // namespace workerd::api
48 changes: 48 additions & 0 deletions src/workerd/api/unsafe.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#pragma once

#include <workerd/jsg/jsg.h>

namespace workerd::api {

// A special binding object that allows for dynamic evaluation.
class UnsafeEval: public jsg::Object {
public:
UnsafeEval() = default;

// A non-capturing eval. Compile and evaluates the given script, returning whatever
// value is returned by the script. This version of eval intentionally does not
// capture any part of the outer scope other than globalThis and globally scoped
// variables. The optional `name` will appear in stack traces for any errors thrown.
//
// console.log(env.unsafe.eval('1 + 1')); // prints 2
//
jsg::JsValue eval(jsg::Lock& js, kj::String script, jsg::Optional<kj::String> name);

using UnsafeEvalFunction = jsg::Function<jsg::Value(jsg::Arguments<jsg::Value>)>;

// Compiles and returns a new Function using the given script. The function does not
// capture any part of the outer scope other than globalThis and globally scoped
// variables. The optional `name` will be set as the name of the function and will
// appear in stack traces for any errors thrown. An optional list of argument names
// can be passed in.
//
// const fn = env.unsafe.newFunction('return m', 'foo', 'm');
// console.log(fn(1)); // prints 1
//
UnsafeEvalFunction newFunction(
jsg::Lock& js,
jsg::JsString script,
jsg::Optional<kj::String> name,
jsg::Arguments<jsg::JsRef<jsg::JsString>> args,
const jsg::TypeHandler<UnsafeEvalFunction>& handler);

JSG_RESOURCE_TYPE(UnsafeEval) {
JSG_METHOD(eval);
JSG_METHOD(newFunction);
}
};

#define EW_UNSAFE_ISOLATE_TYPES \
api::UnsafeEval

} // namespace workerd::api
6 changes: 6 additions & 0 deletions src/workerd/jsg/modules.c++
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,12 @@ v8::Local<v8::Value> CommonJsModuleContext::require(jsg::Lock& js, kj::String sp
}
}

v8::Local<v8::Value> NonModuleScript::runAndReturn(v8::Local<v8::Context> context) const {
auto isolate = context->GetIsolate();
auto boundScript = unboundScript.Get(isolate)->BindToCurrentContext();
return check(boundScript->Run(context));
}

void NonModuleScript::run(v8::Local<v8::Context> context) const {
auto isolate = context->GetIsolate();
auto boundScript = unboundScript.Get(isolate)->BindToCurrentContext();
Expand Down
2 changes: 2 additions & 0 deletions src/workerd/jsg/modules.h
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ class NonModuleScript {
// context then will run it to completion.
void run(v8::Local<v8::Context> context) const;

v8::Local<v8::Value> runAndReturn(v8::Local<v8::Context> context) const;

static jsg::NonModuleScript compile(kj::StringPtr code, jsg::Lock& js, kj::StringPtr name = "worker.js");

private:
Expand Down
8 changes: 8 additions & 0 deletions src/workerd/server/server.c++
Original file line number Diff line number Diff line change
Expand Up @@ -2415,6 +2415,14 @@ static kj::Maybe<WorkerdApiIsolate::Global> createBinding(
.scheme = kj::str(binding.getHyperdrive().getScheme()),
});
}
case config::Worker::Binding::UNSAFE_EVAL: {
if (!experimental) {
errorReporter.addError(kj::str("Unsafe eval is an experimental feature. ",
"You must run workerd with `--experimental` to use this feature."));
return kj::none;
}
return makeGlobal(Global::UnsafeEval {});
}
}
errorReporter.addError(kj::str(
errorContext, "has unrecognized type. Was the config compiled with a newer version of "
Expand Down
8 changes: 8 additions & 0 deletions src/workerd/server/workerd-api.c++
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
#include <workerd/api/r2.h>
#include <workerd/api/r2-admin.h>
#include <workerd/api/trace.h>
#include <workerd/api/unsafe.h>
#include <workerd/api/urlpattern.h>
#include <workerd/api/node/node.h>
#include <workerd/io/promise-wrapper.h>
Expand Down Expand Up @@ -77,6 +78,7 @@ JSG_DECLARE_ISOLATE_TYPE(JsgWorkerdIsolate,
EW_SCHEDULED_ISOLATE_TYPES,
EW_STREAMS_ISOLATE_TYPES,
EW_TRACE_ISOLATE_TYPES,
EW_UNSAFE_ISOLATE_TYPES,
EW_URL_ISOLATE_TYPES,
EW_URL_STANDARD_ISOLATE_TYPES,
EW_URLPATTERN_ISOLATE_TYPES,
Expand Down Expand Up @@ -623,6 +625,9 @@ static v8::Local<v8::Value> createBindingValue(
kj::str(hyperdrive.user), kj::str(hyperdrive.password),
kj::str(hyperdrive.scheme)));
}
KJ_CASE_ONEOF(unsafe, Global::UnsafeEval) {
value = lock.wrap(context, jsg::alloc<api::UnsafeEval>());
}
}

return value;
Expand Down Expand Up @@ -696,6 +701,9 @@ WorkerdApiIsolate::Global WorkerdApiIsolate::Global::clone() const {
KJ_CASE_ONEOF(hyperdrive, Global::Hyperdrive) {
result.value = hyperdrive.clone();
}
KJ_CASE_ONEOF(unsafe, Global::UnsafeEval) {
result.value = Global::UnsafeEval {};
}
}

return result;
Expand Down
3 changes: 2 additions & 1 deletion src/workerd/server/workerd-api.h
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,11 @@ class WorkerdApiIsolate final: public Worker::ApiIsolate {
};
}
};
struct UnsafeEval {};
kj::String name;
kj::OneOf<Json, Fetcher, KvNamespace, R2Bucket, R2Admin, CryptoKey, EphemeralActorNamespace,
DurableActorNamespace, QueueBinding, kj::String, kj::Array<byte>, Wrapped,
AnalyticsEngine, Hyperdrive> value;
AnalyticsEngine, Hyperdrive, UnsafeEval> value;

Global clone() const;
};
Expand Down
3 changes: 3 additions & 0 deletions src/workerd/server/workerd.capnp
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,9 @@ struct Worker {
# A binding for Hyperdrive. Allows workers to use Hyperdrive caching & pooling for Postgres
# databases.

unsafeEval @23 :Void;
# A simple binding that enables access to the UnsafeEval API.

# TODO(someday): dispatch, other new features
}

Expand Down
Loading