Security & Privacy
What happens to your data, what our host can see, and how to check it yourself.
Generated on your device
Your browser makes every QR code and file transfer.
Nothing uploaded
What you type, scan or send is not uploaded. The one exception is a Google Wallet pass you choose to send with your own keys.
No tracking cookies or trackers
No analytics, pixels, fingerprinting or third-party scripts.
Cloudflare keeps standard request logs
Our host sees page requests (IP address, browser, page, time), never your content.
Check it yourself. Open your browser's developer tools, choose Network, then make a QR code: no request carries your content.
CI/CD Security Governance
To protect against supply-chain attacks, this project enforces immutable dependency pinning for all CI/CD components.
- Versioning Standard: All third-party GitHub Actions must be pinned to specific, immutable SHA-1 hashes instead of mutable version tags (e.g.,
@v4). Each pinned hash must be accompanied by a human-readable comment specifying the original version tag (e.g.,# v4.1.0) to maintain readability. - Automated Monitoring: Dependabot is configured to check for updates to external CI/CD dependencies weekly to ensure workflows are running the latest security patches.
- Lint security rules:
pnpm run lintruns ESLint'sno-eval,no-implied-eval,no-new-funcandno-script-url, plus our own rules ineslint/rules/:qrcraftly/no-unsafe-regex(regexes that can backtrack catastrophically),qrcraftly/no-non-literal-regexp,qrcraftly/no-bidi-characters(Trojan Source, CVE-2021-42574) andqrcraftly/exec-through-helper(processes run only through the exec helpers). Lists of blocked schemes such asjavascript:insrc/packages/core/security.tsandsrc/packages/core/url.tscarry a reasonedno-script-urlexemption. - One home for the sanitizers: The URL, text and SVG sanitizers live only in
src/packages/core/security.ts. Every package imports them from there, and thepackages-must-not-import-app-rootboundary rule (no exceptions since #1529) stops a package from reaching a second copy in app code (ADR 0052). - Remediation: In the event a vulnerable action is identified or an automated update PR is generated, developers must review the PR, verify the hash corresponds to the legitimate version update, and merge the update immediately. Any new workflows introduced must adhere to this pinning standard.
Supply Chain
What ships to the browser is our code plus the packages in pnpm-lock.yaml, built by tools from the same lockfile. The policy below keeps a dependency or build tool from slipping code into the static bundle. tests/supply_chain_policy.test.ts checks each rule against the files that carry it.
- One lockfile, frozen in CI.
packageManagerpins the exact pnpm version. CI installs only withpnpm install --frozen-lockfile, so apackage.jsonchange without the matching lockfile change (lockfile drift) fails the install. Every package resolves from the npm registry with a SHA-512 integrity hash; there are no git, tarball or directory dependencies. - Install scripts.
strictDepBuilds: trueinpnpm-workspace.yamlfails an install when a package wants to run an install script that nobody approved.allowBuildsapproves onlyesbuildandworkerd(both download their native binary);sharpis listed as refused. Approving another package lets its script run on every developer machine and in CI, so it needs the script reviewed first. - Release age.
minimumReleaseAge: 1440makes pnpm refuse a version published less than a day ago, the window in which most hijacked releases are found and pulled. A pinned exception goes inminimumReleaseAgeExcludewith the exact version. - New dependencies. A runtime dependency needs its own ADR and an entry in
ALLOWED_DEPENDENCIES(scripts/dependency_compliance.js); a dev dependency needs a reason in its PR and a row in FOUNDRY.md (ADR 0040). Our Rust crates use no third-party crates (scripts/rust_no_deps_check.js). - Updates and advisories. Dependabot opens update PRs for npm (daily), GitHub Actions and pip (weekly); each goes through the full CI like any other PR, and the reviewer checks the changelog and that the new version comes from the expected publisher.
pnpm audit --audit-level=highruns in every CI run, and the Audit workflow fails on moderate advisories. A finding is fixed by updating or overriding the package (overridesinpnpm-workspace.yaml), never by switching the audit off. - CI tooling. Third-party Actions are pinned to commit SHAs (see above), Semgrep installs from a hash-pinned
requirements.txt, and workflow scripts take untrusted values throughenv:only (tests/workflow_hardening.test.ts). - The emitted files. The postbuild audit,
scripts/emitted_asset_audit.js(it runs afterscripts/generate_sw.cjs), readsdist/clientas it will ship and fails the build if HTML loads a script, style, frame, image or form target from another origin, if CSS imports or references another origin, if JavaScript opens a WebSocket or names a host that is not on its reviewed list, or if any file holds a credential (the strict patterns fromscripts/secret-scanner.js) or a private key. The reviewed hosts are link targets and payload formats (wa.me,calendar.google.com), XML namespaces and library error messages, each listed with its reason. A new host means a dependency or a change started naming it: check why, then add it with the reason or remove it.
The owner confirms the repository settings that code cannot check: Dependabot alerts and secret scanning on, and who may approve a Dependabot PR.
Privacy & Compliance
This application is designed with a "Privacy First" architecture. All visual styling choices (including error correction levels, quiet zone margins from 2 to 10 modules, pattern selection, and colors in QRConfig) remain local to the browser. Please refer to COMPLIANCE.md for detailed information on how this application handles data and aligns with regulations like HIPAA.
Content Security Policy (CSP)
Our application utilizes a multi-layered Content Security Policy (CSP) enforced via both meta tags and HTTP response headers. To protect production deployments from script-injection (XSS) attacks, our build pipeline automatically parses compiled static HTML assets and computes SHA-256 integrity hashes for all inline scripts (such as JSON-LD structured blocks, framework hydration elements and the pre-hydration theme initialisation script from src/utils/theme.ts and the deferred page-script loader from scripts/defer_page_scripts.js, both of which must remain static strings so their hashes are stable). Consequently, script-src 'unsafe-inline' is completely absent from production deployments. (style-src 'unsafe-inline' remains, because React renders dynamic preview styles as inline style attributes.) script-src also carries 'wasm-unsafe-eval', which allows WebAssembly compilation (never JavaScript eval) for QRCraftly's own Rust modules (src/wasm/, built from crates/, see ADR 0033). The scanner's third-party zxing-wasm reader, which first needed it, was replaced by our own decoder (ADR 0036), so no third-party WebAssembly ships. They import nothing from JavaScript, so they cannot reach the network or the DOM, and src/packages/wasm-runtime loads them from the site's own origin only. The modules are selftest, which proves the build and the loader; qr-encode, the QR encoder; qr-decode, the QR decoder; prism-fec, the file transfer's outer code; and modem, the optical modem's kernels. The decoder reads camera frames and uploaded images, so it treats every byte as untrusted: it checks claimed sizes before allocating, caps the frame size and the candidates it tries, and never panics on any input (crates/qr-decode/tests/fuzz.rs feeds it random and mutated requests, data streams and grids).
The base policy lives in two places that must stay byte-identical: the meta tag in src/layouts/Head.tsx and BASE_CSP_PATTERN in scripts/csp_hash_injector.js. A unit test fails the build if they drift apart.
In the generated _headers file, only the global /* rule sets Content-Security-Policy, and it carries the inline script hashes of every page. Cloudflare joins a header set by several matching rules into one comma-separated value, and a browser enforces each policy in it, so a second, route-level rule would intersect with the global one and block that route's extra scripts. Each page's meta tag still lists only that page's hashes, so the effective policy on every page is its own. scripts/csp_hash_injector.js fails the build if more than one rule sets the CSP, or if _headers passes Cloudflare's 8 KB limit.
- No third-party origins: The app no longer loads web fonts from Google Fonts; text renders with the system font stack (Tailwind's default
font-sansandfont-mono). Every directive therefore allows only'self', plus thedata:andblob:schemes where they are needed. No visitor IP address or referrer is sent to a font CDN. If a brand typeface is added later, bundle it with the app (for example from an@fontsourcepackage) and keepfont-src 'self'. img-src 'self' data: blob:: SVG export rasterizes itsblob:object URL to check that the code scans, and the logo resize fallback decodes uploads throughblob:URLs.media-src 'self' blob:: The file-transfer receiver plays recorded video files throughblob:object URLs. (The generator's scanner reads images only.)- Regression coverage: The
chromium-cspPlaywright project runse2e/csp-enforcement.spec.tsagainst the built app withbypassCSP: false, so a policy that breaks SVG export fails in CI. The other projects still bypass CSP.
Permissions Policy
public/_headers sends Permissions-Policy: camera=(self), microphone=(), geolocation=(self), payment=(). The camera is available to the site itself for the optical scanner, and to no embedded or cross-origin frame. Geolocation is limited to the site itself, for "Use Current Location" on the Location QR type. The microphone and payment are disabled, since no feature uses them. An empty allowlist () turns a feature off for the site itself too, so never use () for a feature the app calls. tests/security_headers.test.ts checks these values.
Production Headers & Deployment Integrity
The headers in public/_headers only matter if the host sends them, so the Verify Production Deployment job checks the live site after every merge to main, and the Release workflow checks qrcraftly.com after every release (e2e/production-headers.spec.ts). Over plain HTTP, without a browser that could bypass the CSP, it checks:
- every page, the 404 page,
sw.js,manifest.jsonand both kinds of redirect (a retired route from_redirectsand a dropped trailing slash) carry each header in the/*block ofpublic/_headerswith the same value; - every page sends a Content Security Policy with
default-src 'self',object-src 'none', no'unsafe-inline'or'unsafe-eval'scripts and no third-party origin, identical to the page's own<meta http-equiv>policy; - only the
*.fpderuiter.workers.devhosts (the production workers.dev host and every preview) sendX-Robots-Tag: noindex; /version.jsonreports the commit (or release) being verified, and every file the service worker precaches is still served byte for byte: its SHA-256 must match the revisionsw.jswas built with.
sw.js is generated after every build step that rewrites HTML or _headers, and its build hash covers _headers and _redirects, so a header-only fix still installs a new service worker and replaces returning visitors' cached homepage.
Drift. A failure in that job means production is not serving what main built: a header missing or changed by a dashboard rule (Transform Rules, Page Rules, a custom domain setting), a file changed after the build, or a deployment that did not come from Workers Builds. The failure lists every header or file that differs. To check by hand, compare curl -sI https://qrcraftly.com/ with public/_headers and the commit in https://qrcraftly.com/version.json with main.
Zone features on qrcraftly.com. Some Cloudflare settings act only on the custom domain, never on *.workers.dev, so the Verify Production Deployment job (which checks the workers.dev host) does not see them; the Release workflow's check of qrcraftly.com does. Checked on 2026-10-26:
- Speed Brain (Speed, Optimization) adds a
Speculation-Rules: "/cdn-cgi/speculation"header. The browser fetches that same-origin rule file and may prefetch a same-origin page the visitor is about to open (eagerness: conservative). It sends nothing to another origin and is allowed byconnect-src 'self'. - Bot Fight Mode (Security, Settings, Bot traffic) stays off (2026-10-26). On the Free plan it always turns on Cloudflare's JavaScript Detections, which injects an inline loader for
/cdn-cgi/challenge-platform/scripts/jsd/main.jsinto every HTML page. Our hash-based CSP blocks that script, so it never ran, but each page logged twoscript-src-elemviolations ande2e/csp-enforcement.spec.tsfailed againstqrcraftly.com. Cloudflare only adds nonces to a CSP that already uses them, which a static build cannot do, and the site has no forms, login or API for it to protect. Cloudflare's DDoS protection does not depend on it. - Web Analytics is off (#1480): no
cloudflareinsightsbeacon is injected.
Recovery. First stop the cause: remove the dashboard rule, or find who deployed and with which token. Then put production back on a known build: roll back to the previous version in the Cloudflare dashboard (Workers & Pages, qrcraftly, Deployments) or with wrangler rollback, or merge a revert PR so Workers Builds redeploys main. Re-run the Verify Production Deployment job to confirm. These steps need Cloudflare access, so they are the owner's to take.
Reporting a Vulnerability
If you discover a security vulnerability or a privacy leak, please report it immediately.
How to Report
Please email fpderuiter+qrcraftly@gmail.com to report a vulnerability privately. We will acknowledge your report within 48 hours.
Scope
- In Scope:
- Data leaks (e.g., data being sent to a server).
- XSS vulnerabilities.
- Improper configuration of the client-side generator (including vCard 2.1, 3.0, 4.0, MECard, and iCalendar event RRULE recurrence formats).
- Bulk CSV processing (including link/text content types and PNG/SVG/EPS/PDF export formats), custom PNG export resolution settings, & batch ZIP generation privacy boundary violations.
- Out of Scope:
- Physical security of the user's device.
- Browser-level vulnerabilities.
Phone & Social Handle Sanitization & Validation
To prevent injection of arbitrary characters or command payloads into telephone, SMS, or social profile QR codes, the application runs strict sanitization routines entirely on the client side:
- General Phone Validation: By default, general telephone input values are cleaned to remove all non-numeric and non-standard telephone symbols, and semicolons are stripped. A
,dial pause is kept. An extension at the end (x89,ext. 89,;ext=89) is split off first and written as RFC 3966;ext=89, so its digits are never run into the number. The phone and SMS forms list any typed characters the code leaves out. - SMS Multi-Recipient Isolation: To support advanced client-side SMS campaign configurations, the SMS generator uses an isolated sanitization option that preserves semicolons and commas, while rejecting letters, other symbols, and line-break control characters.
- URI Delimiter Encoding: After sanitization,
#in a dial string is percent-encoded as%23(RFC 3966) so it cannot start a URI fragment and truncate the number. Mailto recipients (including optional CC and BCC recipients) are percent-encoded (RFC 6068, keeping@) so?,&, and#in the address cannot inject headers, and crypto wallet addresses, SEPA transfer fields, and fiat payment handles (PayPal, Venmo, Cash App) are sanitized and percent-encoded so&or#cannot inject payment parameters. A UPI ID must match thehandle@bankshape (letters, digits,.,-and_), so it cannot carry&,#or%and keeps its literal@; the UPI payee name and note have control characters replaced and are percent-encoded. - Social Handle Sanitization: Social profile handles (
SocialPlatforminsrc/packages/qr-payload/lib/types.ts) strip leading@signs and path-injection characters usingsanitizeSocialHandlebefore constructing deep link URLs for Instagram, Twitter / X, TikTok, LinkedIn, YouTube, Facebook, WhatsApp, and GitHub. - Contact Card Social Profiles & Photo Avatar: The vCard photo avatar field and vCard X, LinkedIn, Instagram, Facebook and GitHub fields (
socialProfileUrlinsrc/packages/qr-payload/lib/vcardSocial.ts) take a handle or a profile link. Photo avatar URLs and social profile URLs are validated against dangerous URL schemes (isDangerousUrl). Photo avatar URLs are written asPHOTO;VALUE=URL:<url>in vCard 2.1 orPHOTO;VALUE=uri:<url>in vCard 3.0/4.0 payloads, and omitted from MECard payloads. A social handle must be letters, digits,.,-or_and becomeshttps://<network host>/<handle>(LinkedIn:https://linkedin.com/in/<handle>). A link is checked withisDangerousUrl, must usehttporhttpson that network's own host, is written ashttps, and is refused if it holds;,,,\\or a user name or password. Each profile is written as its ownURL;TYPE=<network>:line.
SVG Sanitization & Path Tracking
To prevent custom SVG logo uploads and native vector exports from exposing users to DOM-XSS and structural XML injection, QRCraftly incorporates two security controls:
- Static Path Tracking: The build pipeline and pre-commit checks automatically trace data flows across files. They detect and block any unvalidated path where raw/external SVG code might reach rendering/storage sinks without passing through
sanitizeSvg(). - Mosaic QR Images: A mosaic design (
QRConfig.mosaicImageUrl, ADR 0019) goes through the sameuseImageUploadvalidation, SVG sanitization and resizing as a logo. It is decoded on the device into an in-memory cache of at most four images, never written to browser storage and never sent over the network. - Runtime SVG Sanitization: Uploaded logos and border images are processed entirely within the client browser to maintain offline privacy. The runtime parser enforces a zero-trust strict safe-element allowlist and zero-tolerance styling:
- Discards any elements not present on a strict safe-element allowlist (such as
<foreignObject>,<embed>,<object>,<script>, etc.). - Discards
<style>blocks and elementstyleattributes entirely if they contain@import,image-set()(which loads a bare string withouturl()),expression()orjavascript:. - Limits nested data URIs to safe image MIME-types and strips any with active payload markers or script references. A base64 PNG, JPEG, GIF or WebP must start with that format's file signature and is not searched for text, because random base64 can spell
onloadby chance. SVG data and plain-text data are searched for script markers after decoding. This is validated by an optimized, localized helper function within the security utility to ensure clean code and prevent unused export overhead. - Strips all inline event handlers (attributes starting with
on). - Neutralizes any remote or dangerous resource requests inside style blocks, style attributes, or
href/xlink:hrefreferences (matched by local name, so the XLink attribute is caught under any prefix) while preserving standard layout paths, responsive viewBox attributes, linear gradients, and clip paths. - Applies the same
url()rule to every other attribute whose value containsurl((fill,stroke,filter,mask,clip-path,marker-*and any other), checked by value rather than by a name list:url(#id)and safe data URIs stay, any other reference becomesurl(#), and an attribute with aurl(the rule cannot read is removed (#1591).
- Discards any elements not present on a strict safe-element allowlist (such as
Persistent Browser Storage Allowlist
The build and pre-commit pipelines run static AST analysis (scripts/storage_privacy_ast_auditor.js) on source code before compilation. It scans for browser persistent storage operations (localStorage, sessionStorage, indexedDB, document.cookie, caches) and enforces an explicit allowlist of authorized keys (qrcraftly:theme, qrcraftly:brand-templates, __test__). The qrcraftly:theme key stores only the visitor's colour-theme preference (light, dark or system). The qrcraftly:brand-templates key stores user-saved visual brand templates (capped at 50 templates; visual styling parameters only, never QR payload content, border or template text, or uploaded images). Any attempts to persist transient QR payload data or use unapproved keys immediately abort the build. A saved template stores its style choices (pattern, error correction level, social format, template style) as the string values defined in src/packages/core/types.ts. Those value sets are as const objects since ADR 0045; the change kept every value, so templates saved before it load unchanged, and the template importer still checks each field against Object.values of its set. The Wi-Fi Card template style (wifi_card) is saved as that style value only; the network name and password it shows are read from the current Wi-Fi payload at draw time and never stored in a template. The WPA3-only Wi-Fi type (WifiEncryption.SAE, encoded as T:SAE) is a payload value only; it adds no stored field, and its password is held in memory like every other Wi-Fi password.
Wallet Pass Keys
The wallet dialog leads with the card image, which needs no keys. Signed passes sit in a closed developer section below it ("Have your own Apple or Google developer keys?"), marked Beta, and their signing code loads only when that section is opened.
Signed Apple Wallet passes are made only with the person's own Pass Type ID certificate (ADR 0048); QRCraftly has no signing key and no signing service. The certificate, the WWDR intermediate and the private key are read from files into the tab. The key is imported with WebCrypto as a non-extractable signing key, checked against the certificate by signing and verifying a random probe, and held only in component state, so it is gone when the dialog closes. Nothing is written to storage or sent anywhere. Password-protected keys are refused rather than asking for a password. The pass is a generic pass with no webServiceURL or authentication token, so it can never change or call home. The pass logo is drawn only from a logo already in the page as a data: or blob: image, so making a pass fetches nothing.
Google Wallet passes are signed the same way with the person's own service-account key: a generic class and object in a JWT, signed RS256 in the tab. The panel says before anything is sent that Google receives the whole pass, including the code's content, and keeps it in the Google account that saves it. Only after that confirmation does it open the save link in a new tab (noopener,noreferrer). Image URLs are never put in the JWT, because Google would fetch them.
QR Animation Loops
Animation configuration structures in QRConfig (src/packages/core/types.ts) are strictly statically typed to prevent any runtime execution or script-injection pathways during high-frequency loop playbacks.
Playable Maze Overlay & Web Calendar Integration
Maze overlay configurations in QRConfig (src/packages/core/types.ts) (e.g., isMazeEnabled, isMazeBridgesEnabled, mazeColor, mazePathWidth, showMazeSolution) are statically typed and strictly validated at runtime. This prevents injection or path manipulation during maze rendering. Web calendar providers and iCalendar event types (EventData and CalendarProvider in src/packages/qr-payload/lib/types.ts) construct direct calendar URLs and VEVENT payloads with optional explicit IANA / UTC timezones, validating all parameter links with isDangerousUrl to prevent URI injection attacks.
Independent Eye Colors
Finder pattern color properties in src/packages/core/types.ts (eyeFrameColor and eyeBallColor) are statically typed visual parameters. They allow distinct outer frame and inner eyeball styling while falling back safely to eyeColor.
Eye Shapes
eyeFrameShape and eyeBallShape in src/packages/core/types.ts take one of the fixed EyeShape values (square, rounded, circle, leaf). Style files and brand templates accept only those values (src/utils/styleFields.ts); anything else is skipped. An unset shape draws the eye exactly as the module pattern does. Shapes are drawn on the device like every other part of the code, and the scannability check decodes the result.
Custom Frame Shapes and CTA Badges
Custom frame shape and call-to-action (CTA) badge configurations in src/packages/core/types.ts (e.g., frameStyle, frameText, frameTextColor, frameBgColor, framePosition, frameIcon) are strictly statically typed. The frameText field is validated via validateConfig() to reject control or BiDi characters, sanitized client-side without external persistence, and checked for minimum WCAG AA contrast (>= 4.5:1) in real time.
Gradient Color Controls
Gradient color configurations in src/packages/core/types.ts (gradientType, gradientColorStops, gradientAngle) are strictly statically typed and validated on the client side during canvas matrix rendering, preserving local processing with zero external script execution.
JSON-LD Caching & Performance Security
Structured data is rendered by the server-only Head at prerender time (ADR 0034). To keep repeated renders cheap, the application caches serialized and escaped JSON-LD schema strings. Since JSON-LD requires synchronous regex replacement of unsafe characters (such as < and >), caching the computed string primitives protects the main thread from CPU-heavy operations while keeping cache keys lightweight and clean of memory leaks. The escaping itself lives in safeJsonLdStringify (src/packages/core/security.ts): it serialises any JSON value and rewrites <, > and & as \u003c, \u003e and \u0026, so schema text can never close the surrounding <script> element. JsonLdScript (src/components/ui/JsonLdScript.tsx) is the component that injects the result.
Browser Trust Boundaries
QRCraftly reads untrusted text from five places: what a person types, files they upload (logos, mosaic pictures, CSV, brand templates), pictures and camera frames they scan, files received through the file transfer, and the page's own URL. None of it may become script, markup or a navigation the person did not choose. React renders all of it as text by default, so the risk sits in the few places that turn text into something else. These are all of them; tests/xss_sink_inventory.test.ts fails when a new one appears, until it is reviewed and listed here.
| Sink | Where | Input | Control |
|---|---|---|---|
| Raw HTML | JsonLdScript, Head.tsx |
Build-time data, a static theme script | safeJsonLdStringify escapes <, > and &; the theme script is a constant allowed by its CSP hash |
| Raw HTML | SanitizedHtml on /security |
Docs compiled from this repository at build time | Never runs on user input; the page reads its own prerendered HTML back |
| Markup parser | useImageUpload, sanitizeSvg |
An uploaded SVG logo or mosaic | Parsed into an inert document, then sanitizeSvg (below) before it is shown, stored in memory or exported |
| Markup parser | sceneReader (EPS and PDF export) |
Our own SVG export | Inert document; only shapes, colors, text and inlined image data URLs are read |
| Object URL | Downloads, SVG rasterising, image resizing, a chosen video | Bytes we made or the person chose | Used as a download, an <img> or a <video>, never as a document |
| Object URL and new tab | TransferComplete (new tab via handOffBlob) |
A received file | Only PNG, JPEG, GIF, WebP, PDF and plain text open in a tab; a type that disagrees with the name is saved as application/octet-stream; risky types need a second click to save (analyseReceivedFile) |
| Link | Scan results, payload previews | Text from a scanned code | isDangerousUrl refuses script and data links before an Open button exists; the text itself renders as text |
| Navigation | StressTestButton, the scanner's Open in generator |
Fixed paths | No user text reaches the URL |
| New tab | GooglePassPanel (Google Wallet pass with the person's own keys) |
The pass, signed in the tab | The URL is the fixed https://pay.google.com/gp/v/save/ prefix plus a base64url JWT we build; it opens with noopener,noreferrer only after the person confirms that Google receives the pass |
| Worker messages | Every worker | Messages from our own page | Workers are same-origin; there is no window message listener, so other sites cannot post into the app |
There is no eval, new Function or string timer anywhere in src/.
Tests. src/utils/xssRegression.test.ts runs a corpus of known SVG and URL attacks (script and foreign elements, mixed-case handlers, <animate> and <set> rewriting a link, XLink under another prefix, remote images, @import, image-set(), expression(), entity- and whitespace-obfuscated javascript: links) and checks that nothing active survives. e2e/xss-regression.spec.ts types markup into the generator, uploads a hostile SVG logo and scans a code that holds markup, then checks that no script ran, no dialog opened, no request left for the attacker's host and the SVG export holds no active content. Those Playwright projects bypass the CSP on purpose: the app must stay safe on its own, and the CSP is a second layer.
Trusted Types. require-trusted-types-for 'script' stays off for now (the owner's call). Turning it on needs a policy for the worker and service worker script URLs and for the three DOMParser calls above, which also count as sinks under Trusted Types. The inventory test is the list a policy would have to cover.
URL Sanitization & DOM-XSS Protection
To prevent DOM-based Cross-Site Scripting (DOM-XSS) via dynamic anchors and href bindings of user-controlled URLs, we enforce strict URL sanitization:
- Link scheme check (
isDangerousUrl): Every dynamic value destined for an anchorhref,window.open,locationornavigate()call must passisDangerousUrl, which refusesjavascript:,data:,vbscript:and the other blocked schemes after decoding obfuscation. Like a URL parser, it drops control and zero-width characters anywhere and whitespace only at the start, sojava\tscript:is refused but prose such asAbout: usorFile: invoice.pdfis not a scheme. Script schemes are refused even with a space after the colon. Semgrep enforces this (require-isdangerousurlinsemgrep.yml, with fixtures intests/semgrep/). The oldersanitizeHrefhelper is gone: it would have let a protocol-relative//hostaddress through and nothing used it. - HTML Meta-Character Escaping (
escapeHtml): In addition to scheme enforcement, values rendered as text nodes or embedded inside anchor taghreflinks are escaped. This safely converts characters like&,<,>,", and'into their respective HTML entity equivalents (&,<,>,",'), entirely neutralizing DOM reinterpretation risks and ensuring robust DOM-XSS protection.
Network Requests
QRCraftly is a static site: the browser downloads its files and then works on the device. This is every request it makes.
| When | What the browser requests | What never happens |
|---|---|---|
| Opening any page | The page and its own scripts, styles, images and manifest.json from this site. The service worker then precaches the homepage shell. |
No font, script, image or analytics from another site |
| Moving between pages | The next page's pageContext.json and scripts, from this site or the service worker's cache |
No query strings or form posts |
| Making and downloading a code | Scripts, workers and WebAssembly modules the generator loads on first use, from this site | The content, colours and logo stay in memory; downloads are made on the device |
| Scanning (camera or picture) | The scanner worker and the qr-decode module on first use |
Camera frames and pictures never leave the device; scanned text is only shown and copied |
| File transfer | The transfer page's scripts, workers and modules on first use | The file travels from screen to camera only |
| "Use Current Location" | Nothing from QRCraftly; the browser asks the device for a position | The position goes into the code only |
| Google Wallet pass (own keys) | Nothing from QRCraftly. After the person confirms, a new tab opens https://pay.google.com/gp/v/save/ with the pass signed on the device |
The service-account key never leaves the tab; nothing opens without the confirmation |
| Following a link | Only when the person clicks it: a link in the page, or Open on a scan result | Nothing opens by itself |
| Updates | The browser rechecks sw.js on navigation; a new build replaces the cache |
|
| Offline | Nothing: after one visit the homepage generator works from the cache, and other pages and tools after they were used once online |
Every request goes to the site's own origin, as a GET, without a body. The host still sees each request's address, path, time and browser (see COMPLIANCE.md), but never what a person typed, scanned or sent.
How this is enforced:
- The CSP sets
default-src 'self'andconnect-src 'self', so the browser itself refuses other origins. scripts/bundle_ast_audit.jsallowsfetchonly at reviewed call sites and refusesWebSocket,XMLHttpRequestandsendBeacon;scripts/emitted_asset_audit.jsrefuses resources from other origins in the built HTML and CSS (see Supply Chain).- Every generator, scanner and transfer Playwright spec runs behind
e2e/fixtures.ts, which fails the test on any request to another origin (a person's click to another site aside) and on any request that is not aGETorHEADor carries a body. Theerror-fallbacks,production-headersandsmokespecs do not use it. e2e/network-privacy.spec.tstypes a unique secret into the link, text and Wi-Fi generators and downloads the codes, and scans a picture that holds one;e2e/file-transfer.spec.tssends a file whose name and bytes hold one. Each test fails if the secret appears in any request, even to this site, or if any request has a query string. The same spec checks that the QR code checker still reads a picture after the server has gone.
No Server API
QRCraftly has no server code and no API: production serves static files only, and there are no dynamic (redirect) QR codes (ADR 0022). There is therefore no database, bot check or rate limiter to defend. scripts/bundle_ast_audit.js fails the build if compiled code calls any same-origin /api/ path.
What QRCraftly does with the data you put into it, what our host can see, and what that means if you work under HIPAA or similar rules. The plain-language version is the QRCraftly Pledge.
Where your data goes
QRCraftly makes QR codes in your browser. What you type, upload or scan is not sent to a server, so it can be used for codes that hold protected health information (PHI) without that information reaching us.
- Local Processing: All QR code generation (including vCard 2.1, 3.0, 4.0 with photo avatar support, and MECard contact payloads) happens locally within the user's browser using HTML5 Canvas and JavaScript.
- Data Transmission: The sensitive data you enter to generate a QR code (which may include PHI) remains strictly in your device's memory and is not sent to our servers.
- Volatile Memory: QR content you enter is held only in memory and is cleared when the browser tab is closed or refreshed. The only values written to persistent browser storage are the keys listed under Technical Safeguards below. The service worker also keeps copies of the site's own files in Cache Storage (
qrcraftly-precache-*andqrcraftly-meta) so the generator works offline; those caches hold only the app's files and their update history, never what you type, scan or make. - Undo History and Style Files: The generator keeps up to 50 appearance steps for Undo and Redo in volatile memory only; QR content is never part of a step, and a reload clears them. A style file (
.qrcraftly.json) is a download you choose to make: it holds colors, pattern and layout settings only (never the QR value, text or uploaded images), and loading one accepts only a fixed set of known settings. - Brand Template Gallery Records: Custom brand design templates saved by users are persisted in browser
localStorageunderqrcraftly:brand-templates(capped at 50 templates). Stored template records contain visual style settings only (colors including independent eye frame and ball colors and shapes, linear and radial gradient settings, pattern styles, borders, quiet zone margin padding (2 to 10 modules, default 4), logo size and padding) and never store QR payload content, border or template text, or uploaded images such as logos and mosaic pictures. The style values in a record are the fixed strings defined insrc/packages/core/types.ts(for examplecircuitorH), which did not change when those value sets moved from TypeScript enums to plain constant objects. A record has theBrandTemplateshape defined insrc/types.ts. - Mosaic QR Images: An uploaded mosaic design and its decoded pixels are kept only in volatile memory (at most four decoded images) and are never persisted or uploaded.
- Animation Loop Frames: Any cached frames or matrices generated for animation loops are also kept solely in volatile client-side memory.
- Playable Maze Overlay: All coordinates, keep-out boundary zones, scannability-audited finder pattern bridge channels, and solutions computed for the playable maze overlay are processed completely in-memory locally in the user's browser, ensuring absolute privacy and data isolation.
- Custom Frame Shapes and CTA Badges: Frame CTA text (
frameText) and styling parameters are rendered locally in browser volatile memory and sanitized against control characters; CTA text is never transmitted over the network or saved to persistent storage, while the chosen frame font family name (frameFontFamily) may be kept as part of a saved brand template. - Bulk CSV Batch Processing: An uploaded CSV, the rows parsed from it (at most 500 per batch), the chosen content type (link or plain text), configured PNG, SVG, EPS, or PDF export format choices and resolution settings, and the QR codes and ZIP archive built from them stay in volatile memory on the main thread. The parser and ZIP writer are local code (
src/packages/bulk-csv/) with no network access, nothing is persisted, and the download's Blob URL is revoked right after the download starts. - Calendar Event Processing: Calendar events (including explicit IANA or UTC timezone selections and RFC 5545 RRULE recurrence frequency and count options) and web calendar links (Google Calendar, Outlook Web, Office 365, Yahoo Calendar) are built in client-side memory; QRCraftly sends them nowhere. An iCalendar (.ics) code keeps the event inside the code. A web calendar code is a link that carries the title, times, location and description, so when someone scans it and opens the link, that provider (Google, Microsoft or Yahoo) receives those details. Use iCalendar, the default, for events that contain PHI. The generator says this under the calendar choice.
- Wi-Fi Display Sign Cards: Printable Wi-Fi display sign cards (
TemplateStyle.WIFI_CARD) extract network details (SSID and Password) from Wi-Fi QR payloads entirely in client-side memory usinghydrateWifiData, rendering formatted signage locally without network calls or persistent storage. - Social Media Profile Links: Social profile handles and optional pre-filled WhatsApp messages entered for supported platforms (Instagram, Twitter / X, TikTok, LinkedIn, YouTube, Facebook, WhatsApp, GitHub) are processed entirely in browser memory to construct standard HTTPS universal profile links and are never transmitted over the network or saved to persistent storage.
Anyone who scans a code can read what is in it. Keeping a code private is up to where you print or show it.
What the host can see
QRCraftly has no analytics, telemetry, diagnostics or logs of its own. The site is static files served by Cloudflare (Workers Static Assets, with no server code of ours).
- Cloudflare handles every request for a page or file. Like any web host it processes the IP address, browser user agent, page address (such as
/or/about) and time, to deliver the site and protect it from attacks, under Cloudflare's privacy policy. The project owner sees only totals such as request counts in the Cloudflare dashboard. - If Cloudflare ever challenges your browser, for example during an attack, it may set a short-lived security cookie. It is not used for tracking.
What is never part of a request:
- Anything you type, including contact details and social profile links, email CC and BCC addresses, Wi-Fi passwords (WPA3-only networks included) and WPA2-Enterprise identity fields, and payment details such as crypto addresses, IBANs, UPI IDs and payment handles.
- The QR codes and files you make.
- Diagnostics. Scan and scannability failures are handled on your device and not reported.
Technical safeguards
- HTTPS. Every connection to the site uses HTTPS.
- Nothing in the address bar. User input is never put in URL query parameters (such as
?data=...), so it cannot end up in browser history, proxy logs or server logs. - Storage allowlist. Before every build,
scripts/storage_privacy_ast_auditor.jschecks every use of persistent browser storage against an allowlist:qrcraftly:theme(light, dark or system),qrcraftly:brand-templatesand the__test__probe key. Anything else fails the build. - No unreviewed network calls. The site's Content Security Policy lets the browser connect only to QRCraftly itself, and
scripts/bundle_ast_audit.jsfails the build if shipped code gains a network call that has not been reviewed.
Private file transfers
File transfer can encrypt the stream with a key code that stays on the two screens (AES-256-GCM, with the key derived by HKDF-SHA-256). The sender shows the key code only while a button is held. This protects against a camera that sees the sender's screen but does not have the key. It does not hide that a transfer is happening, and anyone who learns the key code can read the file. The key code is never saved or sent over the network.
HIPAA and other rules
No software is "HIPAA compliant" or "HIPAA certified" on its own. HIPAA applies to covered entities and their business associates and to how they handle PHI. QRCraftly never receives PHI, so there is nothing for it to store, transmit or protect on a server. Whether using it fits your obligations depends on your devices, your policies and where the codes end up. The same goes for GDPR: QRCraftly does not receive what you put in a code, but Cloudflare does process IP addresses to serve the site.
Legal disclaimer
No legal advice. Nothing in this document is legal advice.
No warranty. This software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the software or the use or other dealings in the software.
Your responsibility. Compliance with HIPAA is the responsibility of the covered entity or business associate. Using this tool does not by itself make you compliant.
Found a security problem? Email it privately to fpderuiter+qrcraftly@gmail.com so it can be fixed before it is public.
Report a vulnerability by emailAbout Security & Privacy
What QRCraftly does with your data, what our host Cloudflare can see, how to check it yourself, and how to report a security problem.
Key Features
- QR codes are made in your browser, and what you type is never uploaded
- File transfers can be encrypted with a key code that stays on the two screens
- No analytics, tracking cookies or third-party scripts
- Private vulnerability reports by email
How to check these claims yourself
You do not have to take our word for any of this.
Watch the network
Open your browser’s developer tools, choose Network, and make a QR code. No request carries what you typed.
Go offline
Load the page, turn off your connection and make a code. The generator keeps working, because nothing needs a server.
Report a problem
Email security problems privately to fpderuiter+qrcraftly@gmail.com, so they can be fixed before they are public.