profile fix and csrf protections

This commit is contained in:
Joey Kimsey
2026-09-12 16:46:38 -04:00
parent c0c257d5a4
commit 0f5a33d6db
6 changed files with 142 additions and 22 deletions

View File

@ -10,12 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Electron + electron-vite desktop shell with a login view and a connected workspace.
- Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens. MFA accounts continue in-app (`api/login/mfa/{loginCode}`) until a token is issued. `loginCode` stays in the main process; a pasted Admin token skips MFA.
- Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens. MFA accounts continue in-app (`api/login/mfa/{loginCode}`) until a token is issued. `loginCode` stays in the main process; a pasted Admin token skips MFA. POSTs send session CSRF (`X-CSRF-Token`); HTTP uses Electron `net.fetch` so the PHP session cookie persists.
- Session stored in `userData`, encrypted with `safeStorage` when the OS allows it. The renderer never receives the token.
- Logged-in chrome matches TTP: navy header, Font Awesome 6.7.1 / Bootstrap 5.3, centered full-width search, notifications and messages dropdowns, avatar account menu. Profile settings (avatar, name, gender, newsletter, timezone, date/time, page size, dark mode) live in-app and save through `POST /api/profile/update`. Email, password, and phone open the connected site.
- Logged-in chrome matches TTP: navy header, Font Awesome 6.7.1 / Bootstrap 5.3, centered full-width search, notifications and messages dropdowns, avatar account menu. Profile settings (avatar, gender, newsletter, timezone, date/time, page size, dark mode) live in-app and save through `POST /api/profile/update`. Email, password, and phone open the connected site.
- After sign-in, Capsule loads `GET /api/profile` plus notifications, the messages inbox, and `GET /api/messages/recent` for the header dropdown (avatars + unread count). Inbox rows can mark read/unread or hide. Compose and reply follow `canSend`. Search uses the matching user-token endpoint. Contact and bug reports live on footer pages (`#/contact`, `#/bugreport`) instead of the dashboard. Disabled plugins show an unavailable note instead of demo data.
- Footer chrome matches TTP copy and socials. The upper band keeps a dark-mode toggle, Privacy Policy, Terms of Service, Contact, and Report a Bug. There is no subscribe box.
### Fixed
- Settings no longer posts a display-name field. User CP has no such option; `users.name` is leftover, and `Check::name()` rejected typical values (`Invalid name.` / `malformed input`).
- Avatar file previews are allowed (`blob:` on `img-src`). Choosing a photo no longer shows the missing-image icon. A `{ "error": "not found" }` from the site is reported as a missing Capsule API action (the live TTP install still needs `POST /api/profile/update`).

View File

@ -17,17 +17,17 @@ npm run dev
## How auth works
All HTTP runs in the **main process**. The renderer never sees the token and never talks to the site directly, so TTP?s same-origin CORS policy does not apply.
All HTTP runs in the **main process** (`net.fetch`, so the PHP session cookie sticks). The renderer never sees the token and never talks to the site directly, so TTP's same-origin CORS policy does not apply. POSTs send `X-CSRF-Token` (and POST `token`) after a GET harvests `csrf`.
| Action | Endpoint | Notes |
|--------|----------|-------|
| Password sign-in | `POST /api/login` | `username` + `password`, `application/x-www-form-urlencoded`. Same limiter as browser login. No CSRF, no Turnstile. MFA accounts return `{ mfa }` instead of a token. |
| Password sign-in | `POST /api/login` | `username` + `password`, `application/x-www-form-urlencoded`. Same limiter as browser login. CSRF from `GET /api/login`. No Turnstile. MFA accounts return `{ mfa }` instead of a token. |
| MFA code / method | `POST /api/login/mfa/{loginCode}` | `auth_code` or `mfaMethodSelect`. `loginCode` stays in the main process. |
| MFA reset | `POST /api/login/mfa/{loginCode}/reset` | Clears the chosen method so the picker shows again. |
| Confirm identity | `GET /api/profile` | Bearer user token. Also used to hydrate username after login. `GET /api/users/find/{username}` remains available. |
| Workspace | `GET /api/notifications`, `GET /api/messages`, `GET /api/messages/recent` | First inbox page plus the header dropdown after connect. Plugin-off responses show as unavailable. |
| Search | `GET /api/search` | Header search. `q`, `resource`, `page`. |
| Profile save | `POST /api/profile/update` | Name, avatar, prefs. |
| Profile save | `POST /api/profile/update` | Avatar and prefs. |
| Mail / notices | `POST /api/messages/?`, `POST /api/notifications/?` | View, reply, create, read, unread, delete. |
| Contact / bugs | `POST /api/contact`, `POST /api/bugreport` | Footer pages when those plugins are enabled. |
| Existing token | Admin ? Tokens | Personal or app token. A user token hydrates the workspace; an app token can connect but cannot call the user API. |

View File

