Central de Transparência
RFC 9116

Política de Segurança

Escopo, canal de reporte, prazo de resposta e as limitações conhecidas — declaradas, não escondidas.

Security Policy / Política de Segurança

fotos.lucafchala.com is a single Cloudflare Worker — a public photo gallery with an admin dashboard and LGPD photo-removal / image-use-consent flows. We take the security and privacy of visitors' data seriously and welcome responsible disclosure.

Reporting a vulnerability / Como reportar

This policy is also published in machine-readable form at /.well-known/security.txt (RFC 9116).

Please include a description of the issue and its impact, steps to reproduce (a proof of concept, the affected URL/endpoint), and any logs, requests, or screenshots that help. Do not open a public GitHub issue for security problems — email first, and please give us a reasonable window to fix the issue before any public disclosure.

PT-BR: Encontrou uma falha de segurança ou um possível vazamento de dados pessoais (LGPD)? Envie um e-mail para security@lucafchala.com (de preferência cifrado com a chave PGP acima). Não abra issue pública. Inclua descrição, impacto e passos para reproduzir.

Scope / Escopo

In scope:

Out of scope / known by design:

Controls / Controles

A map of what protects what. Every item is pinned by tests/security.test.js or tests/drive-gate.test.js; the policy itself lives in one place, src/security.js.

ControlWhereWhat it stops
Same-origin gate on every write, before routingsrc/index.js dispatcherCSRF, including the same-site case a SameSite=Strict cookie still allows
Signed page nonce (HMAC, slug-bound, 2 h)/api/drive-linkSweeping every slug with one valid Turnstile token
Signed form token + honeypot/suporte, removal formBots posting straight at the endpoints
__Host- session cookiesessionCookie()A neighbouring host on lucafchala.com planting a session
Session idle timeout + client bindingverifySession()A stolen cookie staying useful for a full 24 h
Layered login rate limit + e-mail alerthandleLogin()Silent brute force
Password policy (12+, classes, weak patterns)validatePassword()An offline attack against a leaked hash
CSV formula-injection guardcsvCell()=HYPERLINK(...) in a visitor-supplied field executing in the admin's spreadsheet
EXIF/GPS stripping on uploadsstripImageMetadata()A removal request handing us the GPS coordinates of the photo
no-store on every data responsedataSecurityHeaders()Personal data sitting in a disk or intermediary cache
Restore sanitisationsanitizeRestoredRequest(), mergeRestore()A hand-edited backup planting junk shapes and javascript: URLs
Attachment filename sanitisationsanitizeFilename()Path traversal and CRLF in the MIME attachment header
Escape-before-format markdown renderingsrc/ui/markdown.jsHTML in a compliance document becoming markup on the page
Link allowlist in rendered documentsresolveDocHref()Dead links, javascript: targets, and any link off to GitHub

CSP: two policies at once

Every HTML response carries both Content-Security-Policy and Content-Security-Policy-Report-Only, built from the same source (contentSecurityPolicy()) so they cannot drift apart.

The flip happens when the reports stop arriving: remove the inline handlers, then let the enforced policy use strict too. Until then, a <script> without a nonce is invisible today and breaks silently on flip day — so CI rejects one, and the deploy smoke test rejects a nonce appearing in the enforced header (.github/workflows/security.yml, deploy.yml).

Rate limits fail open when they cannot be recorded

checkRateLimit() calls a Durable Object that checks and increments a fixed-window counter in one serialized step. Any failure of that call — the object unreachable, a daily Durable Objects limit spent, a bad deploy leaving the binding unset — arrives as a thrown exception.

This used to be a KV read-modify-write, and the failure was routine rather than exceptional: the free tier allows 1000 KV writes per day for the whole account, and a single day of real traffic reached that ceiling on its own. That is no longer the common case — the Durable Objects free tier allows 100,000 rows written per day — but the handling stays, because the reason was never the quota.

Left unhandled, that exception propagated out of checkRateLimit() into the top-level fetch() catch and became a 500 on /api/drive-link — photo delivery down for everyone, on the busiest day of the site's life — and a 500 on /dashboard/login, locking the owner out at exactly the moment they would go looking for the cause. Neither error said anything about the cause.

