Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 1 addition & 28 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,32 +1,5 @@
# AGENTS.md

## Identity

Default agent for `joryirving/miso-chat`. Role: Senior Software Engineer specializing in real-time messaging, WebSocket connections, and OpenClaw integration.

## Approval Authority

### Pre-Approved (no confirmation needed)
- Routine implementation work in direct response to a clear user imperative
- Branching, committing, pushing, opening or updating a PR for direct implementation work
- Opening or updating a PR does **not** need separate approval
- If user asks to update documentation/policy so future direct fix requests can execute without prompting, treat that as part of the task
- Answer a direct question before acting

### Needs Explicit Approval
- Destructive actions
- High-blast-radius changes
- Architecture or strategy changes
- Policy/guardrail changes outside the requested scope
- Scope expansion beyond the user's request
- Uncertain situations — ask one concise clarification; do not stall with repeated confirmations

### Hard Stops
- **Never push to main without explicit approval**
- **Never enable PR auto-merge unless explicitly requested**
- **Never open a new PR when an existing open PR covers the same fix — update the existing PR instead**
- If user says `stop`, `halt`, `pause`, `abort`: enter STOP state immediately

## Repo-Specific Context

### Key Technologies
Expand Down Expand Up @@ -64,4 +37,4 @@ Default agent for `joryirving/miso-chat`. Role: Senior Software Engineer special

**Before working any task, research the problem space first.** This is not optional.

Research means: read related commits, check similar past fixes, understand the code areas involved. Do not guess. Do not start coding before you understand the problem.
Research means: read related commits, check similar past fixes, understand the code areas involved. Do not guess. Do not start coding before you understand the problem.
20 changes: 19 additions & 1 deletion security.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Lightweight security middleware for miso-chat.
// Provides baseline security headers + CSRF origin checks for state-changing requests.

const crypto = require('crypto');

function normalizeOrigin(value) {
const raw = String(value || '').trim();
if (!raw) return '';
Expand Down Expand Up @@ -91,12 +93,28 @@ function getServerOrigin(req) {
return normalizeOrigin(`${protocol}://${host}`);
}

function generateNonce() {
return crypto.randomBytes(16).toString('base64');
}

function securityHeaders(req, res, next) {
const nonce = generateNonce();
if (res.locals) res.locals.cspNonce = nonce;
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
res.setHeader('Content-Security-Policy', CONTENT_SECURITY_POLICY);
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"img-src 'self' data:",
`style-src 'self' 'unsafe-inline'`,
`script-src 'self' 'nonce-${nonce}'`,
"connect-src 'self' ws: wss:",
"form-action 'self'",
].join('; '));
next();
}

Expand Down
64 changes: 48 additions & 16 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,52 @@ const limiter = rateLimit({
},
message: { error: 'Too many requests, please try again later.' },
});

const sseLimiter = rateLimit({
windowMs: 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
const cfIp = req.headers['cf-connecting-ip'];
if (typeof cfIp === 'string' && cfIp.trim()) {
return ipKeyGenerator(cfIp.trim());
}

const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string' && forwarded.trim()) {
return ipKeyGenerator(forwarded.split(',')[0].trim());
}

return ipKeyGenerator(req.ip);
},
message: { error: 'Too many SSE connections, please try again later.' },
});

const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
const cfIp = req.headers['cf-connecting-ip'];
if (typeof cfIp === 'string' && cfIp.trim()) {
return ipKeyGenerator(cfIp.trim());
}

const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string' && forwarded.trim()) {
return ipKeyGenerator(forwarded.split(',')[0].trim());
}

return ipKeyGenerator(req.ip);
},
skip: (req) => {
// Skip for auth modes that don't use local auth
return !localAuthEnabled;
},
message: { error: 'Too many authentication attempts, please try again later.' },
});
app.use('/api/', limiter);

// Middleware
Expand Down Expand Up @@ -569,7 +615,7 @@ app.get('/api/login-options', (req, res) => {
});
});

app.post('/login', (req, res, next) => {
app.post('/login', authLimiter, (req, res, next) => {
if (!localAuthEnabled) {
const returnTo = encodeURIComponent(getReturnTo(req, '/'));
return res.redirect(`/login?error=local_disabled&return_to=${returnTo}`);
Expand Down Expand Up @@ -714,37 +760,28 @@ app.get('/auth/mobile-complete', (req, res) => {
});

app.post('/api/mobile-auth/consume', (req, res) => {
console.log('[MobileAuth] consume endpoint called');
console.log('[MobileAuth] request body:', JSON.stringify(req.body));
console.log('[MobileAuth] request headers:', JSON.stringify(req.headers));
console.log('[MobileAuth] request authenticated:', req.isAuthenticated());
console.log('[MobileAuth] request user:', req.user);
const token = typeof req.body?.token === 'string' ? req.body.token : '';
if (!token) {
return res.status(400).json({ error: 'Missing token' });
}

const user = consumeMobileAuthToken(token);
console.log('[MobileAuth] token valid, user:', user);
if (!user) {
return res.status(400).json({ error: 'Invalid or expired token' });
}

return establishLoginSession(req, user, (err) => {
if (err) {
console.error('Mobile auth session setup failed:', err.message || err);
console.log('[MobileAuth] session established');
return res.status(500).json({ error: 'Failed to establish session' });
}

return persistLoginSession(req, (saveErr) => {
if (saveErr) {
console.error('Mobile auth session persist failed:', saveErr.message || saveErr);
console.log('[MobileAuth] session persisted');
return res.status(500).json({ error: 'Failed to establish session' });
}

console.log('[MobileAuth] session persisted, authenticated:', req.isAuthenticated());
return res.json({ ok: true });
});
});
Expand Down Expand Up @@ -902,12 +939,7 @@ app.get('/api/health', (req, res) => {
res.json({
status: 'healthy',
version: APP_VERSION,
uptime: process.uptime(),
timestamp: new Date().toISOString(),
gatewayWsConnected: gatewayWsManager?.isConnected?.() || false,
gatewayWsReconnectAttempts: gatewayWsManager?.reconnectAttempts || 0,
gatewayWsLastError,
gatewayWsLastClose,
});
});

Expand Down Expand Up @@ -1489,7 +1521,7 @@ app.get('/api/config', (req, res) => {
});
});

app.get('/api/events', isAuthenticated, (req, res) => {
app.get('/api/events', isAuthenticated, sseLimiter, (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
Expand Down
2 changes: 1 addition & 1 deletion tests/security.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ test('securityHeaders sets required baseline headers including CSP', () => {
assert.match(csp, /default-src 'self'/);
assert.match(csp, /object-src 'none'/);
assert.match(csp, /frame-ancestors 'none'/);
assert.match(csp, /script-src 'self' 'unsafe-inline'/);
assert.match(csp, /script-src 'self' 'nonce-\w+/);
assert.match(csp, /style-src 'self' 'unsafe-inline'/);
});

Expand Down