DEV Community

Brian Morearty
Brian Morearty

Posted on

Better Deno Security: Ask for Permission at Runtime

Deno, the new kid on the block in server-side TypeScript and JavaScript, is secure by default. You kind of can’t miss it. They’ve been hammering that point all over their documentation and conference talks just to make sure you know. It’s on their homepage too, repeated in the first three sentences.

I'm glad they chose secure by default. But I'm not crazy about the samples they use to demonstrate security. The samples promote the idea that you should specify in advance what an app’s permissions are going to be. This reminds me of the old Android model, where you had to grant all permissions to an app when installing it. Eventually Android fixed it to be like the better model that iOS and browsers use: let the program request the necessary permissions at runtime so the user can respond to the request in context.

Let's look at sample code from the Deno v1 announcement:

import { serve } from "https://deno.land/std@0.50.0/http/server.ts";

for await (const req of serve({ port: 8000 })) {
  req.respond({ body: "Hello World\n" });
}
Enter fullscreen mode Exit fullscreen mode

I’ve copied this sample to a GitHub repo so you can run it directly:

$ deno run https://raw.githubusercontent.com/BMorearty/deno-permissions-samples/master/serve-original.ts
Enter fullscreen mode Exit fullscreen mode

and you’ll see that it fails, as it should, because it doesn’t have network permission. The v1 page explains this: "The above example will fail unless the --allow-net command-line flag is provided."

Deno network permission error

Fix by running with --allow-net.

$ deno run --allow-net https://raw.githubusercontent.com/BMorearty/deno-permissions-samples/master/serve-original.ts
Enter fullscreen mode Exit fullscreen mode

(You won’t see any output because it’s silently waiting on a port.)

Sounds nice and secure. But when a user downloads a script and runs it, or runs it directly off the Internet as in the examples above, they don’t have advance knowledge of why the program needs network permission—or worse, file permission. So they’ll blindly grant it all the permissions it says it needs before they even run it.

This is similar to what used to be required when installing an Android app. When I had an Android phone I found it vexing. Sometimes I wanted an app but didn’t want to grant it access to, for example my contacts. The slight improvement with Deno is that at least with Deno, permissions aren’t all-or-nothing, chosen by the developer.

A better option: ask for permission at runtime

Thankfully, Deno already provides a better security model than this. It’s just not heavily promoted. The program can ask for permission at the point of need, rather than requiring the user to grant it up front on the command line.

// serve-request.ts
import { serve } from "https://deno.land/std@0.50.0/http/server.ts";

const netPermission = await Deno.permissions.request({ name: "net" });
if (netPermission.state === "granted") {
  for await (const req of serve({ port: 8000 })) {
    req.respond({ body: "Hello World\n" });
  }
} else {
  console.log("Can’t serve pages without net permission.");
}
Enter fullscreen mode Exit fullscreen mode

At the time of this writing, Deno.permissions is not yet part of the stable API so you need to run this with --unstable:

deno run --unstable https://raw.githubusercontent.com/BMorearty/deno-permissions-samples/master/serve-request.ts
Enter fullscreen mode Exit fullscreen mode

Here’s how this looks when granting permission (I typed g and hit Enter):

Ask for permissions at runtime

And here’s how it looks when denying:

Denying permissions at runtime

The "Deno requests" line is part of Deno itself, not controlled by the app. As you can see from the code above, the “Can’t serve pages without net permission” line is the app’s custom response. An app can respond any way it likes to the user’s choice not to grant permission.

Don’t worry about Deno asking for permissions that are already granted. If the user runs the app with --allow-net, the Deno.permissions.request call won’t redundantly ask for permission. It will just return { "state": "granted" } immediately.

The same is true if an app requests the same permission multiple times at runtime. If it’s already been granted or denied once during runtime, the response is remembered in all subsequent permission requests.

// request-twice.ts
const netPermission1 = await Deno.permissions.request({ name: "net" });
console.log(
  `The first permission request returned ${JSON.stringify(netPermission1)}`,
);
const netPermission2 = await Deno.permissions.request({ name: "net" });
console.log(
  `The second permission request returned ${JSON.stringify(netPermission2)}`,
);
Enter fullscreen mode Exit fullscreen mode

Run:

$ deno run --unstable https://raw.githubusercontent.com/BMorearty/deno-permissions-samples/master/request-twice.ts
Enter fullscreen mode Exit fullscreen mode

As you can see, it only asks for permissions once:

Request permission twice

Give context when asking for permission

The iOS Human Interface Guidelines on permissions state:

Explain why your app needs the information. Provide custom text (known as a purpose string or usage description string) for display in the system's permission request alert, and include an example. Keep the text short and specific, use sentence case, and be polite so people don't feel pressured. There’s no need to include your app name—the system already identifies your app.

This is good advice for Deno apps as well. If you give the user context about why you need permission, they’re more likely to grant it:

// serve-context.ts
import { serve } from "https://deno.land/std@0.50.0/http/server.ts";