So the counter write is isolated, and a request whose limit check already passed is allowed through when only the bookkeeping fails. This is a deliberate fail open, in the same direction as SIGNING_SECRET: refusing every visitor's photos in order not to let one extra request through is the worse side of the trade, and rate limits here are abuse-mitigation rather than a guarantee. Two things bound it:

It is one recorder on purpose. It began as two — one for KV, one for the events fallback — and the third case (the consent log) was about to be a third. Three places to remember to raise an alarm is how an alarm gets forgotten; with one, a new degradation anywhere in the code appears on the dashboard without anyone editing auditSite(). noteKvFailure() is a thin wrapper that records which operation failed, because the message names a cause: a failed read logged as a write made healthz assert "probably out of daily write quota" for a fault with nothing to do with the write quota, sending whoever investigates to the wrong place mid-incident. The record is isolate-local and costs nothing: persisting it would need the very write that was just refused.

The other half is not spending the quota in the first place, and that is a question of shape rather than of tuning: a counter written once per visitor makes the site's cost grow with its audience, against a ceiling that does not move. Three changes take that out:

The move also raised the ceiling: Durable Objects on the Workers Free plan allow 100,000 rows written per day, against KV's 1,000 writes per day for the whole account. Existing counts were not reset — an object seeds itself from its old KV value the first time it is touched.

POST /api/drive-link writes one row per acceptance to D1, and that row is the non-repudiation evidence behind the whole LGPD posture: which Terms text, which version, which declaration, when, by whom. The write is ctx.waitUntil and best-effort by design — refusing a visitor their photos because our audit log is down punishes the wrong person — but "best-effort" had come to mean "and nobody finds out". A failed insert logged one line to console.error and the site carried on looking perfect.

That is the worst failure mode in the system: the photos are delivered, the consent that authorised delivering them is not recorded, and nothing anywhere says so. It now fails on both channels — sendErrorAlert() emails the owner (globally throttled to one per 15 minutes, and it never throws) and noteDegraded() puts it on /api/healthz, which the status dashboard turns into an alert. Pinned by tests/drive-gate.test.js, verified failing against the previous console.error.

Logout says so when it does not actually revoke

POST /dashboard/logout deletes the session record from KV. That delete is not housekeeping — it is the revocation. The cookie is cleared in the browser either way, so someone who clicks "sair" lands on the login screen and believes they are out, while the token stays accepted by the server until its 24-hour TTL runs out. Anyone holding a copy taken before the logout keeps the panel.

The timing is the sharp part: a KV delete spends write quota like a put does, so the day of heavy traffic — the day the 1000/day account ceiling is actually reached — is the day logging out can quietly stop revoking. And the Clear-Site-Data header two lines below names the scenario the handler has in mind: a borrowed computer.

Failing here must not interrupt the logout (leaving the admin signed in in the browser is the worse outcome), so the redirect and both cookie clears happen regardless. What changed is that it is no longer a console.error nobody reads: the failure goes to noteDegraded() — so /api/healthz reports it and the status dashboard goes red — and to sendErrorAlert(). Pinned by tests/kv.test.js, verified failing against the previous code.

The same reasoning applies to the session sweep after a password change (PUT /api/settings/password). Changing the password is what you do when you believe it has leaked, and the sweep of the other sessions is what evicts whoever is already inside. The sweep is deliberately best-effort — a KV hiccup must not undo a password change that already succeeded — but its Promise.all rejects on the first delete that fails, so the sweep can also be partial. Either way the admin used to get a plain ok: true while old sessions stayed open for up to 24 hours. It now records a degradation and emails.

A support message that was never sent no longer shows a success screen

/suporte is the site's contact channel, and unlike a removal request — which is stored in KV and read back by the dashboard — a support message exists only inside the email. There is no copy anywhere. So a refused send is not a delayed message, it is a lost one.

