Skip to content

Security: SaplingLearn/Sapling

Security

SECURITY.md

Sapling Security

This document describes the security controls currently implemented in Sapling. It is sourced directly from the code; every claim cites the file (and where useful, the line) that implements it.

The codebase is split into a FastAPI backend (backend/) and a Next.js frontend (frontend/) deployed to Cloudflare Workers. The threat model assumes a hostile public internet, untrusted user-uploaded content, and the need to keep student PII unreadable to anyone who lifts the database.


1. Authentication

1.1 Google OAuth 2.0 with PKCE

Files:backend/routes/auth.py, frontend/src/components/marketing/SignInModal.tsx, frontend/src/app/auth/callback/page.tsx, frontend/src/app/api/auth/session/route.ts

Sign-in is a full-page redirect to ${API_URL}/api/auth/google. The backend drives the OAuth flow and the frontend never touches Google directly.

  • PKCE (S256). Each sign-in mints a fresh 32-byte code verifier via secrets.token_bytes(32). The SHA-256 challenge is sent to Google with code_challenge_method=S256 (auth.py ~lines 68–73, 144–152). The verifier is carried in the OAuth state parameter, base64-encoded JSON: {"action": "signin", "cv": "<verifier>"}.
  • Code exchange. On callback, the backend decodes state, extracts the code verifier, and exchanges the code via flow.fetch_token(code=code, code_verifier=code_verifier). The redirect URI is pinned to GOOGLE_AUTH_REDIRECT_URI (env-supplied) and validated by Google.
  • Domain restriction. Only @bu.edu accounts may sign in. Other domains are redirected to ${FRONTEND_URL}/auth?error=invalid_domain (auth.py ~lines 186–189).
  • OAuth tokens at rest. Google access and refresh tokens are encrypted with AES-256-GCM before insertion into oauth_tokens (auth.py ~lines 228–229). They are never read back in plaintext from any current code path.

1.2 HMAC Session Tokens

Files:backend/services/auth_guard.py, frontend/src/lib/sessionToken.ts, frontend/src/app/api/auth/session/route.ts

Sapling does not use JWTs. Sessions are minimal HMAC-signed payloads of the form:

base64url(payload).base64url(signature)
  • Payload:{"user_id": "<id>", "exp": <unix_timestamp>}. The key name is user_id (snake_case) on both ends — alignment was a recent fix (commit 02b0242).
  • Algorithm: HMAC-SHA256 over the base64 payload using SESSION_SECRET (≥32 bytes, validated at runtime in sessionToken.ts:6).
  • Frontend signs with crypto.subtle.importKey + crypto.subtle.sign('HMAC', …).
  • Backend verifies with hmac.compare_digest() for timing-safe comparison (auth_guard.py:35).
  • Expiry: 30 days (SESSION_MAX_AGE = 2_592_000 in sessionToken.ts:1); enforced on both ends (auth_guard.py:48, sessionToken.ts:60).
  • Lookup: Backend reads the token from cookie sapling_session first, falling back to the auth_token query parameter (auth_guard.py:19).

1.3 Cookie Hardening

Files:frontend/src/app/api/auth/session/route.ts, frontend/wrangler.toml

The session cookie is set exclusively by the Next.js route handler — never by the backend in any deployed environment — using these flags:

FlagValuePurpose
httpOnlytrueInaccessible to JavaScript; mitigates XSS exfil
securetrueHTTPS-only
sameSitelaxImplicit CSRF defense for state-changing requests
path/Whole site
domain.saplinglearn.comShared across all subdomains (api, app, www)
maxAge30 daysSet to 0 on sign-out for immediate deletion

COOKIE_DOMAIN=.saplinglearn.com is shipped via wrangler.toml [vars] (commit b1377ec); the leading dot is what permits api.saplinglearn.com and the app origin to share the same session.

