profile fix and csrf protections
This commit is contained in:
@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user