The handler used to return the success screen regardless of whether the send worked, which sent the visitor away believing their message had arrived. That is the same trade getEvents() already resolves in the other direction when it rethrows instead of returning an empty list: claiming to have the data is worse than admitting the failure. A failed send now returns the form with an error, the submitted values still filled in (resending must not cost retyping) and the direct address to write to instead — and it records a degradation and emails the owner, because a contact form that is silently swallowing messages is exactly the kind of outage nobody reports, since the only people who can see it are the ones whose message vanished. Pinned by tests/kv.test.js.

A removal request is no longer lost when KV is down

A removal request is a data subject exercising a right under the LGPD, with a statutory clock running. It used to be written to KV first, and every step after that — the admin notification, the confirmation to the requester — happened only if that write succeeded. A KV outage therefore propagated to the router's catch and returned the generic 500 page: the request vanished, nobody was notified, and the person was told nothing more than "something went wrong".

The request is now recorded by two independent channels, and only the first depends on KV:

  1. KV (removal_requests) — what the dashboard lists.
  2. Email — the admin notification and the requester's confirmation, which is what actually makes someone act inside the deadline.

Persisting is best-effort, so the emails go out either way. The response tells the truth about which channels held:

KVemailresponse
ok200 {ok:true} — unchanged
downsent200 {ok:true, stored:false} — the request reached the owner and the requester has a confirmation; failing here would only make them retype everything
downnot sent503 with the direct address to write to (privacidade@lucafchala.com) and a note that the deadline runs from that email

The 503 is the one case where the request genuinely exists nowhere, and it is the same trade the support form resolves the same way: claiming to have the request is worse than admitting the failure. Every degraded path calls noteDegraded(), so /api/healthz and the status panel show that a request is outstanding and not in the dashboard — an outage whose only witness would otherwise be the person whose request disappeared. Pinned by tests/removal-request.test.js, which asserts the failure modes against a KV that refuses both reads and writes.

The event list survives KV being unavailable

KV is the only hard dependency on the critical path: without the event list there is no slug, no event, and no Drive link. A KV read outage used to take the gallery, the project pages and the Drive gate down together, with a 500 — the site's one promise, delivering photos, broken by an outage in a store it consults only to find the right folder URL.

getEvents() now degrades in three steps, newest data first:

  1. The isolate's own cache, even expired. It was previously discarded once past its 30 s TTL, so an isolate holding a perfectly good list answered 500 the moment KV faltered. Thirty seconds stale is still the right list.
  2. A copy in the Cache API. Free, no write quota, and — unlike module state — it lives in the colo rather than the isolate, which is what covers a cold isolate. That is the common case in an outage: new traffic lands on new isolates with nothing in memory.
  3. Rethrow. With no cache and no copy there is nothing to serve. Returning an empty list here would turn an outage into "the site exists and has no projects" — 404 everywhere, ok:true on healthz, nothing red on the dashboard. Lying about having no data is worse than admitting the failure.

The copy is written only when the stored value changes, and only after KV has accepted the write, so it can never contradict the source.

What this costs, stated plainly. While serving from the copy, the visitor may see a list that is out of date: a project hidden, corrected, or deleted during the outage still appears. In practice the window is the outage itself — the copy is refreshed on any successful read of a changed value — and it is bounded further by the fact that a KV that cannot be read usually cannot be written either, so there is no new state to miss. The trade is deliberate: delivering photos from a possibly-minutes-old list beats delivering nothing.

The fallback is for visitors only. getEvents(env, true) — the fresh read used by every admin path and every read-modify-write — never falls back; it propagates. Serving a stale list there is not graceful degradation, it is a staged data loss: the saveEvents that follows would write the old list back over the new one, deleting every project changed since the copy was taken. Failing costs the owner an error message; the alternative costs the projects.

Two things that are not relaxed while degraded, both pinned by tests/drive-gate.test.js: the Drive gate refuses exactly what it refuses normally (missing consent, failed Turnstile, comingSoon, unknown slug), and /api/healthz reports kv:false and flips ok:false. The site staying up must never make the dashboard look green. Because healthz reads with fresh, that kv flag is measured by its own read rather than inferred: an earlier version compared a module-global fallback counter before and after, and since that state is shared by every request in the isolate, one concurrent visitor falling back made healthz answer 503 and fail the deploy smoke test while its own read had succeeded. The visitor-side degradation is still reported, as an advisory line in problems with a time window — which is all module state can honestly claim.

