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 ### Added
- Electron + electron-vite desktop shell with a login view and a connected workspace. - 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. - 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. - 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. - 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 ### 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`). - 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 ## 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 | | 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 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. | | 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. | | 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. | | 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`. | | 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. | | 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. | | 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. | | 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, normalizeSiteUrl,
resetMfaMethod, resetMfaMethod,
selectMfaMethod, selectMfaMethod,
submitMfaCode submitMfaCode,
clearCsrf
} from './ttpClient.js' } from './ttpClient.js'
import { publicSession, readSession, writeLastSite, writeSession } from './sessionStore.js' import { publicSession, readSession, writeLastSite, writeSession } from './sessionStore.js'
@ -193,6 +194,7 @@ export function registerSessionIpc() {
} }
clearPendingMfa() clearPendingMfa()
clearCsrf(siteUrl)
const result = await loginWithPassword(siteUrl, username, password) const result = await loginWithPassword(siteUrl, username, password)
if (result.mfa) { if (result.mfa) {
return rememberPendingMfa(siteUrl, username, result.mfa) return rememberPendingMfa(siteUrl, username, result.mfa)
@ -263,6 +265,7 @@ export function registerSessionIpc() {
} }
clearPendingMfa() clearPendingMfa()
clearCsrf(siteUrl)
return persistConnection({ siteUrl, token, username, authMethod: 'token' }) return persistConnection({ siteUrl, token, username, authMethod: 'token' })
}) })
@ -299,6 +302,9 @@ export function registerSessionIpc() {
const lastSiteUrl = pendingMfa?.siteUrl || session?.siteUrl || session?.lastSiteUrl || '' const lastSiteUrl = pendingMfa?.siteUrl || session?.siteUrl || session?.lastSiteUrl || ''
clearPendingMfa() clearPendingMfa()
writeLastSite(lastSiteUrl) writeLastSite(lastSiteUrl)
if (lastSiteUrl) {
clearCsrf(lastSiteUrl)
}
return publicSession(readSession()) 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. * 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. * 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}` 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. * 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>} [options.form] - urlencoded body
* @param {Record<string, string|Blob>} [options.multipart] - multipart fields (avatar) * @param {Record<string, string|Blob>} [options.multipart] - multipart fields (avatar)
* @param {Record<string, string|number>} [options.query] - query string * @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 * @return {Promise<object>} - parsed JSON
*/ */
export async function ttpRequest(options) { export async function ttpRequest(options) {
@ -47,12 +149,19 @@ export async function ttpRequest(options) {
const path = options.path const path = options.path
const method = options.method || 'GET' const method = options.method || 'GET'
const token = options.token const token = options.token
const form = options.form
const multipart = options.multipart
const headers = { Accept: 'application/json' } const headers = { Accept: 'application/json' }
let url = `${siteUrl}${path}` let url = `${siteUrl}${path}`
let form = options.form
let multipart = options.multipart
let body 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') { if (options.query && typeof options.query === 'object') {
const qs = new URLSearchParams() const qs = new URLSearchParams()
Object.entries(options.query).forEach(([key, value]) => { Object.entries(options.query).forEach(([key, value]) => {
@ -92,7 +201,12 @@ export async function ttpRequest(options) {
let response let response
try { try {
response = await fetch(url, { method, headers, body, redirect: 'follow' }) response = await net.fetch(url, {
method,
headers,
body,
session: electronSession.defaultSession
})
} catch { } catch {
throw new Error('Could not reach that site.') 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}).`) 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 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} siteUrl - canonical site base
* @param {string} token - Bearer token * @param {string} token - Bearer token

View File

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

View File

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