One test-only exception (#381).POST /api/auth/test-login (backend/routes/auth.py) sets sapling_session directly, with the same flags, so the pytest integration suite and Playwright's global setup can obtain a session without driving real Google OAuth (which is not headless-automatable). It is hard-gated on APP_ENV in {"local", "test"} — evaluated per request, not captured at import — and returns a stock 404 {"detail": "Not Found"} in every other environment, so it does not advertise its existence; it is also include_in_schema=False, so it never appears in /openapi.json. The allowlist is deliberately narrower than config.IS_LOCAL ({local, development, dev, test}). See backend/tests/test_auth_test_login.py.

1.4 Sign-out

UserContext.signOut() issues DELETE /api/auth/session, which writes a Max-Age=0 cookie under the same domain/Secure/SameSite settings, then clears in-memory and storage state. Tokens are stateless, so revocation is by cookie deletion + 30-day expiry; there is no server-side blacklist.


2. Authorization

2.1 Auth Guards

File:backend/services/auth_guard.py

Three building blocks gate every authenticated route:

  • get_session_user_id(request) — verifies the HMAC token and returns the user_id; raises 401 on missing/invalid/expired tokens.
  • require_self(user_id, request) — confirms the session user matches the target resource owner; raises 403 "Forbidden: not your account" otherwise.
  • require_admin(request) — joins user_roles to roles and checks for the admin slug; raises 403 "Admin access required" otherwise.
  • require_role(role_slug) — generic factory that returns a checker for any role.

Errors are surfaced as standard FastAPI HTTPException with deterministic status codes (401 unauthenticated, 403 authorized-but-forbidden).

2.2 Roles Schema

File:backend/db/migration_roles.sql

roles(id, slug UNIQUE, name, color, icon, description,
is_staff_assigned, is_earnable, display_priority)
user_roles(user_id, role_id, granted_at, granted_by)

Seeded slugs: admin, moderator, verified, vip, early-adopter. user_roles is indexed on user_id for fast guard lookups.

2.3 Admin Surface

File:backend/routes/admin.py

Every admin route declares require_admin(request) as a dependency. Coverage today:

  • Roles: list / create / update / delete; assign / revoke per user.
  • Achievements: list / create / update / delete; manual grant; trigger creation.
  • Cosmetics: list / create / update / delete (avatar frames, banners, name colors, titles).
  • Users: list (with decrypted names/emails for review); PATCH /users/{user_id}/approve.

2.4 Approval Gate

Files:backend/db/migration_add_is_approved.sql, backend/routes/auth.py, frontend/src/middleware.ts

New users are created with users.is_approved = false. The gate is enforced in two places:

  1. Backend OAuth callback. If is_approved is false the user is redirected to ${FRONTEND_URL}/pending instead of receiving a session token (auth.py ~lines 235–236).
  2. Frontend middleware. Even if a token is present, middleware.ts calls /api/auth/me for every protected route and redirects to /pending when data.is_approved !== true (middleware.ts:58).

Promotion happens via PATCH /api/admin/users/{user_id}/approve, which only admins can call.

2.5 Cross-Tenant Isolation

User-owned resources are gated either by require_self() or by explicit user_id filters on every Supabase query. Sampled enforcement:

  • routes/documents.pyrequire_self() on read/upload; deletes filter id eq <id> AND user_id eq <user>.
  • routes/flashcards.pyrequire_self() on list/import/commit; deletes scoped on user_id.
  • routes/gradebook.py_user_owns_course(), _user_owns_category(), _user_owns_assignment() helpers gate every write.
  • routes/calendar.pyrequire_self(user_id, request) on every endpoint.
  • routes/social.py — room membership is verified via room_id eq <id> AND user_id eq <viewer> before exposing chat or member graphs.

2.6 Frontend Route Guard

File:frontend/src/middleware.ts

A Next.js middleware enforces auth + approval before any of the 12 protected paths render:

/dashboard, /learn, /study, /tree, /library, /calendar,
/social, /settings, /achievements, /admin,
/gradebook, /course-planner

For each request it (1) reads sapling_session, (2) verifies the HMAC locally via verifySession, (3) calls the backend /api/auth/me with a 3-second AbortController timeout, and (4) checks is_approved. Any failure redirects to Google sign-in or /pending. NEXT_PUBLIC_LOCAL_MODE=true bypasses the middleware for local development only.


3. Encryption at Rest

3.1 Primitive

File:backend/services/encryption.py

  • Cipher: AES-256-GCM via cryptography.hazmat.primitives.ciphers.aead.AESGCM.
  • Key: 32 bytes loaded from ENCRYPTION_KEY at module import time. Validated as exactly 64 hex characters; any other shape raises RuntimeError and prevents the app from booting (_load_key, lines 43–60).
  • Nonce: 12 random bytes from os.urandom() per call. Never reused (verified in tests/test_encryption.py).
  • Wire format:base64(nonce || ciphertext_with_tag). The 16-byte GCM authentication tag is included in the ciphertext blob, so any bit-flip is detected on decrypt.

3.2 Helpers

HelperPurpose
encrypt(value)Encrypts a string; mints a fresh nonce.
decrypt(value)Decrypts; raises on tamper / wrong key.
encrypt_if_presentReturns None for None; otherwise stringifies and encrypts.
decrypt_if_presentTries to decrypt; falls back to the raw value with a warning if it cannot.
decrypt_numericDecrypts then casts to float; passes through native numerics.
encrypt_jsonCompact-serializes JSON, then encrypts.
decrypt_jsonDecrypts then json.loads; falls back to parsing raw input.

The *_if_present fallback is what lets a partially-backfilled table keep serving traffic: encrypted rows return plaintext, legacy plaintext rows return themselves, and an operator log warning surfaces every legacy read.

3.3 Encrypted Columns

Verified by grepping for encrypt_if_present / decrypt_if_present / encrypt_json / decrypt_json in backend/routes/:

TableColumn(s)Notes
usersname, first_name, last_name, email, bio, locationDecrypted at every read boundary.
user_settingsbio, locationProfile-edit duplicates.
oauth_tokensaccess_token, refresh_tokenEncrypted on insert; never read today.
documentssummary, concept_notesconcept_notes was retyped JSONB→TEXT.
messagescontentTutoring chat history.
room_messagestextStudy-room chat.
sessionssummary_jsonRetyped JSONB→TEXT; write-only today.
assignmentsnotes, points_possible, points_earnedNumeric columns retyped to TEXT.
calendar_*assignment notesEncrypted on every sync.

3.4 Migration & Backfill

  • backend/db/migration_encryption_text_columns.sql retypes columns whose original types couldn't hold base64 (NUMERIC and JSONBTEXT), preserving existing values via USING column::TEXT.
  • backend/db/backfill_encryption.py is idempotent: for each candidate row it tries to decrypt; if that fails, it encrypts and writes (only when --apply is passed). It supports --table for narrow runs and prints per-column counts on completion.

3.5 AI Prompt Boundary

All Gemini callers decrypt before constructing a prompt. None of decrypt_if_present / decrypt_json's outputs leave the process before being assembled into a system or user message:

  • routes/learn.py — student name (get_user_name), document summaries, and concept notes decrypted before _make_system_prompt() (learn.py ~lines 130, 133, 243, 286).
  • routes/quiz.py — student name decrypted before quiz_context_update and quiz_generation prompts (quiz.py ~lines 192, 198).
  • routes/study_guide.py — document summary and concept notes decrypted before the study-guide agent's context block is assembled (study_guide.py ~lines 43, 49, 54).
  • routes/flashcards.py — same decrypt pattern before flashcard extraction (~lines 130, 133, 424, 428).
  • routes/documents.py — concept extension and graph updates decrypt summaries first (~lines 237, 240, 454, 458).

3.6 Tests

backend/tests/test_encryption.py covers: round-trip ASCII / Unicode / 5000-char strings / empty strings, JSON helpers, numeric helpers, the *_if_present fallback path (including a warning assertion for legacy plaintext), nonce randomness on repeated encryption of the same plaintext, and tamper detection (a flipped bit on the auth tag must raise).


4. Secrets and Configuration

Files:backend/config.py, backend/.env.example, frontend/wrangler.toml, frontend/.env*

.env, .env.local, and other secret files are gitignored. The runtime contract:

VariableSurfacePurpose
SUPABASE_URLbothProject base URL.
SUPABASE_SERVICE_KEYbackendRLS-bypass service role key. Never exposed to the browser.
NEXT_PUBLIC_SUPABASE_ANON_KEYfrontendBrowser-safe key used only for Realtime room subscriptions.
ENCRYPTION_KEYbackend64-hex-char (32-byte) AES-256-GCM key.
SESSION_SECRETbothHMAC-SHA256 secret for session tokens (≥32 bytes).
GOOGLE_CLIENT_ID / _SECRETbackendOAuth credentials.
GOOGLE_AUTH_REDIRECT_URIbackendPinned OAuth callback URL.
GEMINI_API_KEYbackendLLM access.
COOKIE_DOMAINfrontend.saplinglearn.com for cross-subdomain session cookies.
OCR_ENGINE, GOT_OCR_ENABLEDbackendOCR backend selection.

ENCRYPTION_KEY and SESSION_SECRET are validated at import time — invalid or missing values fail fast.

Supabase key split

  • Backend (backend/db/connection.py, backend/services/storage_service.py) uses the service role key, which bypasses RLS. All persistent writes flow through the backend.
  • Frontend (frontend/src/lib/supabase.ts) uses the anon key and only for Supabase Realtime subscriptions in Social.tsx:
    • room:${roomId} channel — postgres_changes on room_messages / room_reactions, filtered by room_id.
    • presence:${roomId} channel — typing presence.
  • Room writes still flow through the backend (which checks membership before insert), so the anon key alone cannot post into a room a user has not joined.

5. Transport, CORS, and Deployment

  • TLS. The frontend runs on Cloudflare Workers via @opennextjs/cloudflare (wrangler.toml); TLS is terminated at the edge for *.saplinglearn.com and api.saplinglearn.com.
  • CORS (backend/main.py):
    CORSMiddleware(
    allow_origins=[FRONTEND_URL, "http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"], allow_headers=["*"],
    )
    Only the configured frontend origin and localhost for development are permitted. allow_credentials=True is required because the session cookie is sent cross-origin from the app to the API.
  • No wildcard origins. Production never sets * because credentials would be refused by the browser anyway.

6. File Uploads and OCR

6.1 Documents (backend/routes/documents.py)

  • Allowed types:.pdf, .docx, .pptx. Both extension and MIME type are validated.
  • Max size: 15 MB.
  • No raw file persistence. Files are extracted in-memory; only the metadata (name, category) and AI-generated summary/concept_notes (encrypted) are written to Supabase.
  • Prompt sizing. Extracted text is truncated to 12,000 characters before being passed to Gemini.

6.2 Avatars and Cosmetics (backend/services/storage_service.py)

  • Allowed MIME types:image/jpeg, image/png, image/webp, image/gif.
  • Max size: 5 MB.
  • Storage: Supabase Storage bucket avatars with paths avatars/{user_id}/avatar.{ext} and cosmetics/{cosmetic_id}.{ext}. Uploads use the service key with x-upsert: true; reads are public via signed-bucket URL.

6.3 OCR Backends (backend/services/extraction_backends/)

A thin router (extraction_service.py) selects a backend by OCR_ENGINE:

  • docling (default) — layout-aware markdown extraction.
  • auto — Docling, with GOT-OCR 2.0 fallback for low-density / math pages, only when GOT_OCR_ENABLED=true. GOT-OCR loads a Hugging Face model with trust_remote_code=True, so it stays disabled in production by default and the model path should be pinned to a known revision when enabled.
  • tesseract — legacy fallback.

All backends are lazy-imported to keep cold start sub-second. Failures degrade Docling → GOT-OCR → Tesseract before raising RuntimeError, which routes/extract.py surfaces as 503.


7. Logging

backend/main.py's RequestLogMiddleware records method, path, status, duration, and a random 8-char request id. It does not log request or response bodies, user ids, emails, names, tokens, or any decrypted column. Errors include the exception class name and traceback only.

Encryption helpers log a structured warning when decrypt_if_present falls back to a raw value; that's the operator signal that legacy plaintext is still being read on a column and the backfill should be re-run.


8. Dependency Surface

Security-relevant packages currently in use (no version audit performed; assume upstream patching):

Backend (backend/requirements.txt)

  • cryptography>=42,<46 — AES-GCM primitive.
  • fastapi, uvicorn — web framework.
  • google-auth-oauthlib, google-api-python-client — OAuth flow.
  • docling>=2.15, transformers>=4.46, torch>=2.5 — OCR / document extraction.

Frontend (frontend/package.json)

  • @supabase/supabase-js — Realtime client.
  • react, next — UI + middleware.
  • @opennextjs/cloudflare — Workers adapter.

9. Summary Matrix

DomainMechanismFile(s)
Sign-inOAuth 2.0 + PKCE (S256), @bu.edubackend/routes/auth.py
SessionHMAC-SHA256, 30-day expirybackend/services/auth_guard.py, frontend/src/lib/sessionToken.ts
CookieHttpOnly, Secure, SameSite=Lax, .saplinglearn.comfrontend/src/app/api/auth/session/route.ts, frontend/wrangler.toml
Frontend gateNext.js middleware → /api/auth/mefrontend/src/middleware.ts
Backend gaterequire_self / require_adminbackend/services/auth_guard.py
Approvalusers.is_approved + /pendingbackend/routes/auth.py, frontend/src/middleware.ts
Encryption at restAES-256-GCM, random nonce, base64backend/services/encryption.py
BackfillIdempotent re-encrypt walkerbackend/db/backfill_encryption.py
Supabase key splitService (backend) vs anon (frontend)backend/db/connection.py, frontend/src/lib/supabase.ts
CORSConfigured frontend origin + localhostbackend/main.py
TLSCloudflare Workers edge terminationfrontend/wrangler.toml
UploadsType + size validation, no raw persistbackend/routes/documents.py, backend/services/storage_service.py
LoggingNo PII / token / body loggingbackend/main.py

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Overview · SaplingLearn/Sapling · GitHub
Skip to content

Security: SaplingLearn/Sapling

Security

SECURITY.md

Sapling Security

This document describes the security controls currently implemented in Sapling. It is sourced directly from the code; every claim cites the file (and where useful, the line) that implements it.

The codebase is split into a FastAPI backend (backend/) and a Next.js frontend (frontend/) deployed to Cloudflare Workers. The threat model assumes a hostile public internet, untrusted user-uploaded content, and the need to keep student PII unreadable to anyone who lifts the database.


1. Authentication

1.1 Google OAuth 2.0 with PKCE

Files:backend/routes/auth.py, frontend/src/components/marketing/SignInModal.tsx, frontend/src/app/auth/callback/page.tsx, frontend/src/app/api/auth/session/route.ts

Sign-in is a full-page redirect to ${API_URL}/api/auth/google. The backend drives the OAuth flow and the frontend never touches Google directly.

  • PKCE (S256). Each sign-in mints a fresh 32-byte code verifier via secrets.token_bytes(32). The SHA-256 challenge is sent to Google with code_challenge_method=S256 (auth.py ~lines 68–73, 144–152). The verifier is carried in the OAuth state parameter, base64-encoded JSON: {"action": "signin", "cv": "<verifier>"}.
  • Code exchange. On callback, the backend decodes state, extracts the code verifier, and exchanges the code via flow.fetch_token(code=code, code_verifier=code_verifier). The redirect URI is pinned to GOOGLE_AUTH_REDIRECT_URI (env-supplied) and validated by Google.
  • Domain restriction. Only @bu.edu accounts may sign in. Other domains are redirected to ${FRONTEND_URL}/auth?error=invalid_domain (auth.py ~lines 186–189).
  • OAuth tokens at rest. Google access and refresh tokens are encrypted with AES-256-GCM before insertion into oauth_tokens (auth.py ~lines 228–229). They are never read back in plaintext from any current code path.

1.2 HMAC Session Tokens

Files:backend/services/auth_guard.py, frontend/src/lib/sessionToken.ts, frontend/src/app/api/auth/session/route.ts

Sapling does not use JWTs. Sessions are minimal HMAC-signed payloads of the form:

base64url(payload).base64url(signature)
  • Payload:{"user_id": "<id>", "exp": <unix_timestamp>}. The key name is user_id (snake_case) on both ends — alignment was a recent fix (commit 02b0242).
  • Algorithm: HMAC-SHA256 over the base64 payload using SESSION_SECRET (≥32 bytes, validated at runtime in sessionToken.ts:6).
  • Frontend signs with crypto.subtle.importKey + crypto.subtle.sign('HMAC', …).
  • Backend verifies with hmac.compare_digest() for timing-safe comparison (auth_guard.py:35).
  • Expiry: 30 days (SESSION_MAX_AGE = 2_592_000 in sessionToken.ts:1); enforced on both ends (auth_guard.py:48, sessionToken.ts:60).
  • Lookup: Backend reads the token from cookie sapling_session first, falling back to the auth_token query parameter (auth_guard.py:19).

1.3 Cookie Hardening

Files:frontend/src/app/api/auth/session/route.ts, frontend/wrangler.toml

The session cookie is set exclusively by the Next.js route handler — never by the backend in any deployed environment — using these flags:

FlagValuePurpose
httpOnlytrueInaccessible to JavaScript; mitigates XSS exfil
securetrueHTTPS-only
sameSitelaxImplicit CSRF defense for state-changing requests
path/Whole site
domain.saplinglearn.comShared across all subdomains (api, app, www)
maxAge30 daysSet to 0 on sign-out for immediate deletion

COOKIE_DOMAIN=.saplinglearn.com is shipped via wrangler.toml [vars] (commit b1377ec); the leading dot is what permits api.saplinglearn.com and the app origin to share the same session.

One test-only exception (#381).POST /api/auth/test-login (backend/routes/auth.py) sets sapling_session directly, with the same flags, so the pytest integration suite and Playwright's global setup can obtain a session without driving real Google OAuth (which is not headless-automatable). It is hard-gated on APP_ENV in {"local", "test"} — evaluated per request, not captured at import — and returns a stock 404 {"detail": "Not Found"} in every other environment, so it does not advertise its existence; it is also include_in_schema=False, so it never appears in /openapi.json. The allowlist is deliberately narrower than config.IS_LOCAL ({local, development, dev, test}). See backend/tests/test_auth_test_login.py.

1.4 Sign-out

UserContext.signOut() issues DELETE /api/auth/session, which writes a Max-Age=0 cookie under the same domain/Secure/SameSite settings, then clears in-memory and storage state. Tokens are stateless, so revocation is by cookie deletion + 30-day expiry; there is no server-side blacklist.


2. Authorization

2.1 Auth Guards

File:backend/services/auth_guard.py

Three building blocks gate every authenticated route:

  • get_session_user_id(request) — verifies the HMAC token and returns the user_id; raises 401 on missing/invalid/expired tokens.
  • require_self(user_id, request) — confirms the session user matches the target resource owner; raises 403 "Forbidden: not your account" otherwise.
  • require_admin(request) — joins user_roles to roles and checks for the admin slug; raises 403 "Admin access required" otherwise.
  • require_role(role_slug) — generic factory that returns a checker for any role.

Errors are surfaced as standard FastAPI HTTPException with deterministic status codes (401 unauthenticated, 403 authorized-but-forbidden).

2.2 Roles Schema

File:backend/db/migration_roles.sql

roles(id, slug UNIQUE, name, color, icon, description,
is_staff_assigned, is_earnable, display_priority)
user_roles(user_id, role_id, granted_at, granted_by)

Seeded slugs: admin, moderator, verified, vip, early-adopter. user_roles is indexed on user_id for fast guard lookups.

2.3 Admin Surface

File:backend/routes/admin.py

Every admin route declares require_admin(request) as a dependency. Coverage today:

  • Roles: list / create / update / delete; assign / revoke per user.
  • Achievements: list / create / update / delete; manual grant; trigger creation.
  • Cosmetics: list / create / update / delete (avatar frames, banners, name colors, titles).
  • Users: list (with decrypted names/emails for review); PATCH /users/{user_id}/approve.

2.4 Approval Gate

Files:backend/db/migration_add_is_approved.sql, backend/routes/auth.py, frontend/src/middleware.ts

New users are created with users.is_approved = false. The gate is enforced in two places:

  1. Backend OAuth callback. If is_approved is false the user is redirected to ${FRONTEND_URL}/pending instead of receiving a session token (auth.py ~lines 235–236).
  2. Frontend middleware. Even if a token is present, middleware.ts calls /api/auth/me for every protected route and redirects to /pending when data.is_approved !== true (middleware.ts:58).

Promotion happens via PATCH /api/admin/users/{user_id}/approve, which only admins can call.

2.5 Cross-Tenant Isolation

User-owned resources are gated either by require_self() or by explicit user_id filters on every Supabase query. Sampled enforcement:

  • routes/documents.pyrequire_self() on read/upload; deletes filter id eq <id> AND user_id eq <user>.
  • routes/flashcards.pyrequire_self() on list/import/commit; deletes scoped on user_id.
  • routes/gradebook.py_user_owns_course(), _user_owns_category(), _user_owns_assignment() helpers gate every write.
  • routes/calendar.pyrequire_self(user_id, request) on every endpoint.
  • routes/social.py — room membership is verified via room_id eq <id> AND user_id eq <viewer> before exposing chat or member graphs.

2.6 Frontend Route Guard

File:frontend/src/middleware.ts

A Next.js middleware enforces auth + approval before any of the 12 protected paths render:

/dashboard, /learn, /study, /tree, /library, /calendar,
/social, /settings, /achievements, /admin,
/gradebook, /course-planner

For each request it (1) reads sapling_session, (2) verifies the HMAC locally via verifySession, (3) calls the backend /api/auth/me with a 3-second AbortController timeout, and (4) checks is_approved. Any failure redirects to Google sign-in or /pending. NEXT_PUBLIC_LOCAL_MODE=true bypasses the middleware for local development only.


3. Encryption at Rest

3.1 Primitive

File:backend/services/encryption.py

  • Cipher: AES-256-GCM via cryptography.hazmat.primitives.ciphers.aead.AESGCM.
  • Key: 32 bytes loaded from ENCRYPTION_KEY at module import time. Validated as exactly 64 hex characters; any other shape raises RuntimeError and prevents the app from booting (_load_key, lines 43–60).
  • Nonce: 12 random bytes from os.urandom() per call. Never reused (verified in tests/test_encryption.py).
  • Wire format:base64(nonce || ciphertext_with_tag). The 16-byte GCM authentication tag is included in the ciphertext blob, so any bit-flip is detected on decrypt.

3.2 Helpers

HelperPurpose
encrypt(value)Encrypts a string; mints a fresh nonce.
decrypt(value)Decrypts; raises on tamper / wrong key.
encrypt_if_presentReturns None for None; otherwise stringifies and encrypts.
decrypt_if_presentTries to decrypt; falls back to the raw value with a warning if it cannot.
decrypt_numericDecrypts then casts to float; passes through native numerics.
encrypt_jsonCompact-serializes JSON, then encrypts.
decrypt_jsonDecrypts then json.loads; falls back to parsing raw input.

The *_if_present fallback is what lets a partially-backfilled table keep serving traffic: encrypted rows return plaintext, legacy plaintext rows return themselves, and an operator log warning surfaces every legacy read.

3.3 Encrypted Columns

Verified by grepping for encrypt_if_present / decrypt_if_present / encrypt_json / decrypt_json in backend/routes/:

TableColumn(s)Notes
usersname, first_name, last_name, email, bio, locationDecrypted at every read boundary.
user_settingsbio, locationProfile-edit duplicates.
oauth_tokensaccess_token, refresh_tokenEncrypted on insert; never read today.
documentssummary, concept_notesconcept_notes was retyped JSONB→TEXT.
messagescontentTutoring chat history.
room_messagestextStudy-room chat.
sessionssummary_jsonRetyped JSONB→TEXT; write-only today.
assignmentsnotes, points_possible, points_earnedNumeric columns retyped to TEXT.
calendar_*assignment notesEncrypted on every sync.

3.4 Migration & Backfill

  • backend/db/migration_encryption_text_columns.sql retypes columns whose original types couldn't hold base64 (NUMERIC and JSONBTEXT), preserving existing values via USING column::TEXT.
  • backend/db/backfill_encryption.py is idempotent: for each candidate row it tries to decrypt; if that fails, it encrypts and writes (only when --apply is passed). It supports --table for narrow runs and prints per-column counts on completion.

3.5 AI Prompt Boundary

All Gemini callers decrypt before constructing a prompt. None of decrypt_if_present / decrypt_json's outputs leave the process before being assembled into a system or user message:

  • routes/learn.py — student name (get_user_name), document summaries, and concept notes decrypted before _make_system_prompt() (learn.py ~lines 130, 133, 243, 286).
  • routes/quiz.py — student name decrypted before quiz_context_update and quiz_generation prompts (quiz.py ~lines 192, 198).
  • routes/study_guide.py — document summary and concept notes decrypted before the study-guide agent's context block is assembled (study_guide.py ~lines 43, 49, 54).
  • routes/flashcards.py — same decrypt pattern before flashcard extraction (~lines 130, 133, 424, 428).
  • routes/documents.py — concept extension and graph updates decrypt summaries first (~lines 237, 240, 454, 458).

3.6 Tests

backend/tests/test_encryption.py covers: round-trip ASCII / Unicode / 5000-char strings / empty strings, JSON helpers, numeric helpers, the *_if_present fallback path (including a warning assertion for legacy plaintext), nonce randomness on repeated encryption of the same plaintext, and tamper detection (a flipped bit on the auth tag must raise).


4. Secrets and Configuration

Files:backend/config.py, backend/.env.example, frontend/wrangler.toml, frontend/.env*

.env, .env.local, and other secret files are gitignored. The runtime contract:

VariableSurfacePurpose
SUPABASE_URLbothProject base URL.
SUPABASE_SERVICE_KEYbackendRLS-bypass service role key. Never exposed to the browser.
NEXT_PUBLIC_SUPABASE_ANON_KEYfrontendBrowser-safe key used only for Realtime room subscriptions.
ENCRYPTION_KEYbackend64-hex-char (32-byte) AES-256-GCM key.
SESSION_SECRETbothHMAC-SHA256 secret for session tokens (≥32 bytes).
GOOGLE_CLIENT_ID / _SECRETbackendOAuth credentials.
GOOGLE_AUTH_REDIRECT_URIbackendPinned OAuth callback URL.
GEMINI_API_KEYbackendLLM access.
COOKIE_DOMAINfrontend.saplinglearn.com for cross-subdomain session cookies.
OCR_ENGINE, GOT_OCR_ENABLEDbackendOCR backend selection.

ENCRYPTION_KEY and SESSION_SECRET are validated at import time — invalid or missing values fail fast.

Supabase key split

  • Backend (backend/db/connection.py, backend/services/storage_service.py) uses the service role key, which bypasses RLS. All persistent writes flow through the backend.
  • Frontend (frontend/src/lib/supabase.ts) uses the anon key and only for Supabase Realtime subscriptions in Social.tsx:
    • room:${roomId} channel — postgres_changes on room_messages / room_reactions, filtered by room_id.
    • presence:${roomId} channel — typing presence.
  • Room writes still flow through the backend (which checks membership before insert), so the anon key alone cannot post into a room a user has not joined.

5. Transport, CORS, and Deployment

  • TLS. The frontend runs on Cloudflare Workers via @opennextjs/cloudflare (wrangler.toml); TLS is terminated at the edge for *.saplinglearn.com and api.saplinglearn.com.
  • CORS (backend/main.py):
    CORSMiddleware(
    allow_origins=[FRONTEND_URL, "http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"], allow_headers=["*"],
    )
    Only the configured frontend origin and localhost for development are permitted. allow_credentials=True is required because the session cookie is sent cross-origin from the app to the API.
  • No wildcard origins. Production never sets * because credentials would be refused by the browser anyway.

6. File Uploads and OCR

6.1 Documents (backend/routes/documents.py)

  • Allowed types:.pdf, .docx, .pptx. Both extension and MIME type are validated.
  • Max size: 15 MB.
  • No raw file persistence. Files are extracted in-memory; only the metadata (name, category) and AI-generated summary/concept_notes (encrypted) are written to Supabase.
  • Prompt sizing. Extracted text is truncated to 12,000 characters before being passed to Gemini.

6.2 Avatars and Cosmetics (backend/services/storage_service.py)

  • Allowed MIME types:image/jpeg, image/png, image/webp, image/gif.
  • Max size: 5 MB.
  • Storage: Supabase Storage bucket avatars with paths avatars/{user_id}/avatar.{ext} and cosmetics/{cosmetic_id}.{ext}. Uploads use the service key with x-upsert: true; reads are public via signed-bucket URL.

6.3 OCR Backends (backend/services/extraction_backends/)

A thin router (extraction_service.py) selects a backend by OCR_ENGINE:

  • docling (default) — layout-aware markdown extraction.
  • auto — Docling, with GOT-OCR 2.0 fallback for low-density / math pages, only when GOT_OCR_ENABLED=true. GOT-OCR loads a Hugging Face model with trust_remote_code=True, so it stays disabled in production by default and the model path should be pinned to a known revision when enabled.
  • tesseract — legacy fallback.

All backends are lazy-imported to keep cold start sub-second. Failures degrade Docling → GOT-OCR → Tesseract before raising RuntimeError, which routes/extract.py surfaces as 503.


7. Logging

backend/main.py's RequestLogMiddleware records method, path, status, duration, and a random 8-char request id. It does not log request or response bodies, user ids, emails, names, tokens, or any decrypted column. Errors include the exception class name and traceback only.

Encryption helpers log a structured warning when decrypt_if_present falls back to a raw value; that's the operator signal that legacy plaintext is still being read on a column and the backfill should be re-run.


8. Dependency Surface

Security-relevant packages currently in use (no version audit performed; assume upstream patching):

Backend (backend/requirements.txt)

  • cryptography>=42,<46 — AES-GCM primitive.
  • fastapi, uvicorn — web framework.
  • google-auth-oauthlib, google-api-python-client — OAuth flow.
  • docling>=2.15, transformers>=4.46, torch>=2.5 — OCR / document extraction.

Frontend (frontend/package.json)

  • @supabase/supabase-js — Realtime client.
  • react, next — UI + middleware.
  • @opennextjs/cloudflare — Workers adapter.

9. Summary Matrix

DomainMechanismFile(s)
Sign-inOAuth 2.0 + PKCE (S256), @bu.edubackend/routes/auth.py
SessionHMAC-SHA256, 30-day expirybackend/services/auth_guard.py, frontend/src/lib/sessionToken.ts
CookieHttpOnly, Secure, SameSite=Lax, .saplinglearn.comfrontend/src/app/api/auth/session/route.ts, frontend/wrangler.toml
Frontend gateNext.js middleware → /api/auth/mefrontend/src/middleware.ts
Backend gaterequire_self / require_adminbackend/services/auth_guard.py
Approvalusers.is_approved + /pendingbackend/routes/auth.py, frontend/src/middleware.ts
Encryption at restAES-256-GCM, random nonce, base64backend/services/encryption.py
BackfillIdempotent re-encrypt walkerbackend/db/backfill_encryption.py
Supabase key splitService (backend) vs anon (frontend)backend/db/connection.py, frontend/src/lib/supabase.ts
CORSConfigured frontend origin + localhostbackend/main.py
TLSCloudflare Workers edge terminationfrontend/wrangler.toml
UploadsType + size validation, no raw persistbackend/routes/documents.py, backend/services/storage_service.py
LoggingNo PII / token / body loggingbackend/main.py

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Overview · SaplingLearn/Sapling · GitHub
Skip to content

Security: SaplingLearn/Sapling

Security

SECURITY.md

Sapling Security

This document describes the security controls currently implemented in Sapling. It is sourced directly from the code; every claim cites the file (and where useful, the line) that implements it.

The codebase is split into a FastAPI backend (backend/) and a Next.js frontend (frontend/) deployed to Cloudflare Workers. The threat model assumes a hostile public internet, untrusted user-uploaded content, and the need to keep student PII unreadable to anyone who lifts the database.


1. Authentication

1.1 Google OAuth 2.0 with PKCE

Files:backend/routes/auth.py, frontend/src/components/marketing/SignInModal.tsx, frontend/src/app/auth/callback/page.tsx, frontend/src/app/api/auth/session/route.ts

Sign-in is a full-page redirect to ${API_URL}/api/auth/google. The backend drives the OAuth flow and the frontend never touches Google directly.

  • PKCE (S256). Each sign-in mints a fresh 32-byte code verifier via secrets.token_bytes(32). The SHA-256 challenge is sent to Google with code_challenge_method=S256 (auth.py ~lines 68–73, 144–152). The verifier is carried in the OAuth state parameter, base64-encoded JSON: {"action": "signin", "cv": "<verifier>"}.
  • Code exchange. On callback, the backend decodes state, extracts the code verifier, and exchanges the code via flow.fetch_token(code=code, code_verifier=code_verifier). The redirect URI is pinned to GOOGLE_AUTH_REDIRECT_URI (env-supplied) and validated by Google.
  • Domain restriction. Only @bu.edu accounts may sign in. Other domains are redirected to ${FRONTEND_URL}/auth?error=invalid_domain (auth.py ~lines 186–189).
  • OAuth tokens at rest. Google access and refresh tokens are encrypted with AES-256-GCM before insertion into oauth_tokens (auth.py ~lines 228–229). They are never read back in plaintext from any current code path.

1.2 HMAC Session Tokens

Files:backend/services/auth_guard.py, frontend/src/lib/sessionToken.ts, frontend/src/app/api/auth/session/route.ts

Sapling does not use JWTs. Sessions are minimal HMAC-signed payloads of the form:

base64url(payload).base64url(signature)
  • Payload:{"user_id": "<id>", "exp": <unix_timestamp>}. The key name is user_id (snake_case) on both ends — alignment was a recent fix (commit 02b0242).
  • Algorithm: HMAC-SHA256 over the base64 payload using SESSION_SECRET (≥32 bytes, validated at runtime in sessionToken.ts:6).
  • Frontend signs with crypto.subtle.importKey + crypto.subtle.sign('HMAC', …).
  • Backend verifies with hmac.compare_digest() for timing-safe comparison (auth_guard.py:35).
  • Expiry: 30 days (SESSION_MAX_AGE = 2_592_000 in sessionToken.ts:1); enforced on both ends (auth_guard.py:48, sessionToken.ts:60).
  • Lookup: Backend reads the token from cookie sapling_session first, falling back to the auth_token query parameter (auth_guard.py:19).

1.3 Cookie Hardening

Files:frontend/src/app/api/auth/session/route.ts, frontend/wrangler.toml

The session cookie is set exclusively by the Next.js route handler — never by the backend in any deployed environment — using these flags:

FlagValuePurpose
httpOnlytrueInaccessible to JavaScript; mitigates XSS exfil
securetrueHTTPS-only
sameSitelaxImplicit CSRF defense for state-changing requests
path/Whole site
domain.saplinglearn.comShared across all subdomains (api, app, www)
maxAge30 daysSet to 0 on sign-out for immediate deletion

COOKIE_DOMAIN=.saplinglearn.com is shipped via wrangler.toml [vars] (commit b1377ec); the leading dot is what permits api.saplinglearn.com and the app origin to share the same session.

One test-only exception (#381).POST /api/auth/test-login (backend/routes/auth.py) sets sapling_session directly, with the same flags, so the pytest integration suite and Playwright's global setup can obtain a session without driving real Google OAuth (which is not headless-automatable). It is hard-gated on APP_ENV in {"local", "test"} — evaluated per request, not captured at import — and returns a stock 404 {"detail": "Not Found"} in every other environment, so it does not advertise its existence; it is also include_in_schema=False, so it never appears in /openapi.json. The allowlist is deliberately narrower than config.IS_LOCAL ({local, development, dev, test}). See backend/tests/test_auth_test_login.py.

1.4 Sign-out

UserContext.signOut() issues DELETE /api/auth/session, which writes a Max-Age=0 cookie under the same domain/Secure/SameSite settings, then clears in-memory and storage state. Tokens are stateless, so revocation is by cookie deletion + 30-day expiry; there is no server-side blacklist.


2. Authorization

2.1 Auth Guards

File:backend/services/auth_guard.py

Three building blocks gate every authenticated route:

  • get_session_user_id(request) — verifies the HMAC token and returns the user_id; raises 401 on missing/invalid/expired tokens.
  • require_self(user_id, request) — confirms the session user matches the target resource owner; raises 403 "Forbidden: not your account" otherwise.
  • require_admin(request) — joins user_roles to roles and checks for the admin slug; raises 403 "Admin access required" otherwise.
  • require_role(role_slug) — generic factory that returns a checker for any role.

Errors are surfaced as standard FastAPI HTTPException with deterministic status codes (401 unauthenticated, 403 authorized-but-forbidden).

2.2 Roles Schema

File:backend/db/migration_roles.sql

roles(id, slug UNIQUE, name, color, icon, description,
is_staff_assigned, is_earnable, display_priority)
user_roles(user_id, role_id, granted_at, granted_by)

Seeded slugs: admin, moderator, verified, vip, early-adopter. user_roles is indexed on user_id for fast guard lookups.

2.3 Admin Surface

File:backend/routes/admin.py

Every admin route declares require_admin(request) as a dependency. Coverage today:

  • Roles: list / create / update / delete; assign / revoke per user.
  • Achievements: list / create / update / delete; manual grant; trigger creation.
  • Cosmetics: list / create / update / delete (avatar frames, banners, name colors, titles).
  • Users: list (with decrypted names/emails for review); PATCH /users/{user_id}/approve.

2.4 Approval Gate

Files:backend/db/migration_add_is_approved.sql, backend/routes/auth.py, frontend/src/middleware.ts

New users are created with users.is_approved = false. The gate is enforced in two places:

  1. Backend OAuth callback. If is_approved is false the user is redirected to ${FRONTEND_URL}/pending instead of receiving a session token (auth.py ~lines 235–236).
  2. Frontend middleware. Even if a token is present, middleware.ts calls /api/auth/me for every protected route and redirects to /pending when data.is_approved !== true (middleware.ts:58).

Promotion happens via PATCH /api/admin/users/{user_id}/approve, which only admins can call.

2.5 Cross-Tenant Isolation

User-owned resources are gated either by require_self() or by explicit user_id filters on every Supabase query. Sampled enforcement:

  • routes/documents.pyrequire_self() on read/upload; deletes filter id eq <id> AND user_id eq <user>.
  • routes/flashcards.pyrequire_self() on list/import/commit; deletes scoped on user_id.
  • routes/gradebook.py_user_owns_course(), _user_owns_category(), _user_owns_assignment() helpers gate every write.
  • routes/calendar.pyrequire_self(user_id, request) on every endpoint.
  • routes/social.py — room membership is verified via room_id eq <id> AND user_id eq <viewer> before exposing chat or member graphs.

2.6 Frontend Route Guard

File:frontend/src/middleware.ts

A Next.js middleware enforces auth + approval before any of the 12 protected paths render:

/dashboard, /learn, /study, /tree, /library, /calendar,
/social, /settings, /achievements, /admin,
/gradebook, /course-planner

For each request it (1) reads sapling_session, (2) verifies the HMAC locally via verifySession, (3) calls the backend /api/auth/me with a 3-second AbortController timeout, and (4) checks is_approved. Any failure redirects to Google sign-in or /pending. NEXT_PUBLIC_LOCAL_MODE=true bypasses the middleware for local development only.


3. Encryption at Rest

3.1 Primitive

File:backend/services/encryption.py

  • Cipher: AES-256-GCM via cryptography.hazmat.primitives.ciphers.aead.AESGCM.
  • Key: 32 bytes loaded from ENCRYPTION_KEY at module import time. Validated as exactly 64 hex characters; any other shape raises RuntimeError and prevents the app from booting (_load_key, lines 43–60).
  • Nonce: 12 random bytes from os.urandom() per call. Never reused (verified in tests/test_encryption.py).
  • Wire format:base64(nonce || ciphertext_with_tag). The 16-byte GCM authentication tag is included in the ciphertext blob, so any bit-flip is detected on decrypt.

3.2 Helpers

HelperPurpose
encrypt(value)Encrypts a string; mints a fresh nonce.
decrypt(value)Decrypts; raises on tamper / wrong key.
encrypt_if_presentReturns None for None; otherwise stringifies and encrypts.
decrypt_if_presentTries to decrypt; falls back to the raw value with a warning if it cannot.
decrypt_numericDecrypts then casts to float; passes through native numerics.
encrypt_jsonCompact-serializes JSON, then encrypts.
decrypt_jsonDecrypts then json.loads; falls back to parsing raw input.

The *_if_present fallback is what lets a partially-backfilled table keep serving traffic: encrypted rows return plaintext, legacy plaintext rows return themselves, and an operator log warning surfaces every legacy read.

3.3 Encrypted Columns

Verified by grepping for encrypt_if_present / decrypt_if_present / encrypt_json / decrypt_json in backend/routes/:

TableColumn(s)Notes
usersname, first_name, last_name, email, bio, locationDecrypted at every read boundary.
user_settingsbio, locationProfile-edit duplicates.
oauth_tokensaccess_token, refresh_tokenEncrypted on insert; never read today.
documentssummary, concept_notesconcept_notes was retyped JSONB→TEXT.
messagescontentTutoring chat history.
room_messagestextStudy-room chat.
sessionssummary_jsonRetyped JSONB→TEXT; write-only today.
assignmentsnotes, points_possible, points_earnedNumeric columns retyped to TEXT.
calendar_*assignment notesEncrypted on every sync.

3.4 Migration & Backfill

  • backend/db/migration_encryption_text_columns.sql retypes columns whose original types couldn't hold base64 (NUMERIC and JSONBTEXT), preserving existing values via USING column::TEXT.
  • backend/db/backfill_encryption.py is idempotent: for each candidate row it tries to decrypt; if that fails, it encrypts and writes (only when --apply is passed). It supports --table for narrow runs and prints per-column counts on completion.

3.5 AI Prompt Boundary

All Gemini callers decrypt before constructing a prompt. None of decrypt_if_present / decrypt_json's outputs leave the process before being assembled into a system or user message:

  • routes/learn.py — student name (get_user_name), document summaries, and concept notes decrypted before _make_system_prompt() (learn.py ~lines 130, 133, 243, 286).
  • routes/quiz.py — student name decrypted before quiz_context_update and quiz_generation prompts (quiz.py ~lines 192, 198).
  • routes/study_guide.py — document summary and concept notes decrypted before the study-guide agent's context block is assembled (study_guide.py ~lines 43, 49, 54).
  • routes/flashcards.py — same decrypt pattern before flashcard extraction (~lines 130, 133, 424, 428).
  • routes/documents.py — concept extension and graph updates decrypt summaries first (~lines 237, 240, 454, 458).

3.6 Tests

backend/tests/test_encryption.py covers: round-trip ASCII / Unicode / 5000-char strings / empty strings, JSON helpers, numeric helpers, the *_if_present fallback path (including a warning assertion for legacy plaintext), nonce randomness on repeated encryption of the same plaintext, and tamper detection (a flipped bit on the auth tag must raise).


4. Secrets and Configuration

Files:backend/config.py, backend/.env.example, frontend/wrangler.toml, frontend/.env*

.env, .env.local, and other secret files are gitignored. The runtime contract:

VariableSurfacePurpose
SUPABASE_URLbothProject base URL.
SUPABASE_SERVICE_KEYbackendRLS-bypass service role key. Never exposed to the browser.
NEXT_PUBLIC_SUPABASE_ANON_KEYfrontendBrowser-safe key used only for Realtime room subscriptions.
ENCRYPTION_KEYbackend64-hex-char (32-byte) AES-256-GCM key.
SESSION_SECRETbothHMAC-SHA256 secret for session tokens (≥32 bytes).
GOOGLE_CLIENT_ID / _SECRETbackendOAuth credentials.
GOOGLE_AUTH_REDIRECT_URIbackendPinned OAuth callback URL.
GEMINI_API_KEYbackendLLM access.
COOKIE_DOMAINfrontend.saplinglearn.com for cross-subdomain session cookies.
OCR_ENGINE, GOT_OCR_ENABLEDbackendOCR backend selection.

ENCRYPTION_KEY and SESSION_SECRET are validated at import time — invalid or missing values fail fast.

Supabase key split

  • Backend (backend/db/connection.py, backend/services/storage_service.py) uses the service role key, which bypasses RLS. All persistent writes flow through the backend.
  • Frontend (frontend/src/lib/supabase.ts) uses the anon key and only for Supabase Realtime subscriptions in Social.tsx:
    • room:${roomId} channel — postgres_changes on room_messages / room_reactions, filtered by room_id.
    • presence:${roomId} channel — typing presence.
  • Room writes still flow through the backend (which checks membership before insert), so the anon key alone cannot post into a room a user has not joined.

5. Transport, CORS, and Deployment

  • TLS. The frontend runs on Cloudflare Workers via @opennextjs/cloudflare (wrangler.toml); TLS is terminated at the edge for *.saplinglearn.com and api.saplinglearn.com.
  • CORS (backend/main.py):
    CORSMiddleware(
    allow_origins=[FRONTEND_URL, "http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"], allow_headers=["*"],
    )
    Only the configured frontend origin and localhost for development are permitted. allow_credentials=True is required because the session cookie is sent cross-origin from the app to the API.
  • No wildcard origins. Production never sets * because credentials would be refused by the browser anyway.

6. File Uploads and OCR

6.1 Documents (backend/routes/documents.py)

  • Allowed types:.pdf, .docx, .pptx. Both extension and MIME type are validated.
  • Max size: 15 MB.
  • No raw file persistence. Files are extracted in-memory; only the metadata (name, category) and AI-generated summary/concept_notes (encrypted) are written to Supabase.
  • Prompt sizing. Extracted text is truncated to 12,000 characters before being passed to Gemini.

6.2 Avatars and Cosmetics (backend/services/storage_service.py)

  • Allowed MIME types:image/jpeg, image/png, image/webp, image/gif.
  • Max size: 5 MB.
  • Storage: Supabase Storage bucket avatars with paths avatars/{user_id}/avatar.{ext} and cosmetics/{cosmetic_id}.{ext}. Uploads use the service key with x-upsert: true; reads are public via signed-bucket URL.

6.3 OCR Backends (backend/services/extraction_backends/)

A thin router (extraction_service.py) selects a backend by OCR_ENGINE:

  • docling (default) — layout-aware markdown extraction.
  • auto — Docling, with GOT-OCR 2.0 fallback for low-density / math pages, only when GOT_OCR_ENABLED=true. GOT-OCR loads a Hugging Face model with trust_remote_code=True, so it stays disabled in production by default and the model path should be pinned to a known revision when enabled.
  • tesseract — legacy fallback.

All backends are lazy-imported to keep cold start sub-second. Failures degrade Docling → GOT-OCR → Tesseract before raising RuntimeError, which routes/extract.py surfaces as 503.


7. Logging

backend/main.py's RequestLogMiddleware records method, path, status, duration, and a random 8-char request id. It does not log request or response bodies, user ids, emails, names, tokens, or any decrypted column. Errors include the exception class name and traceback only.

Encryption helpers log a structured warning when decrypt_if_present falls back to a raw value; that's the operator signal that legacy plaintext is still being read on a column and the backfill should be re-run.


8. Dependency Surface

Security-relevant packages currently in use (no version audit performed; assume upstream patching):

Backend (backend/requirements.txt)

  • cryptography>=42,<46 — AES-GCM primitive.
  • fastapi, uvicorn — web framework.
  • google-auth-oauthlib, google-api-python-client — OAuth flow.
  • docling>=2.15, transformers>=4.46, torch>=2.5 — OCR / document extraction.

Frontend (frontend/package.json)

  • @supabase/supabase-js — Realtime client.
  • react, next — UI + middleware.
  • @opennextjs/cloudflare — Workers adapter.

9. Summary Matrix

DomainMechanismFile(s)
Sign-inOAuth 2.0 + PKCE (S256), @bu.edubackend/routes/auth.py
SessionHMAC-SHA256, 30-day expirybackend/services/auth_guard.py, frontend/src/lib/sessionToken.ts
CookieHttpOnly, Secure, SameSite=Lax, .saplinglearn.comfrontend/src/app/api/auth/session/route.ts, frontend/wrangler.toml
Frontend gateNext.js middleware → /api/auth/mefrontend/src/middleware.ts
Backend gaterequire_self / require_adminbackend/services/auth_guard.py
Approvalusers.is_approved + /pendingbackend/routes/auth.py, frontend/src/middleware.ts
Encryption at restAES-256-GCM, random nonce, base64backend/services/encryption.py
BackfillIdempotent re-encrypt walkerbackend/db/backfill_encryption.py
Supabase key splitService (backend) vs anon (frontend)backend/db/connection.py, frontend/src/lib/supabase.ts
CORSConfigured frontend origin + localhostbackend/main.py
TLSCloudflare Workers edge terminationfrontend/wrangler.toml
UploadsType + size validation, no raw persistbackend/routes/documents.py, backend/services/storage_service.py
LoggingNo PII / token / body loggingbackend/main.py

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Overview · SaplingLearn/Sapling · GitHub
Skip to content

Security: SaplingLearn/Sapling

Security

SECURITY.md

Sapling Security

This document describes the security controls currently implemented in Sapling. It is sourced directly from the code; every claim cites the file (and where useful, the line) that implements it.

The codebase is split into a FastAPI backend (backend/) and a Next.js frontend (frontend/) deployed to Cloudflare Workers. The threat model assumes a hostile public internet, untrusted user-uploaded content, and the need to keep student PII unreadable to anyone who lifts the database.


1. Authentication

1.1 Google OAuth 2.0 with PKCE

Files:backend/routes/auth.py, frontend/src/components/marketing/SignInModal.tsx, frontend/src/app/auth/callback/page.tsx, frontend/src/app/api/auth/session/route.ts

Sign-in is a full-page redirect to ${API_URL}/api/auth/google. The backend drives the OAuth flow and the frontend never touches Google directly.

  • PKCE (S256). Each sign-in mints a fresh 32-byte code verifier via secrets.token_bytes(32). The SHA-256 challenge is sent to Google with code_challenge_method=S256 (auth.py ~lines 68–73, 144–152). The verifier is carried in the OAuth state parameter, base64-encoded JSON: {"action": "signin", "cv": "<verifier>"}.
  • Code exchange. On callback, the backend decodes state, extracts the code verifier, and exchanges the code via flow.fetch_token(code=code, code_verifier=code_verifier). The redirect URI is pinned to GOOGLE_AUTH_REDIRECT_URI (env-supplied) and validated by Google.
  • Domain restriction. Only @bu.edu accounts may sign in. Other domains are redirected to ${FRONTEND_URL}/auth?error=invalid_domain (auth.py ~lines 186–189).
  • OAuth tokens at rest. Google access and refresh tokens are encrypted with AES-256-GCM before insertion into oauth_tokens (auth.py ~lines 228–229). They are never read back in plaintext from any current code path.

1.2 HMAC Session Tokens

Files:backend/services/auth_guard.py, frontend/src/lib/sessionToken.ts, frontend/src/app/api/auth/session/route.ts

Sapling does not use JWTs. Sessions are minimal HMAC-signed payloads of the form:

base64url(payload).base64url(signature)
  • Payload:{"user_id": "<id>", "exp": <unix_timestamp>}. The key name is user_id (snake_case) on both ends — alignment was a recent fix (commit 02b0242).
  • Algorithm: HMAC-SHA256 over the base64 payload using SESSION_SECRET (≥32 bytes, validated at runtime in sessionToken.ts:6).
  • Frontend signs with crypto.subtle.importKey + crypto.subtle.sign('HMAC', …).
  • Backend verifies with hmac.compare_digest() for timing-safe comparison (auth_guard.py:35).
  • Expiry: 30 days (SESSION_MAX_AGE = 2_592_000 in sessionToken.ts:1); enforced on both ends (auth_guard.py:48, sessionToken.ts:60).
  • Lookup: Backend reads the token from cookie sapling_session first, falling back to the auth_token query parameter (auth_guard.py:19).

1.3 Cookie Hardening

Files:frontend/src/app/api/auth/session/route.ts, frontend/wrangler.toml

The session cookie is set exclusively by the Next.js route handler — never by the backend in any deployed environment — using these flags:

FlagValuePurpose
httpOnlytrueInaccessible to JavaScript; mitigates XSS exfil
securetrueHTTPS-only
sameSitelaxImplicit CSRF defense for state-changing requests
path/Whole site
domain.saplinglearn.comShared across all subdomains (api, app, www)
maxAge30 daysSet to 0 on sign-out for immediate deletion

COOKIE_DOMAIN=.saplinglearn.com is shipped via wrangler.toml [vars] (commit b1377ec); the leading dot is what permits api.saplinglearn.com and the app origin to share the same session.

One test-only exception (#381).POST /api/auth/test-login (backend/routes/auth.py) sets sapling_session directly, with the same flags, so the pytest integration suite and Playwright's global setup can obtain a session without driving real Google OAuth (which is not headless-automatable). It is hard-gated on APP_ENV in {"local", "test"} — evaluated per request, not captured at import — and returns a stock 404 {"detail": "Not Found"} in every other environment, so it does not advertise its existence; it is also include_in_schema=False, so it never appears in /openapi.json. The allowlist is deliberately narrower than config.IS_LOCAL ({local, development, dev, test}). See backend/tests/test_auth_test_login.py.

1.4 Sign-out

UserContext.signOut() issues DELETE /api/auth/session, which writes a Max-Age=0 cookie under the same domain/Secure/SameSite settings, then clears in-memory and storage state. Tokens are stateless, so revocation is by cookie deletion + 30-day expiry; there is no server-side blacklist.


2. Authorization

2.1 Auth Guards

File:backend/services/auth_guard.py

Three building blocks gate every authenticated route:

  • get_session_user_id(request) — verifies the HMAC token and returns the user_id; raises 401 on missing/invalid/expired tokens.
  • require_self(user_id, request) — confirms the session user matches the target resource owner; raises 403 "Forbidden: not your account" otherwise.
  • require_admin(request) — joins user_roles to roles and checks for the admin slug; raises 403 "Admin access required" otherwise.
  • require_role(role_slug) — generic factory that returns a checker for any role.

Errors are surfaced as standard FastAPI HTTPException with deterministic status codes (401 unauthenticated, 403 authorized-but-forbidden).

2.2 Roles Schema

File:backend/db/migration_roles.sql

roles(id, slug UNIQUE, name, color, icon, description,
is_staff_assigned, is_earnable, display_priority)
user_roles(user_id, role_id, granted_at, granted_by)

Seeded slugs: admin, moderator, verified, vip, early-adopter. user_roles is indexed on user_id for fast guard lookups.

2.3 Admin Surface

File:backend/routes/admin.py

Every admin route declares require_admin(request) as a dependency. Coverage today:

  • Roles: list / create / update / delete; assign / revoke per user.
  • Achievements: list / create / update / delete; manual grant; trigger creation.
  • Cosmetics: list / create / update / delete (avatar frames, banners, name colors, titles).
  • Users: list (with decrypted names/emails for review); PATCH /users/{user_id}/approve.

2.4 Approval Gate

Files:backend/db/migration_add_is_approved.sql, backend/routes/auth.py, frontend/src/middleware.ts

New users are created with users.is_approved = false. The gate is enforced in two places:

  1. Backend OAuth callback. If is_approved is false the user is redirected to ${FRONTEND_URL}/pending instead of receiving a session token (auth.py ~lines 235–236).
  2. Frontend middleware. Even if a token is present, middleware.ts calls /api/auth/me for every protected route and redirects to /pending when data.is_approved !== true (middleware.ts:58).

Promotion happens via PATCH /api/admin/users/{user_id}/approve, which only admins can call.

2.5 Cross-Tenant Isolation

User-owned resources are gated either by require_self() or by explicit user_id filters on every Supabase query. Sampled enforcement:

  • routes/documents.pyrequire_self() on read/upload; deletes filter id eq <id> AND user_id eq <user>.
  • routes/flashcards.pyrequire_self() on list/import/commit; deletes scoped on user_id.
  • routes/gradebook.py_user_owns_course(), _user_owns_category(), _user_owns_assignment() helpers gate every write.
  • routes/calendar.pyrequire_self(user_id, request) on every endpoint.
  • routes/social.py — room membership is verified via room_id eq <id> AND user_id eq <viewer> before exposing chat or member graphs.

2.6 Frontend Route Guard

File:frontend/src/middleware.ts

A Next.js middleware enforces auth + approval before any of the 12 protected paths render:

/dashboard, /learn, /study, /tree, /library, /calendar,
/social, /settings, /achievements, /admin,
/gradebook, /course-planner

For each request it (1) reads sapling_session, (2) verifies the HMAC locally via verifySession, (3) calls the backend /api/auth/me with a 3-second AbortController timeout, and (4) checks is_approved. Any failure redirects to Google sign-in or /pending. NEXT_PUBLIC_LOCAL_MODE=true bypasses the middleware for local development only.


3. Encryption at Rest

3.1 Primitive

File:backend/services/encryption.py

  • Cipher: AES-256-GCM via cryptography.hazmat.primitives.ciphers.aead.AESGCM.
  • Key: 32 bytes loaded from ENCRYPTION_KEY at module import time. Validated as exactly 64 hex characters; any other shape raises RuntimeError and prevents the app from booting (_load_key, lines 43–60).
  • Nonce: 12 random bytes from os.urandom() per call. Never reused (verified in tests/test_encryption.py).
  • Wire format:base64(nonce || ciphertext_with_tag). The 16-byte GCM authentication tag is included in the ciphertext blob, so any bit-flip is detected on decrypt.

3.2 Helpers

HelperPurpose
encrypt(value)Encrypts a string; mints a fresh nonce.
decrypt(value)Decrypts; raises on tamper / wrong key.
encrypt_if_presentReturns None for None; otherwise stringifies and encrypts.
decrypt_if_presentTries to decrypt; falls back to the raw value with a warning if it cannot.
decrypt_numericDecrypts then casts to float; passes through native numerics.
encrypt_jsonCompact-serializes JSON, then encrypts.
decrypt_jsonDecrypts then json.loads; falls back to parsing raw input.

The *_if_present fallback is what lets a partially-backfilled table keep serving traffic: encrypted rows return plaintext, legacy plaintext rows return themselves, and an operator log warning surfaces every legacy read.

3.3 Encrypted Columns

Verified by grepping for encrypt_if_present / decrypt_if_present / encrypt_json / decrypt_json in backend/routes/:

TableColumn(s)Notes
usersname, first_name, last_name, email, bio, locationDecrypted at every read boundary.
user_settingsbio, locationProfile-edit duplicates.
oauth_tokensaccess_token, refresh_tokenEncrypted on insert; never read today.
documentssummary, concept_notesconcept_notes was retyped JSONB→TEXT.
messagescontentTutoring chat history.
room_messagestextStudy-room chat.
sessionssummary_jsonRetyped JSONB→TEXT; write-only today.
assignmentsnotes, points_possible, points_earnedNumeric columns retyped to TEXT.
calendar_*assignment notesEncrypted on every sync.

3.4 Migration & Backfill

  • backend/db/migration_encryption_text_columns.sql retypes columns whose original types couldn't hold base64 (NUMERIC and JSONBTEXT), preserving existing values via USING column::TEXT.
  • backend/db/backfill_encryption.py is idempotent: for each candidate row it tries to decrypt; if that fails, it encrypts and writes (only when --apply is passed). It supports --table for narrow runs and prints per-column counts on completion.

3.5 AI Prompt Boundary

All Gemini callers decrypt before constructing a prompt. None of decrypt_if_present / decrypt_json's outputs leave the process before being assembled into a system or user message:

  • routes/learn.py — student name (get_user_name), document summaries, and concept notes decrypted before _make_system_prompt() (learn.py ~lines 130, 133, 243, 286).
  • routes/quiz.py — student name decrypted before quiz_context_update and quiz_generation prompts (quiz.py ~lines 192, 198).
  • routes/study_guide.py — document summary and concept notes decrypted before the study-guide agent's context block is assembled (study_guide.py ~lines 43, 49, 54).
  • routes/flashcards.py — same decrypt pattern before flashcard extraction (~lines 130, 133, 424, 428).
  • routes/documents.py — concept extension and graph updates decrypt summaries first (~lines 237, 240, 454, 458).

3.6 Tests

backend/tests/test_encryption.py covers: round-trip ASCII / Unicode / 5000-char strings / empty strings, JSON helpers, numeric helpers, the *_if_present fallback path (including a warning assertion for legacy plaintext), nonce randomness on repeated encryption of the same plaintext, and tamper detection (a flipped bit on the auth tag must raise).


4. Secrets and Configuration

Files:backend/config.py, backend/.env.example, frontend/wrangler.toml, frontend/.env*

.env, .env.local, and other secret files are gitignored. The runtime contract:

VariableSurfacePurpose
SUPABASE_URLbothProject base URL.
SUPABASE_SERVICE_KEYbackendRLS-bypass service role key. Never exposed to the browser.
NEXT_PUBLIC_SUPABASE_ANON_KEYfrontendBrowser-safe key used only for Realtime room subscriptions.
ENCRYPTION_KEYbackend64-hex-char (32-byte) AES-256-GCM key.
SESSION_SECRETbothHMAC-SHA256 secret for session tokens (≥32 bytes).
GOOGLE_CLIENT_ID / _SECRETbackendOAuth credentials.
GOOGLE_AUTH_REDIRECT_URIbackendPinned OAuth callback URL.
GEMINI_API_KEYbackendLLM access.
COOKIE_DOMAINfrontend.saplinglearn.com for cross-subdomain session cookies.
OCR_ENGINE, GOT_OCR_ENABLEDbackendOCR backend selection.

ENCRYPTION_KEY and SESSION_SECRET are validated at import time — invalid or missing values fail fast.

Supabase key split

  • Backend (backend/db/connection.py, backend/services/storage_service.py) uses the service role key, which bypasses RLS. All persistent writes flow through the backend.
  • Frontend (frontend/src/lib/supabase.ts) uses the anon key and only for Supabase Realtime subscriptions in Social.tsx:
    • room:${roomId} channel — postgres_changes on room_messages / room_reactions, filtered by room_id.
    • presence:${roomId} channel — typing presence.
  • Room writes still flow through the backend (which checks membership before insert), so the anon key alone cannot post into a room a user has not joined.

5. Transport, CORS, and Deployment

  • TLS. The frontend runs on Cloudflare Workers via @opennextjs/cloudflare (wrangler.toml); TLS is terminated at the edge for *.saplinglearn.com and api.saplinglearn.com.
  • CORS (backend/main.py):
    CORSMiddleware(
    allow_origins=[FRONTEND_URL, "http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"], allow_headers=["*"],
    )
    Only the configured frontend origin and localhost for development are permitted. allow_credentials=True is required because the session cookie is sent cross-origin from the app to the API.
  • No wildcard origins. Production never sets * because credentials would be refused by the browser anyway.

6. File Uploads and OCR

6.1 Documents (backend/routes/documents.py)

  • Allowed types:.pdf, .docx, .pptx. Both extension and MIME type are validated.
  • Max size: 15 MB.
  • No raw file persistence. Files are extracted in-memory; only the metadata (name, category) and AI-generated summary/concept_notes (encrypted) are written to Supabase.
  • Prompt sizing. Extracted text is truncated to 12,000 characters before being passed to Gemini.

6.2 Avatars and Cosmetics (backend/services/storage_service.py)

  • Allowed MIME types:image/jpeg, image/png, image/webp, image/gif.
  • Max size: 5 MB.
  • Storage: Supabase Storage bucket avatars with paths avatars/{user_id}/avatar.{ext} and cosmetics/{cosmetic_id}.{ext}. Uploads use the service key with x-upsert: true; reads are public via signed-bucket URL.

6.3 OCR Backends (backend/services/extraction_backends/)

A thin router (extraction_service.py) selects a backend by OCR_ENGINE:

  • docling (default) — layout-aware markdown extraction.
  • auto — Docling, with GOT-OCR 2.0 fallback for low-density / math pages, only when GOT_OCR_ENABLED=true. GOT-OCR loads a Hugging Face model with trust_remote_code=True, so it stays disabled in production by default and the model path should be pinned to a known revision when enabled.
  • tesseract — legacy fallback.

All backends are lazy-imported to keep cold start sub-second. Failures degrade Docling → GOT-OCR → Tesseract before raising RuntimeError, which routes/extract.py surfaces as 503.


7. Logging

backend/main.py's RequestLogMiddleware records method, path, status, duration, and a random 8-char request id. It does not log request or response bodies, user ids, emails, names, tokens, or any decrypted column. Errors include the exception class name and traceback only.

Encryption helpers log a structured warning when decrypt_if_present falls back to a raw value; that's the operator signal that legacy plaintext is still being read on a column and the backfill should be re-run.


8. Dependency Surface

Security-relevant packages currently in use (no version audit performed; assume upstream patching):

Backend (backend/requirements.txt)

  • cryptography>=42,<46 — AES-GCM primitive.
  • fastapi, uvicorn — web framework.
  • google-auth-oauthlib, google-api-python-client — OAuth flow.
  • docling>=2.15, transformers>=4.46, torch>=2.5 — OCR / document extraction.

Frontend (frontend/package.json)

  • @supabase/supabase-js — Realtime client.
  • react, next — UI + middleware.
  • @opennextjs/cloudflare — Workers adapter.

9. Summary Matrix

DomainMechanismFile(s)
Sign-inOAuth 2.0 + PKCE (S256), @bu.edubackend/routes/auth.py
SessionHMAC-SHA256, 30-day expirybackend/services/auth_guard.py, frontend/src/lib/sessionToken.ts
CookieHttpOnly, Secure, SameSite=Lax, .saplinglearn.comfrontend/src/app/api/auth/session/route.ts, frontend/wrangler.toml
Frontend gateNext.js middleware → /api/auth/mefrontend/src/middleware.ts
Backend gaterequire_self / require_adminbackend/services/auth_guard.py
Approvalusers.is_approved + /pendingbackend/routes/auth.py, frontend/src/middleware.ts
Encryption at restAES-256-GCM, random nonce, base64backend/services/encryption.py
BackfillIdempotent re-encrypt walkerbackend/db/backfill_encryption.py
Supabase key splitService (backend) vs anon (frontend)backend/db/connection.py, frontend/src/lib/supabase.ts
CORSConfigured frontend origin + localhostbackend/main.py
TLSCloudflare Workers edge terminationfrontend/wrangler.toml
UploadsType + size validation, no raw persistbackend/routes/documents.py, backend/services/storage_service.py
LoggingNo PII / token / body loggingbackend/main.py

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Overview · SaplingLearn/Sapling · GitHub
Skip to content

Security: SaplingLearn/Sapling

Security

SECURITY.md

Sapling Security

This document describes the security controls currently implemented in Sapling. It is sourced directly from the code; every claim cites the file (and where useful, the line) that implements it.

The codebase is split into a FastAPI backend (backend/) and a Next.js frontend (frontend/) deployed to Cloudflare Workers. The threat model assumes a hostile public internet, untrusted user-uploaded content, and the need to keep student PII unreadable to anyone who lifts the database.


1. Authentication

1.1 Google OAuth 2.0 with PKCE

Files:backend/routes/auth.py, frontend/src/components/marketing/SignInModal.tsx, frontend/src/app/auth/callback/page.tsx, frontend/src/app/api/auth/session/route.ts

Sign-in is a full-page redirect to ${API_URL}/api/auth/google. The backend drives the OAuth flow and the frontend never touches Google directly.

  • PKCE (S256). Each sign-in mints a fresh 32-byte code verifier via secrets.token_bytes(32). The SHA-256 challenge is sent to Google with code_challenge_method=S256 (auth.py ~lines 68–73, 144–152). The verifier is carried in the OAuth state parameter, base64-encoded JSON: {"action": "signin", "cv": "<verifier>"}.
  • Code exchange. On callback, the backend decodes state, extracts the code verifier, and exchanges the code via flow.fetch_token(code=code, code_verifier=code_verifier). The redirect URI is pinned to GOOGLE_AUTH_REDIRECT_URI (env-supplied) and validated by Google.
  • Domain restriction. Only @bu.edu accounts may sign in. Other domains are redirected to ${FRONTEND_URL}/auth?error=invalid_domain (auth.py ~lines 186–189).
  • OAuth tokens at rest. Google access and refresh tokens are encrypted with AES-256-GCM before insertion into oauth_tokens (auth.py ~lines 228–229). They are never read back in plaintext from any current code path.

1.2 HMAC Session Tokens

Files:backend/services/auth_guard.py, frontend/src/lib/sessionToken.ts, frontend/src/app/api/auth/session/route.ts

Sapling does not use JWTs. Sessions are minimal HMAC-signed payloads of the form:

base64url(payload).base64url(signature)
  • Payload:{"user_id": "<id>", "exp": <unix_timestamp>}. The key name is user_id (snake_case) on both ends — alignment was a recent fix (commit 02b0242).
  • Algorithm: HMAC-SHA256 over the base64 payload using SESSION_SECRET (≥32 bytes, validated at runtime in sessionToken.ts:6).
  • Frontend signs with crypto.subtle.importKey + crypto.subtle.sign('HMAC', …).
  • Backend verifies with hmac.compare_digest() for timing-safe comparison (auth_guard.py:35).
  • Expiry: 30 days (SESSION_MAX_AGE = 2_592_000 in sessionToken.ts:1); enforced on both ends (auth_guard.py:48, sessionToken.ts:60).
  • Lookup: Backend reads the token from cookie sapling_session first, falling back to the auth_token query parameter (auth_guard.py:19).

1.3 Cookie Hardening

Files:frontend/src/app/api/auth/session/route.ts, frontend/wrangler.toml

The session cookie is set exclusively by the Next.js route handler — never by the backend in any deployed environment — using these flags:

FlagValuePurpose
httpOnlytrueInaccessible to JavaScript; mitigates XSS exfil
securetrueHTTPS-only
sameSitelaxImplicit CSRF defense for state-changing requests
path/Whole site
domain.saplinglearn.comShared across all subdomains (api, app, www)
maxAge30 daysSet to 0 on sign-out for immediate deletion

COOKIE_DOMAIN=.saplinglearn.com is shipped via wrangler.toml [vars] (commit b1377ec); the leading dot is what permits api.saplinglearn.com and the app origin to share the same session.

One test-only exception (#381).POST /api/auth/test-login (backend/routes/auth.py) sets sapling_session directly, with the same flags, so the pytest integration suite and Playwright's global setup can obtain a session without driving real Google OAuth (which is not headless-automatable). It is hard-gated on APP_ENV in {"local", "test"} — evaluated per request, not captured at import — and returns a stock 404 {"detail": "Not Found"} in every other environment, so it does not advertise its existence; it is also include_in_schema=False, so it never appears in /openapi.json. The allowlist is deliberately narrower than config.IS_LOCAL ({local, development, dev, test}). See backend/tests/test_auth_test_login.py.

1.4 Sign-out

UserContext.signOut() issues DELETE /api/auth/session, which writes a Max-Age=0 cookie under the same domain/Secure/SameSite settings, then clears in-memory and storage state. Tokens are stateless, so revocation is by cookie deletion + 30-day expiry; there is no server-side blacklist.


2. Authorization

2.1 Auth Guards

File:backend/services/auth_guard.py

Three building blocks gate every authenticated route:

  • get_session_user_id(request) — verifies the HMAC token and returns the user_id; raises 401 on missing/invalid/expired tokens.
  • require_self(user_id, request) — confirms the session user matches the target resource owner; raises 403 "Forbidden: not your account" otherwise.
  • require_admin(request) — joins user_roles to roles and checks for the admin slug; raises 403 "Admin access required" otherwise.
  • require_role(role_slug) — generic factory that returns a checker for any role.

Errors are surfaced as standard FastAPI HTTPException with deterministic status codes (401 unauthenticated, 403 authorized-but-forbidden).

2.2 Roles Schema

File:backend/db/migration_roles.sql

roles(id, slug UNIQUE, name, color, icon, description,
is_staff_assigned, is_earnable, display_priority)
user_roles(user_id, role_id, granted_at, granted_by)

Seeded slugs: admin, moderator, verified, vip, early-adopter. user_roles is indexed on user_id for fast guard lookups.

2.3 Admin Surface

File:backend/routes/admin.py

Every admin route declares require_admin(request) as a dependency. Coverage today:

  • Roles: list / create / update / delete; assign / revoke per user.
  • Achievements: list / create / update / delete; manual grant; trigger creation.
  • Cosmetics: list / create / update / delete (avatar frames, banners, name colors, titles).
  • Users: list (with decrypted names/emails for review); PATCH /users/{user_id}/approve.

2.4 Approval Gate

Files:backend/db/migration_add_is_approved.sql, backend/routes/auth.py, frontend/src/middleware.ts

New users are created with users.is_approved = false. The gate is enforced in two places:

  1. Backend OAuth callback. If is_approved is false the user is redirected to ${FRONTEND_URL}/pending instead of receiving a session token (auth.py ~lines 235–236).
  2. Frontend middleware. Even if a token is present, middleware.ts calls /api/auth/me for every protected route and redirects to /pending when data.is_approved !== true (middleware.ts:58).

Promotion happens via PATCH /api/admin/users/{user_id}/approve, which only admins can call.

2.5 Cross-Tenant Isolation

User-owned resources are gated either by require_self() or by explicit user_id filters on every Supabase query. Sampled enforcement:

  • routes/documents.pyrequire_self() on read/upload; deletes filter id eq <id> AND user_id eq <user>.
  • routes/flashcards.pyrequire_self() on list/import/commit; deletes scoped on user_id.
  • routes/gradebook.py_user_owns_course(), _user_owns_category(), _user_owns_assignment() helpers gate every write.
  • routes/calendar.pyrequire_self(user_id, request) on every endpoint.
  • routes/social.py — room membership is verified via room_id eq <id> AND user_id eq <viewer> before exposing chat or member graphs.

2.6 Frontend Route Guard

File:frontend/src/middleware.ts

A Next.js middleware enforces auth + approval before any of the 12 protected paths render:

/dashboard, /learn, /study, /tree, /library, /calendar,
/social, /settings, /achievements, /admin,
/gradebook, /course-planner

For each request it (1) reads sapling_session, (2) verifies the HMAC locally via verifySession, (3) calls the backend /api/auth/me with a 3-second AbortController timeout, and (4) checks is_approved. Any failure redirects to Google sign-in or /pending. NEXT_PUBLIC_LOCAL_MODE=true bypasses the middleware for local development only.


3. Encryption at Rest

3.1 Primitive

File:backend/services/encryption.py

  • Cipher: AES-256-GCM via cryptography.hazmat.primitives.ciphers.aead.AESGCM.
  • Key: 32 bytes loaded from ENCRYPTION_KEY at module import time. Validated as exactly 64 hex characters; any other shape raises RuntimeError and prevents the app from booting (_load_key, lines 43–60).
  • Nonce: 12 random bytes from os.urandom() per call. Never reused (verified in tests/test_encryption.py).
  • Wire format:base64(nonce || ciphertext_with_tag). The 16-byte GCM authentication tag is included in the ciphertext blob, so any bit-flip is detected on decrypt.

3.2 Helpers

HelperPurpose
encrypt(value)Encrypts a string; mints a fresh nonce.
decrypt(value)Decrypts; raises on tamper / wrong key.
encrypt_if_presentReturns None for None; otherwise stringifies and encrypts.
decrypt_if_presentTries to decrypt; falls back to the raw value with a warning if it cannot.
decrypt_numericDecrypts then casts to float; passes through native numerics.
encrypt_jsonCompact-serializes JSON, then encrypts.
decrypt_jsonDecrypts then json.loads; falls back to parsing raw input.

The *_if_present fallback is what lets a partially-backfilled table keep serving traffic: encrypted rows return plaintext, legacy plaintext rows return themselves, and an operator log warning surfaces every legacy read.

3.3 Encrypted Columns

Verified by grepping for encrypt_if_present / decrypt_if_present / encrypt_json / decrypt_json in backend/routes/:

TableColumn(s)Notes
usersname, first_name, last_name, email, bio, locationDecrypted at every read boundary.
user_settingsbio, locationProfile-edit duplicates.
oauth_tokensaccess_token, refresh_tokenEncrypted on insert; never read today.
documentssummary, concept_notesconcept_notes was retyped JSONB→TEXT.
messagescontentTutoring chat history.
room_messagestextStudy-room chat.
sessionssummary_jsonRetyped JSONB→TEXT; write-only today.
assignmentsnotes, points_possible, points_earnedNumeric columns retyped to TEXT.
calendar_*assignment notesEncrypted on every sync.

3.4 Migration & Backfill

  • backend/db/migration_encryption_text_columns.sql retypes columns whose original types couldn't hold base64 (NUMERIC and JSONBTEXT), preserving existing values via USING column::TEXT.
  • backend/db/backfill_encryption.py is idempotent: for each candidate row it tries to decrypt; if that fails, it encrypts and writes (only when --apply is passed). It supports --table for narrow runs and prints per-column counts on completion.

3.5 AI Prompt Boundary

All Gemini callers decrypt before constructing a prompt. None of decrypt_if_present / decrypt_json's outputs leave the process before being assembled into a system or user message:

  • routes/learn.py — student name (get_user_name), document summaries, and concept notes decrypted before _make_system_prompt() (learn.py ~lines 130, 133, 243, 286).
  • routes/quiz.py — student name decrypted before quiz_context_update and quiz_generation prompts (quiz.py ~lines 192, 198).
  • routes/study_guide.py — document summary and concept notes decrypted before the study-guide agent's context block is assembled (study_guide.py ~lines 43, 49, 54).
  • routes/flashcards.py — same decrypt pattern before flashcard extraction (~lines 130, 133, 424, 428).
  • routes/documents.py — concept extension and graph updates decrypt summaries first (~lines 237, 240, 454, 458).

3.6 Tests

backend/tests/test_encryption.py covers: round-trip ASCII / Unicode / 5000-char strings / empty strings, JSON helpers, numeric helpers, the *_if_present fallback path (including a warning assertion for legacy plaintext), nonce randomness on repeated encryption of the same plaintext, and tamper detection (a flipped bit on the auth tag must raise).


4. Secrets and Configuration

Files:backend/config.py, backend/.env.example, frontend/wrangler.toml, frontend/.env*

.env, .env.local, and other secret files are gitignored. The runtime contract:

VariableSurfacePurpose
SUPABASE_URLbothProject base URL.
SUPABASE_SERVICE_KEYbackendRLS-bypass service role key. Never exposed to the browser.
NEXT_PUBLIC_SUPABASE_ANON_KEYfrontendBrowser-safe key used only for Realtime room subscriptions.
ENCRYPTION_KEYbackend64-hex-char (32-byte) AES-256-GCM key.
SESSION_SECRETbothHMAC-SHA256 secret for session tokens (≥32 bytes).
GOOGLE_CLIENT_ID / _SECRETbackendOAuth credentials.
GOOGLE_AUTH_REDIRECT_URIbackendPinned OAuth callback URL.
GEMINI_API_KEYbackendLLM access.
COOKIE_DOMAINfrontend.saplinglearn.com for cross-subdomain session cookies.
OCR_ENGINE, GOT_OCR_ENABLEDbackendOCR backend selection.

ENCRYPTION_KEY and SESSION_SECRET are validated at import time — invalid or missing values fail fast.

Supabase key split

  • Backend (backend/db/connection.py, backend/services/storage_service.py) uses the service role key, which bypasses RLS. All persistent writes flow through the backend.
  • Frontend (frontend/src/lib/supabase.ts) uses the anon key and only for Supabase Realtime subscriptions in Social.tsx:
    • room:${roomId} channel — postgres_changes on room_messages / room_reactions, filtered by room_id.
    • presence:${roomId} channel — typing presence.
  • Room writes still flow through the backend (which checks membership before insert), so the anon key alone cannot post into a room a user has not joined.

5. Transport, CORS, and Deployment

  • TLS. The frontend runs on Cloudflare Workers via @opennextjs/cloudflare (wrangler.toml); TLS is terminated at the edge for *.saplinglearn.com and api.saplinglearn.com.
  • CORS (backend/main.py):
    CORSMiddleware(
    allow_origins=[FRONTEND_URL, "http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"], allow_headers=["*"],
    )
    Only the configured frontend origin and localhost for development are permitted. allow_credentials=True is required because the session cookie is sent cross-origin from the app to the API.
  • No wildcard origins. Production never sets * because credentials would be refused by the browser anyway.

6. File Uploads and OCR

6.1 Documents (backend/routes/documents.py)

  • Allowed types:.pdf, .docx, .pptx. Both extension and MIME type are validated.
  • Max size: 15 MB.
  • No raw file persistence. Files are extracted in-memory; only the metadata (name, category) and AI-generated summary/concept_notes (encrypted) are written to Supabase.
  • Prompt sizing. Extracted text is truncated to 12,000 characters before being passed to Gemini.

6.2 Avatars and Cosmetics (backend/services/storage_service.py)

  • Allowed MIME types:image/jpeg, image/png, image/webp, image/gif.
  • Max size: 5 MB.
  • Storage: Supabase Storage bucket avatars with paths avatars/{user_id}/avatar.{ext} and cosmetics/{cosmetic_id}.{ext}. Uploads use the service key with x-upsert: true; reads are public via signed-bucket URL.

6.3 OCR Backends (backend/services/extraction_backends/)

A thin router (extraction_service.py) selects a backend by OCR_ENGINE:

  • docling (default) — layout-aware markdown extraction.
  • auto — Docling, with GOT-OCR 2.0 fallback for low-density / math pages, only when GOT_OCR_ENABLED=true. GOT-OCR loads a Hugging Face model with trust_remote_code=True, so it stays disabled in production by default and the model path should be pinned to a known revision when enabled.
  • tesseract — legacy fallback.

All backends are lazy-imported to keep cold start sub-second. Failures degrade Docling → GOT-OCR → Tesseract before raising RuntimeError, which routes/extract.py surfaces as 503.


7. Logging

backend/main.py's RequestLogMiddleware records method, path, status, duration, and a random 8-char request id. It does not log request or response bodies, user ids, emails, names, tokens, or any decrypted column. Errors include the exception class name and traceback only.

Encryption helpers log a structured warning when decrypt_if_present falls back to a raw value; that's the operator signal that legacy plaintext is still being read on a column and the backfill should be re-run.


8. Dependency Surface

Security-relevant packages currently in use (no version audit performed; assume upstream patching):

Backend (backend/requirements.txt)

  • cryptography>=42,<46 — AES-GCM primitive.
  • fastapi, uvicorn — web framework.
  • google-auth-oauthlib, google-api-python-client — OAuth flow.
  • docling>=2.15, transformers>=4.46, torch>=2.5 — OCR / document extraction.

Frontend (frontend/package.json)

  • @supabase/supabase-js — Realtime client.
  • react, next — UI + middleware.
  • @opennextjs/cloudflare — Workers adapter.

9. Summary Matrix

DomainMechanismFile(s)
Sign-inOAuth 2.0 + PKCE (S256), @bu.edubackend/routes/auth.py
SessionHMAC-SHA256, 30-day expirybackend/services/auth_guard.py, frontend/src/lib/sessionToken.ts
CookieHttpOnly, Secure, SameSite=Lax, .saplinglearn.comfrontend/src/app/api/auth/session/route.ts, frontend/wrangler.toml
Frontend gateNext.js middleware → /api/auth/mefrontend/src/middleware.ts
Backend gaterequire_self / require_adminbackend/services/auth_guard.py
Approvalusers.is_approved + /pendingbackend/routes/auth.py, frontend/src/middleware.ts
Encryption at restAES-256-GCM, random nonce, base64backend/services/encryption.py
BackfillIdempotent re-encrypt walkerbackend/db/backfill_encryption.py
Supabase key splitService (backend) vs anon (frontend)backend/db/connection.py, frontend/src/lib/supabase.ts
CORSConfigured frontend origin + localhostbackend/main.py
TLSCloudflare Workers edge terminationfrontend/wrangler.toml
UploadsType + size validation, no raw persistbackend/routes/documents.py, backend/services/storage_service.py
LoggingNo PII / token / body loggingbackend/main.py

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Overview · SaplingLearn/Sapling · GitHub
Skip to content

Security: SaplingLearn/Sapling

Security

SECURITY.md

Sapling Security

This document describes the security controls currently implemented in Sapling. It is sourced directly from the code; every claim cites the file (and where useful, the line) that implements it.

The codebase is split into a FastAPI backend (backend/) and a Next.js frontend (frontend/) deployed to Cloudflare Workers. The threat model assumes a hostile public internet, untrusted user-uploaded content, and the need to keep student PII unreadable to anyone who lifts the database.


1. Authentication

1.1 Google OAuth 2.0 with PKCE

Files:backend/routes/auth.py, frontend/src/components/marketing/SignInModal.tsx, frontend/src/app/auth/callback/page.tsx, frontend/src/app/api/auth/session/route.ts

Sign-in is a full-page redirect to ${API_URL}/api/auth/google. The backend drives the OAuth flow and the frontend never touches Google directly.

  • PKCE (S256). Each sign-in mints a fresh 32-byte code verifier via secrets.token_bytes(32). The SHA-256 challenge is sent to Google with code_challenge_method=S256 (auth.py ~lines 68–73, 144–152). The verifier is carried in the OAuth state parameter, base64-encoded JSON: {"action": "signin", "cv": "<verifier>"}.
  • Code exchange. On callback, the backend decodes state, extracts the code verifier, and exchanges the code via flow.fetch_token(code=code, code_verifier=code_verifier). The redirect URI is pinned to GOOGLE_AUTH_REDIRECT_URI (env-supplied) and validated by Google.
  • Domain restriction. Only @bu.edu accounts may sign in. Other domains are redirected to ${FRONTEND_URL}/auth?error=invalid_domain (auth.py ~lines 186–189).
  • OAuth tokens at rest. Google access and refresh tokens are encrypted with AES-256-GCM before insertion into oauth_tokens (auth.py ~lines 228–229). They are never read back in plaintext from any current code path.

1.2 HMAC Session Tokens

Files:backend/services/auth_guard.py, frontend/src/lib/sessionToken.ts, frontend/src/app/api/auth/session/route.ts

Sapling does not use JWTs. Sessions are minimal HMAC-signed payloads of the form:

base64url(payload).base64url(signature)
  • Payload:{"user_id": "<id>", "exp": <unix_timestamp>}. The key name is user_id (snake_case) on both ends — alignment was a recent fix (commit 02b0242).
  • Algorithm: HMAC-SHA256 over the base64 payload using SESSION_SECRET (≥32 bytes, validated at runtime in sessionToken.ts:6).
  • Frontend signs with crypto.subtle.importKey + crypto.subtle.sign('HMAC', …).
  • Backend verifies with hmac.compare_digest() for timing-safe comparison (auth_guard.py:35).
  • Expiry: 30 days (SESSION_MAX_AGE = 2_592_000 in sessionToken.ts:1); enforced on both ends (auth_guard.py:48, sessionToken.ts:60).
  • Lookup: Backend reads the token from cookie sapling_session first, falling back to the auth_token query parameter (auth_guard.py:19).

1.3 Cookie Hardening

Files:frontend/src/app/api/auth/session/route.ts, frontend/wrangler.toml

The session cookie is set exclusively by the Next.js route handler — never by the backend in any deployed environment — using these flags:

FlagValuePurpose
httpOnlytrueInaccessible to JavaScript; mitigates XSS exfil
securetrueHTTPS-only
sameSitelaxImplicit CSRF defense for state-changing requests
path/Whole site
domain.saplinglearn.comShared across all subdomains (api, app, www)
maxAge30 daysSet to 0 on sign-out for immediate deletion

COOKIE_DOMAIN=.saplinglearn.com is shipped via wrangler.toml [vars] (commit b1377ec); the leading dot is what permits api.saplinglearn.com and the app origin to share the same session.

One test-only exception (#381).POST /api/auth/test-login (backend/routes/auth.py) sets sapling_session directly, with the same flags, so the pytest integration suite and Playwright's global setup can obtain a session without driving real Google OAuth (which is not headless-automatable). It is hard-gated on APP_ENV in {"local", "test"} — evaluated per request, not captured at import — and returns a stock 404 {"detail": "Not Found"} in every other environment, so it does not advertise its existence; it is also include_in_schema=False, so it never appears in /openapi.json. The allowlist is deliberately narrower than config.IS_LOCAL ({local, development, dev, test}). See backend/tests/test_auth_test_login.py.

1.4 Sign-out

UserContext.signOut() issues DELETE /api/auth/session, which writes a Max-Age=0 cookie under the same domain/Secure/SameSite settings, then clears in-memory and storage state. Tokens are stateless, so revocation is by cookie deletion + 30-day expiry; there is no server-side blacklist.


2. Authorization

2.1 Auth Guards

File:backend/services/auth_guard.py

Three building blocks gate every authenticated route:

  • get_session_user_id(request) — verifies the HMAC token and returns the user_id; raises 401 on missing/invalid/expired tokens.
  • require_self(user_id, request) — confirms the session user matches the target resource owner; raises 403 "Forbidden: not your account" otherwise.
  • require_admin(request) — joins user_roles to roles and checks for the admin slug; raises 403 "Admin access required" otherwise.
  • require_role(role_slug) — generic factory that returns a checker for any role.

Errors are surfaced as standard FastAPI HTTPException with deterministic status codes (401 unauthenticated, 403 authorized-but-forbidden).

2.2 Roles Schema

File:backend/db/migration_roles.sql

roles(id, slug UNIQUE, name, color, icon, description,
is_staff_assigned, is_earnable, display_priority)
user_roles(user_id, role_id, granted_at, granted_by)

Seeded slugs: admin, moderator, verified, vip, early-adopter. user_roles is indexed on user_id for fast guard lookups.

2.3 Admin Surface

File:backend/routes/admin.py

Every admin route declares require_admin(request) as a dependency. Coverage today:

  • Roles: list / create / update / delete; assign / revoke per user.
  • Achievements: list / create / update / delete; manual grant; trigger creation.
  • Cosmetics: list / create / update / delete (avatar frames, banners, name colors, titles).
  • Users: list (with decrypted names/emails for review); PATCH /users/{user_id}/approve.

2.4 Approval Gate

Files:backend/db/migration_add_is_approved.sql, backend/routes/auth.py, frontend/src/middleware.ts

New users are created with users.is_approved = false. The gate is enforced in two places:

  1. Backend OAuth callback. If is_approved is false the user is redirected to ${FRONTEND_URL}/pending instead of receiving a session token (auth.py ~lines 235–236).
  2. Frontend middleware. Even if a token is present, middleware.ts calls /api/auth/me for every protected route and redirects to /pending when data.is_approved !== true (middleware.ts:58).

Promotion happens via PATCH /api/admin/users/{user_id}/approve, which only admins can call.

2.5 Cross-Tenant Isolation

User-owned resources are gated either by require_self() or by explicit user_id filters on every Supabase query. Sampled enforcement:

  • routes/documents.pyrequire_self() on read/upload; deletes filter id eq <id> AND user_id eq <user>.
  • routes/flashcards.pyrequire_self() on list/import/commit; deletes scoped on user_id.
  • routes/gradebook.py_user_owns_course(), _user_owns_category(), _user_owns_assignment() helpers gate every write.
  • routes/calendar.pyrequire_self(user_id, request) on every endpoint.
  • routes/social.py — room membership is verified via room_id eq <id> AND user_id eq <viewer> before exposing chat or member graphs.

2.6 Frontend Route Guard

File:frontend/src/middleware.ts

A Next.js middleware enforces auth + approval before any of the 12 protected paths render:

/dashboard, /learn, /study, /tree, /library, /calendar,
/social, /settings, /achievements, /admin,
/gradebook, /course-planner

For each request it (1) reads sapling_session, (2) verifies the HMAC locally via verifySession, (3) calls the backend /api/auth/me with a 3-second AbortController timeout, and (4) checks is_approved. Any failure redirects to Google sign-in or /pending. NEXT_PUBLIC_LOCAL_MODE=true bypasses the middleware for local development only.


3. Encryption at Rest

3.1 Primitive

File:backend/services/encryption.py

  • Cipher: AES-256-GCM via cryptography.hazmat.primitives.ciphers.aead.AESGCM.
  • Key: 32 bytes loaded from ENCRYPTION_KEY at module import time. Validated as exactly 64 hex characters; any other shape raises RuntimeError and prevents the app from booting (_load_key, lines 43–60).
  • Nonce: 12 random bytes from os.urandom() per call. Never reused (verified in tests/test_encryption.py).
  • Wire format:base64(nonce || ciphertext_with_tag). The 16-byte GCM authentication tag is included in the ciphertext blob, so any bit-flip is detected on decrypt.

3.2 Helpers

HelperPurpose
encrypt(value)Encrypts a string; mints a fresh nonce.
decrypt(value)Decrypts; raises on tamper / wrong key.
encrypt_if_presentReturns None for None; otherwise stringifies and encrypts.
decrypt_if_presentTries to decrypt; falls back to the raw value with a warning if it cannot.
decrypt_numericDecrypts then casts to float; passes through native numerics.
encrypt_jsonCompact-serializes JSON, then encrypts.
decrypt_jsonDecrypts then json.loads; falls back to parsing raw input.

The *_if_present fallback is what lets a partially-backfilled table keep serving traffic: encrypted rows return plaintext, legacy plaintext rows return themselves, and an operator log warning surfaces every legacy read.

3.3 Encrypted Columns

Verified by grepping for encrypt_if_present / decrypt_if_present / encrypt_json / decrypt_json in backend/routes/:

TableColumn(s)Notes
usersname, first_name, last_name, email, bio, locationDecrypted at every read boundary.
user_settingsbio, locationProfile-edit duplicates.
oauth_tokensaccess_token, refresh_tokenEncrypted on insert; never read today.
documentssummary, concept_notesconcept_notes was retyped JSONB→TEXT.
messagescontentTutoring chat history.
room_messagestextStudy-room chat.
sessionssummary_jsonRetyped JSONB→TEXT; write-only today.
assignmentsnotes, points_possible, points_earnedNumeric columns retyped to TEXT.
calendar_*assignment notesEncrypted on every sync.

3.4 Migration & Backfill

  • backend/db/migration_encryption_text_columns.sql retypes columns whose original types couldn't hold base64 (NUMERIC and JSONBTEXT), preserving existing values via USING column::TEXT.
  • backend/db/backfill_encryption.py is idempotent: for each candidate row it tries to decrypt; if that fails, it encrypts and writes (only when --apply is passed). It supports --table for narrow runs and prints per-column counts on completion.

3.5 AI Prompt Boundary

All Gemini callers decrypt before constructing a prompt. None of decrypt_if_present / decrypt_json's outputs leave the process before being assembled into a system or user message:

  • routes/learn.py — student name (get_user_name), document summaries, and concept notes decrypted before _make_system_prompt() (learn.py ~lines 130, 133, 243, 286).
  • routes/quiz.py — student name decrypted before quiz_context_update and quiz_generation prompts (quiz.py ~lines 192, 198).
  • routes/study_guide.py — document summary and concept notes decrypted before the study-guide agent's context block is assembled (study_guide.py ~lines 43, 49, 54).
  • routes/flashcards.py — same decrypt pattern before flashcard extraction (~lines 130, 133, 424, 428).
  • routes/documents.py — concept extension and graph updates decrypt summaries first (~lines 237, 240, 454, 458).

3.6 Tests

backend/tests/test_encryption.py covers: round-trip ASCII / Unicode / 5000-char strings / empty strings, JSON helpers, numeric helpers, the *_if_present fallback path (including a warning assertion for legacy plaintext), nonce randomness on repeated encryption of the same plaintext, and tamper detection (a flipped bit on the auth tag must raise).


4. Secrets and Configuration

Files:backend/config.py, backend/.env.example, frontend/wrangler.toml, frontend/.env*

.env, .env.local, and other secret files are gitignored. The runtime contract:

VariableSurfacePurpose
SUPABASE_URLbothProject base URL.
SUPABASE_SERVICE_KEYbackendRLS-bypass service role key. Never exposed to the browser.
NEXT_PUBLIC_SUPABASE_ANON_KEYfrontendBrowser-safe key used only for Realtime room subscriptions.
ENCRYPTION_KEYbackend64-hex-char (32-byte) AES-256-GCM key.
SESSION_SECRETbothHMAC-SHA256 secret for session tokens (≥32 bytes).
GOOGLE_CLIENT_ID / _SECRETbackendOAuth credentials.
GOOGLE_AUTH_REDIRECT_URIbackendPinned OAuth callback URL.
GEMINI_API_KEYbackendLLM access.
COOKIE_DOMAINfrontend.saplinglearn.com for cross-subdomain session cookies.
OCR_ENGINE, GOT_OCR_ENABLEDbackendOCR backend selection.

ENCRYPTION_KEY and SESSION_SECRET are validated at import time — invalid or missing values fail fast.

Supabase key split

  • Backend (backend/db/connection.py, backend/services/storage_service.py) uses the service role key, which bypasses RLS. All persistent writes flow through the backend.
  • Frontend (frontend/src/lib/supabase.ts) uses the anon key and only for Supabase Realtime subscriptions in Social.tsx:
    • room:${roomId} channel — postgres_changes on room_messages / room_reactions, filtered by room_id.
    • presence:${roomId} channel — typing presence.
  • Room writes still flow through the backend (which checks membership before insert), so the anon key alone cannot post into a room a user has not joined.

5. Transport, CORS, and Deployment

  • TLS. The frontend runs on Cloudflare Workers via @opennextjs/cloudflare (wrangler.toml); TLS is terminated at the edge for *.saplinglearn.com and api.saplinglearn.com.
  • CORS (backend/main.py):
    CORSMiddleware(
    allow_origins=[FRONTEND_URL, "http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"], allow_headers=["*"],
    )
    Only the configured frontend origin and localhost for development are permitted. allow_credentials=True is required because the session cookie is sent cross-origin from the app to the API.
  • No wildcard origins. Production never sets * because credentials would be refused by the browser anyway.

6. File Uploads and OCR

6.1 Documents (backend/routes/documents.py)

  • Allowed types:.pdf, .docx, .pptx. Both extension and MIME type are validated.
  • Max size: 15 MB.
  • No raw file persistence. Files are extracted in-memory; only the metadata (name, category) and AI-generated summary/concept_notes (encrypted) are written to Supabase.
  • Prompt sizing. Extracted text is truncated to 12,000 characters before being passed to Gemini.

6.2 Avatars and Cosmetics (backend/services/storage_service.py)

  • Allowed MIME types:image/jpeg, image/png, image/webp, image/gif.
  • Max size: 5 MB.
  • Storage: Supabase Storage bucket avatars with paths avatars/{user_id}/avatar.{ext} and cosmetics/{cosmetic_id}.{ext}. Uploads use the service key with x-upsert: true; reads are public via signed-bucket URL.

6.3 OCR Backends (backend/services/extraction_backends/)

A thin router (extraction_service.py) selects a backend by OCR_ENGINE:

  • docling (default) — layout-aware markdown extraction.
  • auto — Docling, with GOT-OCR 2.0 fallback for low-density / math pages, only when GOT_OCR_ENABLED=true. GOT-OCR loads a Hugging Face model with trust_remote_code=True, so it stays disabled in production by default and the model path should be pinned to a known revision when enabled.
  • tesseract — legacy fallback.

All backends are lazy-imported to keep cold start sub-second. Failures degrade Docling → GOT-OCR → Tesseract before raising RuntimeError, which routes/extract.py surfaces as 503.


7. Logging

backend/main.py's RequestLogMiddleware records method, path, status, duration, and a random 8-char request id. It does not log request or response bodies, user ids, emails, names, tokens, or any decrypted column. Errors include the exception class name and traceback only.

Encryption helpers log a structured warning when decrypt_if_present falls back to a raw value; that's the operator signal that legacy plaintext is still being read on a column and the backfill should be re-run.


8. Dependency Surface

Security-relevant packages currently in use (no version audit performed; assume upstream patching):

Backend (backend/requirements.txt)

  • cryptography>=42,<46 — AES-GCM primitive.
  • fastapi, uvicorn — web framework.
  • google-auth-oauthlib, google-api-python-client — OAuth flow.
  • docling>=2.15, transformers>=4.46, torch>=2.5 — OCR / document extraction.

Frontend (frontend/package.json)

  • @supabase/supabase-js — Realtime client.
  • react, next — UI + middleware.
  • @opennextjs/cloudflare — Workers adapter.

9. Summary Matrix

DomainMechanismFile(s)
Sign-inOAuth 2.0 + PKCE (S256), @bu.edubackend/routes/auth.py
SessionHMAC-SHA256, 30-day expirybackend/services/auth_guard.py, frontend/src/lib/sessionToken.ts
CookieHttpOnly, Secure, SameSite=Lax, .saplinglearn.comfrontend/src/app/api/auth/session/route.ts, frontend/wrangler.toml
Frontend gateNext.js middleware → /api/auth/mefrontend/src/middleware.ts
Backend gaterequire_self / require_adminbackend/services/auth_guard.py
Approvalusers.is_approved + /pendingbackend/routes/auth.py, frontend/src/middleware.ts
Encryption at restAES-256-GCM, random nonce, base64backend/services/encryption.py
BackfillIdempotent re-encrypt walkerbackend/db/backfill_encryption.py
Supabase key splitService (backend) vs anon (frontend)backend/db/connection.py, frontend/src/lib/supabase.ts
CORSConfigured frontend origin + localhostbackend/main.py
TLSCloudflare Workers edge terminationfrontend/wrangler.toml
UploadsType + size validation, no raw persistbackend/routes/documents.py, backend/services/storage_service.py
LoggingNo PII / token / body loggingbackend/main.py

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Overview · SaplingLearn/Sapling · GitHub
Skip to content

Security: SaplingLearn/Sapling

Security

SECURITY.md

Sapling Security

This document describes the security controls currently implemented in Sapling. It is sourced directly from the code; every claim cites the file (and where useful, the line) that implements it.

The codebase is split into a FastAPI backend (backend/) and a Next.js frontend (frontend/) deployed to Cloudflare Workers. The threat model assumes a hostile public internet, untrusted user-uploaded content, and the need to keep student PII unreadable to anyone who lifts the database.


1. Authentication

1.1 Google OAuth 2.0 with PKCE

Files:backend/routes/auth.py, frontend/src/components/marketing/SignInModal.tsx, frontend/src/app/auth/callback/page.tsx, frontend/src/app/api/auth/session/route.ts

Sign-in is a full-page redirect to ${API_URL}/api/auth/google. The backend drives the OAuth flow and the frontend never touches Google directly.

  • PKCE (S256). Each sign-in mints a fresh 32-byte code verifier via secrets.token_bytes(32). The SHA-256 challenge is sent to Google with code_challenge_method=S256 (auth.py ~lines 68–73, 144–152). The verifier is carried in the OAuth state parameter, base64-encoded JSON: {"action": "signin", "cv": "<verifier>"}.
  • Code exchange. On callback, the backend decodes state, extracts the code verifier, and exchanges the code via flow.fetch_token(code=code, code_verifier=code_verifier). The redirect URI is pinned to GOOGLE_AUTH_REDIRECT_URI (env-supplied) and validated by Google.
  • Domain restriction. Only @bu.edu accounts may sign in. Other domains are redirected to ${FRONTEND_URL}/auth?error=invalid_domain (auth.py ~lines 186–189).
  • OAuth tokens at rest. Google access and refresh tokens are encrypted with AES-256-GCM before insertion into oauth_tokens (auth.py ~lines 228–229). They are never read back in plaintext from any current code path.

1.2 HMAC Session Tokens

Files:backend/services/auth_guard.py, frontend/src/lib/sessionToken.ts, frontend/src/app/api/auth/session/route.ts

Sapling does not use JWTs. Sessions are minimal HMAC-signed payloads of the form:

base64url(payload).base64url(signature)
  • Payload:{"user_id": "<id>", "exp": <unix_timestamp>}. The key name is user_id (snake_case) on both ends — alignment was a recent fix (commit 02b0242).
  • Algorithm: HMAC-SHA256 over the base64 payload using SESSION_SECRET (≥32 bytes, validated at runtime in sessionToken.ts:6).
  • Frontend signs with crypto.subtle.importKey + crypto.subtle.sign('HMAC', …).
  • Backend verifies with hmac.compare_digest() for timing-safe comparison (auth_guard.py:35).
  • Expiry: 30 days (SESSION_MAX_AGE = 2_592_000 in sessionToken.ts:1); enforced on both ends (auth_guard.py:48, sessionToken.ts:60).
  • Lookup: Backend reads the token from cookie sapling_session first, falling back to the auth_token query parameter (auth_guard.py:19).

1.3 Cookie Hardening

Files:frontend/src/app/api/auth/session/route.ts, frontend/wrangler.toml

The session cookie is set exclusively by the Next.js route handler — never by the backend in any deployed environment — using these flags:

FlagValuePurpose
httpOnlytrueInaccessible to JavaScript; mitigates XSS exfil
securetrueHTTPS-only
sameSitelaxImplicit CSRF defense for state-changing requests
path/Whole site
domain.saplinglearn.comShared across all subdomains (api, app, www)
maxAge30 daysSet to 0 on sign-out for immediate deletion

COOKIE_DOMAIN=.saplinglearn.com is shipped via wrangler.toml [vars] (commit b1377ec); the leading dot is what permits api.saplinglearn.com and the app origin to share the same session.

One test-only exception (#381).POST /api/auth/test-login (backend/routes/auth.py) sets sapling_session directly, with the same flags, so the pytest integration suite and Playwright's global setup can obtain a session without driving real Google OAuth (which is not headless-automatable). It is hard-gated on APP_ENV in {"local", "test"} — evaluated per request, not captured at import — and returns a stock 404 {"detail": "Not Found"} in every other environment, so it does not advertise its existence; it is also include_in_schema=False, so it never appears in /openapi.json. The allowlist is deliberately narrower than config.IS_LOCAL ({local, development, dev, test}). See backend/tests/test_auth_test_login.py.

1.4 Sign-out

UserContext.signOut() issues DELETE /api/auth/session, which writes a Max-Age=0 cookie under the same domain/Secure/SameSite settings, then clears in-memory and storage state. Tokens are stateless, so revocation is by cookie deletion + 30-day expiry; there is no server-side blacklist.


2. Authorization

2.1 Auth Guards

File:backend/services/auth_guard.py

Three building blocks gate every authenticated route:

  • get_session_user_id(request) — verifies the HMAC token and returns the user_id; raises 401 on missing/invalid/expired tokens.
  • require_self(user_id, request) — confirms the session user matches the target resource owner; raises 403 "Forbidden: not your account" otherwise.
  • require_admin(request) — joins user_roles to roles and checks for the admin slug; raises 403 "Admin access required" otherwise.
  • require_role(role_slug) — generic factory that returns a checker for any role.

Errors are surfaced as standard FastAPI HTTPException with deterministic status codes (401 unauthenticated, 403 authorized-but-forbidden).

2.2 Roles Schema

File:backend/db/migration_roles.sql

roles(id, slug UNIQUE, name, color, icon, description,
is_staff_assigned, is_earnable, display_priority)
user_roles(user_id, role_id, granted_at, granted_by)

Seeded slugs: admin, moderator, verified, vip, early-adopter. user_roles is indexed on user_id for fast guard lookups.

2.3 Admin Surface

File:backend/routes/admin.py

Every admin route declares require_admin(request) as a dependency. Coverage today:

  • Roles: list / create / update / delete; assign / revoke per user.
  • Achievements: list / create / update / delete; manual grant; trigger creation.
  • Cosmetics: list / create / update / delete (avatar frames, banners, name colors, titles).
  • Users: list (with decrypted names/emails for review); PATCH /users/{user_id}/approve.

2.4 Approval Gate

Files:backend/db/migration_add_is_approved.sql, backend/routes/auth.py, frontend/src/middleware.ts

New users are created with users.is_approved = false. The gate is enforced in two places:

  1. Backend OAuth callback. If is_approved is false the user is redirected to ${FRONTEND_URL}/pending instead of receiving a session token (auth.py ~lines 235–236).
  2. Frontend middleware. Even if a token is present, middleware.ts calls /api/auth/me for every protected route and redirects to /pending when data.is_approved !== true (middleware.ts:58).

Promotion happens via PATCH /api/admin/users/{user_id}/approve, which only admins can call.

2.5 Cross-Tenant Isolation

User-owned resources are gated either by require_self() or by explicit user_id filters on every Supabase query. Sampled enforcement:

  • routes/documents.pyrequire_self() on read/upload; deletes filter id eq <id> AND user_id eq <user>.
  • routes/flashcards.pyrequire_self() on list/import/commit; deletes scoped on user_id.
  • routes/gradebook.py_user_owns_course(), _user_owns_category(), _user_owns_assignment() helpers gate every write.
  • routes/calendar.pyrequire_self(user_id, request) on every endpoint.
  • routes/social.py — room membership is verified via room_id eq <id> AND user_id eq <viewer> before exposing chat or member graphs.

2.6 Frontend Route Guard

File:frontend/src/middleware.ts

A Next.js middleware enforces auth + approval before any of the 12 protected paths render:

/dashboard, /learn, /study, /tree, /library, /calendar,
/social, /settings, /achievements, /admin,
/gradebook, /course-planner

For each request it (1) reads sapling_session, (2) verifies the HMAC locally via verifySession, (3) calls the backend /api/auth/me with a 3-second AbortController timeout, and (4) checks is_approved. Any failure redirects to Google sign-in or /pending. NEXT_PUBLIC_LOCAL_MODE=true bypasses the middleware for local development only.


3. Encryption at Rest

3.1 Primitive

File:backend/services/encryption.py

  • Cipher: AES-256-GCM via cryptography.hazmat.primitives.ciphers.aead.AESGCM.
  • Key: 32 bytes loaded from ENCRYPTION_KEY at module import time. Validated as exactly 64 hex characters; any other shape raises RuntimeError and prevents the app from booting (_load_key, lines 43–60).
  • Nonce: 12 random bytes from os.urandom() per call. Never reused (verified in tests/test_encryption.py).
  • Wire format:base64(nonce || ciphertext_with_tag). The 16-byte GCM authentication tag is included in the ciphertext blob, so any bit-flip is detected on decrypt.

3.2 Helpers

HelperPurpose
encrypt(value)Encrypts a string; mints a fresh nonce.
decrypt(value)Decrypts; raises on tamper / wrong key.
encrypt_if_presentReturns None for None; otherwise stringifies and encrypts.
decrypt_if_presentTries to decrypt; falls back to the raw value with a warning if it cannot.
decrypt_numericDecrypts then casts to float; passes through native numerics.
encrypt_jsonCompact-serializes JSON, then encrypts.
decrypt_jsonDecrypts then json.loads; falls back to parsing raw input.

The *_if_present fallback is what lets a partially-backfilled table keep serving traffic: encrypted rows return plaintext, legacy plaintext rows return themselves, and an operator log warning surfaces every legacy read.

3.3 Encrypted Columns

Verified by grepping for encrypt_if_present / decrypt_if_present / encrypt_json / decrypt_json in backend/routes/:

TableColumn(s)Notes
usersname, first_name, last_name, email, bio, locationDecrypted at every read boundary.
user_settingsbio, locationProfile-edit duplicates.
oauth_tokensaccess_token, refresh_tokenEncrypted on insert; never read today.
documentssummary, concept_notesconcept_notes was retyped JSONB→TEXT.
messagescontentTutoring chat history.
room_messagestextStudy-room chat.
sessionssummary_jsonRetyped JSONB→TEXT; write-only today.
assignmentsnotes, points_possible, points_earnedNumeric columns retyped to TEXT.
calendar_*assignment notesEncrypted on every sync.

3.4 Migration & Backfill

  • backend/db/migration_encryption_text_columns.sql retypes columns whose original types couldn't hold base64 (NUMERIC and JSONBTEXT), preserving existing values via USING column::TEXT.
  • backend/db/backfill_encryption.py is idempotent: for each candidate row it tries to decrypt; if that fails, it encrypts and writes (only when --apply is passed). It supports --table for narrow runs and prints per-column counts on completion.

3.5 AI Prompt Boundary

All Gemini callers decrypt before constructing a prompt. None of decrypt_if_present / decrypt_json's outputs leave the process before being assembled into a system or user message:

  • routes/learn.py — student name (get_user_name), document summaries, and concept notes decrypted before _make_system_prompt() (learn.py ~lines 130, 133, 243, 286).
  • routes/quiz.py — student name decrypted before quiz_context_update and quiz_generation prompts (quiz.py ~lines 192, 198).
  • routes/study_guide.py — document summary and concept notes decrypted before the study-guide agent's context block is assembled (study_guide.py ~lines 43, 49, 54).
  • routes/flashcards.py — same decrypt pattern before flashcard extraction (~lines 130, 133, 424, 428).
  • routes/documents.py — concept extension and graph updates decrypt summaries first (~lines 237, 240, 454, 458).

3.6 Tests

backend/tests/test_encryption.py covers: round-trip ASCII / Unicode / 5000-char strings / empty strings, JSON helpers, numeric helpers, the *_if_present fallback path (including a warning assertion for legacy plaintext), nonce randomness on repeated encryption of the same plaintext, and tamper detection (a flipped bit on the auth tag must raise).


4. Secrets and Configuration

Files:backend/config.py, backend/.env.example, frontend/wrangler.toml, frontend/.env*

.env, .env.local, and other secret files are gitignored. The runtime contract:

VariableSurfacePurpose
SUPABASE_URLbothProject base URL.
SUPABASE_SERVICE_KEYbackendRLS-bypass service role key. Never exposed to the browser.
NEXT_PUBLIC_SUPABASE_ANON_KEYfrontendBrowser-safe key used only for Realtime room subscriptions.
ENCRYPTION_KEYbackend64-hex-char (32-byte) AES-256-GCM key.
SESSION_SECRETbothHMAC-SHA256 secret for session tokens (≥32 bytes).
GOOGLE_CLIENT_ID / _SECRETbackendOAuth credentials.
GOOGLE_AUTH_REDIRECT_URIbackendPinned OAuth callback URL.
GEMINI_API_KEYbackendLLM access.
COOKIE_DOMAINfrontend.saplinglearn.com for cross-subdomain session cookies.
OCR_ENGINE, GOT_OCR_ENABLEDbackendOCR backend selection.

ENCRYPTION_KEY and SESSION_SECRET are validated at import time — invalid or missing values fail fast.

Supabase key split

  • Backend (backend/db/connection.py, backend/services/storage_service.py) uses the service role key, which bypasses RLS. All persistent writes flow through the backend.
  • Frontend (frontend/src/lib/supabase.ts) uses the anon key and only for Supabase Realtime subscriptions in Social.tsx:
    • room:${roomId} channel — postgres_changes on room_messages / room_reactions, filtered by room_id.
    • presence:${roomId} channel — typing presence.
  • Room writes still flow through the backend (which checks membership before insert), so the anon key alone cannot post into a room a user has not joined.

5. Transport, CORS, and Deployment

  • TLS. The frontend runs on Cloudflare Workers via @opennextjs/cloudflare (wrangler.toml); TLS is terminated at the edge for *.saplinglearn.com and api.saplinglearn.com.
  • CORS (backend/main.py):
    CORSMiddleware(
    allow_origins=[FRONTEND_URL, "http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"], allow_headers=["*"],
    )
    Only the configured frontend origin and localhost for development are permitted. allow_credentials=True is required because the session cookie is sent cross-origin from the app to the API.
  • No wildcard origins. Production never sets * because credentials would be refused by the browser anyway.

6. File Uploads and OCR

6.1 Documents (backend/routes/documents.py)

  • Allowed types:.pdf, .docx, .pptx. Both extension and MIME type are validated.
  • Max size: 15 MB.
  • No raw file persistence. Files are extracted in-memory; only the metadata (name, category) and AI-generated summary/concept_notes (encrypted) are written to Supabase.
  • Prompt sizing. Extracted text is truncated to 12,000 characters before being passed to Gemini.

6.2 Avatars and Cosmetics (backend/services/storage_service.py)

  • Allowed MIME types:image/jpeg, image/png, image/webp, image/gif.
  • Max size: 5 MB.
  • Storage: Supabase Storage bucket avatars with paths avatars/{user_id}/avatar.{ext} and cosmetics/{cosmetic_id}.{ext}. Uploads use the service key with x-upsert: true; reads are public via signed-bucket URL.

6.3 OCR Backends (backend/services/extraction_backends/)

A thin router (extraction_service.py) selects a backend by OCR_ENGINE:

  • docling (default) — layout-aware markdown extraction.
  • auto — Docling, with GOT-OCR 2.0 fallback for low-density / math pages, only when GOT_OCR_ENABLED=true. GOT-OCR loads a Hugging Face model with trust_remote_code=True, so it stays disabled in production by default and the model path should be pinned to a known revision when enabled.
  • tesseract — legacy fallback.

All backends are lazy-imported to keep cold start sub-second. Failures degrade Docling → GOT-OCR → Tesseract before raising RuntimeError, which routes/extract.py surfaces as 503.


7. Logging

backend/main.py's RequestLogMiddleware records method, path, status, duration, and a random 8-char request id. It does not log request or response bodies, user ids, emails, names, tokens, or any decrypted column. Errors include the exception class name and traceback only.

Encryption helpers log a structured warning when decrypt_if_present falls back to a raw value; that's the operator signal that legacy plaintext is still being read on a column and the backfill should be re-run.


8. Dependency Surface

Security-relevant packages currently in use (no version audit performed; assume upstream patching):

Backend (backend/requirements.txt)

  • cryptography>=42,<46 — AES-GCM primitive.
  • fastapi, uvicorn — web framework.
  • google-auth-oauthlib, google-api-python-client — OAuth flow.
  • docling>=2.15, transformers>=4.46, torch>=2.5 — OCR / document extraction.

Frontend (frontend/package.json)

  • @supabase/supabase-js — Realtime client.
  • react, next — UI + middleware.
  • @opennextjs/cloudflare — Workers adapter.

9. Summary Matrix

DomainMechanismFile(s)
Sign-inOAuth 2.0 + PKCE (S256), @bu.edubackend/routes/auth.py
SessionHMAC-SHA256, 30-day expirybackend/services/auth_guard.py, frontend/src/lib/sessionToken.ts
CookieHttpOnly, Secure, SameSite=Lax, .saplinglearn.comfrontend/src/app/api/auth/session/route.ts, frontend/wrangler.toml
Frontend gateNext.js middleware → /api/auth/mefrontend/src/middleware.ts
Backend gaterequire_self / require_adminbackend/services/auth_guard.py
Approvalusers.is_approved + /pendingbackend/routes/auth.py, frontend/src/middleware.ts
Encryption at restAES-256-GCM, random nonce, base64backend/services/encryption.py
BackfillIdempotent re-encrypt walkerbackend/db/backfill_encryption.py
Supabase key splitService (backend) vs anon (frontend)backend/db/connection.py, frontend/src/lib/supabase.ts
CORSConfigured frontend origin + localhostbackend/main.py
TLSCloudflare Workers edge terminationfrontend/wrangler.toml
UploadsType + size validation, no raw persistbackend/routes/documents.py, backend/services/storage_service.py
LoggingNo PII / token / body loggingbackend/main.py

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Overview · SaplingLearn/Sapling · GitHub
Skip to content

Security: SaplingLearn/Sapling

Security

SECURITY.md

Sapling Security

This document describes the security controls currently implemented in Sapling. It is sourced directly from the code; every claim cites the file (and where useful, the line) that implements it.

The codebase is split into a FastAPI backend (backend/) and a Next.js frontend (frontend/) deployed to Cloudflare Workers. The threat model assumes a hostile public internet, untrusted user-uploaded content, and the need to keep student PII unreadable to anyone who lifts the database.


1. Authentication

1.1 Google OAuth 2.0 with PKCE

Files:backend/routes/auth.py, frontend/src/components/marketing/SignInModal.tsx, frontend/src/app/auth/callback/page.tsx, frontend/src/app/api/auth/session/route.ts

Sign-in is a full-page redirect to ${API_URL}/api/auth/google. The backend drives the OAuth flow and the frontend never touches Google directly.

  • PKCE (S256). Each sign-in mints a fresh 32-byte code verifier via secrets.token_bytes(32). The SHA-256 challenge is sent to Google with code_challenge_method=S256 (auth.py ~lines 68–73, 144–152). The verifier is carried in the OAuth state parameter, base64-encoded JSON: {"action": "signin", "cv": "<verifier>"}.
  • Code exchange. On callback, the backend decodes state, extracts the code verifier, and exchanges the code via flow.fetch_token(code=code, code_verifier=code_verifier). The redirect URI is pinned to GOOGLE_AUTH_REDIRECT_URI (env-supplied) and validated by Google.
  • Domain restriction. Only @bu.edu accounts may sign in. Other domains are redirected to ${FRONTEND_URL}/auth?error=invalid_domain (auth.py ~lines 186–189).
  • OAuth tokens at rest. Google access and refresh tokens are encrypted with AES-256-GCM before insertion into oauth_tokens (auth.py ~lines 228–229). They are never read back in plaintext from any current code path.

1.2 HMAC Session Tokens

Files:backend/services/auth_guard.py, frontend/src/lib/sessionToken.ts, frontend/src/app/api/auth/session/route.ts

Sapling does not use JWTs. Sessions are minimal HMAC-signed payloads of the form:

base64url(payload).base64url(signature)
  • Payload:{"user_id": "<id>", "exp": <unix_timestamp>}. The key name is user_id (snake_case) on both ends — alignment was a recent fix (commit 02b0242).
  • Algorithm: HMAC-SHA256 over the base64 payload using SESSION_SECRET (≥32 bytes, validated at runtime in sessionToken.ts:6).
  • Frontend signs with crypto.subtle.importKey + crypto.subtle.sign('HMAC', …).
  • Backend verifies with hmac.compare_digest() for timing-safe comparison (auth_guard.py:35).
  • Expiry: 30 days (SESSION_MAX_AGE = 2_592_000 in sessionToken.ts:1); enforced on both ends (auth_guard.py:48, sessionToken.ts:60).
  • Lookup: Backend reads the token from cookie sapling_session first, falling back to the auth_token query parameter (auth_guard.py:19).

1.3 Cookie Hardening

Files:frontend/src/app/api/auth/session/route.ts, frontend/wrangler.toml

The session cookie is set exclusively by the Next.js route handler — never by the backend in any deployed environment — using these flags:

FlagValuePurpose
httpOnlytrueInaccessible to JavaScript; mitigates XSS exfil
securetrueHTTPS-only
sameSitelaxImplicit CSRF defense for state-changing requests
path/Whole site
domain.saplinglearn.comShared across all subdomains (api, app, www)
maxAge30 daysSet to 0 on sign-out for immediate deletion

COOKIE_DOMAIN=.saplinglearn.com is shipped via wrangler.toml [vars] (commit b1377ec); the leading dot is what permits api.saplinglearn.com and the app origin to share the same session.

One test-only exception (#381).POST /api/auth/test-login (backend/routes/auth.py) sets sapling_session directly, with the same flags, so the pytest integration suite and Playwright's global setup can obtain a session without driving real Google OAuth (which is not headless-automatable). It is hard-gated on APP_ENV in {"local", "test"} — evaluated per request, not captured at import — and returns a stock 404 {"detail": "Not Found"} in every other environment, so it does not advertise its existence; it is also include_in_schema=False, so it never appears in /openapi.json. The allowlist is deliberately narrower than config.IS_LOCAL ({local, development, dev, test}). See backend/tests/test_auth_test_login.py.

1.4 Sign-out

UserContext.signOut() issues DELETE /api/auth/session, which writes a Max-Age=0 cookie under the same domain/Secure/SameSite settings, then clears in-memory and storage state. Tokens are stateless, so revocation is by cookie deletion + 30-day expiry; there is no server-side blacklist.


2. Authorization

2.1 Auth Guards

File:backend/services/auth_guard.py

Three building blocks gate every authenticated route:

  • get_session_user_id(request) — verifies the HMAC token and returns the user_id; raises 401 on missing/invalid/expired tokens.
  • require_self(user_id, request) — confirms the session user matches the target resource owner; raises 403 "Forbidden: not your account" otherwise.
  • require_admin(request) — joins user_roles to roles and checks for the admin slug; raises 403 "Admin access required" otherwise.
  • require_role(role_slug) — generic factory that returns a checker for any role.

Errors are surfaced as standard FastAPI HTTPException with deterministic status codes (401 unauthenticated, 403 authorized-but-forbidden).

2.2 Roles Schema

File:backend/db/migration_roles.sql

roles(id, slug UNIQUE, name, color, icon, description,
is_staff_assigned, is_earnable, display_priority)
user_roles(user_id, role_id, granted_at, granted_by)

Seeded slugs: admin, moderator, verified, vip, early-adopter. user_roles is indexed on user_id for fast guard lookups.

2.3 Admin Surface

File:backend/routes/admin.py

Every admin route declares require_admin(request) as a dependency. Coverage today:

  • Roles: list / create / update / delete; assign / revoke per user.
  • Achievements: list / create / update / delete; manual grant; trigger creation.
  • Cosmetics: list / create / update / delete (avatar frames, banners, name colors, titles).
  • Users: list (with decrypted names/emails for review); PATCH /users/{user_id}/approve.

2.4 Approval Gate

Files:backend/db/migration_add_is_approved.sql, backend/routes/auth.py, frontend/src/middleware.ts

New users are created with users.is_approved = false. The gate is enforced in two places:

  1. Backend OAuth callback. If is_approved is false the user is redirected to ${FRONTEND_URL}/pending instead of receiving a session token (auth.py ~lines 235–236).
  2. Frontend middleware. Even if a token is present, middleware.ts calls /api/auth/me for every protected route and redirects to /pending when data.is_approved !== true (middleware.ts:58).

Promotion happens via PATCH /api/admin/users/{user_id}/approve, which only admins can call.

2.5 Cross-Tenant Isolation

User-owned resources are gated either by require_self() or by explicit user_id filters on every Supabase query. Sampled enforcement:

  • routes/documents.pyrequire_self() on read/upload; deletes filter id eq <id> AND user_id eq <user>.
  • routes/flashcards.pyrequire_self() on list/import/commit; deletes scoped on user_id.
  • routes/gradebook.py_user_owns_course(), _user_owns_category(), _user_owns_assignment() helpers gate every write.
  • routes/calendar.pyrequire_self(user_id, request) on every endpoint.
  • routes/social.py — room membership is verified via room_id eq <id> AND user_id eq <viewer> before exposing chat or member graphs.

2.6 Frontend Route Guard

File:frontend/src/middleware.ts

A Next.js middleware enforces auth + approval before any of the 12 protected paths render:

/dashboard, /learn, /study, /tree, /library, /calendar,
/social, /settings, /achievements, /admin,
/gradebook, /course-planner

For each request it (1) reads sapling_session, (2) verifies the HMAC locally via verifySession, (3) calls the backend /api/auth/me with a 3-second AbortController timeout, and (4) checks is_approved. Any failure redirects to Google sign-in or /pending. NEXT_PUBLIC_LOCAL_MODE=true bypasses the middleware for local development only.


3. Encryption at Rest

3.1 Primitive

File:backend/services/encryption.py

  • Cipher: AES-256-GCM via cryptography.hazmat.primitives.ciphers.aead.AESGCM.
  • Key: 32 bytes loaded from ENCRYPTION_KEY at module import time. Validated as exactly 64 hex characters; any other shape raises RuntimeError and prevents the app from booting (_load_key, lines 43–60).
  • Nonce: 12 random bytes from os.urandom() per call. Never reused (verified in tests/test_encryption.py).
  • Wire format:base64(nonce || ciphertext_with_tag). The 16-byte GCM authentication tag is included in the ciphertext blob, so any bit-flip is detected on decrypt.

3.2 Helpers

HelperPurpose
encrypt(value)Encrypts a string; mints a fresh nonce.
decrypt(value)Decrypts; raises on tamper / wrong key.
encrypt_if_presentReturns None for None; otherwise stringifies and encrypts.
decrypt_if_presentTries to decrypt; falls back to the raw value with a warning if it cannot.
decrypt_numericDecrypts then casts to float; passes through native numerics.
encrypt_jsonCompact-serializes JSON, then encrypts.
decrypt_jsonDecrypts then json.loads; falls back to parsing raw input.

The *_if_present fallback is what lets a partially-backfilled table keep serving traffic: encrypted rows return plaintext, legacy plaintext rows return themselves, and an operator log warning surfaces every legacy read.

3.3 Encrypted Columns

Verified by grepping for encrypt_if_present / decrypt_if_present / encrypt_json / decrypt_json in backend/routes/:

TableColumn(s)Notes
usersname, first_name, last_name, email, bio, locationDecrypted at every read boundary.
user_settingsbio, locationProfile-edit duplicates.
oauth_tokensaccess_token, refresh_tokenEncrypted on insert; never read today.
documentssummary, concept_notesconcept_notes was retyped JSONB→TEXT.
messagescontentTutoring chat history.
room_messagestextStudy-room chat.
sessionssummary_jsonRetyped JSONB→TEXT; write-only today.
assignmentsnotes, points_possible, points_earnedNumeric columns retyped to TEXT.
calendar_*assignment notesEncrypted on every sync.

3.4 Migration & Backfill

  • backend/db/migration_encryption_text_columns.sql retypes columns whose original types couldn't hold base64 (NUMERIC and JSONBTEXT), preserving existing values via USING column::TEXT.
  • backend/db/backfill_encryption.py is idempotent: for each candidate row it tries to decrypt; if that fails, it encrypts and writes (only when --apply is passed). It supports --table for narrow runs and prints per-column counts on completion.

3.5 AI Prompt Boundary

All Gemini callers decrypt before constructing a prompt. None of decrypt_if_present / decrypt_json's outputs leave the process before being assembled into a system or user message:

  • routes/learn.py — student name (get_user_name), document summaries, and concept notes decrypted before _make_system_prompt() (learn.py ~lines 130, 133, 243, 286).
  • routes/quiz.py — student name decrypted before quiz_context_update and quiz_generation prompts (quiz.py ~lines 192, 198).
  • routes/study_guide.py — document summary and concept notes decrypted before the study-guide agent's context block is assembled (study_guide.py ~lines 43, 49, 54).
  • routes/flashcards.py — same decrypt pattern before flashcard extraction (~lines 130, 133, 424, 428).
  • routes/documents.py — concept extension and graph updates decrypt summaries first (~lines 237, 240, 454, 458).

3.6 Tests

backend/tests/test_encryption.py covers: round-trip ASCII / Unicode / 5000-char strings / empty strings, JSON helpers, numeric helpers, the *_if_present fallback path (including a warning assertion for legacy plaintext), nonce randomness on repeated encryption of the same plaintext, and tamper detection (a flipped bit on the auth tag must raise).


4. Secrets and Configuration

Files:backend/config.py, backend/.env.example, frontend/wrangler.toml, frontend/.env*

.env, .env.local, and other secret files are gitignored. The runtime contract:

VariableSurfacePurpose
SUPABASE_URLbothProject base URL.
SUPABASE_SERVICE_KEYbackendRLS-bypass service role key. Never exposed to the browser.
NEXT_PUBLIC_SUPABASE_ANON_KEYfrontendBrowser-safe key used only for Realtime room subscriptions.
ENCRYPTION_KEYbackend64-hex-char (32-byte) AES-256-GCM key.
SESSION_SECRETbothHMAC-SHA256 secret for session tokens (≥32 bytes).
GOOGLE_CLIENT_ID / _SECRETbackendOAuth credentials.
GOOGLE_AUTH_REDIRECT_URIbackendPinned OAuth callback URL.
GEMINI_API_KEYbackendLLM access.
COOKIE_DOMAINfrontend.saplinglearn.com for cross-subdomain session cookies.
OCR_ENGINE, GOT_OCR_ENABLEDbackendOCR backend selection.

ENCRYPTION_KEY and SESSION_SECRET are validated at import time — invalid or missing values fail fast.

Supabase key split

  • Backend (backend/db/connection.py, backend/services/storage_service.py) uses the service role key, which bypasses RLS. All persistent writes flow through the backend.
  • Frontend (frontend/src/lib/supabase.ts) uses the anon key and only for Supabase Realtime subscriptions in Social.tsx:
    • room:${roomId} channel — postgres_changes on room_messages / room_reactions, filtered by room_id.
    • presence:${roomId} channel — typing presence.
  • Room writes still flow through the backend (which checks membership before insert), so the anon key alone cannot post into a room a user has not joined.

5. Transport, CORS, and Deployment

  • TLS. The frontend runs on Cloudflare Workers via @opennextjs/cloudflare (wrangler.toml); TLS is terminated at the edge for *.saplinglearn.com and api.saplinglearn.com.
  • CORS (backend/main.py):
    CORSMiddleware(
    allow_origins=[FRONTEND_URL, "http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"], allow_headers=["*"],
    )
    Only the configured frontend origin and localhost for development are permitted. allow_credentials=True is required because the session cookie is sent cross-origin from the app to the API.
  • No wildcard origins. Production never sets * because credentials would be refused by the browser anyway.

6. File Uploads and OCR

6.1 Documents (backend/routes/documents.py)

  • Allowed types:.pdf, .docx, .pptx. Both extension and MIME type are validated.
  • Max size: 15 MB.
  • No raw file persistence. Files are extracted in-memory; only the metadata (name, category) and AI-generated summary/concept_notes (encrypted) are written to Supabase.
  • Prompt sizing. Extracted text is truncated to 12,000 characters before being passed to Gemini.

6.2 Avatars and Cosmetics (backend/services/storage_service.py)

  • Allowed MIME types:image/jpeg, image/png, image/webp, image/gif.
  • Max size: 5 MB.
  • Storage: Supabase Storage bucket avatars with paths avatars/{user_id}/avatar.{ext} and cosmetics/{cosmetic_id}.{ext}. Uploads use the service key with x-upsert: true; reads are public via signed-bucket URL.

6.3 OCR Backends (backend/services/extraction_backends/)

A thin router (extraction_service.py) selects a backend by OCR_ENGINE:

  • docling (default) — layout-aware markdown extraction.
  • auto — Docling, with GOT-OCR 2.0 fallback for low-density / math pages, only when GOT_OCR_ENABLED=true. GOT-OCR loads a Hugging Face model with trust_remote_code=True, so it stays disabled in production by default and the model path should be pinned to a known revision when enabled.
  • tesseract — legacy fallback.

All backends are lazy-imported to keep cold start sub-second. Failures degrade Docling → GOT-OCR → Tesseract before raising RuntimeError, which routes/extract.py surfaces as 503.


7. Logging

backend/main.py's RequestLogMiddleware records method, path, status, duration, and a random 8-char request id. It does not log request or response bodies, user ids, emails, names, tokens, or any decrypted column. Errors include the exception class name and traceback only.

Encryption helpers log a structured warning when decrypt_if_present falls back to a raw value; that's the operator signal that legacy plaintext is still being read on a column and the backfill should be re-run.


8. Dependency Surface

Security-relevant packages currently in use (no version audit performed; assume upstream patching):

Backend (backend/requirements.txt)

  • cryptography>=42,<46 — AES-GCM primitive.
  • fastapi, uvicorn — web framework.
  • google-auth-oauthlib, google-api-python-client — OAuth flow.
  • docling>=2.15, transformers>=4.46, torch>=2.5 — OCR / document extraction.

Frontend (frontend/package.json)

  • @supabase/supabase-js — Realtime client.
  • react, next — UI + middleware.
  • @opennextjs/cloudflare — Workers adapter.

9. Summary Matrix

DomainMechanismFile(s)
Sign-inOAuth 2.0 + PKCE (S256), @bu.edubackend/routes/auth.py
SessionHMAC-SHA256, 30-day expirybackend/services/auth_guard.py, frontend/src/lib/sessionToken.ts
CookieHttpOnly, Secure, SameSite=Lax, .saplinglearn.comfrontend/src/app/api/auth/session/route.ts, frontend/wrangler.toml
Frontend gateNext.js middleware → /api/auth/mefrontend/src/middleware.ts
Backend gaterequire_self / require_adminbackend/services/auth_guard.py
Approvalusers.is_approved + /pendingbackend/routes/auth.py, frontend/src/middleware.ts
Encryption at restAES-256-GCM, random nonce, base64backend/services/encryption.py
BackfillIdempotent re-encrypt walkerbackend/db/backfill_encryption.py
Supabase key splitService (backend) vs anon (frontend)backend/db/connection.py, frontend/src/lib/supabase.ts
CORSConfigured frontend origin + localhostbackend/main.py
TLSCloudflare Workers edge terminationfrontend/wrangler.toml
UploadsType + size validation, no raw persistbackend/routes/documents.py, backend/services/storage_service.py
LoggingNo PII / token / body loggingbackend/main.py

There aren't any published security advisories