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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ DATABASE_URL=postgresql://user:password@postgres:5432/pagespace
JWT_SECRET=your_jwt_secret_here_generate_a_secure_random_string
JWT_ISSUER=pagespace
JWT_AUDIENCE=pagespace-users
SERVICE_JWT_SECRET=generate_a_secure_service_secret
# Note: SERVICE_JWT_SECRET is deprecated - system now uses database-backed opaque tokens
PROCESSOR_AUTH_REQUIRED=true
PROCESSOR_UPLOAD_RATE_LIMIT=100
PROCESSOR_UPLOAD_RATE_WINDOW=3600
Expand Down
54 changes: 46 additions & 8 deletions apps/processor/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { NextFunction, Request, Response } from 'express';
import { sessionService, type SessionClaims } from '@pagespace/lib/auth';
import { EnforcedAuthContext } from '@pagespace/lib/permissions';
import { loggers } from '@pagespace/lib/logging/logger-config';

/**
* Authentication is ALWAYS required in production.
Expand Down Expand Up @@ -38,23 +39,42 @@ export async function authenticateService(req: Request, res: Response, next: Nex
}

const header = req.headers.authorization;
const requestContext = {
endpoint: req.path,
method: req.method,
ip: req.ip || req.socket?.remoteAddress,
userAgent: req.headers['user-agent']?.substring(0, 100),
};

if (!header || !header.startsWith('Bearer ')) {
loggers.security.warn('Processor auth: missing or invalid authorization header', requestContext);
respondUnauthorized(res);
return;
}

const token = header.slice(7).trim();
const tokenPrefix = token.substring(0, 12); // Log prefix only for debugging

try {
const claims = await sessionService.validateSession(token);

if (!claims) {
loggers.security.warn('Processor auth: invalid or expired token', {
...requestContext,
tokenPrefix,
});
respondUnauthorized(res, 'Invalid or expired token');
return;
}

// Reject non-service session types - processor is for service-to-service only
if (claims.type !== 'service') {
loggers.security.warn('Processor auth: non-service token rejected', {
...requestContext,
tokenPrefix,
tokenType: claims.type,
userId: claims.userId,
});
respondForbidden(res, 'Service token required');
return;
}
Expand All @@ -64,9 +84,27 @@ export async function authenticateService(req: Request, res: Response, next: Nex
const context = EnforcedAuthContext.fromSession(claims);
req.auth = context;

// Log successful service token validation for audit trail
loggers.security.info('Processor auth: service token validated', {
...requestContext,
userId: claims.userId,
sessionId: claims.sessionId,
scopes: claims.scopes,
resourceType: claims.resourceType,
resourceId: claims.resourceId,
driveId: claims.driveId,
});

next();
} catch (error) {
console.error('Authentication failed:', error);
loggers.security.error(
'Processor auth: validation error',
error instanceof Error ? error : new Error(String(error)),
{
...requestContext,
tokenPrefix,
}
);
respondUnauthorized(res, 'Invalid token');
}
}
Expand All @@ -85,13 +123,13 @@ export function requireScope(scope: string) {
}

if (!auth.hasScope(scope)) {
if (process.env.NODE_ENV !== 'test') {
console.warn('Scope assertion failed', {
required: scope,
userId: auth.userId,
path: req.path,
});
}
loggers.security.warn('Processor auth: scope assertion failed', {
required: scope,
userId: auth.userId,
endpoint: req.path,
method: req.method,
ip: req.ip || req.socket?.remoteAddress,
});
respondForbidden(res, `Missing required scope: ${scope}`, scope);
return;
}
Expand Down
47 changes: 47 additions & 0 deletions apps/processor/src/services/__tests__/user-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ describe('validateServiceUser', () => {
lastStorageCalculated: null,
failedLoginAttempts: 0,
lockedUntil: null,
suspendedAt: null,
suspendedReason: null,
};

vi.mocked(db.query.users.findFirst).mockResolvedValue(mockUser);
Expand Down Expand Up @@ -160,6 +162,8 @@ describe('validateServiceUser', () => {
lastStorageCalculated: null,
failedLoginAttempts: 0,
lockedUntil: null,
suspendedAt: null,
suspendedReason: null,
};

vi.mocked(db.query.users.findFirst).mockResolvedValue(mockUser);
Expand All @@ -173,4 +177,47 @@ describe('validateServiceUser', () => {
} satisfies ServiceUserValidationResult);
});
});

describe('given a suspended user', () => {
it('should return invalid result with suspended reason', async () => {
const mockUser = {
id: 'suspended-123',
name: 'Suspended User',
email: 'suspended@example.com',
role: 'user' as const,
provider: 'email' as const,
subscriptionTier: 'free',
createdAt: new Date(),
updatedAt: new Date(),
emailVerified: new Date(),
image: null,
password: null,
googleId: null,
appleId: null,
tokenVersion: 0,
adminRoleVersion: 0,
storageUsedBytes: 0,
stripeCustomerId: null,
stripeSubscriptionId: null,
tosAcceptedAt: null,
currentAiProvider: 'pagespace',
currentAiModel: 'glm-4.5-air',
activeUploads: 0,
lastStorageCalculated: null,
failedLoginAttempts: 0,
lockedUntil: null,
suspendedAt: new Date(), // User is suspended
suspendedReason: 'Terms of service violation',
};

vi.mocked(db.query.users.findFirst).mockResolvedValue(mockUser);

const result = await validateServiceUser('suspended-123');

expect(result).toEqual({
valid: false,
reason: 'user_suspended',
} satisfies ServiceUserValidationResult);
});
});
});
8 changes: 8 additions & 0 deletions apps/processor/src/services/user-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { db, users, eq } from '@pagespace/db';
export type UserValidationFailureReason =
| 'invalid_input'
| 'user_not_found'
| 'user_suspended'
| 'database_error';

/**
Expand All @@ -41,6 +42,7 @@ export type ServiceUserValidationResult =
*
* Checks:
* 1. User exists in database
* 2. User is not suspended
*
* Note: Token version validation is not performed here because:
* - Service tokens are short-lived (5 minutes default)
Expand Down Expand Up @@ -73,6 +75,7 @@ export async function validateServiceUser(
columns: {
id: true,
role: true,
suspendedAt: true,
},
});

Expand All @@ -81,6 +84,11 @@ export async function validateServiceUser(
return { valid: false, reason: 'user_not_found' };
}

// User suspended (administrative action)
if (user.suspendedAt) {
return { valid: false, reason: 'user_suspended' };
}

return {
valid: true,
userId: user.id,
Expand Down
57 changes: 36 additions & 21 deletions apps/realtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ async function validateSocketToken(token: string): Promise<{ userId: string } |
}

/**
* Origin Validation for WebSocket Connections (Defense-in-Depth Logging)
* Origin Validation for WebSocket Connections (Defense-in-Depth with Blocking)
*
* While Socket.IO CORS configuration handles blocking unauthorized origins,
* this module provides explicit logging for security monitoring.
* Warnings are logged for unexpected origins to aid in detecting potential attacks.
* This module provides explicit origin validation that BLOCKS invalid origins.
* Socket.IO CORS is a first line of defense, but this provides defense-in-depth
* by rejecting connections from unexpected origins at the middleware level.
*/

