
Hardening Memo’s Local Web Server: Rate Limiting, MIME Sniffing, and GET guards
Hardening Memo’s Local Web Server: Rate Limiting, MIME Sniffing, and GET guards
Memo runs as a local desktop application, but its internal architecture is split into a Go backend server and a Flutter frontend. Communication happens over a local web API on port :8090.
Because this API runs on localhost, security and robust API semantics are just as important as they would be in a cloud environment. If another local process or browser tab attempts to communicate with Memo's port, the backend must defend itself.
In our recent changes, we audited and hardened the local web server, fixing an easy rate limiter bypass, correcting HTTP verb violations, and securing streaming file uploads.
1. Rate Limiting by IP, Not IP:Port
To prevent scripts or runaway local processes from spamming the backend, we run a simple token bucket rate limiter middleware. The limiter was configured to key the client buckets using r.RemoteAddr.
// PRE-FIX CODE: vulnerable to port bypass
ip := r.RemoteAddr
limiter := getLimiterFor(ip)
The Problem:
r.RemoteAddr contains both the IP address and the ephemeral source port (e.g. 127.0.0.1:54321). The source port changes with almost every new TCP connection that a client opens.
As a result, a simple script could bypass the 100 req/s rate limit simply by opening a new HTTP connection for each request. The limiter would see a new port, treat it as a brand-new client, and allocate a fresh bucket.
The Fix:
We updated the middleware to strip the source port using Go's net.SplitHostPort before looking up the bucket:
ip := r.RemoteAddr
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
ip = host
}
Now, all TCP connections originating from the same IP share the same rate-limiting bucket, closing the port-rotation loophole.
2. Content Sniffing on File Uploads
When you upload files (images, PDFs, text) to Memo, the backend decides how to handle them. For example, images are routed through a local vision pipeline (e.g. LLaVA or Qwen2-VL), while text is routed to the RAG indexing pipeline.
Previously, the file upload endpoint trusted the client-supplied multipart Content-Type header (e.g. image/png) to decide whether a file was an image.
The Problem:
A client could label a plain text file or script as image/png, forcing it into the vision pipeline, which would fail or crash. Conversely, a client might upload a real image but label it incorrectly as text, bypass the vision analyzer, and pollute the search index with raw binary data.
The Fix:
We introduced a content-sniffing helper, detectIsImageFile, which reads the first 512 bytes of the uploaded file to determine the actual MIME type using Go's http.DetectContentType signature detector.
func detectIsImageFile(path, originalFilename string) bool {
sniffed := ""
if f, err := os.Open(path); err == nil {
buf := make([]byte, 512)
if n, _ := f.Read(buf); n > 0 {
sniffed = http.DetectContentType(buf[:n])
}
f.Close()
}
if strings.HasPrefix(sniffed, "image") {
return true
}
// If sniffing is inconclusive, fall back to file extension
if sniffed != "" && sniffed != "application/octet-stream" {
return false
}
switch strings.ToLower(filepath.Ext(originalFilename)) {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp":
return true
default:
return false
}
}
If the content is confidently recognized as non-image (such as text/plain or application/pdf), the backend rejects the image classification even if the filename ends with .png (preventing extension spoofing). The extension is only used as a fallback for custom or raw image formats that don't have standard magic bytes.
3. Strict HTTP Method Restrictions
Finally, several read-only endpoints (like /api/chats, /api/chats/active, /api/messages, and /api/events) were accepting any HTTP method. An HTTP POST or DELETE request to these endpoints would return the exact same response as a GET request.
While this doesn't present an immediate security exploit locally, it violates standard HTTP semantics and breaks reverse-proxy or local cache routing logic that relies on request verbs.
We hardened the routing by adding explicit method guards to all handlers:
func (s *Server) handleChats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "GET only", http.StatusMethodNotAllowed)
return
}
writeJSON(w, s.bridge.WebListChats())
}
Similarly, critical admin endpoints like /api/shutdown have been locked down to POST requests only, ensuring that simple page loads or pre-fetches from browser extensions cannot accidentally shut down the backend.