© Jean Pierre Kolb — MIT License
Please do not open a public issue for a security problem.
Report it privately through GitHub's private vulnerability reporting: https://github.com/JPKCom/jpkcom-desktop/security/advisories/new. If that is not possible for you, use the contact details on the author's website https://www.jpkc.com/ and ask for a private channel first — do not send the details in a first message.
Please include:
- the affected version (
package.json, or "About this desktop" in the running desktop) and browser; - what an attacker can do, and under which preconditions (a configuration, a crafted page or file, a malicious online service, …);
- steps to reproduce, ideally with a minimal
site/config.js/site/apps.jsor atools/browser-check.mjsscenario; - whether the problem needs a deployment that deviates from the shipped server configurations.
The aim is a first answer within a week. Fixes are released as a patch version and noted in
CHANGELOG.md; you are credited there unless you prefer otherwise. Please give us a
reasonable time to release a fix before you publish details.
| Version | Supported |
|---|---|
| 1.4.x | yes |
| 1.3.x | no — please update to 1.4 |
| 1.2.x | no — please update to 1.4 |
| 1.1.x | no — please update to 1.4 |
| 1.0.x | no — please update to 1.4 |
| < 1.0 | no (never released) |
Only the latest release of the latest minor version receives security fixes.
JPKCom Desktop is a set of static files. It has no server-side code and no build step; everything runs
in the visitor's browser under the deploying site's origin. Its protections are designed so that the
Content Security Policy is the outer wall and the code never relies on it alone. The binding rules
are in docs/ARCHITECTURE.md §5 and §16.
The shipped server configurations (docs/server/) and the local server
(tools/serve.mjs) send:
default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:;
font-src 'self'; connect-src 'self' blob:; frame-src 'self'; worker-src 'self'; manifest-src 'self';
object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'
plus upgrade-insecure-requests and HSTS on HTTPS, X-Content-Type-Options: nosniff,
X-Frame-Options: SAMEORIGIN, Referrer-Policy: strict-origin-when-cross-origin and a
Permissions-Policy. Only three extensions exist, each opt-in: the hosts of online services a site
switches on (connect-src), the origins of framed web apps (frame-src) and 'wasm-unsafe-eval'
for the optional Pagefind search (its worker, and its fallback to the page when the worker fails or
starts too slowly, see docs/deploy.md §6). Nothing else needs a weaker policy.
The code is written so that it works under this policy: no inline script or style, no style=""
attributes, no eval or new Function, and no HTML strings — innerHTML, outerHTML,
insertAdjacentHTML, document.write, setHTMLUnsafe and srcdoc are not used anywhere. The DOM
helper h() (src/core/dom.js) throws on those properties and on string event handlers.
The Reader shows same-origin HTML pages natively, without an iframe. A page is fetched, parsed inert
with DOMParser (scripts never run, images never load) and cleaned by an allowlist sanitiser
(src/modules/reader/sanitize.js) before a single node is imported:
- elements: only allowlisted ones are kept;
script,style,iframe,object,embed,base,link,meta, forms and form controls,template, SVG animation and similar go with their content; unknown wrappers are unwrapped so their text stays; - attributes: an allowlist per element — never
style,on*,data-*ortarget; - URLs are re-resolved against the page and checked: links
http(s),mailto,tel; mediahttp(s)(images alsodata:image/…); SVG references only within the page; - ids get a per-window prefix and classes a
c-prefix, so page content cannot collide with or pick up the desktop's own ids and styles; - code colours (
reader.keepStyles, on by default): a page'sstyletext is never applied. Insidereader.styleScope(defaultpre, code) it is parsed, and onlycolor,background-color,font-style,font-weight,text-decoration-lineand custom properties with the prefixreader.styleVarssurvive, with plain colours or fixed keywords as values — nourl(),var(),calc(), image functions,!important, escapes, comments or quotes, no layout, position, size or display property. The Reader writes each value itself and sets it through CSSOM on the imported node. A contrast guard (2:1) drops colours that would hide text against their background; inside an element that keeps a page's colours every element inherits the checked text colour (no desktop link or heading colour on a background the page chose), andreader.styleVarscannot name a prefix of the desktop's own tokens.
The parser itself comes from a same-origin about:blank iframe that is removed before it parses
(src/modules/reader/parse.js): Chromium checks the CSP of the parser's window while parsing and would
report every style="", <style> and <base> of a fetched page; a detached window's document is not
checked. That document is as inert as before — nothing in it runs or loads, and the sanitiser above still
decides what is imported. Where that parser is unavailable, the window's own DOMParser is used.
No request leaves the site unless two gates are open (src/core/consent.js):
- the site offers the service (
services.<id>: trueinsite/config.js; all arefalseby default), - the visitor agreed — asked before the first request, stored per service, withdrawable in Settings → Online services (and revoked by the settings reset).
Desk.net refuses a request with a service id otherwise. Third-party requests go out with
credentials: 'omit', referrerPolicy: 'no-referrer' and a timeout.
The CSP connect-src must list the service's host as well, so a missing consent check would still be
stopped by the browser.
Private bookmarks are sealed offline with tools/seal-vault.mjs and decrypted in the browser with Web
Crypto (src/modules/vault/vault-core.js):
- PBKDF2-HMAC-SHA-256 over the normalised user name and password, salted with
config.vault.salt,config.vault.iterationsrounds (default 600 000); 512 derived bits: the first 256 are the AES-256-GCM key (imported non-extractable), the next 128 name the file (32 hex characters). - File format version 2 stores the KDF id, iteration count and salt in a header that is the GCM additional data, so the parameters cannot be changed unnoticed; a file sealed with other parameters than the deployment's is refused.
- A wrong password finds no file (
404) or fails authentication; both read as "denied". Files aboveconfig.vault.maxBytesare not read. - "Stay logged in" keeps only the non-extractable
CryptoKeyin IndexedDB — never the password. Plain-text buffers are zeroed after use. The service worker never caches the vault folder. A service worker your site used before this desktop may have done so: when you replace one, list its caches inconfig.offline.legacyCaches(docs/deploy.md§11) so that copies of the sealed files go too. - Requires a secure context (HTTPS). The deployment must not list directories, should serve
site/vault/*.binwithCache-Control: no-cacheandX-Robots-Tag: noindex(the shipped configurations do), and the plain-text bookmark file must stay outside the project and every web root (the sealing tool refuses it there).
The vault protects the bookmarks against anyone who does not know the credentials; its strength is the
strength of the password. Use a site-specific salt (npm run seal -- --new-salt).
Pictures, music, videos and text files a visitor opens or drops never leave the device. Because a
blob: URL has the desktop's origin, a file opened as a document could run script with access to the
desktop's storage — so device files are never opened as documents:
- a blob URL carries a validated type only: a raster image type (viewer), the audio/video type of an
accepted extension (players), otherwise
application/octet-stream; - an SVG never gets a blob URL; the viewer shows a device SVG from a
data:URL, which a page cannot navigate to and which never has the desktop's origin; - no "Open in new tab" and no "Copy link" for windows that show a device file (
canPopOut/canLinkhooks);window.opennever receives ablob:ordata:URL; - images only through
<img>, not draggable, without the browser's context menu; downloads only through<a download>with a neutral type; - text files are read as text and never get a URL.
Everything read from localStorage, IndexedDB, a backup file, the site manifest or the configuration
is treated as untrusted: validated, reduced to known shapes, ids re-checked before they reach a
selector. Manifest URLs are restricted to relative paths and http(s) (link apps https unless a
collection opts in to http); javascript:, data: and protocol-relative URLs are rejected. Links to
other origins open with noopener.
The desktop ships no third-party code at runtime (the Tabler icon subset is generated into
src/icons/tabler.js and committed). npm brings two development tools only, installed with
ignore-scripts, exact versions from the lockfile (npm ci) and Node.js ≥ 24 enforced; we recommend
Socket Firewall Free (sfw npm ci). CI pins every GitHub Action to a commit SHA and verifies registry
signatures (npm audit signatures). Details: CONTRIBUTING.md → Supply chain.
- Content a site owner deploys (pages, apps, framed
webapps, collections) and the configuration of their server, as long as the shipped configurations are not at fault. - Weakening the policy deliberately (
'unsafe-inline','unsafe-eval', wildcard hosts). - The third-party online services themselves; the desktop only limits what is sent to them.
- Attacks that require control of the visitor's device or browser.