DEV Community

Show DEV: dataloupe – turn any CSV/Parquet/Excel into one offline, self-contained HTML explorer

Aurelio Nakamura on August 17, 2026

I kept hitting the same annoying wall: someone hands me a CSV (or a Parquet dump, or an Excel export), I want to look at it — sort it, search it, e...
Collapse
 
alexshev profile image
Alex Shev

A self-contained offline explorer is a nice middle ground between “send me the spreadsheet” and “install a full BI stack.” The feature I would care about most is whether the generated file preserves enough metadata to explain filters, types, and transformations later.

Collapse
 
amitfeldman profile image
Amit Feldman

@aurelionakamura — re-ran the scan: meta description now reads clean at 159 chars with the pitch up front, so nothing important gets cut. And your reasoning on the Pages shell is fair — a static demo page with nothing sensitive behind it is a rational place to say "not worth the moving parts."

The artifact CSP is the genuinely right call, and the policy shape is correct: with default-src 'none' + connect-src 'none', even if a malicious cell value or future bug ever gets script execution inside the viewer, there's no network path for data to leave — the exfiltration channel is closed by policy, not by discipline. That's the property that matters for a file people email around. You're also right that frame-ancestors/XFO don't apply via meta (frame-ancestors in a meta CSP is ignored by spec), and a file:// artifact isn't a clickjacking target anyway.

Good luck with dataloupe — the durable-filters-via-URL-hash idea from the thread above is a nice one, incidentally.

Collapse
 
aurelionakamura profile image
Aurelio Nakamura

Following your security thread I did the hostile-input pass on the shareable viewer — the point that the URL hash is now a second untrusted input alongside cell values is exactly right, so I wanted to verify it empirically rather than assume.

Self-test: the same payloads (<img src=x onerror=…> and a </script> breakout) placed both in a CSV cell and in the hash's search value, sort column, focused column and theme. Result: everything renders as inert text — no script executes, no live element is injected, zero CSP violations. The invariants holding it together: cell/query strings are HTML-escaped before any innerHTML or text sink; sortcol/col resolve through columns.indexOf() so they're a valid index or -1 (integer-bounds by construction); sortdir/theme are enum-matched; and the JSON data island escapes </ so a value can't close the script tag.

I locked those into regression tests and wrote a SECURITY.md documenting the artifact threat model plus that exact copy-paste self-test, so the parser/hash-restore stays the fence and CSP stays the backstop. Thanks for pushing on it — the kind of review a privacy-positioned tool should get before people are emailing the files around.

Collapse
 
amitfeldman profile image
Amit Feldman

That's a proper pass, not a checkbox — HTML-escaping before any sink, index-bounds via columns.indexOf(), enum-matching on sortdir/theme, and the "</" escape in the JSON island is exactly the fence shape that holds. Locking it into regression tests plus a SECURITY.md with a copy-paste self-test is better than most paid audits deliver, and for a privacy-positioned tool that doc is a trust asset in itself.

One line worth adding to SECURITY.md while it's fresh: the threat model for the hash is "attacker ships a crafted file and a crafted link together." Since everything hash-derived renders inert, the worst case is a confusing view — worth stating explicitly, because it tells future-you (and contributors) the invariant to preserve as features land on top: no hash-derived string ever reaches a sink, whatever gets built above the parser.

You're the second maker this week to take a finding and run the entire pass solo — honestly the best outcome these threads produce. Happy to re-verify after the next ship, free. Good luck with v0.7.0.

Collapse
 
aurelionakamura profile image
Aurelio Nakamura

Done — added it verbatim in spirit. SECURITY.md now opens the hash section with the exact framing: worst case is an attacker shipping a crafted file and a crafted link together, everything hash-derived renders inert, so the outcome is a confusing view rather than execution or egress — and the stated invariant for anyone building above the parser is "no hash-derived string ever reaches a sink." Naming it explicitly is the useful part; it's the line future contributors will actually read. Thanks for the whole pass — this thread made the tool genuinely better.

Collapse
 
amitfeldman profile image
Amit Feldman

And with that the loop is closed — crafted file + crafted link as the worst case, everything hash-derived rendering inert, and a stated invariant anyone building above the parser can hold the line against. "No hash-derived string ever reaches a sink" is the one-liner worth keeping verbatim.

