fix(security): Implement Phase 1 critical security fixes
CORS: - Restrict CORS to DASHBOARD_URL environment variable - Default to production dashboard domain Authentication: - Add AuthUser requirement to all agent management endpoints - Add AuthUser requirement to all command endpoints - Add AuthUser requirement to all metrics endpoints - Add audit logging for command execution (user_id tracked) Agent Security: - Replace Unicode characters with ASCII markers [OK]/[ERROR]/[WARNING] - Add certificate pinning for update downloads (allowlist domains) - Fix insecure temp file creation (use /var/run/gururmm with 0700 perms) - Fix rollback script backgrounding (use setsid instead of literal &) Dashboard Security: - Move token storage from localStorage to sessionStorage - Add proper TypeScript types (remove 'any' from error handlers) - Centralize token management functions Legacy Agent: - Add -AllowInsecureTLS parameter (opt-in required) - Add Windows Event Log audit trail when insecure mode used - Update documentation with security warnings Closes: Phase 1 items in issue #1 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import axios from "axios";
|
||||
import axios, { AxiosError } from "axios";
|
||||
|
||||
// Default to production URL, override with VITE_API_URL for local dev
|
||||
const API_URL = import.meta.env.VITE_API_URL || "https://rmm-api.azcomputerguru.com";
|
||||
@@ -10,22 +10,41 @@ export const api = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Add auth token to requests
|
||||
// Token management - use sessionStorage (cleared on tab close) instead of localStorage
|
||||
// This provides better security against XSS attacks as tokens are not persisted
|
||||
const TOKEN_KEY = "gururmm_auth_token";
|
||||
|
||||
export const getToken = (): string | null => {
|
||||
return sessionStorage.getItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
export const setToken = (token: string): void => {
|
||||
sessionStorage.setItem(TOKEN_KEY, token);
|
||||
};
|
||||
|
||||
export const clearToken = (): void => {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
// Request interceptor - add auth header
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("token");
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Handle auth errors
|
||||
// Response interceptor - handle 401 unauthorized
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem("token");
|
||||
window.location.href = "/login";
|
||||
clearToken();
|
||||
// Use a more graceful redirect that preserves SPA state
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
@@ -156,9 +175,31 @@ export interface RegisterRequest {
|
||||
|
||||
// API functions
|
||||
export const authApi = {
|
||||
login: (data: LoginRequest) => api.post<LoginResponse>("/api/auth/login", data),
|
||||
register: (data: RegisterRequest) => api.post<LoginResponse>("/api/auth/register", data),
|
||||
login: async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await api.post<LoginResponse>("/api/auth/login", data);
|
||||
if (response.data.token) {
|
||||
setToken(response.data.token);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
|
||||
register: async (data: RegisterRequest): Promise<LoginResponse> => {
|
||||
const response = await api.post<LoginResponse>("/api/auth/register", data);
|
||||
if (response.data.token) {
|
||||
setToken(response.data.token);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
|
||||
me: () => api.get<User>("/api/auth/me"),
|
||||
|
||||
logout: (): void => {
|
||||
clearToken();
|
||||
},
|
||||
|
||||
isAuthenticated: (): boolean => {
|
||||
return !!getToken();
|
||||
},
|
||||
};
|
||||
|
||||
export const agentsApi = {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import { User, authApi } from "../api/client";
|
||||
import { User, authApi, getToken, clearToken } from "../api/client";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string, name?: string) => Promise<void>;
|
||||
@@ -14,46 +14,49 @@ const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem("token"));
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Check authentication status on mount
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
authApi
|
||||
.me()
|
||||
.then((res) => setUser(res.data))
|
||||
.catch(() => {
|
||||
localStorage.removeItem("token");
|
||||
setToken(null);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
const checkAuth = async () => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
try {
|
||||
const res = await authApi.me();
|
||||
setUser(res.data);
|
||||
} catch {
|
||||
// Token is invalid or expired, clear it
|
||||
clearToken();
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const res = await authApi.login({ email, password });
|
||||
localStorage.setItem("token", res.data.token);
|
||||
setToken(res.data.token);
|
||||
setUser(res.data.user);
|
||||
const response = await authApi.login({ email, password });
|
||||
// Token is automatically stored by authApi.login
|
||||
setUser(response.user);
|
||||
};
|
||||
|
||||
const register = async (email: string, password: string, name?: string) => {
|
||||
const res = await authApi.register({ email, password, name });
|
||||
localStorage.setItem("token", res.data.token);
|
||||
setToken(res.data.token);
|
||||
setUser(res.data.user);
|
||||
const response = await authApi.register({ email, password, name });
|
||||
// Token is automatically stored by authApi.register
|
||||
setUser(response.user);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("token");
|
||||
setToken(null);
|
||||
authApi.logout();
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const isAuthenticated = authApi.isAuthenticated();
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, isLoading, login, register, logout }}>
|
||||
<AuthContext.Provider value={{ user, isAuthenticated, isLoading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useState, FormEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { AxiosError } from "axios";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "../components/Card";
|
||||
import { Input } from "../components/Input";
|
||||
import { Button } from "../components/Button";
|
||||
|
||||
interface ApiErrorResponse {
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function Login() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -21,8 +27,15 @@ export function Login() {
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate("/");
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || "Login failed. Please try again.");
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
const errorData = err.response?.data as ApiErrorResponse | undefined;
|
||||
setError(errorData?.error || errorData?.message || err.message || "Login failed. Please try again.");
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("An unexpected error occurred");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useState, FormEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { AxiosError } from "axios";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "../components/Card";
|
||||
import { Input } from "../components/Input";
|
||||
import { Button } from "../components/Button";
|
||||
|
||||
interface ApiErrorResponse {
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function Register() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -34,8 +40,15 @@ export function Register() {
|
||||
try {
|
||||
await register(email, password, name || undefined);
|
||||
navigate("/");
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || "Registration failed. Please try again.");
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
const errorData = err.response?.data as ApiErrorResponse | undefined;
|
||||
setError(errorData?.error || errorData?.message || err.message || "Registration failed. Please try again.");
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("An unexpected error occurred");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user