Go Security Audit: Finding CORS Reflection, Auth Bypasses, and Error Leaks in an A2A Agent
After migrating from Node.js to Go, security vulnerabilities don't just disappear — they take new forms. Here's what we found in the new A2A agent and Vue frontend.

The Setup
We recently migrated our A2A agent from a Node.js/Express service to a Go-based one using the Google ADK framework. The old agent had a documented set of security vulnerabilities that we fixed in a previous audit (see Security Audit: Finding Auth Bypasses in an A2A Protocol App). The Go rewrite was supposed to be "cleaner and more secure."
We were wrong.
A new codebase doesn't mean new vulnerabilities — it means different vulnerabilities. The architecture had changed enough that old bugs were gone, but new ones had crept in. This post covers the five issues we found in the Go agent and the Vue frontend, and how we fixed each one.
Where We Looked

We audited four layers of the stack:
1. Transport — CORS policy (what origins can call the API?)
2. Authentication — Firebase Auth middleware (can requests slip through?)
3. API Responses — Error messages (what do we leak to callers?)
4. Frontend — CSP headers and debug logging (what does the browser expose?)
Each layer revealed something worth fixing.
1. CORS: The Reflecting Shield
The Problem
The CORS middleware in our Go server had a very convenient — and very dangerous — design:
origin := r.Header.Get("Origin")
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
}This echoes back whatever Origin header the client sends. If a website at https://evil.com makes a request, the server responds with Access-Control-Allow-Origin: https://evil.com. The browser sees a matching origin and allows the cross-origin request.
During development, this is undeniably useful. Your Vite dev server at localhost:5173 works without any configuration. But in production, it means any website can make authenticated requests to your API from a victim's browser.
We also had a fallback for requests without an Origin header:
w.Header().Set("Access-Control-Allow-Origin", "*")This was equally permissive. And combined with our Cloud Run deployment being --allow-unauthenticated, there was zero protection at the infrastructure level.
The Fix
We replaced the reflect-everything approach with a configured allowlist:
allowedOrigins := config.Load().AllowedOrigins
if origin != "" {
if isOriginAllowed(origin, allowedOrigins) {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
}The allowed origins come from a CORS_ALLOWED_ORIGINS environment variable — a comma-separated list. For development it's http://localhost:5173,http://localhost:8080. For production it's the Firebase Hosting domains:
CORS_ALLOWED_ORIGINS: https://XXX.firebaseapp.com,https://XXX.web.appNo more wildcard. No more reflection. If an origin isn't in the list, the browser blocks the cross-origin read.
2. Auth: The Silent Backdoor
The Problem
The Firebase Auth middleware had a design flaw that wasn't obvious until you followed the code path:
func getFirebaseAuth() *auth.Client {
authOnce.Do(initFirebaseAuth)
return firebaseAuth
}initFirebaseAuth checks if FIRESTORE_PROJECT_ID is set. If it's empty (or if Firebase initialization fails), getFirebaseAuth() returns nil.
Now here's where it gets interesting. The middleware handles this:
if getFirebaseAuth() == nil {
ctx := context.WithValue(r.Context(), ContextUserIDKey, "user")
next.ServeHTTP(w, r.WithContext(ctx))
return
}If Firebase isn't configured, every request is allowed through with user ID "user". All requests get grouped under a single anonymous user. There's no authentication check at all.

This isn't just a development convenience — it's a production vulnerability. If the server starts but Firebase initialization fails (network hiccup, misconfigured env vars, credential issue), the service silently becomes open to everyone.
The Fix
We removed the nil-fallback entirely. If Firebase Auth isn't available, the server returns 401:
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
writeJSONError(w, http.StatusUnauthorized, "missing or invalid authorization header")
return
}For local development, we added an explicit SKIP_AUTH=true env var. You have to knowingly opt out of auth — it doesn't happen by accident.
The Lesson
"Fail open" patterns in auth middleware are dangerous. Always fail closed: if you can't verify who someone is, reject them. The inconvenience of a 401 during a misconfiguration is far better than silently exposing all your data.
3. Error Messages: The Leaky Faucet
The Problem
This one was subtle but pervasive. Our API handlers used fmt.Sprintf to build error messages that included Go-level error details:
writeJSONError(w, http.StatusBadRequest,
fmt.Sprintf("invalid request body: %v", err))
writeJSONError(w, http.StatusInternalServerError,
fmt.Sprintf("failed to fetch story: %v", err))
writeJSONError(w, http.StatusInternalServerError,
fmt.Sprintf("pdf generation failed: %v", err))
Each of these reveals different internal details to the API consumer:
An attacker probing the API gets a detailed map of our internal architecture — what libraries we use, what our data model looks like, what our storage paths are.
The Fix
We now log the full error server-side and return a static message to the client:
log.Printf("pdf generate: invalid request body: %v", err)
writeJSONError(w, http.StatusBadRequest, "invalid request body")The client gets "invalid request body" — enough to know something went wrong, not enough to map the internals. The full error goes to stdout where we can debug it.
The Rule
Error messages should tell the client _what_ went wrong, not _how_ the system works internally. Log the details, hide the internals.
4. Frontend: Console Spam and No CSP
The Problem
The Vue frontend had two issues that compounded each other:
First, our auth store logged user UIDs to the browser console:
console.log("[Auth] store: user authenticated —", firebaseUser.uid);
console.log("[Auth] store: auth state changed —", firebaseUser?.uid ?? "null");Seven console.log calls across three files, all logging Firebase user UIDs and auth state transitions. Anyone with browser DevTools open can see authenticated user identifiers.
Second, there was no Content-Security-Policy header:
Cross-Origin-Opener-Policy: unsafe-none
Cross-Origin-Embedder-Policy: unsafe-none
The only security headers were the permissive COOP/COEP defaults from Vite. No CSP means:
The Fix
We removed all console.log calls from the auth flow:
src/firebase/auth.ts — 7 logs removed
src/stores/auth.store.ts — 10 logs removed (kept the timeout warning)
src/App.vue — 3 logs removedAnd added a strict CSP to firebase.json:
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' https://*.firebaseio.com https://*.googleapis.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https://storage.googleapis.com https://*.googleapis.com; connect-src 'self' https://backend-xxxx.run.app https://*.googleapis.com font-src 'self' https://fonts.gstatic.com; frame-ancestors 'none'"
}The CSP restricts:
'self', Firebase, and Google APIs only'self', GCS, and Google APIsframe-ancestors 'none'The Pattern
Frontend security isn't just about sanitizing user input. CSP headers and avoiding debug logging are basic hygiene that often get overlooked in the rush to ship features.
5. Content Validation: The List of Nothing
The Problem
The Go agent had a content validation feature. It checked story titles and content for profanity before saving. The ValidateContent function called checkProfanity which iterated over...
...an empty slice:
var profanityList = []string{}The function existed, the pipeline called it, the logic was all there — but the list had zero words. The profanity check was a no-op that always passed.
The Fix
We populated the list and fixed the matching to use word boundaries:
var profanityList = []string{
"fuck", "shit", "asshole", "bastard", "bitch",
"damn", "crap", "dick", "piss", "slut",
"whore", "cock", "porn", "sex", "sexy",
}The original implementation used strings.Contains which would match substrings — so "classic" would match "ass". We switched to regex word-boundary matching:
pattern := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(word) + `\b`)Now "classic" passes, but "ass" in isolation does not.
The Lesson
A validation feature that validates nothing is worse than having no validation at all — it creates a false sense of security.
Results
Takeaways
1. A rewrite doesn't reset security.
Moving from Node.js to Go fixed the old bugs but introduced new ones. Each new codebase has its own attack surface. Audit early, audit often.
2. "Fail closed" is the only safe default.
If your auth middleware can't verify someone's identity, reject them. A 401 during a configuration hiccup is far better than a week of data exposure before you notice.
3. Error messages are a data leak.
Every %v in an error response is a potential information disclosure. Log the details, return the summary.
4. CSP isn't optional.
A few lines in firebase.json prevent XSS, clickjacking, and data injection. There's no reason not to have it.
5. Empty validation is worse than no validation.
If you build a profanity checker but populate it with an empty list, you've created a false sense of security for zero benefit.
Check more open source repos at: github.com/mnkrana/