Skip to content

Remote UI is untrusted input: a checklist

Server-driven UIDeep dive7 min readMiniwall team

Part of the series: Server-driven UI: a field guide for app teams

On this page

Treat the description of a screen as input from outside, even when your own server sends it. Check it when it's published, allow only a short list of things when it's drawn, and keep everything that can do harm in code your app owns. Below are seven checks in that spirit, a few questions to bring to a security review, and a note on what no renderer can protect.

If you haven't decided yet whether to describe screens from a server at all, start with what server-driven UI is and when not to use it. The map of the whole topic is the field guide.

Your own server isn't a trusted source

"We wrote the server, so we trust what it sends" is the sentence to be suspicious of. A few ordinary ways the payload ends up wrong:

  • An editor's account is taken over, or someone with edit access pastes in something they shouldn't.
  • A draft meant for staging is published to the live project.
  • A tool turns a spreadsheet of copy into screens, and one cell has a surprise in it.
  • A bug in your own authoring UI writes a value the renderer never expected.

What makes this different from a normal deploy is that there's no release between the mistake and your users. That's the point of the whole approach, and it's also why a bad screen is live the moment it's published. So the publish step and the renderer have to be the safety net. Nobody else is standing there.

1. Refuse at publish; don't quietly repair

When a value is wrong, you can fix it up and carry on, or you can say no. We'd say no, and say it where the author can see it.

Take an identifier. If a plan ID or action ID has to follow a pattern, such as letters, numbers and a few separators, a value outside it should stop the publish with a message naming the field. A quiet repair means the screen behaves differently from what the author wrote, and nobody finds out until someone asks why a button does nothing. A refusal costs thirty seconds at the editor and fixes the cause.

The check has to run on the server side of the publish step, not only in the editor's UI, because the editor isn't the only thing that can talk to your API.

A link in a screen is a request to take someone somewhere, so decide what "somewhere" can be. A deny list ("block javascript:") loses, because you have to think of every bad scheme in advance. A short allow list wins, because anything you didn't think of is refused by default.

A sensible list is http, https, mailto, tel, and links that begin with a single /. The single slash matters: a link that starts with two slashes points at another host, so a rule that only says "starts with a slash" would let it through. Whatever you build, report a refused link to someone who can see it. A link that silently does nothing looks like a bug in the screen, and an error callback turns it into something you can count.

3. Text is text

Prices, names and offers flow into a screen from places the author doesn't control. If any of them can reach the page as markup, you've built a way to inject it.

The rule is the one every web developer already knows: put text into the page as text (textContent), not as markup, and add formatting yourself from a small set you control. Don't add a "raw HTML" component because one customer asked. It's the one component where the schema stops describing a screen and starts being a program.

4. Count every URL, because each is a request

Fonts, images and links in a payload are not just styling. Each is a request your visitor's device will make to someone, and that someone sees the visitor's address and the time.

Two rules cover most of it. Accept only https for fonts and images. And know the list of hosts your screens can reach, because your Content Security Policy needs the same list: one directive for images, one for fonts, one for the API. If writing that list feels annoying, that's the check working. A payload that can name any host is a payload you can't describe in a policy.

5. Keep styles apart, and don't call that a sandbox

Drawing a screen inside its own style boundary, for example a shadow root on the web, stops your page's CSS from breaking the screen and the screen's CSS from leaking out. That's worth having. Be clear about what it is. It separates styles. It isn't a sandbox for scripts. What keeps scripts out is checks 2 and 3: the payload carries no code and no markup, so there's nothing to run.

6. Put a cap on size

A payload that's allowed to be any size is a payload someone will eventually make enormous, on purpose or by accident, and every device then downloads and draws it.

Set a limit on the request that saves a screen and a smaller one on the calls devices make. Put a cap on the number of nodes and how deep they nest too, and test a very deep tree before you trust a renderer with it. We'd rather say that plainly than assume the renderer you're evaluating handles it.

7. A public key is public

The key in your page is readable by anyone who views source, so the question isn't how to hide it. It's what it can do. A key that can only read published screens and send view events has a small blast radius. On top of that you can list the sites allowed to use it, which stops casual reuse. For mobile apps a list of app IDs is weaker, because a request that omits the identifying header passes, so treat it as a guard against mistakes and not as a security boundary.

Unknown isn't an error

One more rule is about the future. When a screen uses a component an older app doesn't know, what should the app do? Draw a fallback if the screen has one, and otherwise leave it out. It must not crash. Whatever you build, decide this before the first old build is in the wild, because "refuse the whole screen" and "skip the unknown part" are both defensible, and mixing them isn't. Describing a screen in JSON, a DSL or components comes back to this.

What no renderer can protect

The screen can only ask. When someone taps a button, the result is an action ID your code receives, and what happens next is yours. So treat that ID as input too: handle the ones you know, and make the default case do nothing. Don't build a switch that runs whatever string arrives.

The same goes for money. A screen that describes a purchase button doesn't charge anyone. Your code does, after your own checks. If you let a screen's content decide the price, you've moved the trust to the one place that can't hold it.

Questions for a security review

QuestionWhat a good answer sounds like
What does the renderer do with a link scheme it doesn't allow?It refuses and reports an error you can see.
How is text put into the page?As text, never as markup.
Which hosts can a screen make a device contact?A short list you can put in a policy.
What limits exist on payload size and depth?Named numbers, and a test that proves them.
Who can publish, and what stops a bad publish?A permission you can limit, and a check on the server.
What can a leaked public key do?Read published screens and send events, nothing else.

Swipe sideways to see every column.

This is a checklist, not an audit. It finds the common mistakes, not the clever ones, and if you handle payments or health data a person who does security for a living should read your setup.

When this is more than you need

If every screen is written by two people who also write the app, and none of it is user-generated, you can be lighter. Checks 1 and 2 are still cheap, and the rest you can add as the number of people who can publish grows. If screens carry anything a visitor typed, do all seven.

Next step: list every URL, scheme and ID a payload can carry today, and write the rule next to each one. Wherever the rule is "we hope so", that's your first fix.

Sources

Go further