Every legal and compliance document is served from this site, at /legal/<slug>. The only link on the whole site that points to GitHub is the footer's "Código-fonte". This is enforced in CI (.github/workflows/security.yml), and the markdown renderer independently demotes any github.com link found in a document to plain text (resolveDocHref in src/ui/markdown.js) — so the rule holds even if someone pastes one into a document later.

Why it matters here rather than being a style preference: sending a visitor to a third-party service to read the policy that governs their own data is the opposite of transparency, and an external link is a failure point outside our control on the one page whose entire promise is being accurate.

Rendering documents safely

src/ui/markdown.js is a purpose-built subset renderer, not a dependency. Its security contract is one rule: escape first, format second. Every piece of text goes through escape() before any formatting regex runs, so a <script> in a document is already <script> by the time inline rules act and none of them can reconstruct a tag. Doing it the other way round — format, then try to clean up — is how markdown sanitizers usually fail. Pinned by tests/security.test.js.

The document text itself is generated into src/content/legal-docs.js from the markdown; CI regenerates and diffs it, so a published page can never drift from the document it claims to reproduce.

Two link-handling rules inside the renderer are worth stating explicitly, because they look inconsistent and are not:

The link target is also unescaped in a single pass. Chained replace calls (&&, then "") strip two layers off " and produce a real double quote — the character that closes the href attribute. The emission-time escape() would still contain it, but a control that depends only on the last step breaks the day someone edits the last step. All three issues were found by CodeQL, not by manual review, and each is pinned by a regression test verified to fail against the previous code.

Two controls that failed silently, and how they were found

Both were found by code review over the whole change, then confirmed by driving the running site — neither was visible in a green test suite.

A sibling host could take the admin panel down. verifySession matched (?:__Host-)?session= in a single pattern, and match() returns the first occurrence — so a plain session= cookie won over __Host-session. Any host under lucafchala.com can write a domain cookie but cannot write a __Host- one; that asymmetry is the entire point of the prefix. Writing 64 arbitrary hex characters was enough to lock the owner out. Confirmed against the running panel: /api/metrics returned 401 under the hostile cookie before the fix, 200 after. The __Host- cookie now takes precedence, and login clears the legacy one.

The same fix reached only one of the three readers. A later review found that verifySession() had been corrected while handleLogout() and handleChangePassword() still carried the original (?:__Host-)?session= pattern — so under session=<planted>; __Host-session=<real> the three disagreed about which token the request was presenting. Logout then deleted the wrong KV record: the browser cookie was cleared, the admin saw the login screen, and the real token stayed accepted for the rest of its 24-hour TTL — the exact failure the section above describes, reintroduced through the back door. The password sweep had the mirror bug: it preserved the token named by the planted cookie and deleted the admin's own.

The repair is structural, not another regex: sessionTokenFromCookie() in src/utils.js is now the single reader, and all three call sites go through it. Never re-derive the session token from the Cookie header at a call site — precedence between the two names is the control, and a duplicated pattern is how it was lost twice.

A removal request could email the requester's GPS coordinates. isLikelyImage() accepted HEIC, AVIF and GIF; stripImageMetadata() only strips JPEG, PNG and WebP. Two lists, drifting apart in silence — and HEIC is the iPhone default, so this was the common path, not the exotic one. Someone asking to be removed from a photo was handing over where it was taken, while the published privacy policy stated without qualification that metadata is erased.

The fix is not a third list. The gate is now the strip itself: if stripImageMetadata does not confirm a clean result, the attachment is refused. Teaching the stripper HEIC later opens the gate on its own, with nobody having to remember.

And it did. The stripper now handles HEIC/AVIF and GIF, so the iPhone path goes through cleaned instead of refused — no second list was touched. HEIC and AVIF are not pruned like the other formats: EXIF and XMP live in mdat, addressed by absolute offsets in the meta/iloc table, so deleting those bytes would shift everything after them and invalidate the offsets that point at the image itself. The bytes are zeroed in place — same file length, every offset still valid — with a valid empty TIFF header left where the EXIF was, so a reader that asks for metadata gets "none" instead of garbage. Anything the parser does not understand end to end still returns "not cleaned", and the gate refuses it.

