Quality and migration
Security
What the library protects on its own, what stays your responsibility, and why the absence of
eval changes the size of the possible damage, without becoming a sandbox.
Why it does not use eval
Many libraries that interpret expressions inside attributes use
new Function('with(scope){ ... }'). It is quick to write and works well, but it
brings two consequences: the page needs unsafe-eval in the Content Security Policy,
and any text that reaches an attribute becomes executable code with access to everything.
Here, every expression goes through three stages written by hand inside the library:
- a lexer, which breaks the text into tokens;
- a Pratt parser, which builds the syntax tree;
- a tree interpreter, which evaluates node by node, inside the scope.
No eval, no new Function, no setTimeout with a
string.
Content Security Policy
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'
The 'unsafe-inline' in style-src is there because the CSS of the
interface components is injected at runtime. To do without it, turn the injection off and load
the CSS yourself:
<script src="voodoo.full.min.js" data-no-styles defer></script>
<link rel="stylesheet" href="/css/voodoo-ui.css">
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'
unsafe-eval is never needed, in any configuration.
The access surface of expressions
An identifier is looked up in the scope. When it exists in none of them, the lookup falls back to a closed list of globals:
Math JSON Date Number String Boolean Array Object Intl RegExp Promise
parseInt parseFloat isNaN isFinite encodeURIComponent decodeURIComponent console
Everything outside it returns undefined, window, document,
fetch, eval, globalThis, localStorage. An
attribute with v-text="document.cookie" reads nothing.
The closed list cuts down the possible damage considerably when an attribute is assembled from
data you do not control, but the usual rule still holds: never put user content inside a
v-* attribute without escaping it. Access to services is explicit, through the
magic variables, which also means you can audit what a page is able to do by reading its own
attributes.
v-html and XSS
v-html inserts HTML without escaping it. It exists because content coming from a
rich text editor needs it, and there is no other honest way to solve that.
<!-- dangerous -->
<div v-html="comentario.texto"></div>
<!-- safe -->
<div v-text="comentario.texto"></div>
When the HTML really is necessary, sanitize it first:
import DOMPurify from 'dompurify';
V.config.globals.limpar = (html) => DOMPurify.sanitize(html);
<div v-html="limpar(artigo.corpo)"></div>
The HTML inserted by v-html is walked by Voodoo, so it can bring
directives along: user content makes it possible to inject v-click,
v-init and any other attribute. The same goes for HTML responses from
v-get, v-post and v-target, trust only your own server.
The html option of the notifications also inserts without escaping.
What the library escapes on its own
| Situation | Behavior |
|---|---|
{ interpolacao } | Written as text, never as HTML |
v-text | Written as text |
JSON response rendered by v-get | Every value goes through escapeHtml |
| Validation messages | Written as text |
| Content of toast, alert, confirm and prompt | Written as text, except when you use html |
| Chart labels and values | Written as SVG text |
V.escapeHtml(texto) | Available when you assemble HTML by hand |
CSRF
<meta name="csrf-token" content="...">
Requests that write, POST, PUT, PATCH,
DELETE, automatically send the token read from that meta tag, in the
X-CSRF-TOKEN header. Both names are configurable:
V.http.defaults.csrfMeta = 'meu-token';
V.http.defaults.csrfHeader = 'X-Meu-Token';
Every request also carries X-Requested-With: XMLHttpRequest, which helps the server
tell AJAX calls apart. The default for credentials is same-origin, so
cookies do not leak to another origin unless you ask for it.
Sensitive data in storage
V.storage, V.session and v-persist write to the browser's
storage, which is readable by any script from the same origin. Do not keep long-lived tokens,
card data, or anything that must not be readable by an extension installed in the user's browser
there.
For tokens, prefer HttpOnly cookies set by the server. When that is not possible,
use V.session, which dies with the tab, instead of V.storage.
V.cookie.set('preferencia', 'escuro', { secure: true, sameSite: 'Strict' });
Remember, too, that v-sync publishes the scope's state on a
BroadcastChannel, visible to any tab on the same origin. Do not sync sensitive data.
Uploads
v-upload and v-dropzone send whatever the user picks. Every validation
that matters, type, size, the real content of the file, has to happen on the server. The
accept and multiple attributes are an interface convenience, not
security.
Where the file is served from
The recommended path is to download the file and serve it from your own domain: that is what keeps the security policy simple and avoids depending on a third party for the page to work. If you go with a CDN, pin the version and consider subresource integrity:
<script src="https://exemplo.cdn/voodoo.full.min.js"
integrity="sha384-..."
crossorigin="anonymous"
defer></script>
Recommended headers
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
Reporting a vulnerability
Do not open a public issue. Describe the problem, with steps to reproduce, through a private contact of the project, the CONTRIBUTING.md file in the repository says how.