Security model & limits
The sandbox your code runs in, why every call is checked, how network access works, and every runtime limit with the constant it comes from.
On this page
People install plugins written by strangers into the app holding a novel they have spent two years on. Everything below is what makes that a sane thing to do, and it is worth reading once even if security is not what you came for: most of the rules on the other pages stop looking arbitrary afterwards.
Where your code runs
Sandbox attributes
-
allow-scripts: Your JavaScript runs. -
allow-forms: Forms submit. Added only for surfaces that render UI. - No
allow-same-origin, so the frame has an opaque origin. There is no host document, no cookies and no shared storage to reach.
Content security policy
-
default-src 'none' -
script-src 'unsafe-inline' -
style-src 'unsafe-inline' -
img-src data:
What the host puts in your document
An empty plugin-root element to mount into,
the SDK, the current theme's CSS variables, and a console bridge that forwards your logs to
the host.
What that means for you:
- You cannot touch the app. Not its interface, not its storage, not another plugin’s data.
There is no
window.parentto reach through. - You cannot reach the network from inside the frame.
fetchandXMLHttpRequestfail. To get online you calltintero.net.fetch, and the app makes the request for you, only to hosts you listed up front. - You cannot store anything yourself.
localStorageis not there. Usetintero.storage, which is your own private box, keyed to your plugin id, and survives reloads. - Images have to be inline. The content policy allows
data:images and nothing else, which is whyproject.getImageData()hands you a base64 URL rather than a path.
Every call is checked
Your code never reaches a Tintero service directly. It sends a message, the app checks it, and the app makes the call. Three checks run on every call, in this order:
- Permission. Was this method covered by what you were granted? Which method needs which permission is decided by the app, not by your manifest, so there is no widening it from your side.
- Rate limit. Go past 100 calls / 1 s and the call is refused.
- Argument size. Anything over 5 MB is refused before it is even read.
A refusal always comes back as an error you can catch. Nothing ever fails quietly:
try {
await tintero.project.getCharacters();
} catch (error) {
if (error.message.includes('SCOPE_DENIED')) {
// the manifest did not ask for project.read.characters
}
}
Error codes
| Code | Meaning |
|---|---|
SCOPE_DENIED | The permissions you were granted don’t cover this method. |
RATE_LIMITED | Too many calls, too fast. |
PAYLOAD_TOO_LARGE | The arguments you passed were over the cap. |
NETWORK_DENIED | A net.fetch to a host outside network.domains, or a request that failed. |
The code is in the message text, which is why checking error.message works.
Approval, and what a reload does not change
Your permissions get approved once, at install. The dialog lists exactly what your manifest asked for, grouped by how risky each one is, and the answer is saved against your plugin.
Implicit grants
Some permissions bring others with them, because the job would be impossible otherwise. An exporter that cannot read file contents has nothing to export. Asking for both just makes your install dialog longer for no benefit. The full list is on the permissions page.
Network access
Your frame cannot reach the network at all. Requests go out through the app instead, and only to hosts you named in your manifest:
{
"id": "com.example.sync",
"name": "Sync",
"version": "1.0.0",
"description": "Pushes chapters to an external service",
"author": { "name": "You" },
"type": "tool",
"surfaces": ["background"],
"main": "plugin.js",
"scopes": ["net.fetch", "project.read.files"],
"network": { "domains": ["api.example.com", "*.cdn.example.com"] }
}
You can write an exact host, or put a wildcard on the front to cover subdomains. A bare "*" is
refused at install, and asking for net.fetch without any network.domains
stops the install outright.
The rest of the rules:
- Protocols:
httpandhttpsonly. - Methods: GET, POST, PUT, PATCH, DELETE, HEAD.
- Response size capped, and the request times out. See the table below.
Paths
Every path you write gets added onto your own plugin folder: the files in your zip, main,
icon, the ui paths, and anything you ask fs for. A ../ that slipped through would read or
write outside that folder, so paths are checked before they are joined. The exact rules are on
the manifest page.
Your plugin id gets checked for the same reason. It is a folder name too.
Content is validated before it is written
updateFileContent() and updateDocContent() read and check what you send before they touch
the file. A bad write fails and the chapter is exactly as it was.
The order is the whole point. Checking afterwards would mean a buggy plugin wrecks somebody’s chapter and then tells you about it.
Liveness
Tintero pings your plugin every 15 s and the SDK answers for you, with nothing to write on your side. Stay quiet for more than 3 in a row, roughly 60 s, and your frame is treated as hung and shut down.
Separately, a plugin that throws more than 50 errors / 60 s gets switched off. Both exist so that one broken plugin cannot take the editor down with it.
Every limit
| Limit | Value | When you hit it |
|---|---|---|
API calls RATE_LIMIT_MAX_CALLS | 100 per 1 s, per plugin | The call rejects with RATE_LIMITED. |
Call arguments MAX_PAYLOAD_BYTES | 5 MB | The call rejects with PAYLOAD_TOO_LARGE. |
Call duration CALL_TIMEOUT_MS | 30 s | The promise rejects; the host stops waiting. |
Heartbeat HEARTBEAT_INTERVAL_MS | ping every 15 s | The SDK answers automatically; you do nothing. |
Missed heartbeats PLUGIN_HEARTBEAT_MAX_MISSED_PINGS | more than 3 in a row | The iframe is destroyed as hung, after about 60 s of silence. |
Error budget PLUGIN_ERROR_BUDGET_MAX_ERRORS | more than 50 per 60 s | The plugin is disabled. |
net.fetch response MAX_RESPONSE_BYTES | 10 MB | The request rejects with NETWORK_DENIED. |
net.fetch timeout NET_TIMEOUT_MS | 20 s | The request is aborted. |
Dialog size openDialog | 600 × 400 by default, capped at 95vw / 90vh | A larger request is clamped to the cap. |
Nobody typed these numbers into this page. Each one is read from the constant named beside it when the site is built, so if a limit changes in Tintero, this table changes with it.
What none of this protects against
Worth saying plainly:
- A plugin you gave write access can rewrite your project. The sandbox limits how code runs, not what an approved permission lets it do. Read what you are approving.
- A plugin with
net.fetchcan send whatever you gave it anywhere on its own list of hosts. One holding read access and network access together is one you had better trust. - Nobody checks plugins automatically. The permission dialog is the review.
Which is why the advice to authors never changes: ask for the least you can work with, and use your description to say why you need it.