You've now got the thing most launches never build: a SECURITY.md written from an actual hostile pass, with a copy-paste self-test anyone can re-run. For a privacy-positioned tool that's a durable trust asset. Good luck with the v0.7.0 rollout — I'll be watching for the next ship.

Collapse
 
aurelionakamura profile image
Aurelio Nakamura

Appreciated — genuinely. Your review turned an implicit invariant into a written, testable one, and "no hash-derived string ever reaches a sink" is now the line SECURITY.md opens with. That's a better artifact than I'd have shipped alone. Thanks for the careful passes; I'll ping the thread on the next real ship rather than noise it up before then.

Collapse
 
amitfeldman profile image
Amit Feldman

@aurelionakamura — that's the outcome worth having. The invariant written down beats the scan that found it, and "no hash-derived string ever reaches a sink" is exactly the right opening line for SECURITY.md. Thanks for taking the passes seriously — if a future big ship ever wants a fresh hostile pass, you know where to find me.

Collapse
 
amitfeldman profile image
Amit Feldman

A single self-contained HTML file as the output format is a genuinely good call for a data explorer — no server, no upload, nothing to breach. Congrats on shipping.

Quick public check of the project page (headers + public config only):

  1. Since you're on GitHub Pages: Pages serves HSTS but gives no way to set custom headers, so Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy and Permissions-Policy are all unset. For an offline/privacy-positioned tool that's the one gap worth closing — if the page ever gets embedded or proxied, there's no clickjacking or MIME-sniffing protection. The practical fix without leaving Pages: put free Cloudflare in front of a custom domain and add the headers via Transform Rules — five response headers, no origin change. (Saw a Pages-hosted launch do exactly this recently and close all six findings in a day.)
  2. Meta description is 244 chars — it truncates in search results around ~155. The current copy is good; just front-load the pitch ("turns any CSV/Parquet/Excel into one offline interactive HTML explorer") so the cut doesn't matter.

TLS 1.3, single h1, canonical, and a fast 208ms TTFB all check out. Happy to re-run the scan free if you put Cloudflare in front. Good luck with dataloupe!

Collapse
 
aurelionakamura profile image
Aurelio Nakamura

Thanks for the careful review — and for separating "the product" from "the landing page," which is the right lens here.

The header point pushed me to ship something I should've had from day one: every generated file now embeds a strict CSP meta tag (default-src 'none'; connect-src 'none'; script-src/style-src 'unsafe-inline'; img-src data:). Since the real artifact is the self-contained HTML someone opens locally / emails / commits, this makes the "no data leaves your machine" claim browser-enforced rather than just a promise — any accidental network reference in a future version gets blocked by the page itself. Verified the viewer still renders with zero CSP violations. (frame-ancestors/X-Frame-Options can't be set via meta, but a file:// artifact isn't really an embedding/clickjacking target.)

On the Pages site itself: I'm going to keep it on plain GitHub Pages rather than front a custom domain with Cloudflare — for a static demo shell the extra moving parts aren't worth it, and there's nothing sensitive served there. And I front-loaded the meta description per your tip. Appreciate you taking the time.

Collapse
 
aurelionakamura profile image
Aurelio Nakamura

Great question — that's exactly the tension I was designing around. Today each generated file embeds an inspectable metadata block that travels with it: the source filename + format, generation timestamp, dataloupe version, row count, and for every column the inferred type plus stats (null count, cardinality, min/max/mean/median/std, histograms, top values). So the "what am I actually looking at" part is durable.

What it does NOT yet capture is the interactive part you flag: filters/sorts you apply in the viewer are ephemeral UI state and vanish when you reshare. I like making that durable — persisting active filters/sorts in the URL hash so a shared link reopens the exact same view, plus a small "provenance" panel listing them in plain English. Full transform lineage from the original source is harder (dataloupe only ever sees the final file, not the pipeline that produced it), but a --note/--title flag to stamp human context onto the file is low-hanging fruit. I'll open an issue to track both. Appreciate the sharp nudge.

Collapse
 
aurelionakamura profile image
Aurelio Nakamura

Funny timing — that one's now shipped. v0.7.0 mirrors the active search, sort column/direction, focused column and theme into location.hash, so a filtered/sorted view is bookmarkable and shareable: line up the view, copy the address bar (works for a double-clicked file:// artifact too), and reopening the same file lands on the identical view. Restores on load, reacts to back/forward, stays fully offline under the embedded CSP. Thanks again to you and Alex for the nudge — the provenance panel is the remaining half I'm tracking in #1.