feat(calendar): store only token hash in database to enhance security
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m25s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m25s
feat(federation): escape LIKE special characters in originDomain to prevent wildcard injection feat(oauth): redirect with token in hash fragment to avoid exposure in logs feat(OAuthCallback): retrieve token from hash fragment for improved security
This commit is contained in:
@@ -751,8 +751,9 @@ router.post('/caldav-tokens', authenticateToken, async (req, res) => {
|
|||||||
const token = crypto.randomBytes(32).toString('hex');
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||||
const result = await db.run(
|
const result = await db.run(
|
||||||
'INSERT INTO caldav_tokens (user_id, token, token_hash, name) VALUES (?, ?, ?, ?)',
|
// Store only the hash — never the plaintext — to limit exposure on DB breach.
|
||||||
[req.user.id, token, tokenHash, name.trim()],
|
'INSERT INTO caldav_tokens (user_id, token, token_hash, name) VALUES (?, NULL, ?, ?)',
|
||||||
|
[req.user.id, tokenHash, name.trim()],
|
||||||
);
|
);
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
token: { id: result.lastInsertRowid, name: name.trim() },
|
token: { id: result.lastInsertRowid, name: name.trim() },
|
||||||
|
|||||||
@@ -577,6 +577,9 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res
|
|||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
|
// Escape LIKE special characters in originDomain to prevent wildcard injection.
|
||||||
|
const safeDomain = originDomain.replace(/[\\%_]/g, '\\$&');
|
||||||
|
|
||||||
// Collect all affected users before deleting (for email notifications)
|
// Collect all affected users before deleting (for email notifications)
|
||||||
let affectedUsers = [];
|
let affectedUsers = [];
|
||||||
try {
|
try {
|
||||||
@@ -585,16 +588,16 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res
|
|||||||
`SELECT u.email, u.name, u.language, ci.title, ci.from_user
|
`SELECT u.email, u.name, u.language, ci.title, ci.from_user
|
||||||
FROM calendar_invitations ci
|
FROM calendar_invitations ci
|
||||||
JOIN users u ON ci.to_user_id = u.id
|
JOIN users u ON ci.to_user_id = u.id
|
||||||
WHERE ci.event_uid = ? AND ci.from_user LIKE ?`,
|
WHERE ci.event_uid = ? AND ci.from_user LIKE ? ESCAPE '\\'`,
|
||||||
[event_uid, `%@${originDomain}`]
|
[event_uid, `%@${safeDomain}`]
|
||||||
);
|
);
|
||||||
// Users who already accepted (event in their calendar)
|
// Users who already accepted (event in their calendar)
|
||||||
const calUsers = await db.all(
|
const calUsers = await db.all(
|
||||||
`SELECT u.email, u.name, u.language, ce.title, ce.federated_from AS from_user
|
`SELECT u.email, u.name, u.language, ce.title, ce.federated_from AS from_user
|
||||||
FROM calendar_events ce
|
FROM calendar_events ce
|
||||||
JOIN users u ON ce.user_id = u.id
|
JOIN users u ON ce.user_id = u.id
|
||||||
WHERE ce.uid = ? AND ce.federated_from LIKE ?`,
|
WHERE ce.uid = ? AND ce.federated_from LIKE ? ESCAPE '\\'`,
|
||||||
[event_uid, `%@${originDomain}`]
|
[event_uid, `%@${safeDomain}`]
|
||||||
);
|
);
|
||||||
// Merge, deduplicate by email
|
// Merge, deduplicate by email
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -609,15 +612,15 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res
|
|||||||
// Remove from calendar_invitations for all users on this instance
|
// Remove from calendar_invitations for all users on this instance
|
||||||
await db.run(
|
await db.run(
|
||||||
`DELETE FROM calendar_invitations
|
`DELETE FROM calendar_invitations
|
||||||
WHERE event_uid = ? AND from_user LIKE ?`,
|
WHERE event_uid = ? AND from_user LIKE ? ESCAPE '\\'`,
|
||||||
[event_uid, `%@${originDomain}`]
|
[event_uid, `%@${safeDomain}`]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove from calendar_events (accepted invitations) for all users on this instance
|
// Remove from calendar_events (accepted invitations) for all users on this instance
|
||||||
await db.run(
|
await db.run(
|
||||||
`DELETE FROM calendar_events
|
`DELETE FROM calendar_events
|
||||||
WHERE uid = ? AND federated_from LIKE ?`,
|
WHERE uid = ? AND federated_from LIKE ? ESCAPE '\\'`,
|
||||||
[event_uid, `%@${originDomain}`]
|
[event_uid, `%@${safeDomain}`]
|
||||||
);
|
);
|
||||||
|
|
||||||
log.federation.info(`Calendar event ${event_uid} deleted (origin: ${originDomain})`);
|
log.federation.info(`Calendar event ${event_uid} deleted (origin: ${originDomain})`);
|
||||||
@@ -667,11 +670,13 @@ router.post('/room-deleted', federationReceiveLimiter, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
// Escape LIKE special characters in originDomain to prevent wildcard injection.
|
||||||
|
const safeDomain = originDomain.replace(/[\\%_]/g, '\\$&');
|
||||||
// Mark all federated_rooms with this meet_id (room_uid) from this origin as deleted
|
// Mark all federated_rooms with this meet_id (room_uid) from this origin as deleted
|
||||||
await db.run(
|
await db.run(
|
||||||
`UPDATE federated_rooms SET deleted = 1, updated_at = CURRENT_TIMESTAMP
|
`UPDATE federated_rooms SET deleted = 1, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE meet_id = ? AND from_user LIKE ?`,
|
WHERE meet_id = ? AND from_user LIKE ? ESCAPE '\\'`,
|
||||||
[room_uid, `%@${originDomain}`]
|
[room_uid, `%@${safeDomain}`]
|
||||||
);
|
);
|
||||||
|
|
||||||
log.federation.info(`Room ${room_uid} marked as deleted (origin: ${originDomain})`);
|
log.federation.info(`Room ${room_uid} marked as deleted (origin: ${originDomain})`);
|
||||||
|
|||||||
@@ -248,9 +248,10 @@ router.get('/callback', callbackLimiter, async (req, res) => {
|
|||||||
// Generate JWT
|
// Generate JWT
|
||||||
const token = generateToken(user.id);
|
const token = generateToken(user.id);
|
||||||
|
|
||||||
// Redirect to frontend callback page with token
|
// Redirect to frontend callback page with token.
|
||||||
|
// Use a hash fragment so the token is never sent to the server (not logged, not in Referer headers).
|
||||||
const returnTo = stateData.return_to || '/dashboard';
|
const returnTo = stateData.return_to || '/dashboard';
|
||||||
res.redirect(`${baseUrl}/oauth/callback?token=${encodeURIComponent(token)}&return_to=${encodeURIComponent(returnTo)}`);
|
res.redirect(`${baseUrl}/oauth/callback#token=${encodeURIComponent(token)}&return_to=${encodeURIComponent(returnTo)}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.auth.error(`OAuth callback error: ${err.message}`);
|
log.auth.error(`OAuth callback error: ${err.message}`);
|
||||||
const baseUrl = getBaseUrl(req);
|
const baseUrl = getBaseUrl(req);
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ export default function OAuthCallback() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = searchParams.get('token');
|
// Token is passed via hash fragment (never sent to server, not logged, not in Referer).
|
||||||
|
// Error is still a regular query param since it contains no sensitive data.
|
||||||
|
const hash = window.location.hash.slice(1); // strip leading '#'
|
||||||
|
const hashParams = new URLSearchParams(hash);
|
||||||
|
const token = hashParams.get('token');
|
||||||
const errorMsg = searchParams.get('error');
|
const errorMsg = searchParams.get('error');
|
||||||
const returnTo = searchParams.get('return_to') || '/dashboard';
|
const returnTo = hashParams.get('return_to') || searchParams.get('return_to') || '/dashboard';
|
||||||
|
|
||||||
if (errorMsg) {
|
if (errorMsg) {
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
|
|||||||
Reference in New Issue
Block a user