The last line of defence is a sweep of the result: if an EXIF or XMP signature survives the strip, there was a copy no table declared, and the file is refused rather than attached. The sweep skips exactly one box, iinf, because item names live there — an EXIF item is literally named Exif, and the \0 ending that name followed by the next box's size byte spells Exif\0\0 in a file with no EXIF left at all. The synthetic test fixture never showed this; a file written by a real encoder did, on the first try. A fixture you wrote yourself agrees with the parser you wrote yourself — the same lesson the Durable Object counter taught, in a different costume. The suite now carries real encoder output for HEIC and GIF alongside the hand-built ones.

Required secrets

ADMIN_PASSWORD, TURNSTILE_SECRET_KEY, RESEND_API_KEY, ADMIN_EMAIL and SIGNING_SECRET — see wrangler.toml for what each does.

SIGNING_SECRET deserves a note: unlike the others, its absence breaks nothing. The Drive page nonce and the form tokens simply stop being required, and the site keeps serving as if it were protected. That is a deliberate trade-off (a missing secret is a deploy error, and failing closed here would take the whole photo delivery down over an additional layer), made safe by never being silent: auditSite() flags it, and it shows up in /api/healthz and on the status dashboard until someone runs:

npx wrangler secret put SIGNING_SECRET

Invariants for contributors / Invariantes ao mexer no código

Two guards are easy to half-apply. Both are pinned by tests (tests/drive-gate.test.js, tests/utils.test.js) — if you change either, expect a red suite.

Monitoring / alerting

The top-level fetch() handler catches any unhandled exception from any route, returns a generic 500 to the visitor (with a link back to the gallery and to /suporte, never a stack trace), and fires a best-effort email to ADMIN_EMAIL via Resend (sendErrorAlert() in src/utils.js) — a tripwire so an outage or regression is noticed without watching logs. The alert contains only the error message, a truncated stack, and the route — never request bodies, headers, or visitor IP — and is throttled by a single global 15-minute KV cooldown so a repeating failure can't flood the inbox. Alerting itself is fully isolated: sendErrorAlert() never throws, and the response already sent to the visitor never waits on it (ctx.waitUntil, best-effort). No RESEND_API_KEY/ADMIN_EMAIL configured means alerting silently no-ops — the site keeps working, you just won't be emailed.

Server-side features that depend on an optional integration (e-mail notifications, Web Analytics) already fail closed to "not available" rather than breaking the page they're on — see the sendXEmail() helpers in src/utils.js, all of which catch their own errors and return null/false instead of throwing into the caller.

The daily retention cron (scheduled() in src/index.js, wrangler.toml [triggers]) now fires the same sendErrorAlert() when either of its two prune tasks fails, not just console.error — a retention bug is as much a "you should know about this" event as a request-path exception, and used to be visible only in Cloudflare's logs or, indirectly, once the cron heartbeat (/api/healthz's cron.stale) aged past a day.

Response / Prazo de resposta

This is a personal project maintained by one person. We aim to acknowledge reports within 5 business days and to fix confirmed, high-impact issues as quickly as is reasonable. We'll keep you updated and are happy to credit you if you'd like.

Personal data (LGPD)

The site processes personal data for image-use consent and photo-removal requests. The privacy policy is at /privacidade; data-subject and removal requests can be made through /suporte or the contact above.

A public trust center is published at /legal (also /compliance), bringing together the privacy policy, the terms, this policy, a plain-language summary of what is done with each piece of data, the contact channels and the machine-readable endpoints.

The full compliance pack — records of processing (ROPA), the data-protection impact assessment (RIPD), the legitimate interest assessment (LIA), the retention policy, the international-transfer mapping, the data-subject request procedure, the incident response plan, and image-authorization templates — is published at /legal.

If you are reporting a personal-data incident, follow docs/legal/plano-resposta-incidentes.md — the ANPD notification window is 3 business days from the moment the controller becomes aware.