1
0
forked from wrenn/wrenn

fix: security hardening from CSO audit

- Add auth failure logging (login, API key, JWT) with IP/email/prefix
- Move OAuth JWT from URL params to short-lived cookies to prevent
  token leakage via browser history, server logs, and Referer headers
- Pin Swagger UI to v5.18.2 with SRI integrity hashes
- Upgrade Go toolchain to 1.25.8 (fixes 5 called stdlib vulns)
- Fix unchecked error in host agent credential refresh
- Add .gstack to .gitignore for security report artifacts
This commit is contained in:
2026-04-08 03:46:31 +06:00
parent 3675ecba65
commit dd50cfdcb1
9 changed files with 57 additions and 18 deletions

1
.gitignore vendored
View File

@ -35,6 +35,7 @@ go.work.sum
.claude/ .claude/
e2b/ e2b/
.impeccable.md .impeccable.md
.gstack
## Builds ## Builds
builds/ builds/

View File

@ -1,6 +1,6 @@
module git.omukk.dev/wrenn/sandbox/envd module git.omukk.dev/wrenn/sandbox/envd
go 1.25.5 go 1.25.8
require ( require (
connectrpc.com/authn v0.1.0 connectrpc.com/authn v0.1.0

View File

@ -4,17 +4,37 @@
import { auth } from '$lib/auth.svelte'; import { auth } from '$lib/auth.svelte';
import { teams } from '$lib/teams.svelte'; import { teams } from '$lib/teams.svelte';
// Check for error in URL params (errors are still passed via query params).
const params = $page.url.searchParams; const params = $page.url.searchParams;
const error = params.get('error'); const error = params.get('error');
function getCookie(name: string): string | null {
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
function clearOAuthCookies() {
for (const name of [
'wrenn_oauth_token',
'wrenn_oauth_user_id',
'wrenn_oauth_team_id',
'wrenn_oauth_email',
'wrenn_oauth_name'
]) {
document.cookie = `${name}=; path=/auth/; max-age=0`;
}
}
if (error) { if (error) {
goto(`/login?error=${encodeURIComponent(error)}`); goto(`/login?error=${encodeURIComponent(error)}`);
} else { } else {
const token = params.get('token'); const token = getCookie('wrenn_oauth_token');
const userId = params.get('user_id'); const userId = getCookie('wrenn_oauth_user_id');
const teamId = params.get('team_id'); const teamId = getCookie('wrenn_oauth_team_id');
const email = params.get('email'); const email = getCookie('wrenn_oauth_email');
const name = params.get('name') ?? ''; const name = getCookie('wrenn_oauth_name') ?? '';
clearOAuthCookies();
if (token && userId && teamId && email) { if (token && userId && teamId && email) {
teams.reset(); teams.reset();

2
go.mod
View File

@ -1,6 +1,6 @@
module git.omukk.dev/wrenn/sandbox module git.omukk.dev/wrenn/sandbox
go 1.25.0 go 1.25.8
require ( require (
connectrpc.com/connect v1.19.1 connectrpc.com/connect v1.19.1

View File

@ -3,6 +3,7 @@ package api
import ( import (
"context" "context"
"errors" "errors"
"log/slog"
"net/http" "net/http"
"strings" "strings"
@ -202,6 +203,7 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
user, err := h.db.GetUserByEmail(ctx, req.Email) user, err := h.db.GetUserByEmail(ctx, req.Email)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
slog.Warn("login failed: unknown email", "email", req.Email, "ip", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password") writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
return return
} }
@ -210,10 +212,12 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
} }
if !user.PasswordHash.Valid { if !user.PasswordHash.Valid {
slog.Warn("login failed: no password set", "email", req.Email, "ip", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password") writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
return return
} }
if err := auth.CheckPassword(user.PasswordHash.String, req.Password); err != nil { if err := auth.CheckPassword(user.PasswordHash.String, req.Password); err != nil {
slog.Warn("login failed: wrong password", "email", req.Email, "ip", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password") writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
return return
} }

View File

@ -300,14 +300,23 @@ func (h *oauthHandler) retryAsLogin(w http.ResponseWriter, r *http.Request, prov
} }
func redirectWithToken(w http.ResponseWriter, r *http.Request, base, token, userID, teamID, email, name string) { func redirectWithToken(w http.ResponseWriter, r *http.Request, base, token, userID, teamID, email, name string) {
u := base + "?" + url.Values{ // Set auth data as short-lived cookies instead of URL query parameters.
"token": {token}, // This prevents token leakage via server access logs, Referer headers, and browser history.
"user_id": {userID}, for _, c := range []http.Cookie{
"team_id": {teamID}, {Name: "wrenn_oauth_token", Value: token},
"email": {email}, {Name: "wrenn_oauth_user_id", Value: userID},
"name": {name}, {Name: "wrenn_oauth_team_id", Value: teamID},
}.Encode() {Name: "wrenn_oauth_email", Value: email},
http.Redirect(w, r, u, http.StatusFound) {Name: "wrenn_oauth_name", Value: name},
} {
c.Path = "/auth/"
c.MaxAge = 60
c.HttpOnly = false // frontend JS must read these
c.SameSite = http.SameSiteLaxMode
c.Secure = isSecure(r)
http.SetCookie(w, &c)
}
http.Redirect(w, r, base, http.StatusFound)
} }
func redirectWithError(w http.ResponseWriter, r *http.Request, base, code string) { func redirectWithError(w http.ResponseWriter, r *http.Request, base, code string) {

View File

@ -20,6 +20,7 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler
hash := auth.HashAPIKey(key) hash := auth.HashAPIKey(key)
row, err := queries.GetAPIKeyByHash(r.Context(), hash) row, err := queries.GetAPIKeyByHash(r.Context(), hash)
if err != nil { if err != nil {
slog.Warn("api key auth failed", "prefix", auth.APIKeyPrefix(key), "ip", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid API key") writeError(w, http.StatusUnauthorized, "unauthorized", "invalid API key")
return return
} }
@ -42,6 +43,7 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler
tokenStr := strings.TrimPrefix(header, "Bearer ") tokenStr := strings.TrimPrefix(header, "Bearer ")
claims, err := auth.VerifyJWT(jwtSecret, tokenStr) claims, err := auth.VerifyJWT(jwtSecret, tokenStr)
if err != nil { if err != nil {
slog.Warn("jwt auth failed", "error", err, "ip", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid or expired token") writeError(w, http.StatusUnauthorized, "unauthorized", "invalid or expired token")
return return
} }

View File

@ -208,7 +208,7 @@ func serveDocs(w http.ResponseWriter, r *http.Request) {
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wrenn Sandbox API</title> <title>Wrenn Sandbox API</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css"> <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.18.2/swagger-ui.css" integrity="sha384-rcbEi6xgdPk0iWkAQzT2F3FeBJXdG+ydrawGlfHAFIZG7wU6aKbQaRewysYpmrlW" crossorigin="anonymous">
<style> <style>
body { margin: 0; background: #fafafa; } body { margin: 0; background: #fafafa; }
.swagger-ui .topbar { display: none; } .swagger-ui .topbar { display: none; }
@ -216,7 +216,7 @@ func serveDocs(w http.ResponseWriter, r *http.Request) {
</head> </head>
<body> <body>
<div id="swagger-ui"></div> <div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script> <script src="https://unpkg.com/swagger-ui-dist@5.18.2/swagger-ui-bundle.js" integrity="sha384-NXtFPpN61oWCuN4D42K6Zd5Rt2+uxeIT36R7kpXBuY9tLnZorzrJ4ykpqwJfgjpZ" crossorigin="anonymous"></script>
<script> <script>
SwaggerUIBundle({ SwaggerUIBundle({
url: "/openapi.yaml", url: "/openapi.yaml",

View File

@ -214,7 +214,10 @@ func RefreshCredentials(ctx context.Context, cpURL, credentialsFilePath string)
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read refresh response body: %w", err)
}
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
var errResp errorResponse var errResp errorResponse