Message Inspection

A running canister on the Internet Computer (IC) can receive calls to its public shared functions that may take a message object that contains information about the function call. The function could for example implement caller authentication based on the information provided in the message object.

Additionally, the IC provides message inspection functionality to inspect update or oneway calls (and even update calls to query functions) and either accept or decline the call before running a public shared function.

NOTE
Message inspection is executed by a single replica and does not provide the full security guarantees of going through consensus.

The inspect system function

In Motoko, this functionality can be implemented as a system function called inspect. This function takes a record as an argument and returns a Bool value. If we name the record argument args, then the function signature looks like this:

system func inspect(args) : Bool

Note that the return type is NOT async. Also, this function CANNOT update the state of the actor.

How it works

The inspect function is run before any external update call (via an IC ingress message) to an update, oneway or query function of an actor. The call to the actor is then inspected by the inspect system function.

NOTE
The most common way to call a query function is through a query call, but query functions could also be called by an update call (less common). The latter calls are slower but more trustworthy: they require consensus and are charged in cycles. Update calls to query methods are protected by message inspection too, though query call to query methods are not!

The argument to the inspect function (an object we call args in the function signature above) is provided by the IC and contains information about:

  • The name of the public shared function that is being called and a function to obtain its arguments
  • The caller of the function
  • The binary content of the message argument.

The inspect function may examine this information about the call and decide to either accept or decline the call by returning either true or false respectively.

NOTE
Ingress query calls to query functions and any calls from other canisters are NOT inspected by the inspect system function.

The inspect function argument

The argument to the inspect system function is provided by the IC. The type of this argument depends on the type of the actor. Lets consider an actor of the following actor type:

type myActor = actor {
    f1 : shared () -> async ();
    f2 : shared Nat -> async ();
    f3 : shared Text -> ();
};

The functions f1 and f2 are update functions and f3 is a oneway function. Also, f2 takes a Nat argument and f3 takes a Text argument.

The argument to the inspect system function (which we call args in this example) for this specific actor will be a record of the following type:

type CallArgs = {
    caller : Principal;
    arg : Blob;
    msg : {
        #f1 : () -> ();
        #f2 : () -> Nat;
        #f3 : () -> Text;
    };
};

This record contains three fields with predefined names:

  • The caller field is always of type Principal.
  • The arg field is always of type Blob.
  • The msg field is always a variant type and its fields will depend on the type of the actor that this inspect function is defined in.

The msg variant and 'function-argument-accessor' functions

The msg variant inside the argument for the inspect system function will have a field for every public shared function of the actor. The variant field names #f1, #f2 and #f3 correspond the the function names f1, f2 and f3.

But the associated types for the variant fields ARE NOT the types of the actor functions. Instead, the types of the variant fields #f1, #f2 and #f3 are 'function-argument-accessor' functions that we can call (if needed) inside the inspect system function.

In our example these 'function-argument-accessor' function types are:

() -> ();
() -> Nat;
() -> Text;

These functions return the arguments that were supplied during the call to the public shared function. They always have unit argument type but return the argument type of the corresponding shared function. The return type of each accessor thus depends on the shared function that it is inspecting.

For example function f2 takes a Nat. If we wanted to inspect this value, we could call the associated function of variant #f2, which is of type () -> Nat. This function will provide the actual argument passed in the call to the public shared function.

Actor with Message Inspection

Lets implement the inspect system function for the example actor given above:

import Principal "mo:base/Principal";

actor {
    public shared func f1() : async () {};
    public shared func f2(n : Nat) : async () {};
    public shared func f3(t : Text) {};

    type CallArgs = {
        caller : Principal;
        arg : Blob;
        msg : {
            #f1 : () -> ();
            #f2 : () -> Nat;
            #f3 : () -> Text;
        };
    };

    system func inspect(args : CallArgs) : Bool {
        let caller = args.caller;
        if (Principal.isAnonymous(caller)) { return false };

        let msgArg = args.arg;
        if (msgArg.size() > 1024) { return false };

        switch (args.msg) {
            case (#f1 _) { true };
            case (#f2 f2Args) { f2Args() < 100 };
            case (#f3 f3Args) { f3Args() == "some text" };
        };
    };
};

We implemented an actor that inspects any call to its public shared functions and performs checks on update and oneway functions before they are called.

First, the Principal module is imported and the actor is declared with three public shared functions f1, f2 and f3.

Then, we defined CallArgs as the record type of the expected argument to the inspect system function. We then declare the system function and use CallArgs to annotate the args argument.

We can now access all the information for any function call inside the inspect function through the chosen name args. (We could have chosen any other name)

Inside inspect we first check whether the caller field of args, which is a Principal, is equal to the anonymous principal. If so, then inspect returns false and the call is rejected. Anonymous principals can't call any functions of this actor.

Another check is performed on the size of the arg field value of our args record. If the binary message argument is larger than 1024 bytes, then the call is rejected by returning false once more.

Our inspect implementation ends with a switch expression that checks every case of the msg variant inside args.

In the case function f1 is called, we ignore the possible arguments supplied to it by using the wildcard _ for the 'function-variable-accessor' function and always accept the call by always returning true.

In case function f2 is called, we bind the 'function-argument-accessor' function to the local name f2Args and run it to get the value of the argument to f2, which is a Nat. If this value is smaller then 100, we accept the call, otherwise we reject.

In case function f3 is called, we bind the 'function-argument-accessor' function to the local name f3Args and run it to get the value of the argument to f3, which is a Text. If this value is equal to "some text", we accept the call, otherwise we reject.

Pattern matching and field renaming

Instead of defining the type CallArgs beforehand and referring to the object argument args by name, we could use pattern matching and rename the fields in the function signature:

    system func inspect({
        caller : Principal = id;
        msg : {
            #f1 : Any;
            #f2 : () -> Nat;
            #f3 : () -> Text;
        };
    }) : Bool {
        switch (msg) {
            case (#f2 f2Args) { f2Args() < 100 };
            case _ { not Principal.isAnonymous(id) };
        };
    };

We now declared inspect with a record pattern as the argument.

We pattern match on the caller field and rename it to id. We could now just refer to id inside the function.

By subtyping, we are allowed to ignore the arg field all together.

And notice that we use the Any type as the associated type of the #f1 variant, because we don't care about the variable values supplied in calls to the f1 function.

Inside the function, we switch on msg and only handle the case where function f2 is called. If the variable value to f2 is less than 100, we accept, otherwise we reject the call.

In all other cases, we check whether id is the anonymous principal. If it is, we reject, otherwise we accept the call.

Message inspection vs Caller identifying functions

The inspect system function should not solely be used for secure access control to actors. It may be used to reject possibly unwanted calls to an actor before they are executed. Without message inspection, all calls are accepted by default, executed and charged for in cycles.

Message inspection is executed by a single replica (without full consensus, like a query call) and its result could be spoofed by a malicious boundary node.

Secure access control checks can only be performed by:

  1. Implementing public shared functions in an actor which implement the caller identification pattern.
  2. Additionally guard incoming calls by the inspect system function.