/**
Expand Down Expand Up @@ -202,37 +202,50 @@ function validateWebSocketOrigin(origin: string | undefined): WebSocketOriginVal
}

/**
* Validates and logs WebSocket connection origin for security monitoring
* Validates WebSocket connection origin and returns whether to allow the connection
*
* This function does NOT block connections - Socket.IO CORS handles that.
* It provides explicit logging for unexpected origins to aid security monitoring.
* This function BLOCKS connections from invalid origins (defense-in-depth).
* Socket.IO CORS is a first line of defense, but this provides additional protection.
*
* @param origin - The Origin header value from the connection request
* @param metadata - Additional metadata for logging (socketId, IP, etc.)
* @returns true if connection should be allowed, false if it should be rejected
*/
function validateAndLogWebSocketOrigin(
origin: string | undefined,
metadata: { socketId: string; ip: string | undefined; userAgent: string | undefined }
): void {
): boolean {
const allowedOrigins = getAllowedOrigins();

// No origin header - could be non-browser client, log at debug level
// No origin header - non-browser client (curl, mobile apps, etc.)
// Allow these as they authenticate via tokens, not cookies
if (!origin) {
loggers.realtime.debug('WebSocket origin validation: no Origin header', {
...metadata,
reason: 'Non-browser client or same-origin request',
});
return;
return true;
}

// No allowed origins configured - log warning
// No allowed origins configured in production is a misconfiguration
// In development, allow but warn. In production, this should fail closed.
if (allowedOrigins.length === 0) {
loggers.realtime.warn('WebSocket origin validation: no allowed origins configured', {
const isProduction = process.env.NODE_ENV === 'production';
if (isProduction) {
loggers.realtime.error('WebSocket origin validation: REJECTED - no allowed origins configured in production', {
...metadata,
origin,
severity: 'security',
reason: 'CORS_ORIGIN and WEB_APP_URL not set in production',
});
return false;
}
loggers.realtime.warn('WebSocket origin validation: no allowed origins configured (allowing in development)', {
...metadata,
origin,
reason: 'CORS_ORIGIN and WEB_APP_URL not set',
});
return;
return true;
}

// Check if origin is allowed
Expand All @@ -241,18 +254,18 @@ function validateAndLogWebSocketOrigin(
...metadata,
origin,
});
return;
return true;
}

// Origin not in allowed list - log security warning
// Note: Socket.IO CORS will block this connection, but we log for monitoring
loggers.realtime.warn('WebSocket origin validation: unexpected origin detected', {
// Origin not in allowed list - REJECT the connection
loggers.realtime.warn('WebSocket origin validation: REJECTED - unexpected origin', {
...metadata,
origin,
allowedOrigins,
severity: 'security',
reason: 'Origin not in allowed list - connection may be blocked by CORS',
reason: 'Origin not in allowed list - connection rejected',
});
return false;
}

const requestListener = (req: IncomingMessage, res: ServerResponse) => {
Expand Down Expand Up @@ -368,10 +381,12 @@ io.use(async (socket: AuthSocket, next) => {
userAgent: socket.handshake.headers['user-agent']?.substring(0, 100),
};

// Validate and log Origin header for security monitoring
// Note: Socket.IO CORS configuration handles actual blocking
// Validate Origin header - REJECT invalid origins (defense-in-depth)
const origin = socket.handshake.headers.origin;
validateAndLogWebSocketOrigin(origin, connectionMetadata);
const isOriginValid = validateAndLogWebSocketOrigin(origin, connectionMetadata);
if (!isOriginValid) {
return next(new Error('Origin not allowed'));
}

// Debug: Log all available authentication sources
loggers.realtime.debug('Socket.IO: Authentication attempt', {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/api/auth/__tests__/login-redirect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ describe('/api/auth/login redirect', () => {
tosAcceptedAt: null,
failedLoginAttempts: 0,
lockedUntil: null,
suspendedAt: null,
suspendedReason: null,
createdAt: new Date(),
updatedAt: new Date(),
});
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/api/auth/__tests__/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ const mockUser: User = {
tosAcceptedAt: null,
failedLoginAttempts: 0,
lockedUntil: null,
suspendedAt: null,
suspendedReason: null,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
};
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/api/auth/__tests__/me.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const mockUser: User = {
tosAcceptedAt: null,
failedLoginAttempts: 0,
lockedUntil: null,
suspendedAt: null,
suspendedReason: null,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
};
Expand Down
2 changes: 0 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ services:
- REDIS_RATE_LIMIT_URL=redis://:${REDIS_PASSWORD:-pagespace_redis}@redis-sessions:6379/1
- PORT=3000
- NODE_OPTIONS=--max-old-space-size=640
- SERVICE_JWT_SECRET=${SERVICE_JWT_SECRET}
deploy:
resources:
limits:
Expand Down Expand Up @@ -136,7 +135,6 @@ services:
- NODE_OPTIONS=--max-old-space-size=1024
- ENABLE_OCR=${ENABLE_OCR:-false}
- ENABLE_EXTERNAL_OCR=${ENABLE_EXTERNAL_OCR:-false}
- SERVICE_JWT_SECRET=${SERVICE_JWT_SECRET}
- PROCESSOR_AUTH_REQUIRED=${PROCESSOR_AUTH_REQUIRED:-true}
- PROCESSOR_UPLOAD_RATE_LIMIT=${PROCESSOR_UPLOAD_RATE_LIMIT:-100}
- PROCESSOR_UPLOAD_RATE_WINDOW=${PROCESSOR_UPLOAD_RATE_WINDOW:-3600}
Expand Down
Loading
Loading