- Safe Execution Environments (alpha)
- Overview
- Safe Execution Interface
- Finalizers and runtime contexts
- How to implement the safe execution environment
- CPU limits
- Memory limits
(*Runtime).RequireMem(n uint64)
(*Runtime).ReleaseMem(n uint64)
(*Runtime).RequireBytes(n int) uint64
(*Runtime).RequireSize(sz uintptr) uint64
(*Runtime).RequireArrSize(sz uintptr, n int) uint64
(*Runtime).ReleaseBytes(n int)
(*Runtime).ReleaseSize(sz uintptr)
(*Runtime).ReleaseArrSize(sz uintptr, n int)
- Restricting access to Go functions.
First of all: everything in this document is subject to change!
It can be useful to be able to run untrusted code safely. This is why Golua allows code to be run in a restricted execution environment. This means the following:
- the "amount of CPU" available to the code can be limited
- the "amount of memory" available to the code can be limited
- file IO can be disabled
- unsafe Go functions accessible via modules can be disabled
By "amount of CPU" we mean this: the Golua VM periodically emits ticks during execution. Not all ticks correspond to the same number of CPU cycles but it is guaranteed that there is an upper bound to the number of CPU cycles occurring between two ticks.
Limiting the amount of CPU means declaring that the number of ticks shouldn't exceed a certain number.
The program is required to terminate before the limit is reached.
By "amount of memory" we mean roughly
- the number of bytes that are allocated on the heap
- the size of the "stack frames" associated with Lua functions and Go functions called from Golua.
Memory used can be counted down when it is known that an object is no longer going to be used (e.g. a Lua function "stack frame"), but in many cases this does not happen. So counting memory used works a bit as if GC was mostly turned off.
Below is an example that currently would run have memory counted as used
increasing linearly in terms of n
.
for i = 1, n do
-- The following creates a new table, consuming memory. That table will get
-- GCed shortly but that won't make the amount of memory go down.
t = {}
end
Limiting the amount of memory means declaring that the "amount of memory" used as defined above shouldn't exceed a certain number.
The program is required to terminate before the limit is reached.
When these restricitions are in place, trying to call a function that perform IO access (or runs unsafe) should return an error, but not terminate the program.
There are three ways to apply the limits described above.
- When creating the Lua runtime from the program embedding Golua
- Within a Lua program, to safely execute some Lua code
- When starting the standalone
golua
interpreter
The restrictions are managed via the notion of runtime context, which is an object that accounts for resource limits and resource consumed. A runtime context is associated with the Lua thread of execution (so there is only one such context active at a time).
Command line flags allow running the interpreter with restrictions. Here is the
relevant extract from golua -help
:
-cpulimit uint
CPU limit
-memlimit uint
memory limit
-nogolib
disable Go bridge
-noio
disable file IO
Golua provides a runtime
library which exposes two functions
Returns an object ctx
representing the current context. This object mostly
cannot be mutated but gives useful information about the execution context.
ctx.status
is the status of the context as a string, which can be:"live"
if this is the currently running context;"done"
if this execution context terminated successfully;"error"
if this execution context terminated with an error"killed"
if the context terminated because it would otherwise have exceeded its limits.
ctx.kill
returns an object giving the hard resource limits ofctx
. If any of these limits are reached then the context will be terminated immediately, returning execution to the parent context. Hard limits cannot exceed their parent's hard limits.ctx.stop
returns an object giving the resource soft limits ofctx
. Soft limits cannot exceed hard limits, and by default cannot be increased from the parent's soft limits. In future if there is are clear use-cases for increasing the soft limits from the parent's, another API endpoint can be provided.ctx.used
returns an object giving the used resources ofctx
ctx.flags
returns a string describing the flags that any code running in this context has to comply with. Those flags are"memsafe"
,"cpusafe"
,"timesafe"
and"iosafe"
currently.ctx.due
returns true if any of the context's soft limits have been exhausted.
Additionally there are two methods that allow mutation of the context.
ctx:killnow()
updates the context's state so that its hard limits are considered exhausted. The effect on a running context will be to be terminated immediately.ctx:stopnow()
update the context's state so that its soft limits are considered exhausted. The effect is thatctx.due
returns true.
This function creates a new execution context ctx
from ctxdef
, calls
f(arg1, ...)
in this context, then returns ctx
. Additionally
- if the call was successful, it also returns the returns values of
f(arg1. ...)
; - if there was a non-terminal error in the call, it also returns the error. In
this respect, the
runtime.callcontext()
function always behaves likepcall
.
By default ctx
will inherit from the current context: its CPU and memory
limits will be the amount of unused CPU and memory in the current context, and
it inherits the io
and golib
flags from the current context.
The argument ctxdef
allows restricting ctx
further. It is a table with any
of the following attributes.
kill
: if set, it should be a table. Attributes can be set in this table with namesmem
,cpu
and values a positive integer. This is used to set the context's hard resource limits.stop
: same format askill
but describes soft limits. It will be used to set the context's soft resource limits.flags
: same format as for a context definition (e.g."cpusafe memsafe"
)
Here is a simple example of using this function in the golua repl:
> ctx = runtime.callcontext({kill={cpu=1000}}, function() while true do end end)
> print(ctx)
killed
> print(ctx.used.cpu, ctx.kill.cpu)
999 1000
> print(ctx.flags)
cpusafe
> print(ctx.used.memory, ctx.kill.memory)
0 nil
This function terminates the current context immediately, returning to the parent context. It is as if a hard resource limit had been hit. It can be used when a soft resource limit has been hit and the program decides to stop.
Alternatively contexts have a method to achieve the same: ctx:killnow()
. On a
context that is not currently running, the effect is to kill it as soon at it is
resumed.
This function returns true if any of the soft resource limits has been hit on the currently running context.
Alternatively contexts have a property ctx.due
that is set to true if the
context ctx
has exhausted any of its soft limits.
This function updates the current context so that its soft limits are considered exhaused.
Alternatively contexts have a method to achieve the same: ctx:stopnow()
.
There is a RuntimeContext
interface in the runtime
package. It is
implemented by *runtime.Runtime
and allows inspection of the current execution
context. We will see further down that contexts that are terminated are also
available via this interface.
type RuntimeContext interface {
HardResourceLimits() RuntimeResources
SoftResourceLimits() RuntimeResources
UsedResources() RuntimeResources
Status() RuntimeContextStatus
Parent() RuntimeContext
RequiredFlags() ComplianceFlags
SetStopLevel(StopLevel)
Due() bool
}
The runtime
package also defines a RuntimeContextDef
type whose purpose is
to specify the properties of a new execution context to create.
type RuntimeContextDef struct {
HardLimits RuntimeResources
SoftLimits RuntimeResources
RequiredFlags ComplianceFlags
MessageHandler Callable
}
As mentioned above, a Lua runtime is of type *runtime.Runtime
and implements
the RuntimeContext
interface. It also implements two methods.
Creates a new context from the definition and makes it the active context. As described in the Lua section, the new context is not allowed to be less restrictive than the one it replaces.
Removes the active context from the "context stack" and returns it. It ensures that resources consumed in the popped context will be accounted for in the parent context.
Here is a simple example of how they could be used.
import (
"os"
rt "github.com/arnodel/golua/runtime"
)
func main() {
r := rt.NewRuntime(os.Stdout)
r.PushContext(rt.RuntimeContextDef{
HardLimits: rt.RuntimeResources{
Mem: 100000,
Cpu: 1000000,
},
RequiredFlags: rt.ComplyIoSafe
})
// Now executing Lua code in this runtime will be subject to these limitations
// If the limits are exceeded, the Go runtime will panic with a
// rt.QuotaExceededError.
// Do something in this context
ctx := r.PopContext()
// We are back to the initial execution context. PushContext calls can be
// nested. The returned ctx is a RuntimeContext that can be inspected.
}
The *runtime.Runtime
type has another method.
Similar to Lua's runtime.callcontext
. It is a convenience function to run
some code in a given context, catching the QuotaExceededError
panics if they
occur and returning the finished context. So the above could be rewritten safely
as follows.
import (
"os"
rt "github.com/arnodel/golua/runtime"
)
func main() {
r := rt.NewRuntime(os.Stdout)
ctx, err := r.CallContext(rt.RuntimeContextDef{
HardLimits: rt.RuntimeResources{
Mem: 100000,
Cpu: 1000000,
},
RequiredFlags: rt.ComplyIoSafe
}, func() *rt.Error {
// Do something in this context, returning an error if appropriate.
// That error will set the context status to "error".
})
// Panics due to quota exceeded will be recovered from.
}
Terminate the context immediately if it is live.
In Lua it is possible to add finalizers to two types of values: tables and userdata. Finalizers are run once the garbage collector knows the values are no longer reachable. This is used in the standard library to make sure open files which are no longer used are closed.
The Golua runtime makes sure that when a value is created within a runtime
context with restricted resources, running its finalizer will not use another
context's resources. However, only in the case of userdata, it is sometimes
the case the value contains a resource that should be released unconditionnally
(e.g. a file descriptor). Golua provides a general mechanism to support that,
simply by defining a ReleaseResources
method on the underlying type. That
method is guaranteed to run before the runtime context is closed, but after the
Lua finalizer runs if it exists. For example in the standard library, the
underlying type of file userdata is as follows.
type File struct {
file *os.File
status fileStatus
reader bufReader
writer bufWriter
}
When file userdata becomes unreachable, the underlying File
instance should
close the os.File
instance it owns. This is done as follows.
// The *File type implements the ResourceReleaser interface.
func (f *File) ReleaseResources(d *rt.UserData) {
f.cleanup()
}
// Included to show what happens in Prefinalize
func (f *File) cleanup() {
if !f.IsClosed() {
f.Close()
}
if f.IsTemp() {
_ = os.Remove(f.Name())
}
}
The runtime makes sure that any userdata that implements
runtime.ResourceReleaser
interface will have its ReleaseResources
method
called unconditionally. Of course it is important that the code in those
methods consumes as little resources as possible.
For details about the semantics see the userdata.quotas.lua test file
The basic means of enforcing CPU limits is the following.
This method checks that n
units of CPU are available. If that is the case,
the amount of CPU used is updated and execution continues. Otherwise, the Go
thread panics with runtime.QuotaExceededError
.
The approach is to call RequireCPU
before a unit of work is done.
- In a loop an amount of CPU should be required that is proportional to the number of iterations.
- Nested Go function calls should require CPU proportional to the depth of the nested calls.
- When running code in third party packages (including the Go Standard Library)
it should be possible to obtain and upper bound to the amount of CPU required
ahead of the call and require it. If the third party function is given a
callback it may be possible to use that (e.g.
sort.Sort
).
The basic means of enforcing memory limits are the following.
This methods checks that n
units of memory are available. If that is the case,
the amount of CPU used is updated and execution continues. Otherwise, the Go
thread panics with runtime.QuotaExceededError
.
This methods reduces the amount of memory used by n
units (if possible). It
is generally not used but can be useful in some cases (e.g. when a big temporary
object needs to be allocated).
Often we know how much memory is required in terms of bytes or size of data structures, so there are some convenience methods to address that.
Require enough memory to store n
bytes. Return the number of memory units
required.
Require enough memory to store an obect of size sz
, size as returned by
unsafe.Sizeof()
. Return the number of memory units required.
Require enough memory to store n
objects of size sz
, e.g. a slice or an
array of objects. Return the number of memory units required.
There are corresponding methods for releasing memory
The approach is to call RequireMem
or one of the derived method before some
memory allocation. Memory allocation occurs when
- A new string is created
- A new table is created
- A new item is inserted into a table
- A new Lua closure is created
- A new Lua continuation is created (that is akin to a "Lua call frame")
- A new Go function is created
- A new UserData instance is created
- Buffered IO occurs
- Lua source code is compiled
Moreover it may be that calling a function in the standard library can cause memory allocations.
In some case it may be appropriate to return memory. An example is when a Lua continuation ends. Returning its memory allows tail-calls to have the same memory footprint as loops.
There is a built-in mechanism for making sure that Go function called in the Lua runtime comply with the safe execution environment requirements. As there are different levels of compliance, a number of Compliance Flags can be defined. Any of those can be required in an execution context, and only Go functions which have been declared explicitly as implementing these compliance flags will be allowed to be run.
This approach has several advantages
- Granularity: for each Go function it is required to define what compliance flags it implements. So a single Lua module could include Go functions with different compliance profiles.
- Future proof: if new compliance flags are added, existing functions will not comply with those by default, so it limits the risk of misuse. On the other hand an existing function will still be able to be used in an environment not requiring the new compliance flags.
- Safety: It is safer than controlling access to modules via a blocklist/allowlist. As Lua's runtime is very dynamic, it would probably be easy to circumvent such measures.
The runtime defines a number of compliance flags, currently:
type ComplianceFlags uint16
const (
// Only execute code checks memory availability before allocating memory
ComplyMemSafe ComplianceFlags = 1 << iota
// Only execute code that checks cpu availability before executing a
// computation.
ComplyCpuSafe
// Only execute code that complies with IO restrictions (currently only
// functions that do no IO comply with this)
ComplyIoSafe
)
Any Go functions that can be called from Lua is wrapped in an instance of
*rt.GoFunction
. By default this instances does not include any compliance
flags. It is possible to declare compliance with
(*GoFunction).SolemnlyDeclareCompliance()
Before execution, the current context's RequiredFlags
value is checked against
the compliance flags declared by the Go functions. If any of the required flags
is not complied with by the function, execution will immediately return an error
(but not terminate the context).