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:

  1. a lexer, which breaks the text into tokens;
  2. a Pratt parser, which builds the syntax tree;
  3. 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.

This is not a sandbox

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 detail that makes it worse

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

SituationBehavior
{ interpolacao }Written as text, never as HTML
v-textWritten as text
JSON response rendered by v-getEvery value goes through escapeHtml
Validation messagesWritten as text
Content of toast, alert, confirm and promptWritten as text, except when you use html
Chart labels and valuesWritten 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.