fix(security): owner auth gate, OAuth state cookie binding, 0600 token perms
- Add OWNER_SECRET-based session: signed HMAC cookie, /api/auth/owner login, requireOwner middleware. All Tesla routes refuse 401 without it. - Bind OAuth state to a SameSite=Lax httpOnly cookie at /start, validate match in /callback with constant-time compare. Refuses unmatched callbacks. - Token store now mkdir 0700, writeFile + rename atomic, mode 0600 with defensive chmod. Owner-only on disk. - VIN masked to last 4 in responses; partner-register no longer echoes raw Tesla body to clients; coord bounds checked on send-to-nav. - Client: useTesla also tracks owner status; Connect Tesla button opens an OwnerLoginModal when not authenticated, then continues to Tesla OAuth. Conscious deferrals: - Explicit CSRF tokens on POST routes: mitigated by SameSite=Lax cookies + same-origin CORS. Will revisit if cross-origin clients land. - At-rest token encryption: deferred for single-user app; tokens are on a 0700 Dokku volume readable only by the app uid. Will add AES-GCM if we multi-tenant.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet';
|
||||
import { useTesla, startTeslaConnect, disconnectTesla, sendToTeslaNav, wakeTesla } from '../lib/tesla';
|
||||
import { useTesla, startTeslaConnect, disconnectTesla, sendToTeslaNav, wakeTesla, loginOwner } from '../lib/tesla';
|
||||
import { detectInCar } from '../lib/incar';
|
||||
import L from 'leaflet';
|
||||
import {
|
||||
@@ -1686,6 +1686,9 @@ export default function TeslaTripPlanner() {
|
||||
const [travelDates, setTravelDates] = useState<TravelDates>({ outbound: null, return: null, travellers: 2 });
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false);
|
||||
const [dateAnchor, setDateAnchor] = useState<DOMRect | null>(null);
|
||||
const [ownerLoginOpen, setOwnerLoginOpen] = useState(false);
|
||||
// After successful owner login, fire this pending action.
|
||||
const ownerLoginThenRef = React.useRef<null | (() => void)>(null);
|
||||
const lastODSent = React.useRef<{ from: string; to: string } | null>(null);
|
||||
const [variants, setVariants] = useState<RouteVariant[]>([]);
|
||||
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
|
||||
@@ -2189,6 +2192,14 @@ export default function TeslaTripPlanner() {
|
||||
teslaStatus={tesla.status}
|
||||
teslaState={tesla.state}
|
||||
onConnectTesla={async () => {
|
||||
if (tesla.owner?.required && !tesla.owner.authenticated) {
|
||||
ownerLoginThenRef.current = async () => {
|
||||
try { await startTeslaConnect(); }
|
||||
catch { toast.error('Could not start Tesla OAuth'); }
|
||||
};
|
||||
setOwnerLoginOpen(true);
|
||||
return;
|
||||
}
|
||||
try { await startTeslaConnect(); }
|
||||
catch { toast.error('Could not start Tesla OAuth'); }
|
||||
}}
|
||||
@@ -2636,10 +2647,106 @@ export default function TeslaTripPlanner() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<OwnerLoginModal
|
||||
open={ownerLoginOpen}
|
||||
onClose={() => setOwnerLoginOpen(false)}
|
||||
onSuccess={async () => {
|
||||
setOwnerLoginOpen(false);
|
||||
await tesla.refreshStatus();
|
||||
const fn = ownerLoginThenRef.current;
|
||||
ownerLoginThenRef.current = null;
|
||||
if (fn) fn();
|
||||
}}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function OwnerLoginModal({ open, onClose, onSuccess }: {
|
||||
open: boolean; onClose: () => void; onSuccess: () => void;
|
||||
}) {
|
||||
const [secret, setSecret] = React.useState('');
|
||||
const [pending, setPending] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
if (!open) return null;
|
||||
const submit = async () => {
|
||||
if (!secret) return;
|
||||
setPending(true);
|
||||
try {
|
||||
const ok = await loginOwner(secret);
|
||||
if (ok) {
|
||||
toast.success('Logged in as owner');
|
||||
setSecret('');
|
||||
onSuccess();
|
||||
} else {
|
||||
toast.error('Invalid owner secret');
|
||||
}
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 grid place-items-center p-6"
|
||||
style={{ zIndex: 9999, background: 'rgba(5,5,8,0.72)', backdropFilter: 'blur(8px)' }}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-[440px] max-w-full overflow-hidden"
|
||||
style={{
|
||||
background: 'var(--gd-bg-2)',
|
||||
border: '1px solid var(--gd-border-2)',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 24px 60px rgba(0,0,0,0.55)',
|
||||
}}
|
||||
>
|
||||
<div className="px-5 py-4" style={{ borderBottom: '1px solid var(--gd-border)' }}>
|
||||
<div className="text-[14px] font-semibold">Owner login required</div>
|
||||
<div className="text-[11.5px] mt-0.5" style={{ color: 'var(--gd-text-3)' }}>
|
||||
The Tesla integration is restricted to the deploying user. Enter the OWNER_SECRET set in the deploy environment.
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
||||
placeholder="Owner secret"
|
||||
className="w-full text-[13.5px] px-3 py-2.5 rounded-lg outline-none"
|
||||
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center gap-2" style={{ borderTop: '1px solid var(--gd-border)' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 h-10 text-[12.5px] rounded-lg"
|
||||
style={{ background: 'transparent', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!secret || pending}
|
||||
className="flex-[2] h-10 text-[12.5px] rounded-lg disabled:opacity-40"
|
||||
style={{ background: 'var(--gd-red)', color: '#fff' }}
|
||||
>
|
||||
{pending ? 'Logging in…' : 'Log in'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Modal shell ─────────────────────────────────────────────────────────────
|
||||
function ModalShell({
|
||||
onClose, width = 720, title, subtitle, footer, children,
|
||||
|
||||
Reference in New Issue
Block a user