mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: prevent admin escalation when AUTH_ENABLED=false
When auth was disabled, users could log out, reach the login page, and authenticate with the default admin/admin credentials to gain full admin privileges — defeating the purpose of AUTH_ENABLED=false. Defense-in-depth fix across five layers: - Skip ensureDefaultAdmin() when auth is disabled (no admin user seeded) - Return 403 from POST /api/auth/login when auth is disabled - Return synthetic anonymous user from GET /api/auth/session when auth is disabled - Hide logout button in settings when auth is disabled - Redirect /login and /change-password to / via AuthGuard when auth is disabled Closes #90
This commit is contained in:
@@ -36,8 +36,10 @@ import { userFileRoutes } from "./routes/user-files.js";
|
|||||||
runMigrations();
|
runMigrations();
|
||||||
console.log("Database initialized");
|
console.log("Database initialized");
|
||||||
|
|
||||||
// Create default admin user if no users exist
|
// Create default admin user if no users exist and auth is enabled
|
||||||
await ensureDefaultAdmin();
|
if (env.AUTH_ENABLED) {
|
||||||
|
await ensureDefaultAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
function ensureInstanceId() {
|
function ensureInstanceId() {
|
||||||
const existing = db
|
const existing = db
|
||||||
|
|||||||
@@ -160,6 +160,10 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
{ config: { rateLimit: { max: getLoginAttemptLimit, timeWindow: "1 minute" } } },
|
{ config: { rateLimit: { max: getLoginAttemptLimit, timeWindow: "1 minute" } } },
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
if (!env.AUTH_ENABLED) {
|
||||||
|
return reply.status(403).send({ error: "Authentication is disabled" });
|
||||||
|
}
|
||||||
|
|
||||||
const body = request.body as { username?: string; password?: string } | null;
|
const body = request.body as { username?: string; password?: string } | null;
|
||||||
|
|
||||||
if (!body?.username || !body?.password) {
|
if (!body?.username || !body?.password) {
|
||||||
@@ -230,6 +234,22 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
// GET /api/auth/session
|
// GET /api/auth/session
|
||||||
app.get("/api/auth/session", async (request: FastifyRequest, reply: FastifyReply) => {
|
app.get("/api/auth/session", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
if (!env.AUTH_ENABLED) {
|
||||||
|
return reply.send({
|
||||||
|
user: {
|
||||||
|
id: "anonymous",
|
||||||
|
username: "anonymous",
|
||||||
|
role: "user",
|
||||||
|
mustChangePassword: false,
|
||||||
|
permissions: getPermissions("user"),
|
||||||
|
analyticsEnabled: null,
|
||||||
|
analyticsConsentShownAt: null,
|
||||||
|
analyticsConsentRemindAt: null,
|
||||||
|
},
|
||||||
|
expiresAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const token = extractToken(request);
|
const token = extractToken(request);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return reply.status(401).send({ error: "No session token provided" });
|
return reply.status(401).send({ error: "No session token provided" });
|
||||||
@@ -238,7 +258,6 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const session = db.select().from(schema.sessions).where(eq(schema.sessions.id, token)).get();
|
const session = db.select().from(schema.sessions).where(eq(schema.sessions.id, token)).get();
|
||||||
|
|
||||||
if (!session || session.expiresAt < new Date()) {
|
if (!session || session.expiresAt < new Date()) {
|
||||||
// Clean up expired session if it exists
|
|
||||||
if (session) {
|
if (session) {
|
||||||
db.delete(schema.sessions).where(eq(schema.sessions.id, token)).run();
|
db.delete(schema.sessions).where(eq(schema.sessions.id, token)).run();
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -87,9 +87,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
|
|||||||
const setStoreConsent = useAnalyticsStore((s) => s.setConsent);
|
const setStoreConsent = useAnalyticsStore((s) => s.setConsent);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
// Hydrate the analytics store from session data on initial load.
|
// biome-ignore lint/correctness/useExhaustiveDependencies: only hydrate on session load, not on store changes
|
||||||
// Only hydrate if the store is still in its initial state (user hasn't taken
|
|
||||||
// an explicit action like accepting/declining on the consent page).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!loading &&
|
!loading &&
|
||||||
@@ -103,9 +101,17 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
|
|||||||
analyticsConsentRemindAt: null,
|
analyticsConsentRemindAt: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line -- only hydrate on session load, not on store changes
|
|
||||||
}, [loading, analyticsEnabled, analyticsConsentShownAt, setStoreConsent]);
|
}, [loading, analyticsEnabled, analyticsConsentShownAt, setStoreConsent]);
|
||||||
|
|
||||||
|
// When auth is disabled, redirect away from login/change-password to prevent escalation
|
||||||
|
if (
|
||||||
|
!loading &&
|
||||||
|
!authEnabled &&
|
||||||
|
(location.pathname === "/login" || location.pathname === "/change-password")
|
||||||
|
) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't guard the login or change-password pages
|
// Don't guard the login or change-password pages
|
||||||
if (
|
if (
|
||||||
location.pathname === "/login" ||
|
location.pathname === "/login" ||
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ interface TeamEntry {
|
|||||||
/* ────────────────────── General ────────────────────── */
|
/* ────────────────────── General ────────────────────── */
|
||||||
|
|
||||||
function GeneralSection() {
|
function GeneralSection() {
|
||||||
|
const { authEnabled } = useAuth();
|
||||||
const [user, setUser] = useState<SessionUser | null>(null);
|
const [user, setUser] = useState<SessionUser | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [defaultToolView, setDefaultToolView] = useState("sidebar");
|
const [defaultToolView, setDefaultToolView] = useState("sidebar");
|
||||||
@@ -277,14 +278,16 @@ function GeneralSection() {
|
|||||||
<p className="text-xs text-muted-foreground capitalize">{role}</p>
|
<p className="text-xs text-muted-foreground capitalize">{role}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{authEnabled && (
|
||||||
type="button"
|
<button
|
||||||
onClick={handleLogout}
|
type="button"
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
onClick={handleLogout}
|
||||||
>
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||||
<LogOut className="h-3.5 w-3.5" />
|
>
|
||||||
Log out
|
<LogOut className="h-3.5 w-3.5" />
|
||||||
</button>
|
Log out
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Default view */}
|
{/* Default view */}
|
||||||
|
|||||||
Reference in New Issue
Block a user