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:
1
.gitignore
vendored
1
.gitignore
vendored
@ -35,6 +35,7 @@ go.work.sum
|
|||||||
.claude/
|
.claude/
|
||||||
e2b/
|
e2b/
|
||||||
.impeccable.md
|
.impeccable.md
|
||||||
|
.gstack
|
||||||
|
|
||||||
## Builds
|
## Builds
|
||||||
builds/
|
builds/
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
2
go.mod
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user