let netPermission = await Deno.permissions.query({ name: "net" });
if (netPermission.state === "prompt") {
  console.log("Net permission needed to serve web pages.");
  netPermission = await Deno.permissions.request({ name: "net" });
}
if (netPermission.state === "granted") {
  for await (const req of serve({ port: 8000 })) {
    req.respond({ body: "Hello World\n" });
  }
} else {
  console.log("Can’t serve pages without net permission.");
}
Enter fullscreen mode Exit fullscreen mode

This version prints the reason for the request right before making the request. The gives the user enough context to understand why Deno is requesting permission.

But what’s this call to Deno.permissions.query? Now that we’re displaying some context before the permission prompt is shown, it’s necessary to first check if the app already has permission. Otherwise you’ll display the permission context for no reason.

Deno.permissions.query can return three possible states:

  1. { "state": "granted" } means you already have permission.
  2. { "state": "denied" } means you have already been denied permission. There’s no point in requesting it because it will return "denied" immediately without showing a prompt.
  3. { "state": "prompt" } means if you call request, a prompt will be shown to the user.

Let’s run it:

deno run --unstable https://raw.githubusercontent.com/BMorearty/deno-permissions-samples/master/serve-context.ts
Enter fullscreen mode Exit fullscreen mode

And you’ll see the prompt:

Give context before requesting permission

If you run with --allow-net, you can see that it doesn’t display the context because the call to Deno.permissions.query indicated that the call to Deno.permissions.request would return success.

In my opinion this is the best way to deal with permissions in your code.

Wish list: store permissions persistently for installed scripts

Deno has a deno install command that lets you add a Deno script to your machine:

$ deno install --unstable https://raw.githubusercontent.com/BMorearty/deno-permissions-samples/master/serve-context.ts
Enter fullscreen mode Exit fullscreen mode

This downloads, compiles, and caches the script and its dependencies. It also creates an executable script. On a Mac it’s stored in ~/.deno/bin/serve-context (you can add ~/.deno/bin to your PATH) and looks like this:

#!/bin/sh
# generated by deno install
deno "run" "--unstable" "https://raw.githubusercontent.com/BMorearty/deno-permissions-samples/master/serve-context.ts" "$@"
Enter fullscreen mode Exit fullscreen mode

Notice that parameters you pass to deno install, such as --unstable, get passed on to deno run. You could deno install --allow-net https://my/script and it would store the --allow-net permission in the executable.

Again, it’s not ideal to require your users to decide up front what all the permissions are. But Deno doesn’t store the permissions persistently. After installing serve-context without --allow-net, every time I run it, it asks for net permission.

I’d love to see an improvement to Deno that allows it to locally cache the user’s answers to permission questions per app. This is what browsers do when a domain requests permission, such as camera or geolocation. Of course you’d also need a way to revoke or grant permissions after the fact.

Wish list: let the script pass the reason into the request call

In the serve-context example we had to:

  1. Call Deno.permissions.query to see if a permission has been granted.
  2. If not:
    1. Display a reason that the script needs the permission
    2. Request it with Deno.permissions.request.

It’d be a lot simpler if you could do all this in a single call to Deno.permissions.request. I think Deno should let the script pass in a short reason string to the permission request. If the script doesn’t already have permission, the reason would be displayed before the user is asked to grant permission. If the script already has permission, the reason won’t be displayed.

If Deno supported this, the steps would be shortened to just:

  1. Request permission with Deno.permissions.request.

Here is what the code would look like (not runnable because reason is not currently a valid key to pass in to request):

// serve-reason.ts
import { serve } from "https://deno.land/std@0.50.0/http/server.ts";

const netPermission = await Deno.permissions.request(
  { name: "net", reason: "Net permission needed to serve web pages." },
);
if (netPermission.state === "granted") {
  for await (const req of serve({ port: 8000 })) {
    req.respond({ body: "Hello World\n" });
  }
} else {
  console.log("Can’t serve pages without net permission.");
}
Enter fullscreen mode Exit fullscreen mode

References

Top comments (5)

Collapse
 
craigmorten profile image
Craig Morten

Nice read! Certainly makes hella sense for CLI like setups. How do you see it working when it comes to deployed apps (say web servers) where you're not going to have someone at a terminal? I guess that isn't the use-case you're describing though r.e. ask on need + provide context!

I think the security aspect of Deno is a real appeal, but there are still various aspects of the Permissions API that it feels need to grow in maturity - guess that's why it's currently unstable 😂

Collapse
 
bmorearty profile image
Brian Morearty

That’s right, what I’m describing makes sense for interactive CLI apps but not for deployed apps like web servers.

Collapse
 
mrgrigri profile image
Michael Richins

Fantastic article and gem. I'll be using this methodology!

Collapse
 
dorshinar profile image
Dor Shinar

Loved this article! I wonder why it's not promoted more.

Collapse
 
bmorearty profile image
Brian Morearty • Edited

Thank you! I just posted it a few minutes ago. 🦕