@ -10,7 +10,8 @@ import {
normalizeSiteUrl,
resetMfaMethod,
selectMfaMethod,
submitMfaCode
submitMfaCode,
clearCsrf
} from './ttpClient.js'
import { publicSession, readSession, writeLastSite, writeSession } from './sessionStore.js'
@ -193,6 +194,7 @@ export function registerSessionIpc() {
}
clearPendingMfa()
clearCsrf(siteUrl)
const result = await loginWithPassword(siteUrl, username, password)
if (result.mfa) {
return rememberPendingMfa(siteUrl, username, result.mfa)
@ -263,6 +265,7 @@ export function registerSessionIpc() {
}
clearPendingMfa()
clearCsrf(siteUrl)
return persistConnection({ siteUrl, token, username, authMethod: 'token' })
})
@ -299,6 +302,9 @@ export function registerSessionIpc() {
const lastSiteUrl = pendingMfa?.siteUrl || session?.siteUrl || session?.lastSiteUrl || ''
clearPendingMfa()
writeLastSite(lastSiteUrl)
if (lastSiteUrl) {
clearCsrf(lastSiteUrl)
}
return publicSession(readSession())
})
}

View File

@ -1,7 +1,13 @@
/**
* HTTP calls to a TTP site. Runs in the main process so CORS does not apply.
* Uses Electron net.fetch so the PHP session cookie (CSRF) persists.
*/
import { net, session as electronSession } from 'electron'
/** Session CSRF tokens keyed by canonical site URL. */
const csrfBySite = new Map()
/**
* Normalize a TTP site URL to scheme + host + optional path, no trailing slash.
*
@ -29,6 +35,100 @@ export function normalizeSiteUrl(raw) {
return `${parsed.origin}${path === '/' ? '' : path}`
}
/**
* Forget stored CSRF for one site, or all sites.
*
* @param {string} [siteUrl] - canonical site base
* @return {void}
*/
export function clearCsrf(siteUrl) {
if (siteUrl) {
csrfBySite.delete(siteUrl)
return
}
csrfBySite.clear()
}
/**
* Store a CSRF value from a response header or JSON body.
*
* @param {string} siteUrl - canonical site base
* @param {Response} response - fetch response
* @param {object} [data] - parsed JSON
* @return {void}
*/
function rememberCsrf(siteUrl, response, data) {
const header = response?.headers?.get?.('x-csrf-token') || ''
const body = typeof data?.csrf === 'string' ? data.csrf : ''
const value = body || header
if (value && siteUrl) {
csrfBySite.set(siteUrl, value)
}
}
/**
* True when the API rejected the POST for a missing or stale CSRF token.
*
* @param {object} data - parsed JSON
* @return {boolean}
*/
function isCsrfFailure(data) {
if (data?.error !== 'malformed input') {
return false
}
const errors = data.errors
const text = typeof errors === 'string' ? errors : JSON.stringify(errors || '')
return text.includes('Invalid Token')
}
/**
* GET a CSRF token when this site has none yet.
*
* @param {string} siteUrl - canonical site base
* @param {string} [token] - Bearer token
* @return {Promise<void>}
*/
async function primeCsrf(siteUrl, token) {
if (!siteUrl || csrfBySite.get(siteUrl)) {
return
}
if (token) {
await ttpRequest({ siteUrl, path: '/api/profile', method: 'GET', token, skipCsrfPrime: true })
return
}
await ttpRequest({ siteUrl, path: '/api/login', method: 'GET', skipCsrfPrime: true })
}
/**
* Attach the stored CSRF token to headers and the POST body.
*
* @param {string} siteUrl - canonical site base
* @param {Record<string, string>} headers - request headers
* @param {Record<string, string>} [form] - urlencoded fields
* @param {FormData|Record<string, string|Blob>} [multipart] - multipart fields
* @return {{form?: Record<string, string>, multipart?: FormData|Record<string, string|Blob>}}
*/
function attachCsrf(siteUrl, headers, form, multipart) {
const csrf = csrfBySite.get(siteUrl)
if (!csrf) {
return { form, multipart }
}
headers['X-CSRF-Token'] = csrf
if (multipart instanceof FormData) {
if (!multipart.has('token')) {
multipart.append('token', csrf)
}
return { form, multipart }
}
if (multipart && typeof multipart === 'object') {
return { form, multipart: { ...multipart, token: csrf } }
}
if (form && typeof form === 'object') {
return { form: { ...form, token: csrf }, multipart }
}
return { form: { token: csrf, submit: '1' }, multipart }
}
/**
* Call a TTP API path and parse JSON.
*
@ -40,6 +140,8 @@ export function normalizeSiteUrl(raw) {
* @param {Record<string, string>} [options.form] - urlencoded body
* @param {Record<string, string|Blob>} [options.multipart] - multipart fields (avatar)
* @param {Record<string, string|number>} [options.query] - query string
* @param {boolean} [options.skipCsrfPrime] - skip the GET that harvests CSRF
* @param {boolean} [options.retriedCsrf] - already retried after Invalid Token
* @return {Promise<object>} - parsed JSON
*/
export async function ttpRequest(options) {
@ -47,12 +149,19 @@ export async function ttpRequest(options) {
const path = options.path
const method = options.method || 'GET'
const token = options.token
const form = options.form
const multipart = options.multipart
const headers = { Accept: 'application/json' }
let url = `${siteUrl}${path}`
let form = options.form
let multipart = options.multipart
let body
if (method === 'POST' && !options.skipCsrfPrime) {
await primeCsrf(siteUrl, token)
const attached = attachCsrf(siteUrl, headers, form, multipart)
form = attached.form
multipart = attached.multipart
}
if (options.query && typeof options.query === 'object') {
const qs = new URLSearchParams()
Object.entries(options.query).forEach(([key, value]) => {
@ -92,7 +201,12 @@ export async function ttpRequest(options) {
let response
try {
response = await fetch(url, { method, headers, body, redirect: 'follow' })
response = await net.fetch(url, {
method,
headers,
body,
session: electronSession.defaultSession
})
} catch {
throw new Error('Could not reach that site.')
}
@ -105,6 +219,13 @@ export async function ttpRequest(options) {
throw new Error(`The site did not return API JSON (${response.status}).`)
}
rememberCsrf(siteUrl, response, data)
if (method === 'POST' && isCsrfFailure(data) && !options.retriedCsrf) {
clearCsrf(siteUrl)
return ttpRequest({ ...options, retriedCsrf: true })
}
return data
}
@ -403,7 +524,7 @@ export async function getProfile(siteUrl, token) {
}
/**
* Save name, prefs, optional avatar. POST api/profile/update.
* Save prefs and optional avatar. POST api/profile/update.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token

View File

@ -668,10 +668,6 @@
<label class="form-label d-block" for="settings-avatar-file">Avatar</label>
<input id="settings-avatar-file" class="form-control" type="file" accept="image/*" />
</div>
<div class="mb-3">
<label class="form-label" for="settings-name">Display name</label>
<input id="settings-name" class="form-control" name="name" type="text" autocomplete="name" />
</div>
<div class="mb-3">
<label class="form-label" for="settings-gender">Gender</label>
<select id="settings-gender" class="form-select" name="gender">

View File

@ -270,7 +270,6 @@ function applyProfile(body) {
profile = {
id: user.id,
username: user.username || session?.username || '',
name: user.name || '',
email: user.email || '',
avatar: user.avatar || '',
avatarUrl: user.avatarUrl || '',
@ -575,8 +574,8 @@ function renderPages() {
bugUnavailable.hidden = bugOk
contactUnavailable.textContent = contactOk ? '' : 'Contact is not available on this site.'
bugUnavailable.textContent = bugOk ? '' : 'Bug reports are not available on this site.'
if (contactOk && profile.name && !document.getElementById('contact-name').value) {
document.getElementById('contact-name').value = profile.name || profile.username || ''
if (contactOk && !document.getElementById('contact-name').value) {
document.getElementById('contact-name').value = profile.username || session.username || ''
document.getElementById('contact-email').value = profile.email || ''
}
if (bugOk && session.siteUrl && !document.getElementById('bug-url').value) {
@ -652,13 +651,12 @@ function renderPages() {
document.getElementById('messages-new').hidden = !canSendMessages || Boolean(messages.unavailable)
document.getElementById('thread-reply').hidden = !canSendMessages
const displayName = profile.name || profile.username || session.username || ''
document.getElementById('profile-name').textContent = displayName
document.getElementById('profile-name').textContent =
profile.username || session.username || ''
document.getElementById('profile-registered').textContent = profile.registered || ''
document.getElementById('profile-last-login').textContent = profile.lastLogin || ''
document.getElementById('profile-gender').textContent = profile.gender || ''
document.getElementById('settings-name').value = profile.name || ''
document.getElementById('settings-gender').value = profile.gender
document.getElementById('settings-newsletter').checked = profile.newsletter
document.getElementById('settings-dark').checked = profile.darkMode
@ -1429,14 +1427,12 @@ document.getElementById('settings-form').addEventListener('submit', async (event
profile.timeFormat = document.getElementById('settings-time').value
profile.pageLimit = document.getElementById('settings-limit').value
profile.darkMode = document.getElementById('settings-dark').checked
profile.name = document.getElementById('settings-name').value
applyTheme(profile.darkMode)
document.getElementById('profile-gender').textContent = profile.gender
setMessage(status, 'Saved in preview only.')
return
}
const fields = {
name: document.getElementById('settings-name').value.trim(),
gender: document.getElementById('settings-gender').value,
newsletter: document.getElementById('settings-newsletter').checked ? '1' : '0',
timezone: document.getElementById('settings-timezone').value,