Second Brain stores personal tasks, notes and calendar data, so we take security reports seriously. Thank you for helping keep users safe.
The project is pre-1.0 and ships from main. Only the latest code on main (and the most recent
tagged release, once releases exist) receives security fixes.
| Version | Supported |
|---|---|
main (latest) |
Yes |
Latest 0.x release |
Yes |
| Older releases / forks | No |
Do not open a public issue, discussion or pull request for security problems.
Report privately through GitHub Security Advisories: Report a vulnerability (repository → Security → Advisories → Report a vulnerability).
Maintainer note: private vulnerability reporting must be enabled under Settings → Code security → Private vulnerability reporting.
Please include:
- A description of the issue and its impact (what an attacker can read, change or do).
- Steps to reproduce or a proof of concept, including the affected route, server function, table or migration.
- The commit hash you tested, and whether it was local or self-hosted (Vercel or another host).
- Any suggested fix.
Please test only against your own deployment and accounts. Don't access other users' data, degrade service for others, or run automated scanners against deployments you don't own.
| Stage | Target |
|---|---|
| Acknowledge receipt | within 3 business days |
| Initial assessment and severity | within 7 business days |
| Fix or mitigation for critical / high | within 30 days |
| Fix for medium / low | next planned release, or within 90 days |
This is a volunteer-maintained project, so these are goals rather than guarantees. We will keep you updated, agree on a disclosure date with you, and credit you in the advisory unless you prefer to stay anonymous.
In scope
- Code in this repository: the TanStack Start app (
src/), server functions (src/lib/*.functions.ts), public API routes (src/routes/api/public/*), SQL migrations and RLS policies (drizzle/migrations/), the service worker (Workbox config invite.config.ts) and CI workflows (.github/). - Authorization bypasses (reading or writing another user's or another project's data), secret leakage to the browser, injection, SSRF, XSS, CSRF, and insecure defaults that ship with the repository.
Out of scope
- Vulnerabilities in Supabase, Vercel, Lovable or other third-party services themselves (report those to the vendor), unless our configuration or usage causes the issue.
- Misconfiguration of a specific self-hosted deployment (for example exposing
SUPABASE_SERVICE_ROLE_KEY), except where the documentation is wrong or misleading. - Denial of service by volume, missing rate limiting without a demonstrated impact, social engineering, physical attacks.
- Findings from automated tools without a demonstrated exploit path.
docs/n8n/reference material (not part of this app).
This section describes how the app is meant to be secure, so reporters and contributors can reason about it. It reflects the code at the time of writing; please report any place where the code doesn't match.
- Supabase Auth handles sign-up and sign-in. The browser client uses the publishable (anon)
key only (
VITE_SUPABASE_*). - Sign-up is closed by default.
/loginonly offers sign-up when the build setsVITE_ALLOW_SIGNUP=true. That flag hides UI only: the anon key is public, so anyone can still callauth.signUpdirectly. The real control is Supabase Authentication → Sign In / Providers → "Allow new users to sign up", which must be off for a private instance. Accounts are then created through project invites (below) or by the instance owner (Authentication → Users → Invite user / Add user);accept_project_invites()matches the verified email when the user signs in. - Member invites (
inviteProjectMember,src/lib/invites.functions.ts+src/server/invites.server.ts): only the project owner may invite (checked against the project row as RLS shows it to the caller, and again by theinvites_owner_managepolicy on the insert), the email is zod-validated (max 320 characters), and each user has a budget of 20 invites per hour (consume_rate_limit, bucketmember_invite). The service role is used only for oneauth.admin.inviteUserByEmailcall for that email (which creates the account and sends the Supabase "Invite user" email) and to list one project's pending invites for its members. The owner gets the same answer whether or not the email already has an account, so the form cannot be used to probe which addresses are registered. Refused in demo mode (assertNotDemo). - Password links: "Lupa kata sandi?" on
/logincallsresetPasswordForEmailand always shows the same message (only rate-limit errors are surfaced)./auth/set-password(noindex, never cached by the service worker) accepts a session only when the URL carries an email link (implicit tokens whosesubmatches the stored session, or a PKCE?code=), so opening it on a device with an existing session cannot change that account's password. It is disabled in the demo, whose account is shared. - Magic link and Google (
/login,/auth/callback): the magic link callssignInWithOtpwithshouldCreateUser: falseand shows the same message whether or not the address has an account. Google uses the Supabase Google provider (signInWithOAuth), configured in Supabase Authentication → Sign In / Providers → Google with its own OAuth client (redirect URIhttps://<supabase-ref>.supabase.co/auth/v1/callback); it is separate from the app's Calendar OAuth client and only requestsopenid email profile. Neither method can create accounts while "Allow new users to sign up" is off: Supabase rejects a new Google identity (signup_disabled) and/auth/callbackshows an "ask for an invite" message. The callback only follows a remembered same-origin path (safeRedirect), never a URL from the query string, so it cannot be used as an open redirect. Google is hidden unlessVITE_AUTH_GOOGLE=true; both are hidden in the demo. - Two-step verification (TOTP, Supabase MFA): users enroll in Settings → Keamanan (QR code
plus secret, confirmed with a code; removing a factor requires a current code first, which also
keeps the session at
aal2as Supabase requires). A user with a verified factor is ataal1after the first step and must pass a TOTP challenge before the app opens. Enforcement has three layers:- client: the
_authenticatedguard (requireSession) redirectsaal1sessions withnextLevel = aal2to the TOTP step on/login; - server functions:
requireSupabaseAuthletsaal2tokens through and otherwise asksmfa_satisfied(), soaal1tokens of 2FA users are refused before any service-role code runs; - database (migration 0022): a
RESTRICTIVEpolicymfa_aal2on every RLS table (and onrealtime.messages) requirespublic.mfa_satisfied()= JWTaalisaal2, or the caller has no verified factor.list_project_people()(security definer) checks it too. The RLS layer is what makes 2FA real: the anon key is public and anaal1access token is a valid JWT, so without it a stolen password alone would still read and write all data through PostgREST. It only affects users who enabled 2FA. Tables added later need the same policy (rerun the DO block of 0022 or addmfa_aal2in the new migration). The demo account cannot enroll (trigger onauth.mfa_factors) and the UI hides enrollment there. Owners can remove a lost factor in the Supabase dashboard (Authentication → Users).
- client: the
- 2FA recovery codes (migration 0027,
src/server/mfaRecovery.server.ts): anaal2session can create 10 single-use codes (XXXX-XXXX, 40 random bits each fromcrypto.getRandomValues) in Settings → Keamanan; a new batch replaces the old one. The plaintext is returned once; the tablemfa_recovery_codesstores onlyHMAC-SHA256(TOKEN_ENCRYPTION_KEY-derived key, user_id:code)(a user-salted SHA-256 when that secret is unset) and is service-role only (no grants or permissive policies forauthenticated, plusmfa_aal2).redeemRecoveryCodeis the one server function that accepts anaal1token (requireSupabaseSession): it is limited per IP (10/min, in memory) and per user (5 per 15 min,consume_rate_limit_for, fail closed), compares in constant time, claims the code with a compare-and-swap onused_at, drops the remaining codes and deletes the user's MFA factors (auth.admin.mfa.deleteFactor), so the user signs in and must enroll again. Codes are never logged; the feature is off in the demo (assertNotDemo). - Supabase Auth settings for these flows: Site URL = your app origin; Redirect URLs include
https://<your-app>/auth/set-passwordandhttps://<your-app>/auth/callback; MFA → TOTP enabled (default); the Magic Link template uses{{ .ConfirmationURL }}; the Invite user and Reset Password templates use{{ .ConfirmationURL }}; and production uses custom SMTP (for example Resend), because the built-in mailer is limited to a few emails per hour. src/start.tsregistersattachSupabaseAuthas global function middleware (attaches the user's access token to server function calls) and TanStack Start's CSRF middleware for server functions.- An optional idle-logout timer (
src/hooks/use-idle-logout.ts) signs users out after a configurable period of inactivity.
- Every table in
publichas RLS enabled (every table created by the migrations, includingrate_limitsfrom0011). - Personal data uses owner policies (
user_id = auth.uid()). - Shared projects are handled by
SECURITY DEFINERhelpers defined in0002_workspace_features.sql:is_project_owner,is_project_memberandcan_access_task. Policies for tasks, notes, milestones, comments and members call these helpers instead of queryingprojects/project_membersdirectly, which avoids recursive policy evaluation. Tasks, notes and milestones in a shared project are visible to its members. - Shared-project rows have separate select/insert/update/delete policies
(
0010_member_ownership_guards.sql, all using(select auth.uid())):- Inserts require
user_id = auth.uid()and membership of the target project. - A
BEFORE UPDATEtrigger (prevent_user_id_change) rejects any change ofuser_idby an end-user role on projects, tasks, notes, milestones, canvas tables, comments and dependencies. - Members may edit and archive shared tasks, notes, milestones and boards. Trashing or
restoring (
deleted_at, enforced by theguard_project_row_updatetrigger) and hard deletes are limited to the row's creator or the project owner. Rows can only be moved into projects the user belongs to. Canvas nodes and edges are updated only by their author. - Only the project owner can update or delete a project.
- Inserts require
- Habits (
habits,habit_logs, migration 0025) are private even when a habit is grouped under a shared project: every policy is owner-only (user_id = (select auth.uid()), per operation), a habit'sproject_idmust be a project the owner belongs to, logs can only be written for the caller's own habits and never moved to another habit, both tables carrymfa_aal2and the demo row limits.report_daily(migration 0026), which feeds/reports, isSECURITY INVOKER: it aggregates only the tasks and focus time the caller's RLS already returns. - Other
SECURITY DEFINERfunctions (accept_project_invites,list_project_people,search_semantic_documents, the sign-up trigger, audit and note-version triggers) all pinSET search_path = publicand constrain results withauth.uid(). - Service-role-only tables:
app_config(holdscron_token),app_user_connections(encrypted OAuth tokens),rate_limitsandn8n_events(idempotency ledger) have RLS enabled with no policies forauthenticated, and grants only toservice_role. The helpersn8n_user_id_by_emailandconsume_rate_limit_forare executable only byservice_role.
- Note collaboration uses the private channel
note-collab:<noteId>(src/hooks/use-note-collaboration.ts,config.private = true, JWT refreshed withsupabase.realtime.setAuth()). Realtime Authorization policies onrealtime.messages(0009_private_note_collab_channels.sql) allow SELECT (join/receive) and INSERT (broadcast/presence) only whencan_access_noteholds for the topic's note: the caller owns the note or is a member of its project, and the note is not in the trash. Topics that are notnote-collab:<uuid>are denied without raising. - The client ignores collaboration payloads that do not arrive on the live private channel.
- All server functions (
ai.functions.ts,automations.functions.ts,googleCalendar.functions.ts,invites.functions.ts,telegram.functions.ts) use therequireSupabaseAuthmiddleware, which builds a per-user Supabase client so queries run under the caller's RLS. Inputs are validated with zod. - AI server functions cap their inputs (brain dump 20k characters, paraphrase 5k, meeting notes
50k, audio 10 MB, images 8 MB; base64 sizes are checked before decoding) and share a per-user
budget of 30 calls per 10 minutes. The budget is enforced by the
SECURITY DEFINERfunctionconsume_rate_limit(bucket, max, window_seconds)(migration0011_rate_limits), which always usesauth.uid(); therate_limitstable has RLS on and no client privileges. The limiter fails closed if the database call errors. - The service-role client (
supabaseAdmin,src/integrations/supabase/client.server.ts) bypasses RLS. It is only imported dynamically inside server handlers and must always be scoped to the authenticated user or to an authenticated public-endpoint caller.
- Server secrets (
SUPABASE_SERVICE_ROLE_KEY,AI_API_KEY,SECOND_BRAIN_CRON_SECRET,CRON_SECRET,TELEGRAM_BOT_TOKEN,TELEGRAM_WEBHOOK_SECRET,GOOGLE_CLIENT_SECRET,TOKEN_ENCRYPTION_KEY,N8N_API_KEY) are read only viaprocess.env[...]in server-only modules (*.server.ts,src/server/, server function handlers, API routes). They are never prefixed withVITE_and never sent to the browser. The Telegram token is part of the Bot API URL, sotelegram.server.tsnever logs request URLs. - Automation actions that need secrets (Telegram messages, outgoing webhooks) run on the server
in the automation engine (
src/server/automationEngine.server.ts).
- Each deployment uses its own Google OAuth 2.0 web client (
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET) with the authorization code flow, PKCE (S256),access_type=offlineand the single scopecalendar.events. - The OAuth
stateis AES-256-GCM encrypted and contains the user id, a nonce, the PKCE verifier and a 10-minute expiry. The callback page only forwardscodeandstateto its opener (same-originpostMessage) and clears them from the URL; the server completes the flow only for the signed-in user the state was issued to, so a leaked or injected code cannot connect a calendar to a different account, and the verifier never reaches the browser. - The code is exchanged server-side. The refresh token (and the cached access token) is encrypted
with AES-256-GCM (random 96-bit IV per value,
src/server/tokenCrypto.server.ts, keyTOKEN_ENCRYPTION_KEY, base64 of 32 bytes) and stored inapp_user_connections, which onlyservice_rolecan access. Tokens never reach the browser or n8n and are not shared between users.invalid_granton refresh deletes the connection; disconnect revokes the token at Google. - Two-way sync keeps the same single scope. The Calendar
syncToken, the import opt-in and the last pull time live in the same service-role-onlyapp_user_connectionsrow (RLS on, no policies, no client grants; checked bysupabase/tests/gcal_sync.sql). Pulled events only touch tasks the connected user can reach (own rows or projects they belong to), and only the title and schedule are copied; imported events become tasks owned by that user. The manual "Sinkronkan sekarang" action has its own per-user limit (gcal_sync, 10 per 10 minutes).
These routes have no user session and authenticate the caller themselves:
| Route | Authentication |
|---|---|
POST /api/public/telegram/webhook |
Fails closed: requires TELEGRAM_WEBHOOK_SECRET to be set and a matching X-Telegram-Bot-Api-Secret-Token header (constant-time comparison); otherwise 401. |
/api/public/n8n/* |
Fails closed: header x-api-key must match N8N_API_KEY (or N8N_API_KEY_PREVIOUS during rotation) in constant time; 500 when no key is configured, 401 otherwise. Bodies and queries are validated with zod, bodies are capped at 1 MB. The service-role client is scoped to the user resolved from profiles.telegram_chat_id or the sender email; tasks/notes of shared projects are included only for owners and members. |
GET/POST /api/public/hooks/reminders |
Requires Authorization: Bearer <token> matching SECOND_BRAIN_CRON_SECRET, SECOND_BRAIN_CRON_SECRET_PREVIOUS (rotation) or CRON_SECRET (Vercel Cron), compared in constant time. The legacy app_config.cron_token still works and is only looked up when a bearer token is present and no env secret matched. |
Notes and projects can be published as read-only pages at /s/<token> (migration 0024,
src/server/publicShare.server.ts). The token is the only credential, so the design assumes it
will eventually be forwarded, pasted into chats or seen by a browser extension:
| Threat | Mitigation |
|---|---|
| Guessing or enumerating tokens | 256-bit random tokens (base64url). Per-IP limit on the page (PUBLIC_SHARE_IP_RATE_LIMIT, default 60/min, in memory per instance). Malformed, unknown, revoked and expired tokens all return the same 404 page, so there is no oracle. |
| Database leak exposing live links | Only sha256(token) is stored (public_shares.token_hash); the raw token is shown once in the dialog and cannot be recovered. Losing it means "New link". |
| Over-sharing private data | The page gets a DTO built field by field on the server: no user ids, emails, row or block ids, assignees, comments, tags or properties. Trashed/archived rows are excluded. Query blocks are dropped. |
| Pivoting from a shared note to private ones | [[links]] render as plain text (no URL). ((refs))/embeds resolve to text only from the same note or another active public link of the same owner; anything else shows "content not shared". |
| Members publishing someone else's work | can_share_resource() (RLS on insert and on token rotation): a note's creator or its project owner, a project's owner. Re-checked on every read, so a link dies when the item is trashed, archived or leaves the creator's reach. |
| Anonymous database access | No anon policy or grant. The page uses the service role inside a server function, scoped to the one share row and the one resource it names. record_public_share_view is executable by the service role only. |
| Revoked links still working | Revoke is a row update checked on every request; responses are Cache-Control: private, no-store, the loader never reuses cached data, and the service worker excludes /s/* from its caches. Revoked rows are frozen (cannot be re-enabled). |
| Token leaking via Referer, logs or search | Referrer-Policy: no-referrer (header and meta) on the page; outbound links are rel="noopener noreferrer nofollow ugc". The page loads data through a POST server function, so the token is not in /_serverFn URLs. noindex, nofollow (meta and X-Robots-Tag) unless the owner switched on indexing in the dialog (off by default; createShareLink/setShareIndexing validate it and refuse it in the demo), and no canonical URL. |
| Script injection from note content | Content is rendered as React text nodes (no HTML), only http(s) URLs become links, and the page adds no inline scripts beyond the app shell, so it stays within the existing CSP. |
| Abuse of link creation / demo | Creating or rotating links is rate limited per user (share_link, 30 per 10 min); in demo mode the database caps public_shares at 10 rows per account (demo_limit:public_shares) plus the demo write quota. |
| 2FA bypass with an aal1 session | public_shares has the restrictive mfa_aal2 policy like every other RLS table. |
Residual risks: anyone holding a link can read the page and copy what it shows until it is revoked or expires; view counts are approximate (per-instance throttle, one view per IP per 30 minutes); the per-IP limit is per serverless instance.
- A chat is linked to an account only with a one-time code issued to the signed-in user in
Settings (
createTelegramLinkCode). Codes have 8 characters from a 32-symbol alphabet (40 bits), expire after 10 minutes, work once, and a new code invalidates the previous unused ones. - Only the SHA-256 hash is stored in
telegram_link_codes. RLS lets users read and insert their own rows; clients cannot setexpires_atorused_at. The bot redeems a code with one conditional update (unused and unexpired), so concurrent attempts cannot reuse it. - The bot gives the same reply for wrong, expired and used codes and never looks up users by
email. App mode (
/api/public/telegram/webhook) and n8n mode (/api/public/n8n/bot) share the same redemption code (src/server/telegramLink.server.ts); n8n additionally enforcesSB_TELEGRAM_ALLOWED_CHAT_IDS(n8n env) (empty = deny all).
- n8n is a relay: it never receives Supabase keys, Google tokens or
TOKEN_ENCRYPTION_KEY; it only holdsN8N_API_KEY, the Telegram bot token and its own credentials (Google Drive, SMTP, Gmail OAuth2 or IMAP). - Retries are idempotent:
n8n_events (source, external_id)stores the first response for a Telegramupdate_idor captureexternal_id. - Backups (
GET /api/public/n8n/backup) contain all user data but neverapp_user_connections,app_config, link codes or idempotency rows. The backup workflow stores no execution data; protect the Google Drive folder and the backup mailbox accordingly. - AI calls made for a user from n8n (
/sum, long-email summaries) spend that user's AI budget viaconsume_rate_limit_for.
- Automation webhooks go through an SSRF guard (
src/server/ssrf.server.ts):https:only, no credentials in the URL, port 443 or 8443 only, internal hostnames (localhost,*.local,*.internal,metadata.google.internal, single-label names) and literal private IPs are rejected, and every A/AAAA record of the host must be a public address (private, loopback, link-local incl.169.254.169.254, CGNAT, multicast, unspecified, documentation, NAT64/6to4 and IPv4-mapped IPv6 are blocked). Redirects are not followed (3xx is a failure), requests time out after 5 seconds and at most 64 KB of the response is read. The payload contains only the task's id, title, status, priority, due date, project id, tags and a link. - Residual risk: on runtimes without
node:dns(Cloudflare Workers) the DNS step is skipped and only the static checks apply; Workers cannot reach private networks, so this mainly matters on Node hosts. DNS rebinding between the check and the request is mitigated, not eliminated, by the redirect, timeout and size limits. - AI calls go directly to the configured provider (
AI_BASE_URL, defaultapi.openai.com) with a server-side key; OpenAI requests setstore: false. Telegram goes toapi.telegram.organd Google tooauth2.googleapis.com/www.googleapis.comdirectly; these URLs are fixed, not user-supplied.
- Off by default. Without
VITE_SENTRY_DSNthe browser loads no SDK and sends nothing; withoutSENTRY_DSNthe server sends nothing. Web vitals are only logged toconsole.debugin dev. - With a DSN, the browser SDK is a lazy chunk loaded after first paint (or on the first error) and
sends errors plus LCP/CLS/INP/FCP/TTFB values to the Sentry project; the server reports SSR,
request-middleware and n8n 500 errors through
@sentry/corewith afetchtransport (src/server/sentry.server.ts). Performance tracing is off unlessSENTRY_TRACES_SAMPLE_RATEis set. - Privacy: events carry the error message/stack, route path, environment/release and the Supabase
user id only (no e-mail, name or IP). Request bodies, headers (incl.
Authorization, cookies,x-api-key), query strings, console and DOM-click breadcrumbs are never sent; keys liketitle,content,blocks,description,textare redacted and e-mail addresses are masked in messages (scrubEvent/scrubBreadcrumbinsrc/lib/monitoring-config.ts, unit tested). Error messages written by the app should still avoid echoing note or task text. - Source maps are uploaded from CI only when
SENTRY_AUTH_TOKEN,SENTRY_ORGandSENTRY_PROJECTare set; the build emits hidden maps and the deploy workflow deletes every.mapfile before deploying, so they are never served publicly. VITE_SENTRY_DSNis public by design (it only allows sending events). Configure allowed domains and rate limits in the Sentry project to limit abuse.
These are known weaknesses or missing defenses. They are tracked here so self-hosters can assess risk; contributions are welcome (please coordinate via an issue or a private advisory for the first one).
- CSP resources are report-only. Only framing,
<base>, plugin and form-target directives are enforced; the full resource policy is sent asContent-Security-Policy-Report-Only(see "Content Security Policy"). - Public endpoints are not rate limited at the application level (the AI functions are).
Use the hosting provider's firewall or rate limiting for
/api/public/*.
-
Outgoing automation webhooks allowed SSRF (any
https:URL, including internal hosts, followed redirects, no timeout) and sent the full task row. Fixed with the SSRF guard and a minimal payload. See "Outgoing requests". -
Reminder cron token was compared with a plain string comparison and queried the database on every unauthenticated request. Fixed with constant-time env secrets (with rotation) and a legacy fallback that is only consulted for requests carrying a token.
-
AI server functions had no input limits or rate limit. Fixed with zod limits and a per-user budget. See "Server functions".
-
Sign-out kept the query cache, so the next user of a shared device could briefly see the previous user's data. The cache is cleared on every sign-out path.
-
Backup restore upserted raw rows, letting a crafted file set any column (including
user_id) on rows that RLS allowed, including shared-project rows. Restores now validate each table with a zod schema, drop unknown columns, forceuser_idto the current user, cap the file (20 MB, 10,000 rows per table) and never update rows owned by someone else. -
No security headers were sent. See "Security headers".
-
Note collaboration channels were public (anyone with the anon key and a note UUID could read live edits and inject Yjs updates that the victim's editor autosaved). The channel is now private and authorized by
realtime.messagespolicies. See "Realtime". -
Project members could take over a project by setting
projects.user_idto themselves, and could insert or reassign tasks, notes, milestones and canvas rows as other users, or trash and delete other members' rows. Fixed with immutableuser_id, per-operation policies and owner-only project updates. See "Authorization: Row Level Security". -
Telegram
/link <email>had no verification (anyone who knew a user's email could link their own chat, receive that user's reminders and write to their Inbox; replies also revealed whether an email was registered). Replaced by one-time codes from Settings; the email lookup andauth.admin.listUsers()call were removed. See "Telegram account linking". -
Telegram webhook secret was optional (forged updates were accepted when
TELEGRAM_WEBHOOK_SECRETwas unset). The webhook now fails closed and compares the header in constant time.
src/server/securityHeaders.ts defines one header set. src/server.ts adds it to every SSR and
API response in production builds on any host (routes may set their own values, which win;
SECURITY_HEADERS=off disables it), and vercel.json applies the same set to static assets on
Vercel (a unit test keeps both in sync). vite dev does not send them, so HSTS is never pinned
on localhost and dev tooling is not blocked by the CSP.
| Header | Value |
|---|---|
Strict-Transport-Security |
max-age=63072000; includeSubDomains |
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
only microphone=(self) (voice capture); camera, geolocation etc. off |
Content-Security-Policy |
enforced: frame-ancestors 'none'; base-uri 'self'; object-src 'none'; form-action 'self' |
Content-Security-Policy-Report-Only |
full resource policy, see below |
The resource policy is report-only for now:
default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: https:; font-src 'self' data:;
connect-src 'self' https://*.supabase.co wss://*.supabase.co https://*.ingest.sentry.io
https://*.ingest.us.sentry.io https://*.ingest.de.sentry.io; media-src 'self' blob:;
worker-src 'self'; manifest-src 'self'; frame-src 'self'; frame-ancestors 'none';
base-uri 'self'; object-src 'none'; form-action 'self'
Why report-only: TanStack Start emits inline hydration scripts without a nonce (so
script-src needs 'unsafe-inline'), the browser talks to Supabase directly (a custom Supabase
domain needs its own connect-src entry, as does a self-hosted Sentry). AI, Telegram and Google calls run on the server, so
the browser needs no AI provider or Google origins. To enforce: open the deployed app, use every feature (login, realtime notes,
voice capture, Google Calendar connect, PWA install) with the console open, add any reported
origin, then move the policy from CSP_REPORT_ONLY to CSP_ENFORCED in
src/server/securityHeaders.ts and regenerate the vercel.json entry (the unit test fails until
both match).
- Set
TELEGRAM_WEBHOOK_SECRETand register it with Telegram'ssetWebhook(secret_token). Without it the bot webhook rejects every update. - Keep
SUPABASE_SERVICE_ROLE_KEY,TOKEN_ENCRYPTION_KEY,GOOGLE_CLIENT_SECRET,TELEGRAM_BOT_TOKENandN8N_API_KEYonly in server environment variables; never inVITE_*. - Use a long random
N8N_API_KEY(openssl rand -hex 32), keep n8n behind HTTPS, setSB_TELEGRAM_ALLOWED_CHAT_IDSandSB_EMAIL_ALLOWED_SENDERS, and rotate viaN8N_API_KEY_PREVIOUS. - Restrict Supabase Auth redirect URLs to your own domains (
https://<your-app>/auth/set-passwordandhttps://<your-app>/auth/callbackare the only paths the email links and Google need), and configure custom SMTP for invite, reset and magic-link emails. - Keep Supabase Authentication → Multi-Factor → TOTP enabled, apply migration 0022 (database
enforcement of
aal2for users with 2FA), and encourage owners/admins to turn on two-step verification in Settings → Keamanan. - Review Settings → Tautan publik from time to time and revoke public links that are no longer needed; prefer the 7- or 30-day expiry for anything sensitive.
- Enable email confirmation in Supabase Auth so invites and links are tied to verified emails.
- Disable Authentication → Sign In / Providers → "Allow new users to sign up" in Supabase unless
the instance is meant to be public, and leave
VITE_ALLOW_SIGNUPunset. Add users through Invite user / Add user instead. - Set
SECOND_BRAIN_CRON_SECRETorCRON_SECRET(openssl rand -hex 32) for the reminder cron and stop relying onapp_config.cron_token. To rotate, move the old value toSECOND_BRAIN_CRON_SECRET_PREVIOUSuntil every scheduler uses the new one. - Check the browser console for
Content-Security-Policy-Report-Onlyviolations on your domain before enforcing the full CSP (see "Content Security Policy"). - In Supabase Realtime → Settings, consider disabling "Allow public access" so every channel must be private.
- Keep dependencies updated (Dependabot is configured) and review CodeQL alerts.
- CodeQL (
.github/workflows/codeql.yml) with thesecurity-extendedquery suite on pushes, PRs and weekly. - Dependency review (
.github/workflows/dependency-review.yml) blocks PRs that add dependencies with known high or critical vulnerabilities. - Dependabot (
.github/dependabot.yml) for Bun packages and GitHub Actions. - Bun's
minimumReleaseAge(24 h) inbunfig.tomlguards against freshly published malicious package versions.