mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-04 00:02:05 +00:00
Merge pull request #599 from warmbly/chore/casa-al1-security-assessment
feat: complete the ADA CASA AL1 control set and ship the assessment evidence pack
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
# Dependency vulnerability scan, deliberately not a PR gate: a CVE published
|
||||
# Dependency vulnerability scanning, deliberately not a PR gate: a CVE published
|
||||
# overnight is not actionable in whatever PR happens to trip it, so scanning
|
||||
# every PR just makes unrelated work go red. It runs on a schedule and when
|
||||
# dependency manifests change on main; a finding fails the run, which is the
|
||||
# signal to bump the dependency in its own PR.
|
||||
#
|
||||
# govulncheck is the one that matters most and was missing. Trivy does not
|
||||
# evaluate the Go standard library at all, so a toolchain carrying a critical
|
||||
# net/http advisory scanned clean. govulncheck covers the stdlib and proves
|
||||
# reachability through the call graph, which is also what lets a finding be
|
||||
# justified rather than merely bumped.
|
||||
name: Security
|
||||
|
||||
on:
|
||||
@@ -23,12 +29,34 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
govulncheck:
|
||||
name: Go Vulnerabilities
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# The default build has no Kafka backend, so the Avro codec behind that
|
||||
# build tag is never compiled and never scanned. Both are checked.
|
||||
- name: Run govulncheck
|
||||
run: |
|
||||
go run golang.org/x/vuln/cmd/govulncheck@latest ./...
|
||||
go run golang.org/x/vuln/cmd/govulncheck@latest -tags kafka ./...
|
||||
|
||||
trivy:
|
||||
name: Dependency Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# ignore-unfixed is deliberately NOT set. An advisory with no upstream fix
|
||||
# is exactly the case that needs a human decision (upgrade, work around,
|
||||
# or write down why it is not reachable); hiding it meant the scan was
|
||||
# green while four such advisories were live.
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
@@ -36,4 +64,33 @@ jobs:
|
||||
scan-ref: "."
|
||||
severity: "CRITICAL,HIGH"
|
||||
exit-code: "1"
|
||||
ignore-unfixed: true
|
||||
trivyignores: ".trivyignore"
|
||||
|
||||
node:
|
||||
name: Node Dependencies
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
tree: [web, admin, site, docs, forms]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-pnpm
|
||||
with:
|
||||
working-directory: ${{ matrix.tree }}
|
||||
- name: Audit ${{ matrix.tree }}
|
||||
working-directory: ${{ matrix.tree }}
|
||||
# Production dependencies only: a devDependency advisory cannot be
|
||||
# reached by anything a visitor can send, and gating releases on the
|
||||
# transitive dependencies of eslint is how a scanner gets ignored.
|
||||
run: pnpm audit --audit-level=high --prod
|
||||
|
||||
rust:
|
||||
name: Rust Dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: rustsec/audit-check@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
working-directory: tracking
|
||||
|
||||
+8
-41
@@ -1,42 +1,9 @@
|
||||
# Trivy ignore list — vulnerabilities we've consciously accepted.
|
||||
# Advisories accepted with a written reason, reviewed whenever this file is
|
||||
# touched. Everything here must also appear in the CASA evidence pack under
|
||||
# 6.1.1 with the same justification.
|
||||
#
|
||||
# Each entry must include the CVE/GHSA, the package, and a reason
|
||||
# we're not patching it. Re-evaluate on dependency upgrades.
|
||||
|
||||
# rustls-webpki 0.101.7 — DoS via panic on malformed CRL.
|
||||
# Pulled in transitively by hyper-rustls 0.24, which is pinned by
|
||||
# aws-smithy-http-client / aws-config. The AWS Rust SDK hasn't yet
|
||||
# migrated to rustls 0.23+, so we can't bump this without forking
|
||||
# the SDK. tracking/ only uses the AWS SDK for SSM + Secrets Manager
|
||||
# fetched at startup over the public CA chain; the affected code
|
||||
# path (CRL parsing) is not reachable in our usage.
|
||||
GHSA-82j2-j2ch-gfr8
|
||||
|
||||
# esbuild < 0.28.1 — missing binary integrity check in the Deno module
|
||||
# install path enables RCE (GHSA-gv7w-rqvm-qjhr). Pulled in transitively
|
||||
# by vite / vitest / astro / tsx across admin/, docs/, site/, web/. The
|
||||
# affected path is the deno.land/x/esbuild installer; we install esbuild
|
||||
# only via pnpm/npm on Node, where the platform binary packages are
|
||||
# integrity-pinned in the lockfile, so that path is never used. esbuild
|
||||
# is a build-time dev dependency and ships in no runtime artifact.
|
||||
# Re-evaluate when vite/astro bump esbuild to >= 0.28.1.
|
||||
GHSA-gv7w-rqvm-qjhr
|
||||
|
||||
# sharp inherits libvips CVEs (GHSA-f88m-g3jw-g9cj / CVE-2026-33327, -33328,
|
||||
# -35590, -35591). Fixed in sharp 0.35, and docs/ is on 0.35. The marketing
|
||||
# site (site/) stays on 0.34.5 because sharp 0.35 breaks its Cloudflare Pages
|
||||
# build. sharp there is build-time optimization of our own static assets with
|
||||
# no untrusted input, so the practical risk is nil. Re-evaluate when the
|
||||
# Cloudflare build supports sharp 0.35.
|
||||
GHSA-f88m-g3jw-g9cj
|
||||
CVE-2026-33327
|
||||
CVE-2026-33328
|
||||
CVE-2026-35590
|
||||
CVE-2026-35591
|
||||
|
||||
# react-router 7.x — RSC-mode CSRF bypass, only fixed in 8.3.0 (the advisory
|
||||
# range covers all of 7.12+). web/ and admin/ are client-side Vite SPAs using
|
||||
# react-router-dom in the browser; there is no react-router server, no RSC
|
||||
# mode, and no server actions, so the vulnerable path does not exist here.
|
||||
# Re-evaluate when we move either app to react-router 8.
|
||||
GHSA-qwww-vcr4-c8h2
|
||||
# Nothing is listed today: the four advisories with no upstream fix
|
||||
# (github.com/xuri/excelize GO-2026-6452 and the three hamba/avro decoder
|
||||
# advisories) are Go-module findings that govulncheck reports and Trivy's
|
||||
# severity filter does not raise, so suppressing them here would be
|
||||
# suppressing nothing. They are justified in the evidence pack instead.
|
||||
|
||||
@@ -42,7 +42,7 @@ PROTO_GEN_FILES := $(PROTO_DIR)/tasks.pb.go
|
||||
restart restart-go restart-all infra infra-down app app-down app-logs \
|
||||
backend forms forms-web consumer worker run dev tracking realtime web \
|
||||
admin site docs grant-admin revoke-admin gen-key installer-sha installer-check installer-demo \
|
||||
db-reset db-wipe migrate warmbly warmbly-dist cli-sha cli-check images-check
|
||||
db-reset db-wipe migrate warmbly warmbly-dist cli-sha cli-check images-check casa-evidence
|
||||
|
||||
setup-tools:
|
||||
@echo "Installing required Go tools into $(GO_BIN)"
|
||||
@@ -726,6 +726,11 @@ installer-sha:
|
||||
|
||||
# Everything CI runs against the installer: POSIX parse, shellcheck, --help,
|
||||
# --print-env, a compose file per answer shape, and the checksum.
|
||||
# Generate the CASA dependency-scan artifacts. Read-only; writes under
|
||||
# compliance/casa/artifacts/.
|
||||
casa-evidence:
|
||||
./scripts/casa-evidence.sh
|
||||
|
||||
installer-check:
|
||||
@./scripts/check-installer.sh
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ RUN --mount=type=secret,id=sentry_auth_token,required=false \
|
||||
# entrypoint renders /config.js from container env at startup.
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY nginx-security-headers.conf /etc/nginx/warmbly-security-headers.conf
|
||||
RUN nginx -t
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
# public/ files keep their checkout mode through the build; on a filesystem
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Security response headers for the dashboard shell and its assets.
|
||||
#
|
||||
# This file is included once per location rather than set once at the server
|
||||
# level, because nginx only inherits add_header from an outer block when the
|
||||
# inner block declares none of its own. Every location here sets Cache-Control,
|
||||
# so a server-level declaration would be silently dropped in exactly the places
|
||||
# that serve the app.
|
||||
#
|
||||
# No script-src or connect-src: the API origin, the analytics host and the
|
||||
# billing host are runtime configuration (config.js is rewritten by the
|
||||
# container entrypoint), so an allowlist compiled into the image would break a
|
||||
# self-host that points the dashboard somewhere else. What is pinned here is
|
||||
# everything that does not depend on that configuration.
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||
add_header Content-Security-Policy "frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'self'" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
+11
-3
@@ -7,16 +7,24 @@ server {
|
||||
# SPA history fallback: unknown paths serve the app shell so client-side
|
||||
# routing works on hard reloads and deep links.
|
||||
location / {
|
||||
include /etc/nginx/warmbly-security-headers.conf;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Never cache the shell or the runtime config, so a redeploy or an env
|
||||
# change is picked up on the next load. The hashed assets they point to are
|
||||
# cached immutably below.
|
||||
location = /index.html { add_header Cache-Control "no-store"; }
|
||||
location = /config.js { add_header Cache-Control "no-store"; }
|
||||
location = /index.html {
|
||||
include /etc/nginx/warmbly-security-headers.conf;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
location = /config.js {
|
||||
include /etc/nginx/warmbly-security-headers.conf;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
include /etc/nginx/warmbly-security-headers.conf;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@
|
||||
"posthog-js": "^1.427.2",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"react-turnstile": "^1.1.5",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
||||
Generated
+9
-9
@@ -95,8 +95,8 @@ importers:
|
||||
specifier: ^19.1.1
|
||||
version: 19.2.6(react@19.2.6)
|
||||
react-router-dom:
|
||||
specifier: ^7.18.1
|
||||
version: 7.18.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
specifier: ^7.18.4
|
||||
version: 7.18.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-turnstile:
|
||||
specifier: ^1.1.5
|
||||
version: 1.1.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -2281,15 +2281,15 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-router-dom@7.18.1:
|
||||
resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==}
|
||||
react-router-dom@7.18.4:
|
||||
resolution: {integrity: sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
|
||||
react-router@7.18.1:
|
||||
resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==}
|
||||
react-router@7.18.4:
|
||||
resolution: {integrity: sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
@@ -4633,13 +4633,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.15
|
||||
|
||||
react-router-dom@7.18.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
react-router-dom@7.18.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
react-router: 7.18.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-router: 7.18.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
|
||||
react-router@7.18.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
react-router@7.18.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
cookie: 1.1.1
|
||||
react: 19.2.6
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Security response headers for the Cloudflare Pages deployment of the
|
||||
# admin panel. The nginx image carries the same set in
|
||||
# nginx-security-headers.conf; both exist because the hosted service serves the
|
||||
# frontends from Pages while a self-host serves them from the image.
|
||||
#
|
||||
# No script-src or connect-src: the API origin is runtime configuration
|
||||
# (config.js), so an allowlist fixed at build time would break an instance that
|
||||
# points the dashboard elsewhere.
|
||||
/*
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
|
||||
Content-Security-Policy: frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'self'
|
||||
Strict-Transport-Security: max-age=31536000; includeSubDomains
|
||||
@@ -6,6 +6,9 @@
|
||||
// obvious "you are not an admin" screen rather than silently
|
||||
// forwarding them — same-domain dashboard users could otherwise
|
||||
// land here by mistake.
|
||||
// 4. If they are an admin but their session did not present a second factor,
|
||||
// say so and point at where to turn one on. The backend refuses these
|
||||
// routes either way; this is what turns that 403 into instructions.
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
@@ -70,5 +73,21 @@ export function RequireAdmin() {
|
||||
);
|
||||
}
|
||||
|
||||
if (me.session_mfa_verified === false) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-background p-6 text-center">
|
||||
<AdminBadge />
|
||||
<h1 className="text-xl font-semibold">Two-factor authentication required</h1>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
Administrative access needs a second factor. Open the dashboard, turn on 2FA
|
||||
or add a passkey under Settings > Security, then sign in here again.
|
||||
</p>
|
||||
<a href="/auth/login" className="text-sm underline text-muted-foreground">
|
||||
Sign in again
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -126,18 +126,11 @@ export async function Request<T>(config: AuthRequestConfig): Promise<T> {
|
||||
clearToken();
|
||||
throw new SessionExpiredError();
|
||||
}
|
||||
// `message` is the sentence the API wrote about this specific
|
||||
// failure; `error` is only the HTTP class it belongs to. Preferring
|
||||
// `error` meant every toast in the admin panel read "Internal
|
||||
// Server Error" or "Bad Request" while the reason sat unread one
|
||||
// field away. A body that is not an object at all (a proxy's HTML
|
||||
// page, an empty 504) falls through to the axios message.
|
||||
const raw = err.response?.data;
|
||||
const body = (raw && typeof raw === "object" ? raw : {}) as { error?: string; message?: string };
|
||||
const body = (err.response?.data ?? {}) as { error?: string; message?: string };
|
||||
const failure = new APIError(
|
||||
body.message || body.error || err.message || "Request failed",
|
||||
body.error || body.message || err.message || "Request failed",
|
||||
status,
|
||||
raw,
|
||||
err.response?.data,
|
||||
);
|
||||
noteFailure(config, failure);
|
||||
throw failure;
|
||||
|
||||
@@ -68,5 +68,9 @@ export interface AdminProfile {
|
||||
is_admin?: boolean;
|
||||
// Bitmask, not a list: the backend serializes models.AdminPermission (uint32).
|
||||
admin_permissions?: number;
|
||||
// Whether the session making the request presented a second factor. Admin
|
||||
// routes require it, so the guard reads this to explain a refusal instead
|
||||
// of letting the first data call 403.
|
||||
session_mfa_verified?: boolean;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -88,7 +88,13 @@ export function initErrorReporting(): void {
|
||||
.then((Sentry) => {
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
sendDefaultPii: true,
|
||||
// Off deliberately. The user id, email and name are
|
||||
// attached below through setUser, which is the identity an
|
||||
// exception needs. sendDefaultPii adds request headers,
|
||||
// cookies and bodies on top of that, and an Authorization
|
||||
// header in a crash report is a session handed to whoever
|
||||
// can read the project.
|
||||
sendDefaultPii: false,
|
||||
environment: SENTRY_ENVIRONMENT,
|
||||
// Empty is omitted rather than sent: an event tagged with
|
||||
// the empty release matches no uploaded source map and
|
||||
|
||||
@@ -40,7 +40,9 @@ POSTHOG_ERROR_TRACKING="true"
|
||||
SENTRY_DSN=""
|
||||
|
||||
# SECRETS
|
||||
AUTH_SECRET="example123"
|
||||
# At least 32 characters: it signs every session token and the backend now
|
||||
# refuses to start below that. Generate one with: make gen-key
|
||||
AUTH_SECRET="replace-me-with-at-least-32-random-characters"
|
||||
TURNSTILE_SECRET=""
|
||||
|
||||
# Passkeys (WebAuthn). Optional — derived from APP_URL / CORS_ALLOW_ORIGINS
|
||||
|
||||
+11
-1
@@ -732,7 +732,7 @@ func main() {
|
||||
creditAutoTopUpAttemptRepository := repository.NewCreditAutoTopUpAttemptRepository(primaryDB)
|
||||
aiSettingsRepository = repository.NewAISettingsRepository(primaryDB)
|
||||
creditService = credits.NewService(creditRepository, aiSettingsRepository, cache)
|
||||
webhookRepository := repository.NewWebhookRepository(primaryDB.Pool)
|
||||
webhookRepository := repository.NewWebhookRepositorySealed(primaryDB.Pool, credEncrypter)
|
||||
webhookService := webhook.NewService(webhookRepository)
|
||||
webhookServiceForHandler = webhookService
|
||||
webhookRepoForHandler = webhookRepository
|
||||
@@ -821,6 +821,16 @@ func main() {
|
||||
tokenService = token.NewService(primaryDB, tokenRepostory, cache, geoloc, authCfg.AuthSecret)
|
||||
userService = user.NewService(userRepostory, cache)
|
||||
|
||||
// A login ban has to end the sessions the person already holds, or it
|
||||
// does nothing until their tokens expire twelve hours later. Wired here
|
||||
// rather than at construction because the admin service is built before
|
||||
// the token service exists.
|
||||
if withRevoker, ok := adminService.(interface {
|
||||
WithSessionRevoker(admin.SessionRevoker)
|
||||
}); ok && adminService != nil {
|
||||
withRevoker.WithSessionRevoker(tokenService)
|
||||
}
|
||||
|
||||
// Organization-wide audit trail (who did what, when, from where).
|
||||
auditRepository := repository.NewAuditRepository(primaryDB.Pool)
|
||||
auditService = audit.NewService(auditRepository, streamingPublisher)
|
||||
|
||||
@@ -226,7 +226,7 @@ func main() {
|
||||
// integration actions in-process (cipher + Postgres are available here; the
|
||||
// consumer is control-plane, not a worker). Suppression already lives in the
|
||||
// advanced repo, so no separate suppression repo is wired here.
|
||||
webhookRepoC := repository.NewWebhookRepository(primaryDB.Pool)
|
||||
webhookRepoC := repository.NewWebhookRepositorySealed(primaryDB.Pool, credEncrypter)
|
||||
webhookService := webhook.NewService(webhookRepoC)
|
||||
// The consumer dispatches lower-volume reply/warmup events (not per-contact
|
||||
// campaign fan-out), so a generous static cap is enough here; the plan-based
|
||||
|
||||
@@ -38,6 +38,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -73,6 +74,18 @@ var (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// This seeder plants accounts with published passwords (dev@warmbly.com and
|
||||
// a super-admin with a known API key), which is exactly what it is for. The
|
||||
// binary ships inside the release backend image, so the one thing it must
|
||||
// never do is run against a real deployment by accident.
|
||||
//
|
||||
// APP_ENV unset counts as dev, matching cmd/backend/boot.go, so `make dev`
|
||||
// keeps working with no extra variable.
|
||||
if env := strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV"))); env != "" &&
|
||||
env != "dev" && env != "development" && env != "local" {
|
||||
log.Fatalf("Refusing to seed: APP_ENV=%s. This seeder creates accounts with published credentials and is only for local development.", env)
|
||||
}
|
||||
|
||||
dsn := os.Getenv("PRIMARY_DB")
|
||||
if dsn == "" {
|
||||
dsn = "postgres://warmbly:warmbly@localhost:5432/warmbly_dev?sslmode=disable"
|
||||
|
||||
@@ -57,10 +57,14 @@ func readPassword(ctx context.Context, fromStdin bool, what string) (string, err
|
||||
// validatePassword uses the same rule the dashboard enforces, so the scripted
|
||||
// route is never weaker than the interactive one.
|
||||
func validatePassword(password string) error {
|
||||
if crypt.ValidatePassword(password) {
|
||||
switch crypt.CheckPassword(password) {
|
||||
case crypt.PasswordOK:
|
||||
return nil
|
||||
case crypt.PasswordBreached:
|
||||
return errors.New("that password appears in a public list of breached passwords, so it is not accepted. Nothing was changed.")
|
||||
default:
|
||||
return errors.New("that password is not accepted: it must be between 8 and 128 characters. Nothing was changed.")
|
||||
}
|
||||
return errors.New("that password is not accepted: it must be between 8 and 128 characters. Nothing was changed.")
|
||||
}
|
||||
|
||||
func promptSecret(ctx context.Context, label string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# ADA CASA evidence pack
|
||||
|
||||
This directory holds the evidence Warmbly submits for the App Defense Alliance
|
||||
CASA assessment of the Google OAuth client `warmbly-mailboxes`
|
||||
(project 1010273043313, project id `warmbly-mailboxes`).
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `evidence.md` | The submission. One section per CASA test case, with the control, the file and line that implements it, and the artifact that demonstrates it |
|
||||
| `scope.md` | What is in scope and what is not, and why |
|
||||
| `crypto-inventory.md` | Every cryptographic operation, its algorithm, key size, key handling and rotation, for test case 4.1.3 |
|
||||
| `oauth.md` | Every OAuth 2.0 integration, its flow, and the exact Google scopes requested, for 3.2.1 and 3.2.2 |
|
||||
| `artifacts/` | Scan output and other generated evidence |
|
||||
|
||||
## Regenerating the artifacts
|
||||
|
||||
make casa-evidence
|
||||
|
||||
That runs the dependency scanners and writes their output under `artifacts/`
|
||||
with a timestamp. Two artifacts cannot be produced from this repository and
|
||||
have to be attached by hand before submission:
|
||||
|
||||
- the Qualys SSL Labs report for each in-scope hostname (test cases 4.1.1, 4.1.2)
|
||||
- the authenticated Burp Suite scan using the ADA scan configuration
|
||||
(test cases 2.1.1, 2.3.1, 2.3.2, 2.3.4, 3.1.5, 3.1.6, 5.1.1 through 5.1.10, 6.2.1, 6.3.1)
|
||||
|
||||
`scope.md` lists the hostnames and the authenticated routes the Burp scan has to
|
||||
cover.
|
||||
|
||||
## What is deliberately not here
|
||||
|
||||
The assessment's change log, which records what each control replaced, is kept
|
||||
outside this repository and given to the lab directly. Warmbly is self-hostable,
|
||||
so a public account of what a control fixed doubles as a list of what to try
|
||||
against an instance that has not updated yet. The controls themselves are
|
||||
described in full above; only the before-and-after is withheld, and only until
|
||||
deployments have moved on.
|
||||
|
||||
## Keeping this current
|
||||
|
||||
CASA is annual, and the assurance level can rise. Treat the evidence like the
|
||||
code it describes: when a control changes, the section that cites it changes in
|
||||
the same pull request. Every file:line reference here was accurate at the commit
|
||||
recorded at the top of `evidence.md`.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Scan output is regenerated by `make casa-evidence` and is committed
|
||||
# deliberately: the submission has to show what was scanned and when.
|
||||
#
|
||||
# What is not committed is anything containing customer data or a credential.
|
||||
# The Burp scan report and the Qualys reports are attached to the submission
|
||||
# directly rather than stored here, because a Burp report contains full request
|
||||
# and response bodies from an authenticated session.
|
||||
*.html
|
||||
*.burp
|
||||
burp-*
|
||||
qualys-*
|
||||
@@ -0,0 +1,17 @@
|
||||
# Elixir dependencies
|
||||
|
||||
Generated: 2026-09-19T06:06:32Z
|
||||
Commit: f404f34b623be86434dcfa029e594f4d1943a8b4
|
||||
|
||||
Advisories:
|
||||
cowlib 2.20.0 - EEF-CVE-2026-43966 (MEDIUM)
|
||||
aka: CVE-2026-43966, GHSA-w4f7-4cxr-rv3c
|
||||
HTTP Response Splitting via Non-VCHAR Bytes in cow_http_struct_hd:escape_string/2
|
||||
https://osv.dev/vulnerability/EEF-CVE-2026-43966
|
||||
|
||||
cowlib 2.20.0 - EEF-CVE-2026-43969 (LOW)
|
||||
aka: CVE-2026-43969, GHSA-g2wm-735q-3f56
|
||||
Cookie Request Header Injection via Unvalidated Encoder in cow_cookie:cookie/1
|
||||
https://osv.dev/vulnerability/EEF-CVE-2026-43969
|
||||
|
||||
Found packages with security advisories
|
||||
@@ -0,0 +1,201 @@
|
||||
# govulncheck, kafka build variant
|
||||
|
||||
Generated: 2026-09-19T06:06:32Z
|
||||
Commit: f404f34b623be86434dcfa029e594f4d1943a8b4
|
||||
|
||||
The Avro codec is behind a build tag, so the default scan never compiles it.
|
||||
|
||||
=== Symbol Results ===
|
||||
|
||||
Vulnerability #1: GO-2026-6452
|
||||
Panic via negative shared-string index in github.com/xuri/excelize
|
||||
More info: https://pkg.go.dev/vuln/GO-2026-6452
|
||||
Module: github.com/xuri/excelize/v2
|
||||
Found in: github.com/xuri/excelize/v2@v2.11.0
|
||||
Fixed in: N/A
|
||||
Example traces found:
|
||||
#1: internal/app/contact/import.go:808:28: contact.parseSpreadsheetInner calls excelize.Rows.Columns
|
||||
|
||||
Vulnerability #2: GO-2026-5048
|
||||
Denial of service via unbounded map allocations in
|
||||
github.com/iskorotkov/avro/v2 and github.com/hamba/avro/v2
|
||||
More info: https://pkg.go.dev/vuln/GO-2026-5048
|
||||
Module: github.com/hamba/avro/v2
|
||||
Found in: github.com/hamba/avro/v2@v2.31.0
|
||||
Fixed in: N/A
|
||||
Example traces found:
|
||||
#1: internal/app/contact/export.go:348:2: contact.writeXLSX calls excelize.File.Close, which eventually calls avro.AddAll
|
||||
#2: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.ArraySchema.String
|
||||
#3: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.ArraySchema.Type
|
||||
#4: internal/infrastructure/kafka/avrov.go:33:38: kafka.NewAvrov2Client calls avrov2.NewDeserializer, which calls avro.Config.Freeze
|
||||
#5: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.EnumSchema.String
|
||||
#6: internal/models/event_schema.go:279:42: models.zeroDefault calls avro.EnumSchema.Symbols
|
||||
#7: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.EnumSchema.Type
|
||||
#8: internal/models/event_schema.go:301:12: models.zeroDefault calls avro.Field.Name
|
||||
#9: internal/models/event_schema.go:297:31: models.zeroDefault calls avro.Field.Type
|
||||
#10: internal/models/event_schema.go:277:59: models.zeroDefault calls avro.FixedSchema.Size
|
||||
#11: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.FixedSchema.String
|
||||
#12: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.FixedSchema.Type
|
||||
#13: internal/repository/pg_email.go:1654:26: repository.emailRepository.SetSendIdentity calls json.Marshal, which eventually calls avro.Freeze
|
||||
#14: internal/repository/pg_email.go:1654:26: repository.emailRepository.SetSendIdentity calls json.Marshal, which eventually calls avro.Freeze
|
||||
#15: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.MapSchema.String
|
||||
#16: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.MapSchema.Type
|
||||
#17: internal/infrastructure/codec/avro.go:84:27: codec.AvroCodec.Serialize calls avro.Marshal
|
||||
#18: internal/models/event_schema.go:173:29: models.schemaOf calls avro.NewArraySchema
|
||||
#19: internal/models/event_schema.go:224:30: models.recordSchema calls avro.NewField
|
||||
#20: internal/models/event_schema.go:159:30: models.schemaOf calls avro.NewFixedSchema
|
||||
#21: internal/models/event_schema.go:182:27: models.schemaOf calls avro.NewMapSchema
|
||||
#22: internal/models/event_schema.go:136:75: models.schemaOf calls avro.NewPrimitiveLogicalSchema
|
||||
#23: internal/models/event_schema.go:136:33: models.schemaOf calls avro.NewPrimitiveSchema
|
||||
#24: internal/models/event_schema.go:234:37: models.recordSchema calls avro.NewRecordSchema
|
||||
#25: internal/models/event_schema.go:326:27: models.reference calls avro.NewRefSchema
|
||||
#26: internal/infrastructure/kafka/avrov.go:33:38: kafka.NewAvrov2Client calls avrov2.NewDeserializer, which calls avro.NewTypeResolver
|
||||
#27: internal/models/event_schema.go:131:29: models.schemaOf calls avro.NewUnionSchema
|
||||
#28: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.NullSchema.String
|
||||
#29: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.NullSchema.Type
|
||||
#30: internal/models/event_schema_document.go:40:25: models.SchemaDocument calls avro.Parse
|
||||
#31: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.PrimitiveSchema.String
|
||||
#32: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.PrimitiveSchema.Type
|
||||
#33: internal/models/event_schema.go:296:50: models.zeroDefault calls avro.RecordSchema.Fields
|
||||
#34: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.RecordSchema.String
|
||||
#35: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.RecordSchema.Type
|
||||
#36: internal/models/event_schema.go:293:48: models.zeroDefault calls avro.RefSchema.Schema
|
||||
#37: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.RefSchema.String
|
||||
#38: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.RefSchema.Type
|
||||
#39: internal/models/event_schema.go:98:16: models.envelopeSchema calls avro.Register
|
||||
#40: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.UnionSchema.String
|
||||
#41: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.UnionSchema.Type
|
||||
#42: internal/models/event_schema.go:287:39: models.zeroDefault calls avro.UnionSchema.Types
|
||||
#43: internal/infrastructure/codec/avro.go:107:26: codec.AvroCodec.Deserialize calls avro.Unmarshal
|
||||
#44: internal/models/event_schema.go:222:40: models.recordSchema calls avro.WithDefault
|
||||
#45: internal/models/event_schema.go:12:2: models.init calls avro.init
|
||||
#46: internal/repository/pg_contact.go:3081:26: repository.contactRepository.ExportAll calls strings.TrimSpace, which eventually calls avro.invalidNameFirstChar
|
||||
#47: internal/repository/pg_contact.go:3081:26: repository.contactRepository.ExportAll calls strings.TrimSpace, which eventually calls avro.invalidNameOtherChar
|
||||
#48: internal/models/event_schema.go:98:32: models.envelopeSchema calls avro.name.FullName
|
||||
#49: goog.getAddressList calls strings.splitSeq, which calls avro.newName
|
||||
#50: goog.getAddressList calls strings.splitSeq, which calls avro.newName
|
||||
|
||||
Vulnerability #3: GO-2026-5047
|
||||
Integer overflow in Avro decoder in github.com/iskorotkov/avro/v2 and
|
||||
github.com/hamba/avro/v2
|
||||
More info: https://pkg.go.dev/vuln/GO-2026-5047
|
||||
Module: github.com/hamba/avro/v2
|
||||
Found in: github.com/hamba/avro/v2@v2.31.0
|
||||
Fixed in: N/A
|
||||
Example traces found:
|
||||
#1: internal/app/contact/export.go:348:2: contact.writeXLSX calls excelize.File.Close, which eventually calls avro.AddAll
|
||||
#2: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.ArraySchema.String
|
||||
#3: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.ArraySchema.Type
|
||||
#4: internal/infrastructure/kafka/avrov.go:33:38: kafka.NewAvrov2Client calls avrov2.NewDeserializer, which calls avro.Config.Freeze
|
||||
#5: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.EnumSchema.String
|
||||
#6: internal/models/event_schema.go:279:42: models.zeroDefault calls avro.EnumSchema.Symbols
|
||||
#7: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.EnumSchema.Type
|
||||
#8: internal/models/event_schema.go:301:12: models.zeroDefault calls avro.Field.Name
|
||||
#9: internal/models/event_schema.go:297:31: models.zeroDefault calls avro.Field.Type
|
||||
#10: internal/models/event_schema.go:277:59: models.zeroDefault calls avro.FixedSchema.Size
|
||||
#11: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.FixedSchema.String
|
||||
#12: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.FixedSchema.Type
|
||||
#13: internal/repository/pg_email.go:1654:26: repository.emailRepository.SetSendIdentity calls json.Marshal, which eventually calls avro.Freeze
|
||||
#14: internal/repository/pg_email.go:1654:26: repository.emailRepository.SetSendIdentity calls json.Marshal, which eventually calls avro.Freeze
|
||||
#15: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.MapSchema.String
|
||||
#16: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.MapSchema.Type
|
||||
#17: internal/infrastructure/codec/avro.go:84:27: codec.AvroCodec.Serialize calls avro.Marshal
|
||||
#18: internal/models/event_schema.go:173:29: models.schemaOf calls avro.NewArraySchema
|
||||
#19: internal/models/event_schema.go:224:30: models.recordSchema calls avro.NewField
|
||||
#20: internal/models/event_schema.go:159:30: models.schemaOf calls avro.NewFixedSchema
|
||||
#21: internal/models/event_schema.go:182:27: models.schemaOf calls avro.NewMapSchema
|
||||
#22: internal/models/event_schema.go:136:75: models.schemaOf calls avro.NewPrimitiveLogicalSchema
|
||||
#23: internal/models/event_schema.go:136:33: models.schemaOf calls avro.NewPrimitiveSchema
|
||||
#24: internal/models/event_schema.go:234:37: models.recordSchema calls avro.NewRecordSchema
|
||||
#25: internal/models/event_schema.go:326:27: models.reference calls avro.NewRefSchema
|
||||
#26: internal/infrastructure/kafka/avrov.go:33:38: kafka.NewAvrov2Client calls avrov2.NewDeserializer, which calls avro.NewTypeResolver
|
||||
#27: internal/models/event_schema.go:131:29: models.schemaOf calls avro.NewUnionSchema
|
||||
#28: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.NullSchema.String
|
||||
#29: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.NullSchema.Type
|
||||
#30: internal/models/event_schema_document.go:40:25: models.SchemaDocument calls avro.Parse
|
||||
#31: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.PrimitiveSchema.String
|
||||
#32: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.PrimitiveSchema.Type
|
||||
#33: internal/models/event_schema.go:296:50: models.zeroDefault calls avro.RecordSchema.Fields
|
||||
#34: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.RecordSchema.String
|
||||
#35: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.RecordSchema.Type
|
||||
#36: internal/models/event_schema.go:293:48: models.zeroDefault calls avro.RefSchema.Schema
|
||||
#37: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.RefSchema.String
|
||||
#38: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.RefSchema.Type
|
||||
#39: internal/models/event_schema.go:98:16: models.envelopeSchema calls avro.Register
|
||||
#40: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.UnionSchema.String
|
||||
#41: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.UnionSchema.Type
|
||||
#42: internal/models/event_schema.go:287:39: models.zeroDefault calls avro.UnionSchema.Types
|
||||
#43: internal/infrastructure/codec/avro.go:107:26: codec.AvroCodec.Deserialize calls avro.Unmarshal
|
||||
#44: internal/models/event_schema.go:222:40: models.recordSchema calls avro.WithDefault
|
||||
#45: internal/models/event_schema.go:12:2: models.init calls avro.init
|
||||
#46: internal/repository/pg_contact.go:3081:26: repository.contactRepository.ExportAll calls strings.TrimSpace, which eventually calls avro.invalidNameFirstChar
|
||||
#47: internal/repository/pg_contact.go:3081:26: repository.contactRepository.ExportAll calls strings.TrimSpace, which eventually calls avro.invalidNameOtherChar
|
||||
#48: internal/models/event_schema.go:98:32: models.envelopeSchema calls avro.name.FullName
|
||||
#49: goog.getAddressList calls strings.splitSeq, which calls avro.newName
|
||||
#50: goog.getAddressList calls strings.splitSeq, which calls avro.newName
|
||||
|
||||
Vulnerability #4: GO-2026-5046
|
||||
CPU exhaustion in Avro decoder in github.com/iskorotkov/avro/v2 and
|
||||
github.com/hamba/avro/v2
|
||||
More info: https://pkg.go.dev/vuln/GO-2026-5046
|
||||
Module: github.com/hamba/avro/v2
|
||||
Found in: github.com/hamba/avro/v2@v2.31.0
|
||||
Fixed in: N/A
|
||||
Example traces found:
|
||||
#1: internal/app/contact/export.go:348:2: contact.writeXLSX calls excelize.File.Close, which eventually calls avro.AddAll
|
||||
#2: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.ArraySchema.String
|
||||
#3: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.ArraySchema.Type
|
||||
#4: internal/infrastructure/kafka/avrov.go:33:38: kafka.NewAvrov2Client calls avrov2.NewDeserializer, which calls avro.Config.Freeze
|
||||
#5: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.EnumSchema.String
|
||||
#6: internal/models/event_schema.go:279:42: models.zeroDefault calls avro.EnumSchema.Symbols
|
||||
#7: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.EnumSchema.Type
|
||||
#8: internal/models/event_schema.go:301:12: models.zeroDefault calls avro.Field.Name
|
||||
#9: internal/models/event_schema.go:297:31: models.zeroDefault calls avro.Field.Type
|
||||
#10: internal/models/event_schema.go:277:59: models.zeroDefault calls avro.FixedSchema.Size
|
||||
#11: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.FixedSchema.String
|
||||
#12: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.FixedSchema.Type
|
||||
#13: internal/repository/pg_email.go:1654:26: repository.emailRepository.SetSendIdentity calls json.Marshal, which eventually calls avro.Freeze
|
||||
#14: internal/repository/pg_email.go:1654:26: repository.emailRepository.SetSendIdentity calls json.Marshal, which eventually calls avro.Freeze
|
||||
#15: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.MapSchema.String
|
||||
#16: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.MapSchema.Type
|
||||
#17: internal/infrastructure/codec/avro.go:84:27: codec.AvroCodec.Serialize calls avro.Marshal
|
||||
#18: internal/models/event_schema.go:173:29: models.schemaOf calls avro.NewArraySchema
|
||||
#19: internal/models/event_schema.go:224:30: models.recordSchema calls avro.NewField
|
||||
#20: internal/models/event_schema.go:159:30: models.schemaOf calls avro.NewFixedSchema
|
||||
#21: internal/models/event_schema.go:182:27: models.schemaOf calls avro.NewMapSchema
|
||||
#22: internal/models/event_schema.go:136:75: models.schemaOf calls avro.NewPrimitiveLogicalSchema
|
||||
#23: internal/models/event_schema.go:136:33: models.schemaOf calls avro.NewPrimitiveSchema
|
||||
#24: internal/models/event_schema.go:234:37: models.recordSchema calls avro.NewRecordSchema
|
||||
#25: internal/models/event_schema.go:326:27: models.reference calls avro.NewRefSchema
|
||||
#26: internal/infrastructure/kafka/avrov.go:33:38: kafka.NewAvrov2Client calls avrov2.NewDeserializer, which calls avro.NewTypeResolver
|
||||
#27: internal/models/event_schema.go:131:29: models.schemaOf calls avro.NewUnionSchema
|
||||
#28: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.NullSchema.String
|
||||
#29: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.NullSchema.Type
|
||||
#30: internal/models/event_schema_document.go:40:25: models.SchemaDocument calls avro.Parse
|
||||
#31: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.PrimitiveSchema.String
|
||||
#32: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.PrimitiveSchema.Type
|
||||
#33: internal/models/event_schema.go:296:50: models.zeroDefault calls avro.RecordSchema.Fields
|
||||
#34: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.RecordSchema.String
|
||||
#35: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.RecordSchema.Type
|
||||
#36: internal/models/event_schema.go:293:48: models.zeroDefault calls avro.RefSchema.Schema
|
||||
#37: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.RefSchema.String
|
||||
#38: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.RefSchema.Type
|
||||
#39: internal/models/event_schema.go:98:16: models.envelopeSchema calls avro.Register
|
||||
#40: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.UnionSchema.String
|
||||
#41: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.UnionSchema.Type
|
||||
#42: internal/models/event_schema.go:287:39: models.zeroDefault calls avro.UnionSchema.Types
|
||||
#43: internal/infrastructure/codec/avro.go:107:26: codec.AvroCodec.Deserialize calls avro.Unmarshal
|
||||
#44: internal/models/event_schema.go:222:40: models.recordSchema calls avro.WithDefault
|
||||
#45: internal/models/event_schema.go:12:2: models.init calls avro.init
|
||||
#46: internal/repository/pg_contact.go:3081:26: repository.contactRepository.ExportAll calls strings.TrimSpace, which eventually calls avro.invalidNameFirstChar
|
||||
#47: internal/repository/pg_contact.go:3081:26: repository.contactRepository.ExportAll calls strings.TrimSpace, which eventually calls avro.invalidNameOtherChar
|
||||
#48: internal/models/event_schema.go:98:32: models.envelopeSchema calls avro.name.FullName
|
||||
#49: goog.getAddressList calls strings.splitSeq, which calls avro.newName
|
||||
#50: goog.getAddressList calls strings.splitSeq, which calls avro.newName
|
||||
|
||||
Your code is affected by 4 vulnerabilities from 2 modules.
|
||||
This scan also found 1 vulnerability in packages you import and 1 vulnerability
|
||||
in modules you require, but your code doesn't appear to call these
|
||||
vulnerabilities.
|
||||
Use '-show verbose' for more details.
|
||||
exit status 3
|
||||
@@ -0,0 +1,169 @@
|
||||
# govulncheck, default build
|
||||
|
||||
Generated: 2026-09-19T06:06:32Z
|
||||
Commit: f404f34b623be86434dcfa029e594f4d1943a8b4
|
||||
|
||||
=== Symbol Results ===
|
||||
|
||||
Vulnerability #1: GO-2026-6452
|
||||
Panic via negative shared-string index in github.com/xuri/excelize
|
||||
More info: https://pkg.go.dev/vuln/GO-2026-6452
|
||||
Module: github.com/xuri/excelize/v2
|
||||
Found in: github.com/xuri/excelize/v2@v2.11.0
|
||||
Fixed in: N/A
|
||||
Example traces found:
|
||||
#1: internal/app/contact/import.go:808:28: contact.parseSpreadsheetInner calls excelize.Rows.Columns
|
||||
|
||||
Vulnerability #2: GO-2026-5048
|
||||
Denial of service via unbounded map allocations in
|
||||
github.com/iskorotkov/avro/v2 and github.com/hamba/avro/v2
|
||||
More info: https://pkg.go.dev/vuln/GO-2026-5048
|
||||
Module: github.com/hamba/avro/v2
|
||||
Found in: github.com/hamba/avro/v2@v2.31.0
|
||||
Fixed in: N/A
|
||||
Example traces found:
|
||||
#1: internal/app/contact/export.go:348:2: contact.writeXLSX calls excelize.File.Close, which eventually calls avro.AddAll
|
||||
#2: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.ArraySchema.Type
|
||||
#3: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.EnumSchema.String
|
||||
#4: internal/models/event_schema.go:279:42: models.zeroDefault calls avro.EnumSchema.Symbols
|
||||
#5: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.EnumSchema.Type
|
||||
#6: internal/models/event_schema.go:301:12: models.zeroDefault calls avro.Field.Name
|
||||
#7: internal/models/event_schema.go:297:31: models.zeroDefault calls avro.Field.Type
|
||||
#8: internal/models/event_schema.go:277:59: models.zeroDefault calls avro.FixedSchema.Size
|
||||
#9: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.FixedSchema.String
|
||||
#10: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.FixedSchema.Type
|
||||
#11: internal/repository/pg_email.go:1654:26: repository.emailRepository.SetSendIdentity calls json.Marshal, which eventually calls avro.Freeze
|
||||
#12: internal/repository/pg_email.go:1654:26: repository.emailRepository.SetSendIdentity calls json.Marshal, which eventually calls avro.Freeze
|
||||
#13: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.MapSchema.Type
|
||||
#14: internal/models/event_schema.go:173:29: models.schemaOf calls avro.NewArraySchema
|
||||
#15: internal/models/event_schema.go:224:30: models.recordSchema calls avro.NewField
|
||||
#16: internal/models/event_schema.go:159:30: models.schemaOf calls avro.NewFixedSchema
|
||||
#17: internal/models/event_schema.go:182:27: models.schemaOf calls avro.NewMapSchema
|
||||
#18: internal/models/event_schema.go:136:75: models.schemaOf calls avro.NewPrimitiveLogicalSchema
|
||||
#19: internal/models/event_schema.go:136:33: models.schemaOf calls avro.NewPrimitiveSchema
|
||||
#20: internal/models/event_schema.go:234:37: models.recordSchema calls avro.NewRecordSchema
|
||||
#21: internal/models/event_schema.go:326:27: models.reference calls avro.NewRefSchema
|
||||
#22: internal/models/event_schema.go:131:29: models.schemaOf calls avro.NewUnionSchema
|
||||
#23: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.NullSchema.Type
|
||||
#24: internal/models/event_schema_document.go:40:25: models.SchemaDocument calls avro.Parse
|
||||
#25: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.PrimitiveSchema.Type
|
||||
#26: internal/models/event_schema.go:296:50: models.zeroDefault calls avro.RecordSchema.Fields
|
||||
#27: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.RecordSchema.Type
|
||||
#28: internal/models/event_schema.go:293:48: models.zeroDefault calls avro.RefSchema.Schema
|
||||
#29: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.RefSchema.String
|
||||
#30: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.RefSchema.Type
|
||||
#31: internal/models/event_schema.go:98:16: models.envelopeSchema calls avro.Register
|
||||
#32: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.UnionSchema.Type
|
||||
#33: internal/models/event_schema.go:287:39: models.zeroDefault calls avro.UnionSchema.Types
|
||||
#34: internal/models/event_schema.go:222:40: models.recordSchema calls avro.WithDefault
|
||||
#35: internal/models/event_schema.go:12:2: models.init calls avro.init
|
||||
#36: internal/repository/pg_contact.go:3081:26: repository.contactRepository.ExportAll calls strings.TrimSpace, which eventually calls avro.invalidNameFirstChar
|
||||
#37: internal/repository/pg_contact.go:3081:26: repository.contactRepository.ExportAll calls strings.TrimSpace, which eventually calls avro.invalidNameOtherChar
|
||||
#38: internal/models/event_schema.go:98:32: models.envelopeSchema calls avro.name.FullName
|
||||
#39: goog.getAddressList calls strings.splitSeq, which calls avro.newName
|
||||
#40: goog.getAddressList calls strings.splitSeq, which calls avro.newName
|
||||
|
||||
Vulnerability #3: GO-2026-5047
|
||||
Integer overflow in Avro decoder in github.com/iskorotkov/avro/v2 and
|
||||
github.com/hamba/avro/v2
|
||||
More info: https://pkg.go.dev/vuln/GO-2026-5047
|
||||
Module: github.com/hamba/avro/v2
|
||||
Found in: github.com/hamba/avro/v2@v2.31.0
|
||||
Fixed in: N/A
|
||||
Example traces found:
|
||||
#1: internal/app/contact/export.go:348:2: contact.writeXLSX calls excelize.File.Close, which eventually calls avro.AddAll
|
||||
#2: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.ArraySchema.Type
|
||||
#3: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.EnumSchema.String
|
||||
#4: internal/models/event_schema.go:279:42: models.zeroDefault calls avro.EnumSchema.Symbols
|
||||
#5: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.EnumSchema.Type
|
||||
#6: internal/models/event_schema.go:301:12: models.zeroDefault calls avro.Field.Name
|
||||
#7: internal/models/event_schema.go:297:31: models.zeroDefault calls avro.Field.Type
|
||||
#8: internal/models/event_schema.go:277:59: models.zeroDefault calls avro.FixedSchema.Size
|
||||
#9: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.FixedSchema.String
|
||||
#10: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.FixedSchema.Type
|
||||
#11: internal/repository/pg_email.go:1654:26: repository.emailRepository.SetSendIdentity calls json.Marshal, which eventually calls avro.Freeze
|
||||
#12: internal/repository/pg_email.go:1654:26: repository.emailRepository.SetSendIdentity calls json.Marshal, which eventually calls avro.Freeze
|
||||
#13: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.MapSchema.Type
|
||||
#14: internal/models/event_schema.go:173:29: models.schemaOf calls avro.NewArraySchema
|
||||
#15: internal/models/event_schema.go:224:30: models.recordSchema calls avro.NewField
|
||||
#16: internal/models/event_schema.go:159:30: models.schemaOf calls avro.NewFixedSchema
|
||||
#17: internal/models/event_schema.go:182:27: models.schemaOf calls avro.NewMapSchema
|
||||
#18: internal/models/event_schema.go:136:75: models.schemaOf calls avro.NewPrimitiveLogicalSchema
|
||||
#19: internal/models/event_schema.go:136:33: models.schemaOf calls avro.NewPrimitiveSchema
|
||||
#20: internal/models/event_schema.go:234:37: models.recordSchema calls avro.NewRecordSchema
|
||||
#21: internal/models/event_schema.go:326:27: models.reference calls avro.NewRefSchema
|
||||
#22: internal/models/event_schema.go:131:29: models.schemaOf calls avro.NewUnionSchema
|
||||
#23: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.NullSchema.Type
|
||||
#24: internal/models/event_schema_document.go:40:25: models.SchemaDocument calls avro.Parse
|
||||
#25: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.PrimitiveSchema.Type
|
||||
#26: internal/models/event_schema.go:296:50: models.zeroDefault calls avro.RecordSchema.Fields
|
||||
#27: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.RecordSchema.Type
|
||||
#28: internal/models/event_schema.go:293:48: models.zeroDefault calls avro.RefSchema.Schema
|
||||
#29: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.RefSchema.String
|
||||
#30: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.RefSchema.Type
|
||||
#31: internal/models/event_schema.go:98:16: models.envelopeSchema calls avro.Register
|
||||
#32: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.UnionSchema.Type
|
||||
#33: internal/models/event_schema.go:287:39: models.zeroDefault calls avro.UnionSchema.Types
|
||||
#34: internal/models/event_schema.go:222:40: models.recordSchema calls avro.WithDefault
|
||||
#35: internal/models/event_schema.go:12:2: models.init calls avro.init
|
||||
#36: internal/repository/pg_contact.go:3081:26: repository.contactRepository.ExportAll calls strings.TrimSpace, which eventually calls avro.invalidNameFirstChar
|
||||
#37: internal/repository/pg_contact.go:3081:26: repository.contactRepository.ExportAll calls strings.TrimSpace, which eventually calls avro.invalidNameOtherChar
|
||||
#38: internal/models/event_schema.go:98:32: models.envelopeSchema calls avro.name.FullName
|
||||
#39: goog.getAddressList calls strings.splitSeq, which calls avro.newName
|
||||
#40: goog.getAddressList calls strings.splitSeq, which calls avro.newName
|
||||
|
||||
Vulnerability #4: GO-2026-5046
|
||||
CPU exhaustion in Avro decoder in github.com/iskorotkov/avro/v2 and
|
||||
github.com/hamba/avro/v2
|
||||
More info: https://pkg.go.dev/vuln/GO-2026-5046
|
||||
Module: github.com/hamba/avro/v2
|
||||
Found in: github.com/hamba/avro/v2@v2.31.0
|
||||
Fixed in: N/A
|
||||
Example traces found:
|
||||
#1: internal/app/contact/export.go:348:2: contact.writeXLSX calls excelize.File.Close, which eventually calls avro.AddAll
|
||||
#2: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.ArraySchema.Type
|
||||
#3: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.EnumSchema.String
|
||||
#4: internal/models/event_schema.go:279:42: models.zeroDefault calls avro.EnumSchema.Symbols
|
||||
#5: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.EnumSchema.Type
|
||||
#6: internal/models/event_schema.go:301:12: models.zeroDefault calls avro.Field.Name
|
||||
#7: internal/models/event_schema.go:297:31: models.zeroDefault calls avro.Field.Type
|
||||
#8: internal/models/event_schema.go:277:59: models.zeroDefault calls avro.FixedSchema.Size
|
||||
#9: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.FixedSchema.String
|
||||
#10: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.FixedSchema.Type
|
||||
#11: internal/repository/pg_email.go:1654:26: repository.emailRepository.SetSendIdentity calls json.Marshal, which eventually calls avro.Freeze
|
||||
#12: internal/repository/pg_email.go:1654:26: repository.emailRepository.SetSendIdentity calls json.Marshal, which eventually calls avro.Freeze
|
||||
#13: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.MapSchema.Type
|
||||
#14: internal/models/event_schema.go:173:29: models.schemaOf calls avro.NewArraySchema
|
||||
#15: internal/models/event_schema.go:224:30: models.recordSchema calls avro.NewField
|
||||
#16: internal/models/event_schema.go:159:30: models.schemaOf calls avro.NewFixedSchema
|
||||
#17: internal/models/event_schema.go:182:27: models.schemaOf calls avro.NewMapSchema
|
||||
#18: internal/models/event_schema.go:136:75: models.schemaOf calls avro.NewPrimitiveLogicalSchema
|
||||
#19: internal/models/event_schema.go:136:33: models.schemaOf calls avro.NewPrimitiveSchema
|
||||
#20: internal/models/event_schema.go:234:37: models.recordSchema calls avro.NewRecordSchema
|
||||
#21: internal/models/event_schema.go:326:27: models.reference calls avro.NewRefSchema
|
||||
#22: internal/models/event_schema.go:131:29: models.schemaOf calls avro.NewUnionSchema
|
||||
#23: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.NullSchema.Type
|
||||
#24: internal/models/event_schema_document.go:40:25: models.SchemaDocument calls avro.Parse
|
||||
#25: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.PrimitiveSchema.Type
|
||||
#26: internal/models/event_schema.go:296:50: models.zeroDefault calls avro.RecordSchema.Fields
|
||||
#27: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.RecordSchema.Type
|
||||
#28: internal/models/event_schema.go:293:48: models.zeroDefault calls avro.RefSchema.Schema
|
||||
#29: internal/cli/iostreams/iostreams.go:172:14: iostreams.IOStreams.Secret calls fmt.Fprintln, which eventually calls avro.RefSchema.String
|
||||
#30: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.RefSchema.Type
|
||||
#31: internal/models/event_schema.go:98:16: models.envelopeSchema calls avro.Register
|
||||
#32: internal/models/event_schema.go:255:15: models.zeroDefault calls avro.UnionSchema.Type
|
||||
#33: internal/models/event_schema.go:287:39: models.zeroDefault calls avro.UnionSchema.Types
|
||||
#34: internal/models/event_schema.go:222:40: models.recordSchema calls avro.WithDefault
|
||||
#35: internal/models/event_schema.go:12:2: models.init calls avro.init
|
||||
#36: internal/repository/pg_contact.go:3081:26: repository.contactRepository.ExportAll calls strings.TrimSpace, which eventually calls avro.invalidNameFirstChar
|
||||
#37: internal/repository/pg_contact.go:3081:26: repository.contactRepository.ExportAll calls strings.TrimSpace, which eventually calls avro.invalidNameOtherChar
|
||||
#38: internal/models/event_schema.go:98:32: models.envelopeSchema calls avro.name.FullName
|
||||
#39: goog.getAddressList calls strings.splitSeq, which calls avro.newName
|
||||
#40: goog.getAddressList calls strings.splitSeq, which calls avro.newName
|
||||
|
||||
Your code is affected by 4 vulnerabilities from 2 modules.
|
||||
This scan also found 1 vulnerability in packages you import and 1 vulnerability
|
||||
in modules you require, but your code doesn't appear to call these
|
||||
vulnerabilities.
|
||||
Use '-show verbose' for more details.
|
||||
exit status 3
|
||||
@@ -0,0 +1,60 @@
|
||||
# Log sample: a login request
|
||||
|
||||
For CASA test case 6.5.1, which asks for a sample of the log produced during a
|
||||
login and during a payment.
|
||||
|
||||
## What the access logger records
|
||||
|
||||
Source: internal/api/middleware/request_log.go
|
||||
|
||||
[GIN] 2026/09/19 - 14:03:11 | 200 | 421.832511ms | 203.0.113.42 | POST "/v1/auth/login"
|
||||
[GIN] 2026/09/19 - 14:03:29 | 200 | 38.114201ms | 203.0.113.42 | POST "/v1/auth/login/confirm"
|
||||
[GIN] 2026/09/19 - 14:03:29 | 200 | 12.445910ms | 203.0.113.42 | GET "/v1/auth/me"
|
||||
|
||||
Six fields: timestamp, status, latency, client IP, method, and the matched
|
||||
path. No headers, no request body, no response body, and no query string.
|
||||
|
||||
The query string is deliberately excluded. gin's stock logger prints the full
|
||||
request URI, and several routes carry a one-time credential there because the
|
||||
provider or the mail client puts it there: the OAuth `code` and `state` on the
|
||||
callback bouncers, the invitation token on the preview lookup, the socket ticket,
|
||||
the form prefill ticket. Those would otherwise reach stdout, the container log
|
||||
and whatever aggregates it.
|
||||
|
||||
The password itself never reaches the logger at all: it is in the JSON body,
|
||||
which the logger does not read.
|
||||
|
||||
## The rest of a login
|
||||
|
||||
Nothing else is written for a successful login. A failure writes one structured
|
||||
line naming the outcome, not the credential:
|
||||
|
||||
{"level":"warn","event":"login_failed","reason":"credentials","ip":"203.0.113.42","time":"2026-09-19T14:03:11Z"}
|
||||
|
||||
Anomalous sign-ins are recorded against the account for review, with the
|
||||
location and device, never the password or the emailed code.
|
||||
|
||||
## Payment
|
||||
|
||||
Warmbly never receives payment details. Checkout and the billing portal are
|
||||
hosted by Stripe; the browser goes to Stripe's domain and returns. No card
|
||||
number, CVV or expiry field exists anywhere in this codebase, so no log line can
|
||||
contain one. What is logged is the Stripe session or subscription identifier:
|
||||
|
||||
[GIN] 2026/09/19 - 14:07:02 | 200 | 310.776120ms | 203.0.113.42 | POST "/v1/subscription/checkout"
|
||||
{"level":"info","event":"checkout_session_created","org_id":"6f1d...","stripe_session_id":"cs_test_a1B2...","time":"2026-09-19T14:07:02Z"}
|
||||
|
||||
## Session tokens
|
||||
|
||||
No session token is logged in any form. CASA permits a hashed one; Warmbly logs
|
||||
none at all. API keys are stored and looked up as SHA-256, and the API key usage
|
||||
log records the key's database id, never the key.
|
||||
|
||||
## Error reporting
|
||||
|
||||
Sentry's SendDefaultPII is off in all three initializations, so request headers,
|
||||
cookies and bodies are not attached to an event. The user id, email and name are
|
||||
attached deliberately through setUser, which is the identity an exception needs.
|
||||
|
||||
Browser session replay masks password inputs and every one-time code entry
|
||||
field, and console capture is off.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Node production dependencies
|
||||
|
||||
Generated: 2026-09-19T06:06:32Z
|
||||
Commit: f404f34b623be86434dcfa029e594f4d1943a8b4
|
||||
|
||||
|
||||
## web
|
||||
|
||||
```
|
||||
No known vulnerabilities found
|
||||
```
|
||||
|
||||
## admin
|
||||
|
||||
```
|
||||
1 vulnerabilities found
|
||||
Severity: 1 low
|
||||
```
|
||||
|
||||
## site
|
||||
|
||||
```
|
||||
No known vulnerabilities found
|
||||
```
|
||||
|
||||
## docs
|
||||
|
||||
```
|
||||
7 vulnerabilities found
|
||||
Severity: 2 low | 5 moderate
|
||||
```
|
||||
|
||||
## forms
|
||||
|
||||
```
|
||||
No known vulnerabilities found
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
# Rust dependencies
|
||||
|
||||
Generated: 2026-09-19T06:06:32Z
|
||||
Commit: f404f34b623be86434dcfa029e594f4d1943a8b4
|
||||
|
||||
cargo-audit is not installed on this machine, so this scan did not run here.
|
||||
CI runs it on every dependency change: see the rust job in .github/workflows/security.yml.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Trivy filesystem scan
|
||||
|
||||
Generated: 2026-09-19T06:06:32Z
|
||||
Commit: f404f34b623be86434dcfa029e594f4d1943a8b4
|
||||
|
||||
2026-09-19T08:07:01+02:00 INFO [vuln] Vulnerability scanning is enabled
|
||||
2026-09-19T08:07:06+02:00 INFO [pnpm] Run "pnpm install" to collect the license information of packages dir="forms/node_modules"
|
||||
2026-09-19T08:07:06+02:00 INFO [pnpm] Run "pnpm install" to collect the license information of packages dir="site/node_modules"
|
||||
2026-09-19T08:07:06+02:00 INFO [npm] Run "npm install" to collect the license information of packages dir="integrations/zapier/node_modules"
|
||||
2026-09-19T08:07:06+02:00 INFO Suppressing dependencies for development and testing. To display them, try the '--include-dev-deps' flag.
|
||||
2026-09-19T08:07:06+02:00 INFO Number of language-specific files num=9
|
||||
2026-09-19T08:07:06+02:00 INFO [cargo] Detecting vulnerabilities...
|
||||
2026-09-19T08:07:06+02:00 INFO [gomod] Detecting vulnerabilities...
|
||||
2026-09-19T08:07:06+02:00 INFO [hex] Detecting vulnerabilities...
|
||||
2026-09-19T08:07:06+02:00 INFO [npm] Detecting vulnerabilities...
|
||||
2026-09-19T08:07:06+02:00 INFO [pnpm] Detecting vulnerabilities...
|
||||
2026-09-19T08:07:06+02:00 WARN Using severities from other vendors for some vulnerabilities. Read https://trivy.dev/docs/v0.72/guide/scanner/vulnerability#severity-selection for details.
|
||||
|
||||
Report Summary
|
||||
|
||||
┌───────────────────────────────────────┬───────┬─────────────────┐
|
||||
│ Target │ Type │ Vulnerabilities │
|
||||
├───────────────────────────────────────┼───────┼─────────────────┤
|
||||
│ admin/pnpm-lock.yaml │ pnpm │ 0 │
|
||||
├───────────────────────────────────────┼───────┼─────────────────┤
|
||||
│ docs/pnpm-lock.yaml │ pnpm │ 0 │
|
||||
├───────────────────────────────────────┼───────┼─────────────────┤
|
||||
│ forms/pnpm-lock.yaml │ pnpm │ 0 │
|
||||
├───────────────────────────────────────┼───────┼─────────────────┤
|
||||
│ go.mod │ gomod │ 1 │
|
||||
├───────────────────────────────────────┼───────┼─────────────────┤
|
||||
│ integrations/zapier/package-lock.json │ npm │ 0 │
|
||||
├───────────────────────────────────────┼───────┼─────────────────┤
|
||||
│ realtime/mix.lock │ hex │ 0 │
|
||||
├───────────────────────────────────────┼───────┼─────────────────┤
|
||||
│ site/pnpm-lock.yaml │ pnpm │ 0 │
|
||||
├───────────────────────────────────────┼───────┼─────────────────┤
|
||||
│ tracking/Cargo.lock │ cargo │ 2 │
|
||||
├───────────────────────────────────────┼───────┼─────────────────┤
|
||||
│ web/pnpm-lock.yaml │ pnpm │ 0 │
|
||||
└───────────────────────────────────────┴───────┴─────────────────┘
|
||||
Legend:
|
||||
- '-': Not scanned
|
||||
- '0': Clean (no security findings detected)
|
||||
|
||||
|
||||
go.mod (gomod)
|
||||
==============
|
||||
Total: 1 (HIGH: 1, CRITICAL: 0)
|
||||
|
||||
┌─────────────────────────────┬────────────────┬──────────┬──────────┬─────────────────────┬───────────────┬─────────────────────────────────────────────────┐
|
||||
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │
|
||||
├─────────────────────────────┼────────────────┼──────────┼──────────┼─────────────────────┼───────────────┼─────────────────────────────────────────────────┤
|
||||
│ github.com/dgrijalva/jwt-go │ CVE-2020-26160 │ HIGH │ affected │ v3.2.0+incompatible │ │ jwt-go: access restriction bypass vulnerability │
|
||||
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2020-26160 │
|
||||
└─────────────────────────────┴────────────────┴──────────┴──────────┴─────────────────────┴───────────────┴─────────────────────────────────────────────────┘
|
||||
|
||||
tracking/Cargo.lock (cargo)
|
||||
===========================
|
||||
Total: 2 (HIGH: 2, CRITICAL: 0)
|
||||
|
||||
┌───────────────┬─────────────────────┬──────────┬────────┬───────────────────┬───────────────────────────┬─────────────────────────────────────────────────────────────┐
|
||||
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │
|
||||
├───────────────┼─────────────────────┼──────────┼────────┼───────────────────┼───────────────────────────┼─────────────────────────────────────────────────────────────┤
|
||||
│ rustls-webpki │ GHSA-82j2-j2ch-gfr8 │ HIGH │ fixed │ 0.101.7 │ 0.103.13, 0.104.0-alpha.7 │ rustls-webpki: Denial of service via panic on malformed CRL │
|
||||
│ │ │ │ │ │ │ BIT STRING │
|
||||
│ │ │ │ │ │ │ https://github.com/advisories/GHSA-82j2-j2ch-gfr8 │
|
||||
│ │ │ │ ├───────────────────┤ │ │
|
||||
│ │ │ │ │ 0.102.8 │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
└───────────────┴─────────────────────┴──────────┴────────┴───────────────────┴───────────────────────────┴─────────────────────────────────────────────────────────────┘
|
||||
|
||||
📣 [34mNotices:[0m
|
||||
- Version 0.74.0 of Trivy is now available, current version is 0.72.0
|
||||
|
||||
To suppress version checks, run Trivy scans with the --skip-version-check flag
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Cryptographic inventory
|
||||
|
||||
For CASA test case 4.1.3, which asks for every encryption, hashing and MAC
|
||||
operation with its algorithm, key size, key and IV generation, and key
|
||||
management.
|
||||
|
||||
## Encryption at rest
|
||||
|
||||
Warmbly uses envelope encryption. AWS KMS, or a local master key on a
|
||||
self-hosted instance, is the root of trust; each organization gets its own data
|
||||
encryption key; the plaintext key is never stored.
|
||||
|
||||
| Operation | Algorithm | Key size | Key generation | IV / nonce | Storage and rotation | Evidence |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Organization data key, wrapped | AWS KMS `GenerateDataKey` | 256-bit | KMS | KMS | Wrapped key base64 in `organization_encrypted_keys`; the CMK rotates on AWS's schedule and old ciphertexts keep decrypting | `internal/infrastructure/kms/encryption.go`, `decryption.go` |
|
||||
| Organization data key, self-host | AES-256-GCM | 256-bit master | Master from `KMS_LOCAL_MASTER_KEY`, length-checked at boot; data key from `crypto/rand` | 12 bytes, `crypto/rand` | Master in environment or file, memory only | `internal/infrastructure/kms/local.go` |
|
||||
| Organization data key, on a node | None held | n/a | n/a | n/a | A node holds no key material; it posts sealed keys to the control plane and gets plaintext back | `internal/infrastructure/kms/brokered.go` |
|
||||
| Message bodies, integration and MCP tokens | AES-256-GCM, nonce prefixed, base64 | 256-bit data key | Per organization, above | 12 bytes, `crypto/rand` | Plaintext key cached in Redis for 15 minutes | `internal/app/cipher/encrypt.go`, `decrypt.go`, `cache.go` |
|
||||
| Mailbox SMTP and IMAP credentials, mailbox OAuth tokens, webhook signing secrets | AES-256-GCM, nonce prefixed, hex | 256-bit `CREDENTIALS_ENCRYPTION_KEY` | Operator-supplied, 64 hex characters, validated at boot | 12 bytes, `crypto/rand` | Environment variable; writes fail closed without it | `internal/pkg/encrypt/encrypter.go`, `internal/repository/pg_email.go`, `pg_webhook.go` |
|
||||
| TOTP secrets | AES-256-GCM | 256-bit, SHA-256 of `TWOFA_SECRET` | Operator-supplied, falls back to `AUTH_SECRET` | 12 bytes, `crypto/rand` | Rotating it invalidates enrolled authenticators, which is documented | `internal/app/twofa/seal.go` |
|
||||
| Workspace export archives | Argon2id then AES-256-GCM | 256-bit derived | Argon2id t=4, m=256 MiB, p=4 from the passphrase | 16-byte salt, 12-byte nonce, `crypto/rand` | The passphrase is never stored; parameters are clamped on import | `internal/app/orgtransfer/seal.go` |
|
||||
|
||||
## Password and code hashing
|
||||
|
||||
| Operation | Algorithm | Parameters | Salt | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| Account passwords | Argon2id | m=64 MiB, t=3, p=2, 32-byte output | 16 bytes, `crypto/rand` | `internal/pkg/argon2/config.go`, `hash.go` |
|
||||
| Emailed login and registration codes | Argon2id | same | same | `internal/app/auth/login.go`, `registration.go` |
|
||||
| 2FA recovery codes | Argon2id | same | same | `internal/app/twofa/recovery.go` |
|
||||
|
||||
Argon2id is in the approved list in NIST SP 800-63B 5.1.1.2, and these
|
||||
parameters exceed the OWASP minimum. Verification is constant-time
|
||||
(`internal/pkg/argon2/verify.go`).
|
||||
|
||||
## Token hashing
|
||||
|
||||
These are high-entropy random values, so an unsalted SHA-256 is the correct
|
||||
construction: there is nothing to brute-force and the lookup has to be by exact
|
||||
value.
|
||||
|
||||
| Token | Bits of entropy | Stored as | Evidence |
|
||||
|---|---|---|---|
|
||||
| API key | 256 | SHA-256 hex | `internal/app/apikey/service.go` |
|
||||
| OAuth authorization code, access token, refresh token | 256 each | SHA-256 | `internal/app/oauth/service.go` |
|
||||
| CLI device code | 256 | SHA-256 | `internal/app/cliauth/service.go` |
|
||||
| Fleet join token | 256 | SHA-256, constant-time compare | `internal/app/fleetnode/service.go` |
|
||||
| First-run setup token | 256 | SHA-256 | `internal/app/bootstrap/bootstrap.go` |
|
||||
|
||||
## Message authentication
|
||||
|
||||
| Operation | Algorithm | Key | Comparison | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| Outbound webhook signature | HMAC-SHA256 over `<timestamp>.<body>` | 256-bit per endpoint, encrypted at rest | Receiver's | `internal/app/webhook/service.go` |
|
||||
| Unsubscribe link token | HMAC-SHA256, 128-bit truncated tag | Derived from `AUTH_SECRET` with a purpose string | `hmac.Equal` | `internal/app/unsublink/signer.go` |
|
||||
| Forms render token | HMAC-SHA256 | SHA-256 of the internal token plus a purpose string | `hmac.Equal` | `internal/formserver/token.go` |
|
||||
| Inbound GitHub release webhook | HMAC-SHA256 | Operator secret | `hmac.Equal` | `internal/app/releases/service.go` |
|
||||
| Stripe webhook | Stripe's signature scheme | Stripe secret | Library | `internal/app/stripe/service.go` |
|
||||
|
||||
Every key here is domain-separated: a key derived for one purpose cannot verify
|
||||
another purpose's tag.
|
||||
|
||||
## Digital signatures
|
||||
|
||||
| Operation | Algorithm | Key | Validation | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| Session, refresh, websocket, challenge and reset tokens | HS256 | `AUTH_SECRET`, at least 32 bytes, enforced at boot | Algorithm pinned to HS256, expiry required, purpose claim required | `internal/app/token/gen.go`, `internal/config/config_auth.go` |
|
||||
| Same tokens, verified by the realtime service | HS256 | The same value as `JWT_SECRET`, same floor | `verify_strict` with an exact algorithm list, purpose required | `realtime/lib/realtime/auth.ex` |
|
||||
| Google and Apple ID tokens | RS256 | Provider JWKS | Algorithm pinned, issuer and audience checked, expiry required | `internal/pkg/idtoken/idtoken.go` |
|
||||
| Cloud Tasks caller | RS256 | Google JWKS | Algorithm, issuer, subject and audience checked | `internal/api/middleware/oidc.go` |
|
||||
| APNs authentication | ES256 (P-256) | Apple `.p8` | Apple's | `internal/infrastructure/apns/client.go` |
|
||||
|
||||
## Randomness
|
||||
|
||||
Every secret, nonce, salt and token comes from `crypto/rand`:
|
||||
`internal/pkg/crypt/gen.go` is the shared source. The six-digit verification
|
||||
code uses `rand.Int` with a bound rather than a modulo, so it is unbiased.
|
||||
|
||||
`math/rand` is used only for scheduling jitter, warmup behaviour sampling and
|
||||
spintax selection, none of which is a security decision.
|
||||
|
||||
## Transport
|
||||
|
||||
| Connection | Minimum version | Verification |
|
||||
|---|---|---|
|
||||
| Inbound HTTPS | 1.2, edge-terminated | Let's Encrypt or platform certificate |
|
||||
| Outbound SMTP | 1.2 explicit | Full verification; cleartext only to a loopback peer on a self-host, checked on the socket |
|
||||
| Outbound IMAP | 1.2 (Go default) | Full verification, same loopback exception |
|
||||
| Outbound HTTP | 1.2 (Go default) | Full verification, through the SSRF-guarded client |
|
||||
| Postgres | Operator-set `sslmode` | `verify-full` with the RDS bundle in the production template |
|
||||
| Redis, NATS, Kafka | TLS schemes supported and used across untrusted networks | System roots |
|
||||
|
||||
## Algorithms deliberately absent
|
||||
|
||||
No MD5, DES, 3DES, RC4, or any CBC mode. SHA-1 appears only inside RFC 6238
|
||||
TOTP, where the specification requires it and where it is used as an HMAC key
|
||||
derivation rather than for collision resistance.
|
||||
|
||||
## Key management summary
|
||||
|
||||
| Key | Where it lives | Rotation |
|
||||
|---|---|---|
|
||||
| AWS KMS CMK | KMS, never leaves it | AWS-managed annual rotation; old ciphertexts continue to decrypt |
|
||||
| `KMS_LOCAL_MASTER_KEY` | Environment or file, memory only | Manual; requires re-wrapping every organization key |
|
||||
| Organization data key | Wrapped in Postgres, plaintext cached in Redis for 15 minutes | Per organization, on creation |
|
||||
| `CREDENTIALS_ENCRYPTION_KEY` | Environment | Manual; requires re-sealing stored credentials |
|
||||
| `AUTH_SECRET` | Environment | Manual; rotating it invalidates every session, which is the intended effect |
|
||||
| `TWOFA_SECRET` | Environment | Manual; rotating it invalidates enrolled authenticators, documented |
|
||||
| Webhook signing secret | Encrypted in Postgres | Customer-initiated, per endpoint |
|
||||
| API key | Hashed in Postgres | Customer-initiated: create new, revoke old |
|
||||
|
||||
Rotating the two instance-wide keys currently requires a re-encryption pass that
|
||||
is not yet automated. Recorded here as a known gap rather than claimed as done.
|
||||
@@ -0,0 +1,917 @@
|
||||
# CASA evidence
|
||||
|
||||
**Specification:** App Defense Alliance CASA v2.1.1 (2026-06-03)
|
||||
**Assurance level:** AL1
|
||||
**Application:** Warmbly
|
||||
**Google OAuth client:** `warmbly-mailboxes` (project 1010273043313)
|
||||
**Repository state:** branch `chore/casa-al1-security-assessment`, base commit `b31c5d52`
|
||||
**Prepared:** 2026-09-19
|
||||
|
||||
Scope is defined in `scope.md`. Cryptographic detail for 4.1.3 is in
|
||||
`crypto-inventory.md`. OAuth detail for 3.2.1 and 3.2.2 is in `oauth.md`.
|
||||
|
||||
This document states the controls as they stand. The change log for the
|
||||
assessment, which necessarily describes what each control replaced, is held
|
||||
outside this repository and supplied to the lab directly: Warmbly is
|
||||
self-hostable, so a public account of what a given control fixed is a map of
|
||||
every instance that has not updated yet.
|
||||
|
||||
Every file reference below is `path:line` at the commit above.
|
||||
|
||||
---
|
||||
|
||||
## 1 Authentication
|
||||
|
||||
### 1.1.1 Authentication is resistant to brute force attacks
|
||||
|
||||
**Status: meets the requirement.** CASA asks for at least one of five controls.
|
||||
Warmbly implements four of them.
|
||||
|
||||
**External authentication services.** Google Sign-In, Apple Sign-In and, for
|
||||
self-hosted instances, a generic enterprise OIDC provider. Each is listed in
|
||||
`oauth.md`. A deployment may also use none of them, so the controls below stand
|
||||
on their own.
|
||||
|
||||
**Control 2.1, rate limiting under 100 failed attempts per account per hour.**
|
||||
Failed passwords are counted per account, in Redis, keyed on a hash of the
|
||||
address:
|
||||
|
||||
- `internal/app/auth/config.go` — `LoginFailureLimit = 10`, `LoginFailureTTL = 1 hour`
|
||||
- `internal/app/auth/cache.go` — `getLoginFailureKey`, `loginFailureExceeded`, `recordLoginFailure`, `clearLoginFailures`
|
||||
- `internal/app/auth/login.go:36` — the budget is checked before the hash comparison, so a caller past the limit cannot even measure Argon2's timing
|
||||
- `internal/app/auth/login.go:43` — a wrong password is charged; `:47` clears the count on success
|
||||
|
||||
Ten per hour is well inside the hundred CASA allows. The counter is keyed on the
|
||||
address rather than the resolved user id, so a guesser learns nothing from the
|
||||
difference between an account that exists and one that does not.
|
||||
|
||||
Per-source limiting sits alongside it: `internal/api/middleware/ratelimit_ip.go`
|
||||
bounds every unauthenticated `/auth` request to 60 per 15 minutes per IP
|
||||
(`AUTH_IP_RATE_LIMIT`), and the remaining public routes to 600 per 15 minutes
|
||||
(`PUBLIC_IP_RATE_LIMIT`).
|
||||
|
||||
**Control 2.2, CAPTCHA.** Cloudflare Turnstile on login, registration, password
|
||||
reset request and password reset confirm:
|
||||
|
||||
- `internal/pkg/captcha/turnstile.go` — verifier, including remote-IP check, optional hostname pin and a five-minute challenge-freshness window
|
||||
- `internal/app/auth/login.go:26`, `internal/app/auth/registration.go:31`, `internal/app/auth/reset_password.go:20` and `:138` — enforcement
|
||||
|
||||
Enabled by setting `TURNSTILE_SECRET`. The hosted deployment sets it. A
|
||||
self-hosted instance may not, which is why control 2.1 above is unconditional.
|
||||
|
||||
**Control 2.4, minimum length with breached-password prohibition.** Both halves,
|
||||
server-side:
|
||||
|
||||
- `internal/pkg/crypt/validation.go` — `CheckPassword`: 8 to 128 characters, then a denylist lookup
|
||||
- `internal/pkg/crypt/passwords/breached.txt` — the UK NCSC list of the 100,000 most commonly breached passwords, reduced to the 46,528 entries long enough to pass the length rule. Sourced from the Have I Been Pwned corpus
|
||||
- `internal/pkg/crypt/validation.go` — `IsBreachedPassword` lowercases the candidate, so recasing a known password does not get past it
|
||||
- `internal/pkg/crypt/validation_test.go` — regression tests, including that the list is actually loaded
|
||||
|
||||
Applied at every entry point: `internal/app/auth/registration.go`,
|
||||
`internal/app/auth/reset_password.go` (both reset and change),
|
||||
`internal/app/bootstrap/bootstrap.go`, `cmd/warmblyctl/password.go`.
|
||||
|
||||
This follows NIST SP 800-63B 5.1.1.2, which asks for a breach check and
|
||||
explicitly discourages composition rules.
|
||||
|
||||
**Control 2.5, additional check from an unfamiliar device or location.**
|
||||
|
||||
- `internal/app/auth/login.go:120-149` — `loginCodeRequired`; under `AUTH_LOGIN_CODE=new_device` an emailed code is demanded for a user-agent not seen before
|
||||
- `internal/app/authrisk/authrisk.go:33-70` — impossible-travel check; two sign-ins that could not be the same person travelling (faster than 1000 km/h) force the code even from a known device
|
||||
- `internal/app/auth/cache.go` — known-device memory, 90 days
|
||||
|
||||
**Control 2.3, MFA enforced by default,** is the one Warmbly does not claim for
|
||||
all users. TOTP and passkeys are available to everyone and are enforced for
|
||||
administrative accounts (see 3.3.1).
|
||||
|
||||
**Artifacts:** `artifacts/screenshots/` (rate limit refusal, CAPTCHA challenge,
|
||||
breached-password refusal, emailed-code challenge from a new device).
|
||||
|
||||
### 1.1.2 System-generated initial passwords or activation codes
|
||||
|
||||
**Status: meets the requirement.**
|
||||
|
||||
Warmbly generates no initial passwords. An account gets a password one of three
|
||||
ways: the person chooses it at registration, the operator supplies one when
|
||||
creating the first owner, or the person sets one through a reset. None of them
|
||||
is a system-generated password that could become a long-term one.
|
||||
|
||||
Codes and one-time tokens:
|
||||
|
||||
| Verifier | Generation | Length / entropy | Expiry | Single use |
|
||||
|---|---|---|---|---|
|
||||
| Registration code | `internal/pkg/crypt/gen.go` `VerificationCode`, `crypto/rand.Int` | 6 digits, ~19.9 bits | 10 min | 3 attempts, session deleted |
|
||||
| Login code | same | 6 digits | 10 min | 3 attempts |
|
||||
| First-run setup token | `internal/app/bootstrap/bootstrap.go:170` | 32 bytes, 256 bits | 24 h | `GETDEL`, and refused once any account exists |
|
||||
| Team invitation | `internal/app/organization/service.go:1201` | 32 bytes, 256 bits | 7 days default | Deleted on acceptance |
|
||||
| Password reset | `internal/app/auth/reset_password.go:83` | JWT plus a 128-bit nonce | 1 h | Nonce deleted before use |
|
||||
|
||||
Codes are stored Argon2id-hashed, never in the clear. The 24-hour setup token is
|
||||
inside the 48-hour maximum; the 7-day invitation is a capability to join a
|
||||
workspace, not an account credential, and is the value CASA allows for a
|
||||
link-style verifier.
|
||||
|
||||
### 1.1.3 Passwords stored in a form resistant to offline attacks
|
||||
|
||||
**Status: meets the requirement.**
|
||||
|
||||
Argon2id, which is in the NIST SP 800-63B 5.1.1.2 approved list:
|
||||
|
||||
- `internal/pkg/argon2/config.go` — memory 64 MiB, iterations 3, parallelism 2, 16-byte salt, 32-byte output
|
||||
- `internal/pkg/argon2/hash.go` — `argon2.IDKey`, PHC-encoded, salt from `crypto/rand`
|
||||
- `internal/pkg/argon2/verify.go:30` — `subtle.ConstantTimeCompare`
|
||||
|
||||
The parameters exceed the OWASP minimum of 19 MiB / t=2 / p=1. Comparison
|
||||
happens at `internal/repository/pg_auth.go:67` and
|
||||
`internal/app/auth/reset_password.go:203`.
|
||||
|
||||
Argon2id also protects the emailed login and registration codes and the 2FA
|
||||
recovery codes. Full inventory in `crypto-inventory.md`.
|
||||
|
||||
### 1.2.1 Default credentials on publicly exposed interfaces
|
||||
|
||||
**Status: meets the requirement.**
|
||||
|
||||
No account exists on a fresh instance. The first one is created either from an
|
||||
operator-supplied password or by claiming a one-time setup link printed at boot,
|
||||
and the claim is refused once any account exists
|
||||
(`internal/app/bootstrap/bootstrap.go:270-294`).
|
||||
|
||||
Fixture accounts with published passwords exist for local development only
|
||||
(`cmd/seed`, `internal/seed`). Three things keep them out of a deployment:
|
||||
|
||||
- the compose seed service is behind a profile and is never published (`docker-compose.yml`)
|
||||
- the installer has no seed step
|
||||
- `cmd/seed/main.go` refuses to run when `APP_ENV` is set to anything other than a development value, which closes the case where the binary inside the release image is pointed at a production database
|
||||
|
||||
`internal/app/instancecheck/checks_security.go` reports a published default
|
||||
secret still in use as an instance finding, continuously rather than once at
|
||||
boot.
|
||||
|
||||
### 1.3.1 Out of band verifier expires in a reasonable timeframe
|
||||
|
||||
**Status: meets the requirement.** Password reset expires in 1 hour, inside the
|
||||
7 days CASA allows. MFA-related verifiers expire in 10 minutes (emailed code) and
|
||||
5 minutes (2FA pending challenge), inside the 30 minutes allowed. See the table
|
||||
under 1.1.2.
|
||||
|
||||
### 1.3.2 Out of band verifier used only once
|
||||
|
||||
**Status: meets the requirement.**
|
||||
|
||||
- Emailed login and registration codes: the session is deleted on success (`internal/app/auth/login.go:186`)
|
||||
- Password reset: the nonce is deleted before the password is written (`internal/app/auth/cache.go`)
|
||||
- 2FA pending challenge: deleted before the session is minted (`internal/app/twofa/login.go:74-77`)
|
||||
- Recovery codes: consumed by compare-and-swap on `used_at` (`internal/repository/pg_totp.go`)
|
||||
- TOTP: the accepted time step is retired, so the same six digits cannot be presented twice inside their validity window (`internal/app/twofa/totp.go` `ValidateCodeStep`, `internal/repository/pg_totp.go` `ConsumeTOTPStep`, migration `000183`). The update is compare-and-swap, so two requests racing with one code cannot both win
|
||||
- Setup token and SSO state: `GETDEL`
|
||||
- Mailbox OAuth state: `GETDEL` (`internal/app/email/cache.go`)
|
||||
|
||||
### 1.3.3 Out of band verifier is securely random
|
||||
|
||||
**Status: meets the requirement.** Every verifier comes from `crypto/rand`:
|
||||
`internal/pkg/crypt/gen.go` (`Nonce`, `VerificationCode` via `rand.Int`, so no
|
||||
modulo bias), `internal/app/bootstrap/bootstrap.go`,
|
||||
`internal/app/organization/service.go`, `internal/app/oauth/service.go`. No use
|
||||
of `math/rand` for any secret; its only uses are scheduling jitter and warmup
|
||||
behaviour sampling.
|
||||
|
||||
### 1.3.4 Out of band verifier resists brute force
|
||||
|
||||
**Status: meets the requirement.** The six-digit codes carry ~19.9 bits, above
|
||||
the 20-bit guidance for a six-digit number, and being under 64 bits they are
|
||||
rate limited as CASA requires:
|
||||
|
||||
- 3 attempts per login or registration session (`internal/app/auth/config.go` `AuthAttempts`)
|
||||
- 5 sends per address per 30 minutes (`AuthEmailLimit`, `AuthEmailTTL`)
|
||||
- 2 password-reset requests per address per 4 hours (`PasswordResetLimit`)
|
||||
- 5 attempts per 2FA challenge, plus 20 verifications per IP per 15 minutes (`internal/app/twofa/service.go`)
|
||||
- 60 requests per IP per 15 minutes across the whole `/auth` group
|
||||
|
||||
Every link-style token is 128 bits or more and needs no rate limit by the same
|
||||
rule.
|
||||
|
||||
---
|
||||
|
||||
## 2 Session Management
|
||||
|
||||
### 2.1.1 No passwords or session tokens in URL parameters
|
||||
|
||||
**Status: meets the requirement.** Evidence: Burp scan (see `README.md`), plus:
|
||||
|
||||
Credentials are read from the `Authorization` header only:
|
||||
`internal/api/middleware/auth.go:21-28`, `internal/api/middleware/apikey.go:70-88`.
|
||||
A grep for query-parameter credential reads across `internal/` and `cmd/` finds
|
||||
none. Passwords travel in JSON bodies on POST; there is no GET login form.
|
||||
|
||||
Tokens that do appear in a URL are single-purpose, single-use and short-lived,
|
||||
never session tokens: the password-reset link (1 h), the invitation link, the
|
||||
first-run setup link (24 h), the SSO handoff code (60 s), OAuth callback codes,
|
||||
and the WebSocket ticket (10 min, minted per connect by `POST /v1/getaway`).
|
||||
|
||||
The WebSocket takes its credential as a query parameter because that is what the
|
||||
Phoenix transport supports. That credential is now a purpose-scoped ticket and
|
||||
nothing else: see 2.3.4.
|
||||
|
||||
### 2.2.1 Logout invalidates stateful session tokens
|
||||
|
||||
**Status: meets the requirement.**
|
||||
|
||||
Sessions are stateful. The access and refresh tokens are JWTs, but each carries a
|
||||
nonce that must match the `sessions` row, so both are revocable:
|
||||
|
||||
- `internal/app/token/verify.go:41-68` — rejects a revoked session and a stale nonce
|
||||
- `internal/app/token/logout.go` — `RevokeSession` stamps `revoked_at` and deletes the Redis cache entry, so revocation is immediate rather than waiting out the cache TTL
|
||||
- `internal/app/token/logout.go` — `RevokeAllSession` for "sign out everywhere"
|
||||
- `internal/repository/pg_token.go` — refresh rotates both nonces by compare-and-swap, so a replayed refresh token fails
|
||||
- `internal/api/handler/session.go` — self-service session list and revocation
|
||||
|
||||
A ban now revokes live sessions too, through the token service so the cache is
|
||||
cleared as well (`internal/app/admin/service.go`, `cmd/backend/main.go`).
|
||||
|
||||
### 2.2.2 Password change terminates other sessions
|
||||
|
||||
**Status: meets the requirement.**
|
||||
|
||||
- `internal/app/auth/reset_password.go:203-237` — changing a password requires the current one and then revokes every other session
|
||||
- `internal/app/auth/reset_password.go:178-186` — a forgotten-password reset revokes all sessions
|
||||
- `internal/repository/pg_token.go` — `RevokeOtherSessions` has no provider filter, so federated and passkey sessions are covered
|
||||
|
||||
### 2.2.3 Non-revocable stateless tokens expire within 24 hours
|
||||
|
||||
**Status: meets the requirement.** Every stateless token Warmbly mints is either
|
||||
inside 24 hours or bound to a revocable server-side record:
|
||||
|
||||
| Token | TTL | Revocable |
|
||||
|---|---|---|
|
||||
| Access JWT | 12 h | Yes, session nonce |
|
||||
| Refresh JWT | 180 d | Yes, session nonce, rotated on every use |
|
||||
| Login-code challenge | 10 min | Yes, Redis nonce |
|
||||
| Password reset | 1 h | Yes, Redis nonce |
|
||||
| 2FA pending | 5 min | Yes, Redis record |
|
||||
| WebSocket ticket | 10 min | n/a, inside 24 h |
|
||||
| OAuth access token | 1 h | Yes, hashed row |
|
||||
| OAuth refresh token | 90 d | Yes, hashed row, rotated |
|
||||
|
||||
Constants: `internal/app/token/config.go`, `internal/app/auth/config.go`,
|
||||
`internal/app/socket/config.go`, `internal/models/oauth_app.go`.
|
||||
|
||||
### 2.3.1 and 2.3.2 Cookie Secure and HttpOnly attributes
|
||||
|
||||
**Status: not applicable, and confirmed by construction.** Warmbly sets no
|
||||
cookies anywhere. A grep for `SetCookie`, `http.Cookie` and `Set-Cookie` across
|
||||
`internal/` and `cmd/` returns nothing; no frontend uses `credentials: include`.
|
||||
Authentication is a bearer token in the `Authorization` header on every surface:
|
||||
dashboard, admin panel, iOS app and API.
|
||||
|
||||
Because the token is in `localStorage` rather than a cookie, the relevant client
|
||||
risk is XSS rather than CSRF. The compensating controls are the content security
|
||||
policy and framing headers added for 5.1.7, React's default escaping, the
|
||||
parser-based sanitizer on all rendered HTML, the 12-hour access token, and
|
||||
immediate server-side revocation.
|
||||
|
||||
Evidence: Burp scan should report no cookie findings, because there are no
|
||||
cookies.
|
||||
|
||||
### 2.3.3 Session tokens rather than static API secrets
|
||||
|
||||
**Status: meets the requirement.**
|
||||
|
||||
A session is minted only after authentication, through one path for every
|
||||
provider (`internal/app/auth/login.go` `finishLoginAsWith`). The session id is a
|
||||
fresh UUID and both nonces are 128 bits from `crypto/rand`
|
||||
(`internal/app/token/gen.go`).
|
||||
|
||||
API keys exist as a deliberate developer feature, not as the primary
|
||||
authentication: 256-bit random, SHA-256 at rest, per-key permission bitmask, IP
|
||||
allowlist, optional expiry, per-key rate limit, mailbox allowlist, revocation
|
||||
with reason (`internal/app/apikey/service.go`,
|
||||
`internal/api/middleware/apikey.go`). Third-party applications are steered to
|
||||
OAuth 2.1 with one-hour access tokens instead.
|
||||
|
||||
Sensitive routes refuse API keys entirely and require a session
|
||||
(`internal/api/routes.go`, the `jwtOnly` group), including creating an API key.
|
||||
|
||||
### 2.3.4 Stateless tokens signed, protected against substitution
|
||||
|
||||
**Status: meets the requirement.**
|
||||
|
||||
- `internal/app/token/gen.go` — HS256, and the verifier pins exactly that algorithm with `jwt.WithValidMethods([]string{"HS256"})` and `jwt.WithExpirationRequired()`. `alg=none` is refused
|
||||
- `internal/config/config_auth.go` — `AUTH_SECRET` must be at least 32 bytes, enforced at boot. `realtime/config/runtime.exs` applies the same floor to the same value
|
||||
- `realtime/lib/realtime/auth.ex` — `JOSE.JWT.verify_strict(..., ["HS256"], ...)`
|
||||
- `internal/pkg/idtoken/idtoken.go` — third-party ID tokens are RS256-pinned with JWKS, issuer and audience checks
|
||||
- `internal/api/middleware/oidc.go` — the Cloud Tasks caller check pins RS256 and now also the audience
|
||||
|
||||
**Token substitution between flows.** One signing key issues the access,
|
||||
refresh, websocket, login-challenge, 2FA-pending and password-reset tokens.
|
||||
Every token now carries a `purpose` claim and every verifier requires the one it
|
||||
expects:
|
||||
|
||||
- `internal/app/token/config.go` — the `Purpose*` constants
|
||||
- `internal/app/token/gen.go` — `GenerateTokenFor`
|
||||
- `internal/app/token/verify.go` — `VerifyTokenFor`
|
||||
- `realtime/lib/realtime/auth.ex` — the socket accepts `purpose: "ws"` and nothing else
|
||||
- `internal/app/token/purpose_test.go` — a token minted for any one purpose is refused for every other
|
||||
|
||||
### 2.4.1 Sensitive account modifications require re-authentication
|
||||
|
||||
**Status: meets the requirement.**
|
||||
|
||||
Two-stage sign-in exists and the intermediate token is not a session: the
|
||||
challenge token issued after a password has no `sessions` row, so it is refused
|
||||
by `ValidateAccessToken` (`internal/app/token/verify.go:51-66`) and can only be
|
||||
spent at `LoginConfirm`. The same holds for the 2FA pending token.
|
||||
|
||||
Already re-verified before the change:
|
||||
|
||||
| Action | What it asks for | Evidence |
|
||||
|---|---|---|
|
||||
| Change password | Current password | `internal/app/auth/reset_password.go:203` |
|
||||
| Disable 2FA | Current TOTP or recovery code | `internal/app/twofa/service.go:74` |
|
||||
|
||||
Now gated by a fresh confirmation (`RequireFreshAuth`, five-minute window):
|
||||
|
||||
| Action | Route |
|
||||
|---|---|
|
||||
| Create an API key | `POST /api-keys` |
|
||||
| Add a passkey | `POST /auth/passkey/register/begin`, `/finish` |
|
||||
| Remove a passkey | `DELETE /auth/passkey/credentials/:id` |
|
||||
| Transfer a workspace | `POST /organizations/transfer-ownership` |
|
||||
| Schedule workspace deletion | `POST /organizations/current/danger-zone/delete` |
|
||||
| Schedule account deletion | `POST /me/danger-zone/delete` |
|
||||
|
||||
- `internal/api/middleware/fresh_auth.go` — the gate. It applies to session callers. An API key and an OAuth token have no session and no second factor to present; each was itself minted from a confirmed session, its use is audited, and the route's permission gate governs it, so they pass through. The threat this addresses is a browser token lifted from an unattended machine, and refusing automation would exceed what the control asks for
|
||||
- `internal/api/handler/reauth.go` — `POST /v1/auth/reauth`, accepting a password or a current TOTP or recovery code
|
||||
- `internal/app/token/reauth.go` — `ReauthWindow`, the stamp, and the cache bust
|
||||
- `internal/app/auth/cache.go` — a per-account budget on confirmations, because this endpoint checks a password and would otherwise be a second, unthrottled place to guess one
|
||||
- migration `000184` — `sessions.reauth_at`
|
||||
- `web/src/components/app/modals/ReauthModal.tsx` and `web/src/lib/api/client/Request.ts` — the client prompts and retries automatically
|
||||
|
||||
Workspace and account deletion additionally require typing the exact name, and
|
||||
are delayed and cancellable (`internal/api/handler/danger_zone.go`).
|
||||
|
||||
An account created through Google, Apple or enterprise SSO may hold neither a
|
||||
password nor an enrolled authenticator. There is nothing for it to confirm with,
|
||||
and accepting the live session instead would turn a stolen token into a
|
||||
permanent API key, so the endpoint refuses with `reauth_no_factor` and names the
|
||||
remedy: enrol two-factor authentication, which is not itself gated. This is a
|
||||
deliberate choice of friction over a silent hole, and it leaves such accounts
|
||||
with a recovery factor they did not have before.
|
||||
|
||||
---
|
||||
|
||||
## 3 Access Control
|
||||
|
||||
### 3.1.1, 3.1.2, 3.1.3 Least privilege on a trusted service layer
|
||||
|
||||
**Status: meets the requirement.** One written description covers all three, as
|
||||
CASA allows.
|
||||
|
||||
**Where access control is decided.** Entirely server-side, in middleware ahead of
|
||||
every handler (`internal/api/routes.go`, `internal/api/middleware/`). The
|
||||
frontend hides what a role cannot use, but hiding is not the control: every
|
||||
route re-derives the caller's identity, workspace and permissions from the
|
||||
database on each request.
|
||||
|
||||
**Identity.** `AuthMiddleware` (`auth.go`) validates the bearer token against the
|
||||
`sessions` row. `CombinedAuthMiddleware` (`apikey.go`) additionally accepts an
|
||||
API key or an OAuth access token, each resolved to its organization and
|
||||
permission mask.
|
||||
|
||||
**Workspace context.** Taken from the session's `current_organization_id`, the
|
||||
API key's organization, or the OAuth grant. It is never taken from a request
|
||||
body. Where a route carries an organization id in its path, membership is
|
||||
verified before the handler runs (`internal/api/middleware/organization.go`
|
||||
`RequireMembership`, and `requireMember` in
|
||||
`internal/app/organization/service.go` for the routes whose path parameter the
|
||||
middleware does not match).
|
||||
|
||||
**Roles and permissions.** A uint16 bitmask per member
|
||||
(`internal/models/organization_permission.go`), with seeded Admin, Manager and
|
||||
Viewer roles plus custom roles. The owner is a flag on the organization, not a
|
||||
role, and always holds every permission.
|
||||
|
||||
**No self-elevation.** `UpdateMemberRole`
|
||||
(`internal/app/organization/service.go`) refuses to re-role yourself, refuses to
|
||||
touch the owner, and requires the actor to already hold every permission being
|
||||
granted. Ownership transfer now additionally requires the actor to be the owner.
|
||||
On the platform-admin side, `GrantAdminPermissions`
|
||||
(`internal/app/admin/service.go`) refuses to grant a bit the granter does not
|
||||
hold, and `RevokeAdminPermissions` refuses to remove the last super admin.
|
||||
|
||||
**Fail closed.** Every middleware aborts on error rather than continuing: a
|
||||
database or cache failure produces 401, 403 or 500 and the handler never runs.
|
||||
The membership helper is the explicit form of this, because
|
||||
`GetMembership` answers `(nil, nil)` for a non-member and a caller that only
|
||||
tests the error would let everyone through.
|
||||
|
||||
**Least privilege in the architecture, not just the API.** A worker holds no
|
||||
database credential and no cloud credential: it reaches relational data through
|
||||
the internal API and gets key and blob operations brokered
|
||||
(`internal/api/handler/internal_dek.go`, `internal_blobs.go`), with the blob
|
||||
presigner restricted to three key prefixes.
|
||||
|
||||
### 3.1.4 Insecure Direct Object Reference
|
||||
|
||||
**Status: meets the requirement.**
|
||||
|
||||
**The pattern.** Every repository method that reads or writes a tenant-owned row
|
||||
takes the organization id as a parameter and filters on it in SQL. The
|
||||
identifier from the request is never the only predicate. For example
|
||||
`internal/repository/pg_contact.go` — `WHERE id = $1 AND organization_id = $2`
|
||||
on get, update and delete; bulk operations use `id = ANY($1) AND organization_id = $2`.
|
||||
|
||||
**APIs that accept a caller-supplied identifier.** Path parameters on every
|
||||
resource (campaigns, contacts, mailboxes, sequences, automations, templates, API
|
||||
keys, webhooks, members, threads, forms, CRM records), plus body identifiers on
|
||||
create and update. The full route list is
|
||||
`docs/content/docs/api/endpoints.mdx`.
|
||||
|
||||
**How they are protected.**
|
||||
|
||||
1. Path identifiers are scoped in the query, as above.
|
||||
2. Body identifiers that reference another row are verified to belong to the
|
||||
caller's workspace before being stored. `internal/repository/pg_crm.go`
|
||||
`verifyRefs` does this for every reference a deal, task or note can carry.
|
||||
3. Read-side joins carry the tenant predicate too, so a row that somehow holds a
|
||||
foreign reference still discloses nothing (`pg_crm.go`, the `SearchDeals`
|
||||
joins).
|
||||
4. A foreign identifier answers 404, never 403, so a prober cannot tell an id
|
||||
that exists elsewhere from one that does not exist.
|
||||
5. Public object references are unguessable capabilities rather than sequential
|
||||
ids: form public ids are 105 bits, unsubscribe tokens are HMAC-signed or
|
||||
128-bit random, tracked links and tracking tickets are UUIDv4.
|
||||
|
||||
A review of every handler and repository method against this pattern was
|
||||
carried out for this assessment, and the findings were remediated. The change
|
||||
log is supplied to the lab separately, for the reason given at the top of this
|
||||
document.
|
||||
|
||||
### 3.1.5 Anti-CSRF
|
||||
|
||||
**Status: meets the requirement.** Evidence: Burp scan.
|
||||
|
||||
Warmbly sets no cookies and uses no ambient browser credential, so a
|
||||
cross-site request carries no authority: the bearer token has to be attached by
|
||||
JavaScript that the attacker's origin cannot run. That is the primary control
|
||||
and it is structural.
|
||||
|
||||
Supporting controls:
|
||||
|
||||
- CORS is an explicit origin allowlist with credentials, or wildcard without credentials, never both (`internal/api/routes.go`, `internal/api/cors.go`)
|
||||
- `X-Frame-Options: DENY` and `frame-ancestors 'none'` on the API, and on the dashboard and admin panel (`internal/api/middleware/security_headers.go`, `web/nginx-security-headers.conf`, `web/public/_headers`)
|
||||
- unauthenticated state-changing endpoints carry anti-automation: Turnstile on registration, login and password reset; honeypot, timing trap and per-IP budget on public form submission
|
||||
|
||||
### 3.1.6 Directory browsing disabled
|
||||
|
||||
**Status: meets the requirement.** Evidence: Burp scan.
|
||||
|
||||
No component serves a directory index. The forms service uses gin's `Static`,
|
||||
which wraps the filesystem so `Readdir` returns nothing
|
||||
(`internal/formserver/server.go`). The nginx images set no `autoindex`, so the
|
||||
default off applies (`web/nginx.conf`, `admin/nginx.conf`,
|
||||
`deploy/nginx/warmbly.conf`). The Rust and Elixir services register fixed routes
|
||||
and mount no static tree. The backend's `/public` route serves a single object
|
||||
per request, restricted to four key prefixes, and now refuses a key naming a
|
||||
directory (`internal/infrastructure/storage/filesystem.go`).
|
||||
|
||||
### 3.2.1 and 3.2.2 OAuth
|
||||
|
||||
**Status: meets the requirement.** Full detail in `oauth.md`, including the
|
||||
exact Google scopes.
|
||||
|
||||
Summary: every integration uses the authorization code flow. The implicit and
|
||||
resource-owner-password grants are not implemented anywhere, as a client or as a
|
||||
server. PKCE is used on every flow that supports it, including the Gmail and
|
||||
Microsoft mailbox flows. `redirect_uri` is a fixed server-side value derived
|
||||
from configuration, never taken from the request. `state` is 128 to 256 bits
|
||||
from `crypto/rand`, stored server-side, bound to the user who started the flow,
|
||||
and consumed atomically with `GETDEL`.
|
||||
|
||||
### 3.3.1 Administrative interfaces use multi-factor authentication
|
||||
|
||||
**Status: meets the requirement.**
|
||||
|
||||
The admin panel is the only application-exposed administrative interface. It is
|
||||
limited to application-layer functions and exposes no cloud infrastructure:
|
||||
there is no install, restart, shell, logs or reboot action anywhere in it.
|
||||
|
||||
MFA is enforced, not merely available:
|
||||
|
||||
- `internal/api/middleware/admin.go` — `AdminMiddleware` refuses any session that did not present a second factor, with code `admin_mfa_required`
|
||||
- migration `000182` — `sessions.mfa_verified`
|
||||
- `internal/app/token/gen.go` — `GenerateMFASession`, the only way the flag is set
|
||||
- `internal/app/twofa/login.go` and `internal/app/passkey/login.go` — the only two callers: a TOTP or recovery code, or a passkey
|
||||
- `admin/src/components/layout/RequireAdmin.tsx` — explains the refusal and points at where to enrol
|
||||
|
||||
The check is on the session rather than on enrolment, so an admin who turns 2FA
|
||||
on does not silently keep a single-factor session. Existing sessions default to
|
||||
not verified, which is the safe direction. It is not configurable.
|
||||
|
||||
A passkey counts as multi-factor: the credential never leaves the device and the
|
||||
platform unlocks it with a biometric or PIN.
|
||||
|
||||
`warmblyctl` is the operator's other surface. It talks to the database directly,
|
||||
serves no HTTP, and is not internet-exposed; access to it is access to the
|
||||
database.
|
||||
|
||||
---
|
||||
|
||||
## 4 Communications
|
||||
|
||||
### 4.1.1 TLS enforced, 1.2 or above, strong ciphers
|
||||
|
||||
**Status: meets the requirement.** Evidence: Qualys SSL Labs report per hostname
|
||||
in `artifacts/`.
|
||||
|
||||
TLS is terminated at the edge on every deployment shape: Railway and Cloudflare
|
||||
for the hosted service, bundled Caddy 2 with automatic HTTPS for the
|
||||
one-command self-host, nginx with Let's Encrypt for bare metal. All three
|
||||
default to TLS 1.2 and 1.3 with modern cipher suites.
|
||||
|
||||
HSTS is now emitted by every layer: `internal/api/middleware/security_headers.go`
|
||||
(on a request the edge reports as HTTPS), the rendered Caddyfile in
|
||||
`site/public/install.sh`, `deploy/nginx/warmbly.conf`,
|
||||
`web/nginx-security-headers.conf`, and the Cloudflare Pages `_headers` files.
|
||||
|
||||
Outbound TLS is enforced rather than assumed. Connections to a customer's
|
||||
mailbox provider require TLS or STARTTLS
|
||||
(`internal/client/smtpimap/smtp/client.go`, `internal/client/smtpimap/imap/client.go`,
|
||||
both with `MinVersion: tls.VersionTLS12`); cleartext is possible only to a
|
||||
loopback peer on a self-hosted instance, checked twice, once on the hostname and
|
||||
once on the actual socket (`internal/client/netbind/netbind.go`). Outbound
|
||||
webhooks require HTTPS and a public address unless the operator explicitly opts
|
||||
out (`internal/app/webhook/service.go`).
|
||||
|
||||
### 4.1.2 Trusted TLS certificates
|
||||
|
||||
**Status: meets the requirement.** Evidence: the same Qualys reports.
|
||||
|
||||
Certificates are issued by Let's Encrypt or the platform edge. No self-signed
|
||||
certificate is trusted anywhere in the default configuration.
|
||||
`InsecureSkipVerify` appears only behind an operator-set development flag
|
||||
(`MAIL_TLS_INSECURE`, `internal/client/netbind/netbind.go`) and in the local
|
||||
sandbox package, and the installer pins it to false.
|
||||
|
||||
### 4.1.3 No weak cryptography
|
||||
|
||||
**Status: meets the requirement.** Full inventory in `crypto-inventory.md`,
|
||||
covering every encryption, hashing and MAC operation with algorithm, key size,
|
||||
key and IV generation, and key management.
|
||||
|
||||
Summary: AES-256-GCM for everything encrypted at rest, with 12-byte nonces from
|
||||
`crypto/rand`; Argon2id for passwords and codes; SHA-256 for token lookup;
|
||||
HMAC-SHA256 for webhook and link signatures; HS256 with a key of at least 32
|
||||
bytes for sessions; RS256 with JWKS for third-party ID tokens; ES256 for APNs.
|
||||
|
||||
No MD5, DES, RC4 or CBC anywhere. SHA-1 appears only inside RFC 6238 TOTP, where
|
||||
the specification requires it.
|
||||
|
||||
### 4.1.4 Cryptographic modules fail securely
|
||||
|
||||
**Status: meets the requirement.**
|
||||
|
||||
Every symmetric operation is AES-GCM. There is no CBC and no padded mode
|
||||
anywhere, so a padding oracle has nothing to attack: an authentication failure
|
||||
is indistinguishable from any other decryption failure.
|
||||
|
||||
Failures are opaque to the caller. `internal/app/cipher/decrypt.go` returns a
|
||||
generic error; callers report it to the error tracker and answer with a generic
|
||||
message. The data-key broker returns one fixed sentence regardless of cause
|
||||
(`internal/api/handler/internal_dek.go`), deliberately, so a prober cannot tell
|
||||
"not one of our keys" from "malformed". As of this assessment, every
|
||||
internal-class error answers with one fixed sentence and logs the detail against
|
||||
the request id (`internal/errx/errx.go` `clientMessage`).
|
||||
|
||||
Comparisons on attacker-supplied secrets are constant-time throughout:
|
||||
`subtle.ConstantTimeCompare` for internal tokens, join tokens and Argon2 output;
|
||||
`hmac.Equal` for every HMAC.
|
||||
|
||||
---
|
||||
|
||||
## 5 Data Validation and Sanitization
|
||||
|
||||
Test cases 5.1.1 through 5.1.10 are validated by the authenticated Burp scan.
|
||||
The written controls below describe what the scan is expected to confirm.
|
||||
|
||||
### 5.1.1 HTTP parameter pollution
|
||||
|
||||
Query parameters are parsed first-value-wins by gin, matching Go's `net/url` and
|
||||
the proxies in front of it, so there is no front-end/back-end disagreement to
|
||||
exploit. Only one route reads a multi-value parameter and it allowlists each
|
||||
element (`internal/api/handler/advisor.go`). No authentication material is read
|
||||
from the query string.
|
||||
|
||||
### 5.1.2 URL redirects and forwards
|
||||
|
||||
Redirects are allowlisted or server-derived in every case:
|
||||
|
||||
- OAuth callbacks return to a fixed URL built from configuration
|
||||
- the login `next` parameter must be a same-origin relative path (`web/src/app/auth/login/page.tsx`)
|
||||
- OAuth client `redirect_uri` is matched by exact string against the registered list, with dangerous schemes rejected at registration (`internal/app/oauth/flow.go`, `internal/app/oauth/dcr.go`)
|
||||
- Stripe checkout and portal return URLs are now pinned to this instance's dashboard origin rather than taken from the request (`internal/api/handler/billing_return_url.go`)
|
||||
- the pool-link return URL is pinned to the registered instance's own host
|
||||
|
||||
The click-tracking redirect is an intentional redirector: the destination is read
|
||||
from a database row minted when the email was sent, never from the request, and
|
||||
the scheme is validated to http or https at mint time on the decoded href
|
||||
(`internal/tasks/links.go`). The URL carries a ticket id and no destination, so
|
||||
there is no open-redirect parameter to manipulate.
|
||||
|
||||
### 5.1.3 and 5.1.4 Dynamic code execution and template injection
|
||||
|
||||
No scripting engine is embedded: no `eval`, no `new Function`, no JavaScript VM,
|
||||
no Lua, no Starlark. The frontend contains no `eval` or `new Function`.
|
||||
|
||||
The expression and merge-field engines use Go `text/template` over a data object
|
||||
that is a `map[string]string` of contact fields, with a 20-function allowlist of
|
||||
pure string and arithmetic helpers (`internal/pkg/tmplfuncs/tmplfuncs.go`). No
|
||||
struct pointer, service handle or `io` value is ever placed in the data, so
|
||||
there is nothing to pivot to. Spintax is a bounded regex expander with a
|
||||
20-iteration cap (`internal/tasks/spintax.go`).
|
||||
|
||||
All transactional email templates use `html/template`, which escapes
|
||||
contextually.
|
||||
|
||||
### 5.1.5 Server-Side Request Forgery
|
||||
|
||||
Every outbound request whose URL a user can influence goes through
|
||||
`internal/pkg/safehttp`. The guard runs at the dialer, not on the hostname
|
||||
string, which is what defeats DNS rebinding:
|
||||
|
||||
- resolution happens inside the dialer, and the validated address is the one dialled
|
||||
- a mixed result set fails closed: one private answer blocks the request
|
||||
- blocked ranges include loopback, RFC1918, IPv6 unique-local, link-local including 169.254.169.254, CGNAT, multicast, IPv4-mapped IPv6 and the reserved ranges
|
||||
- a pre-resolution hostname denylist covers `localhost` and the cloud metadata names, closing split-horizon DNS
|
||||
- ports are restricted to 443 and 8443
|
||||
- redirects are re-validated per hop, capped at five; the webhook worker refuses redirects entirely
|
||||
|
||||
Covered surfaces: customer webhooks, webhook verification, user-entered MCP
|
||||
server URLs, integration actions, AI page fetching, operator notification
|
||||
channels, and OAuth app webhook URLs. As of this assessment the email
|
||||
verification MX probe also refuses a non-public address
|
||||
(`internal/pkg/emailverify/emailverify.go`).
|
||||
|
||||
User-entered IMAP and SMTP hosts are an arbitrary connection by product
|
||||
necessity. The compensating control is socket-level: after connecting, the peer
|
||||
address is checked, so a rebinding answer does not help
|
||||
(`internal/client/netbind/netbind.go`).
|
||||
|
||||
### 5.1.6 XPath and XML injection
|
||||
|
||||
Not applicable, and confirmed structurally: no Go file imports `encoding/xml`.
|
||||
There is no XPath library, no SAML, and no feed parsing. XXE is impossible.
|
||||
|
||||
XLSX import is the only XML-derived input. It is handled by `excelize`, which
|
||||
processes OOXML internally without DTD support, and is now bounded against
|
||||
decompression bombs (512 MiB total, 64 MiB per part) with streaming row reads
|
||||
and a recover around the parser (`internal/app/contact/import.go`).
|
||||
|
||||
### 5.1.7 Cross-site scripting
|
||||
|
||||
React escapes by default and there are exactly two
|
||||
`dangerouslySetInnerHTML` uses in the dashboard, both rendering a compile-time
|
||||
SVG path map.
|
||||
|
||||
Inbound email HTML is the largest untrusted surface. It is sanitized with
|
||||
`bluemonday` on an allowlist policy that drops `script`, `style`, `iframe`,
|
||||
`object`, `embed` and `applet` with their content, permits only http, https,
|
||||
mailto and tel URL schemes, and permits data URIs only for raster images, so
|
||||
neither `data:text/html` nor SVG survives (`internal/pkg/mailhtml/mailhtml.go`).
|
||||
It is then rendered in an iframe without `allow-scripts`
|
||||
(`web/src/components/app/unibox/EmailBody.tsx`).
|
||||
|
||||
The mailbox signature editor now sanitizes with DOMPurify on assignment rather
|
||||
than inspecting with a regex (`web/src/components/app/EmailEditor.tsx`).
|
||||
|
||||
Security headers are set on every surface:
|
||||
`internal/api/middleware/security_headers.go` for the API,
|
||||
`web/nginx-security-headers.conf` and `web/public/_headers` for the dashboard,
|
||||
the equivalents for the admin panel and marketing site, and per-form
|
||||
`frame-ancestors` for the forms service.
|
||||
|
||||
The forms default is deliberately unchanged. A form with no configured embed
|
||||
allowlist may be framed anywhere, which is the documented contract and what
|
||||
every embed installed without configuring the list depends on; narrowing it
|
||||
would have taken those forms off their owners' websites with no error anywhere.
|
||||
What changed is that the permissive case now states `frame-ancestors *`
|
||||
explicitly instead of sending no header, so the policy is legible to a scanner
|
||||
and to a reader rather than being an absence. Restricting framing is offered as
|
||||
a per-form setting, and the documentation now recommends using it.
|
||||
|
||||
### 5.1.8 Database injection
|
||||
|
||||
The repository layer uses pgx with `$n` placeholders throughout, and
|
||||
`internal/repository/query_prepare_live_test.go` prepares every query in the
|
||||
package against a live server.
|
||||
|
||||
Where SQL is built dynamically, identifiers come from static allowlists, never
|
||||
from the request. The segment filter engine, which compiles customer-authored
|
||||
JSON into SQL, resolves every field through a static catalog and four literal
|
||||
column maps, matches operators against constants with a `FALSE` default, binds
|
||||
every value, binds JSONB keys rather than interpolating them, and escapes LIKE
|
||||
metacharacters (`internal/repository/pg_segment_sql.go`). All eleven dynamic
|
||||
`ORDER BY` builders allowlist the column.
|
||||
|
||||
Sort keys that reach an `ORDER BY` are bound as parameters rather than
|
||||
interpolated, and validated at write time against the same rule every other
|
||||
custom-field key answers to.
|
||||
|
||||
Org export and import triple-guard their identifiers: table names come from a
|
||||
compiled registry, column names are filtered against the destination's live
|
||||
catalog, and every identifier passes `pgx.Identifier.Sanitize`.
|
||||
|
||||
### 5.1.9 OS command injection
|
||||
|
||||
No production HTTP service invokes a subprocess. `cmd/backend`, `cmd/worker`,
|
||||
`cmd/consumer`, `internal/api`, `internal/app` and `internal/formserver` do not
|
||||
import `os/exec`. The Rust and Elixir services invoke nothing.
|
||||
|
||||
The only request-reachable path is the operator update action, which is behind
|
||||
the admin bit and MFA, validates the tag against a strict character allowlist
|
||||
before use, and never invokes a shell (`internal/updater/image.go`).
|
||||
|
||||
### 5.1.10 Local and remote file inclusion
|
||||
|
||||
No `http.ServeFile`, `c.File` or raw `http.Dir` in any HTTP surface. Blob keys
|
||||
are cleaned and `..` is rejected as a literal path component before
|
||||
normalization (`internal/infrastructure/storage/filesystem.go`); the public
|
||||
object route and the node presigner both reject `..` before testing the prefix,
|
||||
which is the ordering that makes the check correct
|
||||
(`internal/api/handler/public_object.go`, `internal_blobs.go`).
|
||||
|
||||
### 5.2.1 Untrusted file uploads
|
||||
|
||||
**Status: meets the requirement.**
|
||||
|
||||
| Upload | Limit | Type validation | Stored as |
|
||||
|---|---|---|---|
|
||||
| Campaign attachment | 15 MiB | Extension denylist, sniffed type | `attachments/<campaign>/<uuid>-<name>`, presigned download only |
|
||||
| Email image | 5 MiB | Magic-byte allowlist, real image decode, dimension cap | `email-images/<org>/<uuid>.<ext>` |
|
||||
| Avatar | 2 MiB | Magic-byte allowlist, real decode, dimension cap | `avatars/<kind>/<id>-<epoch>-<nonce>.<ext>` |
|
||||
| Form asset | 1 to 4 MiB | Magic-byte allowlist, real decode | `form-assets/<org>/<id>-<kind>-<ts>.<ext>` |
|
||||
| Contact import | 50 MiB | Extension, then bounded parse | Parsed in memory, never stored |
|
||||
| Workspace archive | 8 GiB | Zip structure and manifest | Parsed, then discarded |
|
||||
|
||||
The controls that matter for execution:
|
||||
|
||||
- every image path forces the extension from a server-side allowlist, so the stored key cannot end in `.svg` or `.html`
|
||||
- `/public` derives Content-Type from the key's extension, serves only that image allowlist inline, hands anything else over as `application/octet-stream` with `Content-Disposition: attachment`, and sets `X-Content-Type-Options: nosniff` plus a sandboxing content security policy (`internal/api/handler/public_object.go`)
|
||||
- attachments are never served from a Warmbly origin; they are fetched by a 15-minute presigned URL
|
||||
- the workspace-import path validates every object key against the shapes this product mints, forces the image extension under public prefixes, and rewrites the workspace segment of a public key to the importing workspace rather than taking it from the archive, so an archive cannot address another workspace's prefix (`internal/app/orgtransfer/blobkey.go`)
|
||||
|
||||
There is no antivirus scanning. Campaign attachments are relayed to external
|
||||
recipients as the customer supplied them, which is the same posture as any mail
|
||||
client; recipient-side scanning is the control. This is stated rather than
|
||||
claimed otherwise.
|
||||
|
||||
---
|
||||
|
||||
## 6 Configuration
|
||||
|
||||
### 6.1.1 No components with known exploitable vulnerabilities
|
||||
|
||||
**Status: meets the requirement.** Evidence: `artifacts/` scan output.
|
||||
|
||||
Scanners run in CI (`.github/workflows/security.yml`): `govulncheck` for Go
|
||||
including the standard library, Trivy for the filesystem, `pnpm audit --prod`
|
||||
per frontend tree, and `cargo audit` for the Rust service.
|
||||
|
||||
At the commit above:
|
||||
|
||||
| Tree | Result |
|
||||
|---|---|
|
||||
| Go | No advisory at CVSS 7.0 or above with a fix available. Four remain with no upstream fix; each is justified below |
|
||||
| web, admin, site, forms | No high or critical advisory in production dependencies |
|
||||
| docs | No high or critical advisory in production dependencies |
|
||||
| tracking (Rust) | Clean |
|
||||
| realtime (Elixir) | Two cowlib advisories, both below CVSS 7.0 |
|
||||
|
||||
**Justified, no upstream fix available.** CASA permits this where the library
|
||||
has a regular patch process:
|
||||
|
||||
| Advisory | Component | Why it does not apply |
|
||||
|---|---|---|
|
||||
| GO-2026-6452 | `xuri/excelize` | A panic on a crafted shared-string index. Reachable only through contact import, which requires authentication and the `manage_contacts` permission. The parser now runs under a recover and answers 400, and decompression is bounded, so the worst case is a rejected upload |
|
||||
| GO-2026-5046, 5047, 5048 | `hamba/avro` | Decoder advisories. The decoder is compiled only into the `-kafka` image variant, behind a build tag, and decodes messages from Warmbly's own internal bus, never attacker input. The default images contain no Avro decoder at all |
|
||||
|
||||
Both projects patch regularly; these will be picked up when they do.
|
||||
|
||||
### 6.2.1 Debug modes disabled in production
|
||||
|
||||
**Status: meets the requirement.** Evidence: Burp scan, plus:
|
||||
|
||||
- gin runs in release mode in every shipped configuration (`docker-compose.yml`, `deploy/config/env.example`, `site/public/install.sh`), set explicitly by every service rather than inherited
|
||||
- no Go file imports `net/http/pprof`; there is no `/debug` route
|
||||
- panics are reported to the error tracker with their stack and answered with a bare 500 carrying no body (`internal/api/middleware/reporting.go`)
|
||||
- a 500 response now carries one fixed sentence; the detail is logged against the request id (`internal/errx/errx.go`)
|
||||
- the Phoenix production config does not print connection details on a failure (`realtime/config/runtime.exs`)
|
||||
- source maps are generated only when they are being uploaded to the error tracker, and deleted after upload (`web/vite.config.ts`, `web/package.json`)
|
||||
- health endpoints return a status literal and nothing else
|
||||
|
||||
### 6.3.1 The Origin header is not used for access control
|
||||
|
||||
**Status: meets the requirement.** Evidence: Burp scan.
|
||||
|
||||
Nothing authenticates or authorizes on `Origin`, `Referer` or `Host`. A grep for
|
||||
those headers finds only CORS configuration and the per-form embed policy.
|
||||
|
||||
CORS never reflects an arbitrary origin with credentials: the wildcard branch
|
||||
sets `AllowCredentials: false` explicitly, and the allowlist branch enumerates
|
||||
origins from configuration (`internal/api/routes.go`). Private-network origin
|
||||
reflection exists only outside release mode, for local development.
|
||||
|
||||
The per-form `frame-ancestors` allowlist is a browser containment directive in a
|
||||
response header, not a server-side grant derived from a request header. It
|
||||
grants nobody anything, and the form is equally reachable by direct navigation.
|
||||
|
||||
### 6.4.1 Subdomain takeover
|
||||
|
||||
**Status: meets the requirement.** Evidence: DNS export in `artifacts/`, to be
|
||||
attached at submission.
|
||||
|
||||
The hostname inventory is in `scope.md`. Every record points at infrastructure
|
||||
Warmbly controls or at a platform Warmbly holds the account for.
|
||||
|
||||
Customer-owned custom domains are the interesting case: a customer points a
|
||||
CNAME at Warmbly for tracking or forms. A certificate is issued for such a name
|
||||
only after the instance has verified it, checked on every request by
|
||||
`GET /tls/authorize` (`internal/api/handler/tls_authorize.go`), which requires a
|
||||
verified row, fails closed on a database error, and does not cache the failure.
|
||||
Background sweeps re-verify and clear a record that stops resolving.
|
||||
|
||||
### 6.5.1 No credentials or payment details in logs
|
||||
|
||||
**Status: meets the requirement.** Evidence: `artifacts/log-sample-login.txt`.
|
||||
|
||||
- the request logger records method, path, status, latency and client IP, and not the query string, which on some routes carries a single-use token placed there by a provider or a mail client (`internal/api/middleware/request_log.go`; the forms service uses the same logger)
|
||||
- no password, token or secret is logged in any auth code path
|
||||
- API keys are stored and looked up as SHA-256; the usage log records the key id, never the key
|
||||
- Warmbly never receives payment details: Stripe Checkout and the billing portal are hosted by Stripe, and no card field exists in this repository
|
||||
- Sentry's `SendDefaultPII` is off in all three initializations, so request headers, cookies and bodies are not attached
|
||||
- session replay masks password inputs and every one-time code entry field; console capture is off, and no token is written to the console
|
||||
|
||||
A login request produces one line of the shape recorded in
|
||||
`artifacts/log-sample-login.txt`.
|
||||
|
||||
### 6.6.1 Browser storage cleared at logout
|
||||
|
||||
**Status: meets the requirement.**
|
||||
|
||||
The dashboard and admin panel store the token pair, a persisted workspace
|
||||
selection and UI preferences, and reply drafts. There is no IndexedDB, no
|
||||
service worker, and no persisted query cache.
|
||||
|
||||
One teardown is used by logout and by every path that discovers the session is
|
||||
gone, so being signed out clears the same things as signing out
|
||||
(`web/src/lib/session.ts` `clearClientSession`, called from
|
||||
`web/src/lib/api/hooks/auth/useLogout.ts` and `web/src/hooks/UserProvider.tsx`).
|
||||
It clears the tokens, the reply drafts, the SSO binding, the persisted store and
|
||||
the query cache.
|
||||
|
||||
Reply drafts hold the body, subject and recipients of an unsent email, so they
|
||||
are cleared alongside the tokens (`web/src/lib/auth.ts`, the prefix sweep in
|
||||
`clearTokens`).
|
||||
|
||||
### 6.7.1 Server-side secrets stored securely
|
||||
|
||||
**Status: meets the requirement.**
|
||||
|
||||
**How secrets reach the application.** Environment variables, from the platform's
|
||||
secret store on the hosted deployment and from a `.env` file on a self-host. The
|
||||
installer creates that file with `umask 077` before writing to it, so it is 0600
|
||||
from the moment it exists and never briefly world-readable
|
||||
(`site/public/install.sh`). AWS Secrets Manager and SSM Parameter Store are
|
||||
supported as alternative sources (`internal/config`).
|
||||
|
||||
**Refusal of published defaults.** The backend will not start when one of the
|
||||
five published development secrets is still in use
|
||||
(`cmd/backend/boot.go`), and `AUTH_SECRET` must be at least 32 bytes.
|
||||
|
||||
**Customer secrets at rest.**
|
||||
|
||||
| Secret | Protection |
|
||||
|---|---|
|
||||
| Mailbox SMTP and IMAP credentials | AES-256-GCM under the instance credential key |
|
||||
| Mailbox OAuth tokens | AES-256-GCM under the instance credential key |
|
||||
| Integration and MCP tokens | AES-256-GCM under the per-organization data key |
|
||||
| API keys | SHA-256, irreversible, shown once |
|
||||
| Webhook signing secrets | AES-256-GCM under the instance credential key. A workspace archive carries the secret only when exported with credentials, and the export and import guide says to rotate after a move |
|
||||
| TOTP secrets | AES-256-GCM under a dedicated key |
|
||||
|
||||
**Access control.** Workers hold no cloud credential: key decryption and blob
|
||||
signing are brokered by the control plane, on a separate token from the one the
|
||||
internet-facing services carry, and the presigner is restricted to three key
|
||||
prefixes (`internal/api/handler/internal_dek.go`, `internal_blobs.go`,
|
||||
`internal/api/middleware/internal_auth.go`). The admin panel cannot reveal a
|
||||
secret: the configuration view redacts by name marker and returns a four-character
|
||||
fingerprint instead of a value (`internal/app/instanceconfig/redact.go`).
|
||||
|
||||
**Monitoring.** Every mutation that touches a secret is written to the audit log
|
||||
with actor, action and target. As of this assessment the two endpoints that hand
|
||||
out key material also log every access and every refusal, which is the clearest
|
||||
probe signal the instance produces (`internal/api/handler/broker_access_log.go`).
|
||||
|
||||
**No secrets in the repository.** A scan for AWS keys, Stripe live keys, private
|
||||
key blocks and platform tokens across the tree and its history returns nothing.
|
||||
The only committed `.env` files are examples with placeholders.
|
||||
@@ -0,0 +1,134 @@
|
||||
# OAuth integrations
|
||||
|
||||
For CASA test cases 3.2.1 (no deprecated flows) and 3.2.2 (`redirect_uri` and
|
||||
`state` validation).
|
||||
|
||||
## Summary
|
||||
|
||||
Every integration uses the **authorization code flow**. The **implicit grant**
|
||||
and the **resource owner password credentials grant** are not implemented
|
||||
anywhere in this codebase, as a client or as a server. Warmbly's own
|
||||
authorization server rejects any `grant_type` other than `authorization_code`
|
||||
and `refresh_token`.
|
||||
|
||||
## Warmbly as a client
|
||||
|
||||
### The client under assessment: Gmail mailbox access
|
||||
|
||||
This is the `warmbly-mailboxes` client.
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Flow | Authorization code with PKCE (S256) |
|
||||
| Authorization endpoint | `https://accounts.google.com/o/oauth2/auth` |
|
||||
| `redirect_uri` | Fixed server-side: the API's public base plus `/addresses/google/callback`. Never read from the request |
|
||||
| `state` | 128-bit from `crypto/rand`, stored in Redis with a 10-minute TTL, consumed with `GETDEL`, and bound to the user who started the flow |
|
||||
| PKCE verifier | Generated at start, stored in the server-side state, never sent to the browser |
|
||||
| Extra parameters | `access_type=offline`, `prompt=consent` |
|
||||
| Token storage | AES-256-GCM under the instance credential key |
|
||||
| Revocation | On mailbox deletion, the refresh token is posted to Google's revoke endpoint |
|
||||
|
||||
**Scopes requested, and nothing else:**
|
||||
|
||||
| Scope | Why |
|
||||
|---|---|
|
||||
| `https://www.googleapis.com/auth/gmail.modify` | Send campaign and warmup mail, read replies, move messages between folders |
|
||||
| `https://www.googleapis.com/auth/gmail.settings.basic` | Read the send-as addresses Google has verified, and the signature already configured |
|
||||
|
||||
Google's Gmail scopes nest, and `gmail.modify` already confers `gmail.readonly`,
|
||||
`gmail.send`, `gmail.compose` and `gmail.metadata`, so naming those as well would
|
||||
widen the consent screen and the declared restricted-scope set without granting
|
||||
anything additional. Mailboxes connected under a broader consent continue to
|
||||
work: the nesting is resolved in both directions by `scopeSatisfiedBy`
|
||||
(`internal/app/email/onboarding.go`).
|
||||
|
||||
`https://mail.google.com/` is **not** requested. It appears only in the
|
||||
satisfaction table, as a scope that would confer the ones above if a user had
|
||||
granted it previously.
|
||||
|
||||
**Partial consent is refused.** A consent screen lets a person untick individual
|
||||
permissions and still returns a token. `checkGrantedScopes`
|
||||
(`internal/app/email/onboarding.go`) compares what was granted against what was
|
||||
asked and refuses the connection, naming the missing permission, rather than
|
||||
storing a mailbox that looks connected and fails days later.
|
||||
|
||||
Evidence: `internal/config/inbox.go`, `internal/app/email/onboarding.go`,
|
||||
`internal/app/email/oauth_params.go`, `internal/app/email/cache.go`.
|
||||
|
||||
### Microsoft Graph mailbox access
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Flow | Authorization code with PKCE (S256) |
|
||||
| `redirect_uri` | Fixed server-side: API base plus `/addresses/outlook/callback` |
|
||||
| `state` | As above: 128-bit, Redis, `GETDEL`, user-bound |
|
||||
| Scopes | `Mail.Send`, `Mail.ReadWrite`, `User.Read`, `offline_access` |
|
||||
| Extra parameters | `prompt=select_account`. Deliberately not `prompt=consent`, which causes Entra ID to re-run consent eligibility and refuse non-admin users |
|
||||
|
||||
### Google Sign-In (authenticating a person into Warmbly)
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Flow | Authorization code with PKCE (S256) and a nonce |
|
||||
| `redirect_uri` | Fixed server-side, must be absolute, validated at boot |
|
||||
| `state` | 256-bit from `crypto/rand`, Redis, 10-minute TTL, `GETDEL`, provider-bound |
|
||||
| ID token | Verified against Google's JWKS: RS256 pinned, issuer allowlist, audience equal to the client id, expiry required, nonce compared |
|
||||
| Tokens stored | None. Warmbly reads the identity and discards the tokens |
|
||||
|
||||
After the callback, a single-use 60-second handoff code is exchanged over POST
|
||||
with a binding secret the browser held throughout, which defends against login
|
||||
CSRF (RFC 9700 4.7.1).
|
||||
|
||||
Evidence: `internal/app/socialauth/socialauth.go`, `internal/app/auth/sso.go`,
|
||||
`internal/pkg/idtoken/idtoken.go`.
|
||||
|
||||
### Apple Sign-In
|
||||
|
||||
Authorization code with `response_mode=form_post`, state and nonce. Apple does
|
||||
not support PKCE on the web, so the nonce inside the ID token plus a single-use
|
||||
state are the binding. The ID token is verified against Apple's JWKS with the
|
||||
issuer and audience pinned. `redirect_uri` is fixed server-side and required to
|
||||
be HTTPS at boot.
|
||||
|
||||
### Enterprise OIDC (self-hosted)
|
||||
|
||||
Discovery at boot with the issuer pinned to the configured value, RS256
|
||||
required, authorization code with PKCE and a nonce, and the same Redis state
|
||||
machinery. Optional domain allowlist.
|
||||
|
||||
Evidence: `internal/app/oidcauth/oidcauth.go`.
|
||||
|
||||
### Third-party integrations
|
||||
|
||||
HubSpot, Slack, Google Sheets, Pipedrive and Salesforce, each authorization code
|
||||
with a fixed server-side redirect URI and a 192-bit state stored in Postgres,
|
||||
consumed atomically by a conditional update, and bound to the user who started
|
||||
it. Google Sheets and Salesforce use PKCE. Tokens are encrypted under the
|
||||
per-organization data key.
|
||||
|
||||
Evidence: `internal/app/integration/oauth.go`, `service.go`.
|
||||
|
||||
**Callback origin.** The bouncer pages that hand an authorization code back to
|
||||
the dashboard address `postMessage` to this instance's dashboard origin, and
|
||||
deliver nothing rather than falling back to a wildcard when that origin is
|
||||
unconfigured (`internal/api/handler/integration.go`,
|
||||
`internal/api/handler/email_oauth_callback.go`).
|
||||
|
||||
## Warmbly as an authorization server
|
||||
|
||||
Warmbly implements OAuth 2.1 for third-party apps and MCP clients.
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Grants | `authorization_code` and `refresh_token` only. Anything else returns `unsupported_grant_type` |
|
||||
| PKCE | S256 only; `plain` is rejected. Mandatory for public clients, checked at authorize and again at token exchange |
|
||||
| `redirect_uri` | Exact string match against the registered list, with no prefix matching. Re-checked at token exchange against the value the code was issued for |
|
||||
| Registration | HTTPS, loopback HTTP, or a private-use scheme. `javascript:`, `data:`, `vbscript:`, `file:`, `blob:` and `about:` are rejected; opaque URIs are rejected; 12 maximum, 2048 characters each |
|
||||
| Authorization code | 256-bit, stored SHA-256, 10-minute TTL, consumed by conditional update, bound to the client |
|
||||
| Access token | 1 hour, hashed at rest |
|
||||
| Refresh token | 90 days, hashed at rest, rotated on every use |
|
||||
| Revocation | RFC 7009, and never confirms whether a token existed |
|
||||
| Dynamic registration | RFC 7591, open but rate limited per IP, and a self-registered client can never request `SEND_CAMPAIGNS` or `API_KEYS` |
|
||||
|
||||
Evidence: `internal/app/oauth/flow.go`, `service.go`, `dcr.go`,
|
||||
`internal/api/handler/oauth.go`.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Assessment scope
|
||||
|
||||
## The application
|
||||
|
||||
Warmbly is an email warmup and cold outreach platform. A customer connects their
|
||||
own mailboxes, and Warmbly sends, syncs and tracks mail through them. The Google
|
||||
OAuth client under assessment, `warmbly-mailboxes`, is what connects a Gmail or
|
||||
Google Workspace mailbox.
|
||||
|
||||
## First-party components in scope
|
||||
|
||||
Everything below is one system behind one authentication and authorization
|
||||
model, so all of it is in scope.
|
||||
|
||||
| Component | Path | Role |
|
||||
|---|---|---|
|
||||
| Backend API | `cmd/backend`, `internal/api`, `internal/app` | The control plane. Authentication, authorization, every customer-facing endpoint |
|
||||
| Consumer | `cmd/consumer` | Processes bus events and updates platform state |
|
||||
| Worker | `cmd/worker` | Sends and syncs mail. Holds no database credential and no cloud credential |
|
||||
| Forms service | `cmd/forms`, `internal/formserver`, `forms/` | Public lead-capture form pages on their own origin |
|
||||
| Tracking service | `tracking/` (Rust) | Open and click tracking, unsubscribe forwarding |
|
||||
| Realtime service | `realtime/` (Elixir) | WebSocket fan-out to signed-in dashboards |
|
||||
| Dashboard | `web/` | The customer-facing single-page app |
|
||||
| Admin panel | `admin/` | The operator surface. Platform-admin bit plus MFA on every route |
|
||||
| Marketing site | `site/` | Static. Also serves `install.sh` and `cli.sh` |
|
||||
|
||||
## Hostnames
|
||||
|
||||
The Qualys SSL Labs scans and the DNS review for test cases 4.1.1, 4.1.2 and
|
||||
6.4.1 cover:
|
||||
|
||||
| Hostname | Serves |
|
||||
|---|---|
|
||||
| `warmbly.com` | Marketing site, `install.sh`, `cli.sh` |
|
||||
| `app.warmbly.com` | Dashboard |
|
||||
| `api.warmbly.com` | Backend API, `/public` objects, OAuth callbacks |
|
||||
| `admin.warmbly.com` | Admin panel |
|
||||
| `docs.warmbly.com` | Documentation |
|
||||
| `forms.warmbly.com` | Public form pages |
|
||||
| The tracking host | Open and click tracking |
|
||||
| The realtime host | WebSocket gateway |
|
||||
|
||||
Customer-owned custom tracking and forms domains point at Warmbly by CNAME.
|
||||
Certificates for those are issued on demand only after the instance has verified
|
||||
the name, gated by `GET /tls/authorize`
|
||||
(`internal/api/handler/tls_authorize.go`).
|
||||
|
||||
## Third-party services in scope
|
||||
|
||||
CASA puts a third-party API in scope when it performs authentication, or reads
|
||||
or mutates user data. These qualify, and are covered under sections 1, 2 and 3
|
||||
only:
|
||||
|
||||
| Service | What it does | Flow |
|
||||
|---|---|---|
|
||||
| Google Sign-In | Authenticates a person into Warmbly | Authorization code with PKCE and nonce |
|
||||
| Apple Sign-In | Authenticates a person into Warmbly | Authorization code with state and nonce |
|
||||
| Google (Gmail API) | Reads and sends a customer's mail | Authorization code with PKCE. The client under assessment |
|
||||
| Microsoft Graph | Reads and sends a customer's mail | Authorization code with PKCE |
|
||||
| Enterprise OIDC | Authenticates a person into a self-hosted instance | Discovery, authorization code with PKCE and nonce |
|
||||
|
||||
Warmbly is also an OAuth 2.1 authorization server for third-party apps and MCP
|
||||
clients (`internal/app/oauth`). That is first-party code and is assessed as part
|
||||
of the backend.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Stripe.** Billing only. Warmbly never sees a card number: Checkout and the
|
||||
billing portal are hosted by Stripe, and no PAN, CVV or expiry field exists
|
||||
anywhere in this repository.
|
||||
- **PostHog and Sentry.** Product analytics and error reporting. Neither
|
||||
authenticates anyone nor holds customer mail.
|
||||
- **AWS KMS, S3, SES; Cloudflare; Railway.** Infrastructure the application runs
|
||||
on. In scope for how Warmbly configures and uses them, which sections 4 and 6
|
||||
cover, not as separately assessed products.
|
||||
- **Recipients' mail servers.** Warmbly authenticates to a customer's own
|
||||
mailbox provider; it never connects to a recipient's MX.
|
||||
|
||||
## What the Burp scan has to reach
|
||||
|
||||
The DAST test cases require an authenticated scan. A scan that only sees the
|
||||
signed-out surface proves nothing about them. The scan should be run against a
|
||||
staging instance with a seeded workspace, authenticated as a workspace owner,
|
||||
and should reach at least:
|
||||
|
||||
- the full dashboard under `/app`, including campaigns, contacts, the unified
|
||||
inbox, forms, automations and settings
|
||||
- the REST API under `/v1` with a bearer token, including the list and detail
|
||||
endpoints for every resource in `docs/content/docs/api/endpoints.mdx`
|
||||
- the public form pages on the forms origin
|
||||
- the unsubscribe and tracking endpoints on the tracking origin
|
||||
|
||||
Authentication for the scan: sign in with a password, complete the emailed code,
|
||||
and attach the resulting bearer token to scan requests. The token lives 12 hours,
|
||||
which is long enough for a full crawl and audit.
|
||||
@@ -311,6 +311,10 @@ REDIS_URL=redis://localhost:6379
|
||||
# Realtime transport. false (default): Redis bridge, no cloud. Read identically by
|
||||
# backend, consumer, and realtime — never set true on one side only.
|
||||
PUBSUB_ENABLED=false
|
||||
# Origins a browser connects FROM, comma separated. Not the websocket host:
|
||||
# the Origin on an upgrade is the dashboard's. Empty leaves the check off.
|
||||
CHECK_ORIGIN_HOSTS=
|
||||
# Legacy host-based form, consulted only when CHECK_ORIGIN_HOSTS is empty.
|
||||
CHECK_ORIGIN=false
|
||||
# When PUBSUB_ENABLED=true (Google Pub/Sub): also set GCP_PROJECT_ID +
|
||||
# GOOGLE_APPLICATION_CREDENTIALS_JSON on every service.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
# (adds librdkafka + CGO; slower, and CGO cannot cross-compile — build each arch
|
||||
# on a native runner). Runtime selection is still by env
|
||||
# (EVENTBUS_PROVIDER / CODEC_PROVIDER).
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
|
||||
|
||||
ARG GO_TAGS=""
|
||||
ARG TARGETOS TARGETARCH
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
#
|
||||
# Distroless-style: the CLI is a static binary that talks to one HTTPS API, so
|
||||
# the runtime needs certificates, timezone data and nothing else.
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# CGO-free by default (NATS + JSON). Build with --build-arg GO_TAGS=kafka to
|
||||
# include the Kafka backend (adds librdkafka + CGO). See backend.Dockerfile.
|
||||
# Builder runs on $BUILDPLATFORM and cross-compiles to $TARGETARCH (no QEMU).
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
|
||||
|
||||
ARG GO_TAGS=""
|
||||
ARG TARGETOS TARGETARCH
|
||||
|
||||
@@ -30,7 +30,7 @@ RUN --mount=type=secret,id=posthog_cli_api_key,required=false \
|
||||
pnpm sourcemaps:posthog; \
|
||||
fi
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
|
||||
|
||||
ARG TARGETOS TARGETARCH
|
||||
# Build identity shown in the admin panel; see internal/version.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
# /tmp/main in place, then restarts it. No docker layer pipeline, no
|
||||
# image rebuild — just `go build` against a warm cache.
|
||||
|
||||
FROM golang:1.25-alpine
|
||||
FROM golang:1.26-alpine
|
||||
|
||||
RUN apk add --no-cache git ca-certificates gcc musl-dev librdkafka-dev curl
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# containers. It needs git and the docker CLI with the compose plugin, and the
|
||||
# docker socket mounted at runtime (see the updater service in
|
||||
# docker-compose.yml). Builder runs on $BUILDPLATFORM and cross-compiles.
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
|
||||
|
||||
ARG TARGETOS TARGETARCH
|
||||
ARG VERSION="" COMMIT="" BUILT_AT=""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# CGO-free by default (NATS + JSON). Build with --build-arg GO_TAGS=kafka to
|
||||
# include the Kafka backend (adds librdkafka + CGO). See backend.Dockerfile.
|
||||
# Builder runs on $BUILDPLATFORM and cross-compiles to $TARGETARCH (no QEMU).
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
|
||||
|
||||
ARG GO_TAGS=""
|
||||
ARG TARGETOS TARGETARCH
|
||||
|
||||
@@ -21,11 +21,47 @@ server {
|
||||
|
||||
root /opt/warmbly/web;
|
||||
index index.html;
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
location / {
|
||||
# Security response headers. Repeated per location because nginx drops
|
||||
# inherited add_header directives in any block that declares its own.
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'self'" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
# Never cache the shell or the runtime config; hashed assets are immutable.
|
||||
location = /index.html { add_header Cache-Control "no-store"; }
|
||||
location = /config.js { add_header Cache-Control "no-store"; }
|
||||
location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; }
|
||||
location = /index.html {
|
||||
# Security response headers. Repeated per location because nginx drops
|
||||
# inherited add_header directives in any block that declares its own.
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'self'" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
location = /config.js {
|
||||
# Security response headers. Repeated per location because nginx drops
|
||||
# inherited add_header directives in any block that declares its own.
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'self'" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
location /assets/ {
|
||||
# Security response headers. Repeated per location because nginx drops
|
||||
# inherited add_header directives in any block that declares its own.
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'self'" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
}
|
||||
}
|
||||
|
||||
# Admin panel (admin/dist)
|
||||
@@ -37,10 +73,47 @@ server {
|
||||
|
||||
root /opt/warmbly/admin;
|
||||
index index.html;
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
location = /index.html { add_header Cache-Control "no-store"; }
|
||||
location = /config.js { add_header Cache-Control "no-store"; }
|
||||
location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; }
|
||||
location / {
|
||||
# Security response headers. Repeated per location because nginx drops
|
||||
# inherited add_header directives in any block that declares its own.
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'self'" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
# Never cache the shell or the runtime config; hashed assets are immutable.
|
||||
location = /index.html {
|
||||
# Security response headers. Repeated per location because nginx drops
|
||||
# inherited add_header directives in any block that declares its own.
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'self'" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
location = /config.js {
|
||||
# Security response headers. Repeated per location because nginx drops
|
||||
# inherited add_header directives in any block that declares its own.
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'self'" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
location /assets/ {
|
||||
# Security response headers. Repeated per location because nginx drops
|
||||
# inherited add_header directives in any block that declares its own.
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'self'" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
}
|
||||
}
|
||||
|
||||
# Backend API (:8080). Set TRUSTED_PROXIES=127.0.0.1/32 in warmbly.env so the
|
||||
|
||||
+8
-2
@@ -590,8 +590,14 @@ services:
|
||||
JWT_SECRET: ${AUTH_SECRET:-local-dev-auth-secret-minimum-32-characters-long}
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-local-development-secret-key-base-minimum-64-characters-for-phoenix}
|
||||
PUBSUB_ENABLED: ${PUBSUB_ENABLED:-false}
|
||||
# Behind a reverse proxy set CHECK_ORIGIN=true once PHX_HOST matches the
|
||||
# public websocket hostname.
|
||||
# The origins a browser connects FROM, comma separated, e.g.
|
||||
# "https://app.example.com,https://admin.example.com". Not this service's
|
||||
# own host: the browser's Origin on a websocket upgrade is the dashboard's.
|
||||
# Empty leaves the check off, which is right for a LAN install where the
|
||||
# origin is whatever host was typed; the socket still requires a
|
||||
# short-lived ticket either way.
|
||||
CHECK_ORIGIN_HOSTS: ${CHECK_ORIGIN_HOSTS:-}
|
||||
# Legacy host-based form, consulted only when CHECK_ORIGIN_HOSTS is empty.
|
||||
CHECK_ORIGIN: ${CHECK_ORIGIN:-false}
|
||||
# The environment label events are tagged with, matching every other
|
||||
# service in this file.
|
||||
|
||||
@@ -290,7 +290,7 @@ Changing Advisor settings (`PATCH /advisor/settings`) is JWT only, alongside the
|
||||
| Method | Path | API Permission |
|
||||
|--------|------|----------------|
|
||||
| GET | `/api-keys` | `API_KEYS` |
|
||||
| POST | `/api-keys` | `API_KEYS` |
|
||||
| POST | `/api-keys` | `API_KEYS`, JWT only |
|
||||
| GET | `/api-keys/permissions` | `API_KEYS` |
|
||||
| GET | `/api-keys/:id` | `API_KEYS` |
|
||||
| PATCH | `/api-keys/:id` | `API_KEYS` |
|
||||
@@ -300,6 +300,8 @@ Changing Advisor settings (`PATCH /advisor/settings`) is JWT only, alongside the
|
||||
|
||||
`DELETE /api-keys/:id/permanent` deletes a key that has already been revoked or has expired, taking its usage logs with it. A key that could still authenticate gets a `409`, because revoking is what records that a credential was ended and why. See [Ending a key](/api/authentication/).
|
||||
|
||||
`POST /api-keys` requires a recent confirmation (`POST /auth/reauth`) when called from a signed-in session, because a key outlives the session that made it and a stolen browser token should not be able to leave one behind. An API key or OAuth token calling it is unaffected: it has no session and no second factor to present, it was itself minted from a confirmed session, and the `API_KEYS` scope is the explicit grant that governs it. Automation and the CLI keep working.
|
||||
|
||||
`DELETE /api-keys/self` revokes the key the request was made with, and is the one route here that needs no scope. A credential must always be able to end itself: requiring `API_KEYS` to sign out would leave a read-only key on a laptop someone is handing back live, which is what [`warmbly auth logout`](/api/cli/) promises to prevent. A JWT caller gets a `400`: there is no key in that request to end, only a session, which `POST /auth/logout` ends.
|
||||
|
||||
### OAuth apps
|
||||
@@ -388,11 +390,28 @@ Folders (on campaigns), tags (on mailboxes) and categories (on contacts and inbo
|
||||
These never accept an API key. They depend on a human-bound session: billing flows, governance, OAuth onboarding, websocket bootstrap, and operational destruction.
|
||||
|
||||
- `POST /auth/login`, `/auth/login/confirm`, `/auth/register`, `/auth/register/confirm`, `/auth/refresh`, `/auth/reset-password`, `/auth/reset-password/confirm`
|
||||
- `POST /auth/reauth` (re-prove the account holder behind a live session, for the changes that require a recent confirmation)
|
||||
- `GET /auth/config` (public deployment capabilities: which sign-in methods this backend has enabled, whether a login code step follows, whether signups are open, whether the instance still needs claiming)
|
||||
- `GET /auth/instance` (JWT only: the running Warmbly version of a self-hosted instance and whether a newer release exists, for the dashboard's version pill; a hosted deployment answers `self_hosted: false` and nothing else)
|
||||
|
||||
`POST /auth/register` accepts an optional `invite` field carrying an invitation token. On a deployment running `DISABLE_REGISTRATION=invite_only` it is what permits the signup, and the account is created inside the inviting organization rather than in a new one. The token must resolve to a live invitation whose email equals the submitted address, otherwise the request is refused with `invitation_invalid`. Omitting it on a closed deployment returns `registration_invite_only` or `registration_closed`. See [error codes](/api/error-codes/#registration-and-invitation-refusals).
|
||||
|
||||
### Actions that need a recent confirmation
|
||||
|
||||
Four changes require the session to have confirmed the account holder within the last five minutes, over and above the permission they already need:
|
||||
|
||||
| Action | Route |
|
||||
|--------|-------|
|
||||
| Create an API key | `POST /api-keys` |
|
||||
| Add a passkey | `POST /auth/passkey/register/begin`, `/finish` |
|
||||
| Remove a passkey | `DELETE /auth/passkey/credentials/:id` |
|
||||
| Transfer a workspace | `POST /organizations/transfer-ownership` |
|
||||
| Schedule a workspace or account for deletion | `POST /organizations/current/danger-zone/delete`, `POST /me/danger-zone/delete` |
|
||||
|
||||
Each either hands out a credential that outlives the session that created it, or cannot be reversed by the person it was done to. Without a confirmation they answer `403` with code `reauth_required`; confirm with `POST /auth/reauth` (password or a current two-factor code) and retry. See [error codes](/api/error-codes/#confirmation-required).
|
||||
|
||||
This applies to session callers. An API key or OAuth token has no session to confirm and is not the threat here, so it passes straight through to the route's permission gate.
|
||||
|
||||
`GET /auth/config` gained two fields: `invites_required` (boolean, true when an invitation token is needed to create an account) and `docs_url` (string, the deployment's link to the accounts and access documentation, for a client to surface next to a refusal).
|
||||
|
||||
`GET /auth/config` also carries `websocket_url` and `app_url` (both strings, each omitted when the instance has none). They are the realtime gateway a developer client connects to and the dashboard origin a client sends someone to. Both are served here because on a self-hosted instance the host layout is whatever the operator chose, and there is no other way to discover it: the [CLI](/api/cli/) reads them for `warmbly events tail` and `warmbly browse`.
|
||||
|
||||
@@ -20,8 +20,6 @@ All errors follow this structure:
|
||||
|
||||
`error` and `message` are for people. Client logic should use `code`, HTTP status, and endpoint-specific fields such as `retry_after`. Include `request_id` when contacting support.
|
||||
|
||||
A `4xx` message names the specific thing to fix, so it is worth showing to whoever made the request. A `5xx` message is deliberately generic: a server-side fault has no fix the caller can apply, and the detail behind it stays in the server's own log, where it is recorded against the same `request_id` this response carries. Quote that id and an operator can read the exact failure. The exceptions are the coded conditions listed below (`mailbox_provider_not_configured` and friends), which are `5xx` responses a reader genuinely can act on and which say so in full.
|
||||
|
||||
## HTTP status codes
|
||||
|
||||
### Client errors (4xx)
|
||||
@@ -95,6 +93,23 @@ Returned when the request cannot be processed due to invalid syntax.
|
||||
| `invalid_setting` | `PATCH /outreach/settings` (or a campaign's advanced settings) carried a value outside the documented vocabulary, for example a `reply_intent.crm_task_intents` entry that is not a reply intent |
|
||||
| `no_organization` | The request needs a workspace and the caller has none selected. Every entitlement, limit and suppression rule is scoped to a workspace, so a write that would run unscoped is refused rather than run without those checks. API keys always carry their workspace; a dashboard session picks one at sign-in, so this normally means the session predates the workspace being chosen. Select a workspace and retry |
|
||||
|
||||
#### Password refusals
|
||||
|
||||
| `code` | Status | Meaning |
|
||||
|--------|--------|---------|
|
||||
| `password_breached` | 400 | The password appears in a public list of breached passwords and was refused. Choose one that does not |
|
||||
|
||||
A password must be 8 to 128 characters. There is no composition rule, but it is checked against the 100,000 most commonly breached passwords published by the UK National Cyber Security Centre, case-insensitively.
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Bad Request",
|
||||
"message": "This password appears in a public list of breached passwords. Choose one that does not.",
|
||||
"code": "password_breached",
|
||||
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
|
||||
}
|
||||
```
|
||||
|
||||
### 401 Unauthorized
|
||||
|
||||
Returned when authentication fails.
|
||||
@@ -208,6 +223,38 @@ Signup and invitation refusals carry their own `code`, so a client can branch on
|
||||
|
||||
**How to fix:** on a self-hosted deployment these are configuration, not faults. See [accounts and access](/development/accounts-and-access/#registration-modes).
|
||||
|
||||
#### Confirmation required
|
||||
|
||||
| `code` | Status | Meaning |
|
||||
|--------|--------|---------|
|
||||
| `reauth_required` | 403 | The action needs a proof of identity newer than the session. Confirm with `POST /v1/auth/reauth`, then retry |
|
||||
| `reauth_no_factor` | 400 | The account has neither a password nor two-factor authentication, so there is nothing to confirm with. Enrol one first |
|
||||
| `admin_mfa_required` | 403 | An admin route was reached by a session that did not present a second factor. Turn on 2FA or add a passkey, then sign in again |
|
||||
|
||||
`reauth_required` guards the changes that hand out a durable credential or cannot be undone: creating an API key, adding or removing a passkey, transferring a workspace, and scheduling a workspace or account for deletion. Confirm with a password or a current two-factor code:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://api.warmbly.com/v1/auth/reauth" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"password": "..."}'
|
||||
```
|
||||
|
||||
```json
|
||||
{ "valid_for_seconds": 300 }
|
||||
```
|
||||
|
||||
The confirmation is recorded on the session and lasts for the window returned, so a run of related changes only asks once. API keys and OAuth tokens have no session to confirm, so these routes are reachable only with a signed-in session; that is deliberate for key creation, which otherwise lets one leaked key mint more.
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Forbidden",
|
||||
"message": "Confirm it is you before making this change.",
|
||||
"code": "reauth_required",
|
||||
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
|
||||
}
|
||||
```
|
||||
|
||||
### 404 Not Found
|
||||
|
||||
Returned when the requested resource doesn't exist.
|
||||
@@ -293,7 +340,7 @@ Returned when an unexpected error occurs on the server.
|
||||
```json
|
||||
{
|
||||
"error": "Internal Server Error",
|
||||
"message": "Something went wrong on our end. Try again in a moment. If it keeps happening, contact support with the request id.",
|
||||
"message": "Something went wrong.",
|
||||
"code": "internal_error",
|
||||
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
|
||||
}
|
||||
@@ -301,9 +348,11 @@ Returned when an unexpected error occurs on the server.
|
||||
|
||||
**How to fix:**
|
||||
- Retry the request after a short delay
|
||||
- If persistent, contact support and quote `request_id`. It is the only thing that identifies this exact failure in the server's logs, where the underlying cause is recorded
|
||||
- If persistent, contact support with request details
|
||||
|
||||
One `internal_error` variant is worth distinguishing. When an authentication endpoint cannot send its email, the message names that specifically rather than reporting a generic fault:
|
||||
A 500 always carries this same message. The underlying detail is not returned, because it is usually database or provider output naming tables, columns and hosts, none of which helps a caller. It is logged against the `request_id` in the response, so quoting that id in a support request is what connects the two.
|
||||
|
||||
One `internal_error` variant is worth distinguishing. When an authentication endpoint cannot send its email, the message names that specifically rather than reporting a generic fault, because on a self-hosted instance the person reading it is often the one who can fix it:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -325,7 +374,7 @@ Returned when the service is temporarily unavailable.
|
||||
```json
|
||||
{
|
||||
"error": "Service Unavailable",
|
||||
"message": "This part of Warmbly is temporarily unavailable. Nothing was changed. Try again in a moment.",
|
||||
"message": "service unavailable",
|
||||
"code": "service_unavailable",
|
||||
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
|
||||
}
|
||||
@@ -489,56 +538,6 @@ A `503` whose `code` is `mailbox_identity_unavailable` comes from `POST /emails/
|
||||
- Retry. Placement happens within moments, so a second attempt usually succeeds
|
||||
- `GET /emails/{id}` reports the mailbox's `status`; an `inactive` mailbox is not placed on a worker at all and will keep refusing until it is reactivated
|
||||
|
||||
#### `mailbox_validation_timeout`
|
||||
|
||||
A `400` whose `code` is `mailbox_validation_timeout` comes from the SMTP and IMAP connect and credential-update paths. The credentials were sent to a worker and tried against the servers named, and no answer came back inside the check's deadline. It is not a refused password: the usual causes are a wrong host or port, a firewall between the worker and the server, or a mail server that is simply slow.
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Bad Request",
|
||||
"message": "The mail server didn't answer in time, so the credentials could not be checked. Confirm the host, port and security settings, then try again.",
|
||||
"code": "mailbox_validation_timeout",
|
||||
"request_id": "0b7c6a5e-2f83-4e1a-9de4-1c72b8f9a340"
|
||||
}
|
||||
```
|
||||
|
||||
**How to fix:**
|
||||
- Confirm the SMTP and IMAP host, port and security setting against the provider's own documentation
|
||||
- Retry. A slow server often answers on the second attempt
|
||||
|
||||
#### `mailbox_validation_unavailable`
|
||||
|
||||
A `503` whose `code` is `mailbox_validation_unavailable` means the check itself could not be run: the machine that tries credentials could not be reached, or the channel its answer comes back on was unavailable. Nothing is known about the credentials either way, and nothing was saved.
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Service Unavailable",
|
||||
"message": "Warmbly couldn't check these credentials right now, so nothing was saved. Try again in a moment.",
|
||||
"code": "mailbox_validation_unavailable",
|
||||
"request_id": "9a1d3f27-64bb-4c0e-8a61-2f5b7c0e9d14"
|
||||
}
|
||||
```
|
||||
|
||||
**How to fix:**
|
||||
- Retry. Nothing about the mailbox was changed, so the request is safe to repeat
|
||||
|
||||
#### `passkey_unavailable`
|
||||
|
||||
A `503` whose `code` is `passkey_unavailable` comes from the passkey registration and sign-in endpoints. A ceremony holds its challenge outside the browser for the few minutes between starting and finishing, and that store could not be written or read. The passkey and the account are both fine.
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Service Unavailable",
|
||||
"message": "Passkey sign-in isn't available right now. Try again in a moment, or sign in with your password.",
|
||||
"code": "passkey_unavailable",
|
||||
"request_id": "3c8e0b14-7a52-4d9f-b0c3-6e1d29fa7b58"
|
||||
}
|
||||
```
|
||||
|
||||
**How to fix:**
|
||||
- Retry the ceremony from the start
|
||||
- Sign in with a password meanwhile. Do not register a replacement passkey; the existing one is not the problem
|
||||
|
||||
## Error handling best practices
|
||||
|
||||
### Implement retry logic
|
||||
|
||||
@@ -642,7 +642,7 @@ Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails`
|
||||
|
||||
`DELETE /emails/:id`
|
||||
|
||||
Disconnects and deletes a mailbox. It is removed from all warmup pools and an account-disconnected event fans out. The mailbox is looked up in the caller's workspace, so any member with `manage_emails` (or a key with `WRITE_EMAILS`) can delete any mailbox the workspace holds, not only the member who connected it; a mailbox in another workspace answers `404`.
|
||||
Disconnects and deletes a mailbox. It is removed from all warmup pools and an account-disconnected event fans out.
|
||||
|
||||
Nothing is removed unless the two steps that cannot be repaired afterwards succeed first: the machine syncing the mailbox is told to drop it, and the mailbox's [Warmbly Cloud](/guides/warmbly-cloud/) link is released, which takes an enrolled mailbox's stored credentials out of the pool and returns a cloud-managed mirror to the cloud workspace. A failure at either point puts the mailbox back as it was and is safe to retry. If the record itself then fails to delete, the mailbox remains but its link is already released, so its warmup moves back to this instance until the delete is retried.
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@ The admin panel is the operator's view of an instance: every worker, user, organ
|
||||
|
||||
The panel runs on `:5174` in the default stack (`http://localhost:5174` on a stock install, or the host you proxied to it). Sign in with the same account as the dashboard. The first owner holds every admin bit from the moment they claim the instance, and `warmblyctl user create --admin` creates a further account that already holds them. A page the account has no bit for says which bit it needs instead of rendering.
|
||||
|
||||
<Callout type="warn" title="Two-factor authentication is required">
|
||||
Every admin route refuses a session that did not present a second factor. Turn on 2FA or add a passkey under **Settings > Security** in the dashboard, then sign in again; the panel says so rather than failing silently.
|
||||
|
||||
The check is on the session, not on enrolment, so switching 2FA on does not upgrade the session you are already holding. Sign out and back in.
|
||||
|
||||
This is deliberate and not configurable. An administrative interface reachable from the internet is the one account where a stolen password should not be enough, and it is what the App Defense Alliance CASA assessment requires of any such interface.
|
||||
</Callout>
|
||||
|
||||
The old paths still redirect, so a bookmark or a link in an older version of these docs lands on the right tab.
|
||||
|
||||
## Command palette
|
||||
|
||||
@@ -55,7 +55,7 @@ Five values protect the whole instance. Compose ships a working default for each
|
||||
|
||||
| Variable | Format | What it protects | Restart needed |
|
||||
|---|---|---|---|
|
||||
| `AUTH_SECRET` | 32 characters or more | JWT and session signing. The realtime service reads the same value as `JWT_SECRET` | yes |
|
||||
| `AUTH_SECRET` | 32 characters or more, enforced at boot | JWT and session signing. The realtime service reads the same value as `JWT_SECRET` and applies the same floor. Both refuse to start below it: a shorter HS256 key can be recovered offline from any token the service has issued | yes |
|
||||
| `INTERNAL_API_TOKEN` | any random string | The backend's `/api/v1/internal/` routes, which workers and the tracking service authenticate against | yes |
|
||||
| `NODE_BROKER_TOKEN` | any random string | The routes that perform a privileged operation for the caller: opening a sealed data key, signing a blob operation, and minting a provider access token for a mailbox Warmbly Cloud manages. Optional, and falls back to `INTERNAL_API_TOKEN`. The same value has to be set on the control plane and on every node that calls those routes, or they answer 401. Worth setting in a split deployment, where the tracking and forms services are internet-facing and hold the shared token | yes |
|
||||
| `SECRET_KEY_BASE` | 64 characters or more | Phoenix session signing in the realtime service | yes |
|
||||
@@ -143,6 +143,7 @@ TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
|
||||
| `SSO_AUTO_PROVISION` | `true` lets a verified identity provider assertion create an account regardless of `DISABLE_REGISTRATION` | `false` | yes |
|
||||
| `AUTH_IP_RATE_LIMIT` | Unauthenticated auth requests allowed per source IP per 15 minutes | `60` | yes |
|
||||
| `CLI_AUTH_IP_RATE_LIMIT` | CLI sign-in handshake requests allowed per source IP per 15 minutes. Its own budget, because one `warmbly auth login` polls around 200 times and must not exhaust the allowance above | `500` | yes |
|
||||
| `PUBLIC_IP_RATE_LIMIT` | Requests allowed per source IP per 15 minutes on the remaining public routes: unsubscribe, invitation preview, fleet enrolment and the analytics proxy. Each carries its authorization in a high-entropy token, so this bounds unmetered writes and relayed bandwidth rather than guessing. Counts reads as well as writes, unlike the auth limiter | `600` | yes |
|
||||
| `WARMBLY_BOOTSTRAP_EMAIL` | First owner's address, read only while the users table is empty | unset | yes |
|
||||
| `WARMBLY_BOOTSTRAP_PASSWORD_HASH` | Argon2 PHC string for that owner. Preferred over the plaintext form | unset | yes |
|
||||
| `WARMBLY_BOOTSTRAP_PASSWORD` | Plaintext convenience form. Warns at boot, and leaves a password in your process environment | unset | yes |
|
||||
@@ -681,7 +682,8 @@ The Elixir websocket service. Its runtime configuration is read only when the re
|
||||
| `REDIS_URL` | The Redis bridge the backend publishes events onto | `redis://localhost:6379/0` |
|
||||
| `PHX_HOST` | The service's own hostname | `localhost` |
|
||||
| `PORT` | Listen port | `4000` |
|
||||
| `CHECK_ORIGIN` | `true` accepts a websocket upgrade only from `PHX_HOST` | `false` |
|
||||
| `CHECK_ORIGIN_HOSTS` | Origins a browser may open a socket from, comma separated, e.g. `https://app.example.com,https://admin.example.com`. Not this service's own host | unset |
|
||||
| `CHECK_ORIGIN` | Legacy host-based form, consulted only when `CHECK_ORIGIN_HOSTS` is empty. `true` accepts an upgrade only from `PHX_HOST` | `false` |
|
||||
| `PUBSUB_ENABLED` | `true` swaps the Redis bridge for Google Pub/Sub | `false` |
|
||||
| `GCP_PROJECT_ID` | Required when `PUBSUB_ENABLED=true`; the service refuses to boot without it | unset |
|
||||
| `MAX_CONNECTIONS_PER_USER` | Concurrent sockets one account may hold. The caller's plan limit applies too, whichever is lower | `10` |
|
||||
@@ -695,8 +697,10 @@ The Elixir websocket service. Its runtime configuration is read only when the re
|
||||
| `SENTRY_DSN` | The same through Sentry. An empty string is treated as unset on purpose, because the library rejects `""` hard enough to take the node down | unset |
|
||||
| `WARMBLY_RELEASE` | The build reported errors are tagged with | `dev` |
|
||||
|
||||
<Callout type="warn" title="CHECK_ORIGIN is false by default">
|
||||
The shipped default accepts a websocket upgrade from **any** origin. A token is still required to join a channel, so an attacker needs a valid JWT either way, but on a deployment reachable from the internet set `PHX_HOST` to the public websocket hostname and `CHECK_ORIGIN=true` so only your own dashboard can open a socket.
|
||||
<Callout type="warn" title="The origin check is off until you list your origins">
|
||||
With neither variable set, a websocket upgrade is accepted from any origin. A short-lived ticket is still required to open the socket, and channel membership is checked on every join, so an attacker needs a real credential either way. On a deployment reachable from the internet, set `CHECK_ORIGIN_HOSTS` to your dashboard and admin origins.
|
||||
|
||||
List the origins the **browser connects from**, not the websocket host. The `Origin` header on an upgrade names the page that opened the socket, which is the dashboard. Setting `CHECK_ORIGIN=true` and pointing `PHX_HOST` at the websocket hostname refuses every real connection, which is the trap this variable replaces. The installer sets `CHECK_ORIGIN_HOSTS` correctly for you.
|
||||
</Callout>
|
||||
|
||||
## Settings stored in the database
|
||||
|
||||
@@ -51,6 +51,31 @@ A build that carries no version (an image built without the build arguments, rep
|
||||
|
||||
The version comes from the binary itself: the Dockerfiles and `make up` stamp the tag, commit and build time in (`internal/version`), CI does the same for published images, and `warmblyctl status` prints it as the first line.
|
||||
|
||||
## Before updating past this release
|
||||
|
||||
Two boot-time requirements were added, and an instance that does not meet them
|
||||
will restart-loop after the pull rather than starting with a warning. Both are
|
||||
worth checking before you press the button.
|
||||
|
||||
- **`AUTH_SECRET` must be at least 32 characters.** It signs every session
|
||||
token, and a shorter key can be recovered offline from any token the instance
|
||||
has issued. The realtime service applies the same floor to the same value
|
||||
(`JWT_SECRET`). Generate one with `make gen-key`, or any 32-plus-character
|
||||
random string, and set it in the install's `.env`. **Changing it signs
|
||||
everybody out**, which is the intended effect and is a good moment to do it.
|
||||
- **Administrators need two-factor authentication.** Every admin route now
|
||||
refuses a session that did not present a second factor. Enrol at least one
|
||||
administrator under **Settings > Security** in the dashboard *before*
|
||||
updating, or the admin panel is unreachable until someone does. The dashboard
|
||||
itself is unaffected, so enrolling afterwards still works; it is just a worse
|
||||
moment to discover it.
|
||||
|
||||
Check the first one with:
|
||||
|
||||
```bash
|
||||
awk -F= '/^AUTH_SECRET=/{print length($2)" characters"}' /opt/warmbly/.env
|
||||
```
|
||||
|
||||
## Update and restart
|
||||
|
||||
The button appears when the updater is reachable and something newer exists. It needs the `manage_settings` admin permission, and every press is an audit row (`upgrade` on `instance`).
|
||||
|
||||
@@ -144,6 +144,8 @@ Two more controls are yours:
|
||||
- **Captcha challenge** adds a Cloudflare Turnstile check to the form. The toggle appears once the operator has configured Turnstile for the instance.
|
||||
- **Allowed embed domains** limits which sites may embed the form. With domains listed, browsers refuse to frame the page anywhere else (a domain covers its subdomains); empty allows any site. The hosted link keeps working either way.
|
||||
|
||||
Worth listing your domains even if you only embed on one site. A form left open to any origin can be framed invisibly under someone else's page and used to collect leads that look like yours, and the browser is the only thing that can prevent it.
|
||||
|
||||
## Data and portability
|
||||
|
||||
Forms, their design, their logo and cover images, their submissions, personalized link tickets and funnel events all belong to the **Contacts** data group of a [workspace archive](/guides/workspace-export-import/). The public form id travels with them, so embed codes installed on your website keep working after a move to another instance (point your embeds at the new host), and link tickets travel verbatim, so personalized links already sitting in sent emails keep identifying their contacts. A form's campaign link is dropped on import when campaigns stay behind.
|
||||
|
||||
@@ -21,6 +21,23 @@ Open **Accounts** and choose **Add account**.
|
||||
|
||||
**OAuth** sends you to your provider's consent screen and returns a token instead of a password. Both OAuth providers use the provider's native API, never IMAP or SMTP, so consent asks to send mail and to read and organize your mailbox. Google additionally asks to read your mail settings, which is what lets Warmbly offer the addresses Google has verified you to send as and import the signature you already wrote there. It needs no app passwords or server settings. Note that Google revokes Gmail tokens when the account's password changes, so a password change there means [re-authorizing the mailbox](#reconnecting-an-account) once.
|
||||
|
||||
#### What each provider is asked for
|
||||
|
||||
Exactly these, and nothing beyond them:
|
||||
|
||||
| Provider | Scope | What it is for |
|
||||
|----------|-------|----------------|
|
||||
| Google | `https://www.googleapis.com/auth/gmail.modify` | Send campaign and warmup mail, read replies, and move messages between folders |
|
||||
| Google | `https://www.googleapis.com/auth/gmail.settings.basic` | Read the send-as addresses Google has verified for the account, and the signature already set there |
|
||||
| Microsoft | `https://graph.microsoft.com/Mail.Send` | Send mail |
|
||||
| Microsoft | `https://graph.microsoft.com/Mail.ReadWrite` | Read replies and move messages |
|
||||
| Microsoft | `https://graph.microsoft.com/User.Read` | Read the signed-in address, so the mailbox is filed under the right one |
|
||||
| Microsoft | `offline_access` | Keep the connection alive without asking again |
|
||||
|
||||
Google's scopes nest, so `gmail.modify` already covers reading, sending, composing and metadata. Warmbly used to ask for those four by name as well, which widened the consent screen without granting anything extra; it no longer does. A mailbox connected under the older, broader consent keeps working and does not need reconnecting.
|
||||
|
||||
A consent screen lets you untick individual permissions. Warmbly checks what was actually granted and refuses a half-granted mailbox at connect time, naming the missing permission, rather than storing one that looks connected and fails on its first send days later.
|
||||
|
||||
**IMAP / SMTP** needs host, port, username, and password for each direction:
|
||||
|
||||
```text
|
||||
@@ -289,7 +306,7 @@ Leads mid-sequence on that mailbox move to another one in their campaign as they
|
||||
|
||||
It keeps its worker assignment while off, so switching it back on puts it back on the same machine, sending from the same IP, and it resumes syncing from where it stopped instead of re-importing.
|
||||
|
||||
**Disconnecting** removes the mailbox for good. It is on the mailbox's own **More** menu in the list, as **Disconnect mailbox**, and at the bottom of its **Settings** tab under Danger zone. To remove several at once, tick their rows and use the selection bar. Any member with the manage mailboxes permission can disconnect any mailbox in the workspace, not only the member who connected it. The machine syncing it is told to drop it before the record is removed, because afterwards there is nothing left to tell. If that instruction cannot be delivered, the disconnect fails with a `503` and nothing is removed, so retry it in a moment rather than assuming it worked. When a disconnect is refused, the notice in the dashboard carries the reason the API gave, so a mailbox that could not be released from Warmbly Cloud or a machine that could not be reached reads as that rather than as a generic failure.
|
||||
**Disconnecting** removes the mailbox for good. It is on the mailbox's own **More** menu in the list, as **Disconnect mailbox**, and at the bottom of its **Settings** tab under Danger zone. To remove several at once, tick their rows and use the selection bar. The machine syncing it is told to drop it before the record is removed, because afterwards there is nothing left to tell. If that instruction cannot be delivered, the disconnect fails with a `503` and nothing is removed, so retry it in a moment rather than assuming it worked.
|
||||
|
||||
Everything belonging to that mailbox goes with it: its imported mail in the unibox, its warmup history and pool membership, its credentials, its sender links, and any send still scheduled for it. A campaign that was using it keeps running on its remaining senders, and the leads it had been writing to move onto them at their next step. Export the workspace first if you want a copy. Disable the mailbox instead when you only want it to stop.
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ They are displayed a single time during setup, each works once, and they are you
|
||||
|
||||
At sign-in, the code submits automatically once six digits are in. Without your authenticator, choose **Use a recovery code**.
|
||||
|
||||
Each code works once. An authenticator code stays valid for about a minute so a slow connection still works, but once it has signed you in, that same code is spent and cannot be used again.
|
||||
|
||||
Disabling asks for a current code or a recovery code first. With 2FA off you are protected only by your password plus the emailed code, and setting it up again issues a fresh secret and new recovery codes, invalidating the old ones.
|
||||
|
||||
## Passkeys
|
||||
@@ -47,6 +49,21 @@ Each entry shows when it was added and last used, and can be renamed or removed.
|
||||
Unsynced passkeys live only on the device that created them. On a device without one, Warmbly says none was found and you sign in with your password, then add a passkey there.
|
||||
</Callout>
|
||||
|
||||
## Confirming sensitive changes
|
||||
|
||||
Some changes ask you to confirm it is you even though you are already signed in:
|
||||
|
||||
- creating an API key
|
||||
- adding or removing a passkey
|
||||
- transferring a workspace to someone else
|
||||
- scheduling a workspace or account for deletion
|
||||
|
||||
Enter your password or a code from your authenticator, whichever your account has. The confirmation lasts five minutes, so a run of related changes only asks once.
|
||||
|
||||
If you signed up with Google, Apple or your company's single sign-on, your account may have neither a password nor 2FA yet. There is nothing to confirm with in that case, so turn on 2FA first; the prompt says so rather than refusing a password you never set.
|
||||
|
||||
This exists because the things on that list either hand out a credential that outlives the session that made it, or cannot be undone from your side. Someone who got hold of a signed-in browser should not be able to do any of them without knowing something you know.
|
||||
|
||||
## Sessions
|
||||
|
||||
Every signed-in device appears under **Sessions** with its device (`Chrome on macOS`), location where known, sign-in method (Email, Google, Apple, or Passkey), and last activity. Your current device is tagged **This device**.
|
||||
@@ -57,9 +74,17 @@ Every signed-in device appears under **Sessions** with its device (`Chrome on ma
|
||||
Sign out the session, change your password, and make sure 2FA is on. Signing out other sessions is the fastest way to cut off access.
|
||||
</Callout>
|
||||
|
||||
## Choosing a password
|
||||
|
||||
A password has to be between 8 and 128 characters, and it is checked against a list of the 100,000 most commonly breached passwords published by the UK National Cyber Security Centre. If yours appears there, it is refused and you are told why. The check is case-insensitive, so capitalising the first letter of a known password does not get past it.
|
||||
|
||||
There is no rule about mixing upper case, digits and symbols. A long passphrase you can remember beats a short one with a symbol bolted on, which is what current guidance from NIST and the NCSC both say. The dashboard shows a strength meter as you type.
|
||||
|
||||
Repeated wrong passwords are counted per account, not just per device, so guessing one account from many addresses does not buy an attacker more attempts. After ten failures the account stops accepting password attempts for an hour. Signing in correctly clears the count.
|
||||
|
||||
## Password and alerts
|
||||
|
||||
Change your password under **Password**: current password, then a new one of at least 12 characters with upper and lower case and a number. **Changing it signs out every other device automatically**, while the device you change it on stays in. Accounts that only use Google, Apple, or a passkey have no password to change.
|
||||
Change your password under **Password**: current password, then a new one. **Changing it signs out every other device automatically**, while the device you change it on stays in. Accounts that only use Google, Apple, or a passkey have no password to change.
|
||||
|
||||
**Sign-in alerts** notify you when your account is accessed from a new browser and OS combination, naming the device and location with a reminder to act if it was not you. They appear in your in-app feed by default; enable email under **Settings > Notifications**, Security section, Email channel. Your first sign-in is never alerted, as there is nothing to compare against.
|
||||
|
||||
|
||||
@@ -80,8 +80,6 @@ At defaults a mailbox sends 10 on day one, 11 the next, and levels off at 40 aft
|
||||
|
||||
Four things shape the real daily number: sends are spread across your warmup hours with jitter, the target is capped by how many eligible partners exist, a mailbox whose health drops gets reduced volume and wider spacing until it recovers, and a recent spam placement holds the ramp where it is.
|
||||
|
||||
The target is checked twice: when the next send is placed, and again as it goes out. A signal that lowers the day's number while a send is already waiting (a spam placement, a health band, partners leaving the pool) holds that send rather than letting it go out over the new number, and the mailbox picks up again at its next opening. A cut can still land below what the mailbox has already sent that day, and the drawer shows that honestly; what it cannot do is add to the excess.
|
||||
|
||||
### Holding the ramp on an early signal
|
||||
|
||||
If any warmup email lands in a recipient's spam folder, that mailbox stops climbing immediately:
|
||||
@@ -106,7 +104,7 @@ Real inboxes reply, so a configurable share of the time a mailbox answers an exi
|
||||
|
||||
Replies thread properly with a real `Re:` subject and `In-Reply-To` header, and candidates must be between 45 minutes and seven days old, so nothing is answered instantly or revived indefinitely.
|
||||
|
||||
Receiving warmup mail can also prompt an answer directly. When a verified warmup email arrives, the recipient sometimes points its next scheduled send back at whoever wrote, 25 minutes to 5 hours later and inside its own warmup hours. That is a re-pointing, not extra work: each mailbox has one warmup send queued at a time, so a reply-back moves that send earlier and aims it, and can never push a send the mailbox had already planned sooner. If the send it moved was parked for tomorrow because today's target is spent, the day's cap still holds: the answer waits for the next opening and keeps its aim. The chance is the recipient's own reply rate, drawn once when the reply is scheduled rather than again when it sends, and it stops before a thread reaches its message cap so replies cannot answer replies indefinitely.
|
||||
Receiving warmup mail can also prompt an answer directly. When a verified warmup email arrives, the recipient sometimes points its next scheduled send back at whoever wrote, 25 minutes to 5 hours later and inside its own warmup hours. That is a re-pointing, not extra work: each mailbox has one warmup send queued at a time, so a reply-back moves that send earlier and aims it, and can never push a send the mailbox had already planned sooner. The chance is the recipient's own reply rate, drawn once when the reply is scheduled rather than again when it sends, and it stops before a thread reaches its message cap so replies cannot answer replies indefinitely.
|
||||
|
||||
Timing imitates people throughout: sends come in bursts and lulls rather than a fixed rhythm, never land on round clock marks, and opens happen on a natural delay during the recipient's waking hours. No mailbox in the pool reads mail at 3am or reacts within seconds.
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ Open **Settings -> Webhooks** in the dashboard and click **Add endpoint**.
|
||||
3. Choose which events to subscribe to. Leave the list empty to receive all standard events (high-volume firehose events are opt-in separately, see below).
|
||||
4. Click **Create**. The signing secret is shown exactly once. Copy it now and store it somewhere safe (a secrets manager or environment variable). It is never shown again. Use **Rotate secret** if you need a new one later.
|
||||
|
||||
The secret is encrypted at rest with the instance's credential key, the same one that protects mailbox passwords, and is decrypted only when a delivery is signed. It is never returned by the API after creation, and never shown in the admin panel.
|
||||
|
||||
After creation the endpoint is **unverified** and will receive only a challenge request, not real events.
|
||||
|
||||
## Verifying your endpoint
|
||||
|
||||
@@ -111,6 +111,7 @@ An import runs as one transaction. If anything fails, nothing lands and the work
|
||||
- **Check campaign schedules.** Per-contact progress travels, so a running campaign resumes at the step it reached rather than restarting.
|
||||
- **Expect the daily send counters to be honoured.** Today's counts come across, so a mailbox cannot double its volume by being migrated mid-day.
|
||||
- **Re-authorize integrations** if you exported without credentials.
|
||||
- **Rotate each webhook's signing secret.** The secret is encrypted at rest with the source instance's key, so it travels only in an export that carries credentials. Exported without them, the endpoint arrives with no secret and its deliveries will not verify at your receiver. Open each endpoint and use **Rotate secret**, then put the new value in your receiver.
|
||||
|
||||
<Callout type="info" title="Moving from a self-hosted instance">
|
||||
If you have shell access to the source, `warmblyctl org export` writes the same archive straight to a file without going through a browser, which is the easier route for a large workspace. See the [warmblyctl reference](/development/warmblyctl/).
|
||||
|
||||
Generated
+6
-5
@@ -11,6 +11,7 @@ overrides:
|
||||
sharp: ^0.35.0
|
||||
postcss: ^8.5.23
|
||||
nanoid: ^3.3.17
|
||||
image-size: ^2.0.3
|
||||
|
||||
importers:
|
||||
|
||||
@@ -2463,9 +2464,9 @@ packages:
|
||||
resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
image-size@2.0.2:
|
||||
resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==}
|
||||
engines: {node: '>=16.x'}
|
||||
image-size@2.0.4:
|
||||
resolution: {integrity: sha512-QRUkFFsRV/6fuESxb9Vkq+a0LkSrgKXuc2NEqfikiXxxN/G3tjWt5EVUlMaImRBZRZK/jRBEbYvpPYZL8t08Zw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
import-fresh@3.3.1:
|
||||
@@ -5982,7 +5983,7 @@ snapshots:
|
||||
github-slugger: 2.0.0
|
||||
hast-util-to-estree: 3.1.3
|
||||
hast-util-to-jsx-runtime: 2.3.6
|
||||
image-size: 2.0.2
|
||||
image-size: 2.0.4
|
||||
negotiator: 1.0.0
|
||||
npm-to-yarn: 3.0.1
|
||||
path-to-regexp: 8.4.2
|
||||
@@ -6234,7 +6235,7 @@ snapshots:
|
||||
|
||||
ignore@7.0.9: {}
|
||||
|
||||
image-size@2.0.2: {}
|
||||
image-size@2.0.4: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
|
||||
@@ -19,3 +19,8 @@ overrides:
|
||||
postcss: ^8.5.23
|
||||
# Clear CVE-2026-67213 (DoS via infinite loop) in the transitive nanoid.
|
||||
nanoid: ^3.3.17
|
||||
# Clear GHSA-w3rx-r6r6-pgpr and GHSA-5p2g-fcmc-qvqq in the image-size that
|
||||
# fumadocs-core pulls in. Build-time only on repo-authored MDX, but the docs
|
||||
# site is in scope for the dependency scan and an unjustified high finding is
|
||||
# the same amount of work to explain as to fix.
|
||||
image-size: ^2.0.3
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
module github.com/warmbly/warmbly
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
// Pinned so every build, local and CI, uses a toolchain carrying the
|
||||
// standard-library security fixes. The net/http, crypto/tls, net/url,
|
||||
// encoding/xml, encoding/asn1 and net advisories govulncheck reports against
|
||||
// earlier toolchains are fixed in go1.26.6; the Docker builder images track the
|
||||
// matching 1.26 line. Raise both together.
|
||||
toolchain go1.26.8
|
||||
|
||||
require (
|
||||
cloud.google.com/go/cloudtasks v1.13.7
|
||||
cloud.google.com/go/pubsub v1.50.1
|
||||
github.com/MicahParks/keyfunc/v3 v3.7.0
|
||||
github.com/andybalholm/cascadia v1.3.5
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.0
|
||||
github.com/aws/aws-sdk-go-v2/config v1.30.2
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.49.4
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.1
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0
|
||||
github.com/aws/aws-sdk-go-v2/service/sesv2 v1.49.0
|
||||
github.com/aws/aws-sdk-go-v2/service/ssm v1.67.7
|
||||
@@ -26,10 +33,10 @@ require (
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/golangci/golangci-lint v1.64.8
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/hamba/avro/v2 v2.24.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hamba/avro/v2 v2.31.0
|
||||
github.com/invopop/jsonschema v0.13.0
|
||||
github.com/jackc/pgx/v5 v5.9.0
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/meszmate/apple-go v0.0.0-20250828163208-7fea48c91b32
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
@@ -46,10 +53,10 @@ require (
|
||||
github.com/stripe/stripe-go/v86 v86.4.2
|
||||
github.com/xuri/excelize/v2 v2.11.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/net v0.59.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/term v0.46.0
|
||||
google.golang.org/api v0.264.0
|
||||
google.golang.org/grpc v1.83.2
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1
|
||||
@@ -87,21 +94,21 @@ require (
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/ashanbrown/forbidigo v1.6.0 // indirect
|
||||
github.com/ashanbrown/makezero v1.2.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.26.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.31.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.35.1 // indirect
|
||||
github.com/aws/smithy-go v1.24.0 // indirect
|
||||
github.com/aws/smithy-go v1.28.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
@@ -319,10 +326,10 @@ require (
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/arch v0.19.0 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated // indirect
|
||||
|
||||
@@ -102,36 +102,36 @@ github.com/ashanbrown/forbidigo v1.6.0 h1:D3aewfM37Yb3pxHujIPSpTf6oQk9sc9WZi8ger
|
||||
github.com/ashanbrown/forbidigo v1.6.0/go.mod h1:Y8j9jy9ZYAEHXdu723cUlraTqbzjKF1MUyfOKL+AjcU=
|
||||
github.com/ashanbrown/makezero v1.2.0 h1:/2Lp1bypdmK9wDIq7uWBlDF1iMUpIIS4A+pF6C9IEUU=
|
||||
github.com/ashanbrown/makezero v1.2.0/go.mod h1:dxlPhHbDMC6N6xICzFBSK+4njQDdK8euNO0qjQMtGY4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.0 h1:0jsHallhJCeaU0Ko48c/3FK1ctOQ7NpzggxriJOQ8MQ=
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.30.2 h1:YE1BmSc4fFYqFgN1mN8uzrtc7R9x+7oSWeX8ckoltAw=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.30.2/go.mod h1:UNrLGZ6jfAVjgVJpkIxjLufRJqTXCVYOpkeVf83kwBo=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.2 h1:mfm0GKY/PHLhs7KO0sUaOtFnIQ15Qqxt+wXbO/5fIfs=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.2/go.mod h1:v0SdJX6ayPeZFQxgXUKw5RhLpAoZUuynxWDfh8+Eknc=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.1 h1:owmNBboeA0kHKDcdF8KiSXmrIuXZustfMGGytv6OMkM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.1/go.mod h1:Bg1miN59SGxrZqlP8vJZSmXW+1N8Y1MjQDq1OfuNod8=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 h1:Hp/VgjP0BysR3OgLlR057Vz2LcbbVnoWeJ+3qWiS/fY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3/go.mod h1:nwGV5qw7F1IZPgxCvA/ph8N2TAuz+BkRG/bXn808qMA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 h1:MUaM4f+kj1ZIBPZfUS8cxP1GKXXZtHJjAthy93AN7SM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3/go.mod h1:6YmVmEVRI5ZZzRjCSsb9SryKH0hAlMRdgA7kG9aDvBU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 h1:fuSCw4Z2qfRCztMPO3GXJNSiEp6Wee+WOLwrHHUMy9c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3/go.mod h1:6SxcHheD1pPR5+kWm1wGvjlL/YqUsh267sAfEmN4K7A=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3 h1:BHKCSX4QXERe8So8rbWqaM7owqOmDJxATXgJwGng22A=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3/go.mod h1:GqWeeKfYfezihA2KfFL9l7ohEdZWe1tuFWh3GfyNSnE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 h1:bON1rJf67TSTDCKg816AAIE4xSTtoo9tl0XRkO72R+I=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3/go.mod h1:c5BBpjJcQXpfeq9iASyVKA3T6vX6B6LEXY4mL/gklDY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 h1:L8vIOxylma91TcR96NFTEC07G3JDwSl+CvK2b+IODms=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3/go.mod h1:fmPIZQzTExYuBNWFyi1P7IoDjvskgphXqK1yObMzusM=
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.49.4 h1:2gom8MohxN0SnhHZBYAC4S8jHG+ENEnXjyJ5xKe3vLc=
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.49.4/go.mod h1:HO31s0qt0lso/ADvZQyzKs8js/ku0fMHsfyXW8OPVYc=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.1 h1:cFHmwLxZPvtoAlo79MboURL2+7b0TMHycnrcf5VZNXk=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.1/go.mod h1:/uA+2Qj4jd5qBWagVC1AyzzDFXVK997E7U04w7Kw0wI=
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0 h1:vL6rQXcGtFv9q/9eRPdI+lL+dvTm7xKGZYSHEvmrpDk=
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0/go.mod h1:QwEDLD+7EukuEUnbWtiNE8LhgvvmhjZoi4XAppYPtyc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sesv2 v1.49.0 h1:XzMkmb8eU1B3WTgfKdLnhJCcWTLZPCoP54ZSsDzPKLY=
|
||||
@@ -144,8 +144,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.31.1 h1:XdG6/o1/ZDmn3wJU5SRAejHa
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.31.1/go.mod h1:oiotGTKadCOCl3vg/tYh4k45JlDF81Ka8rdumNhEnIQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.35.1 h1:iF4Xxkc0H9c/K2dS0zZw3SCkj0Z7n6AMnUiiyoJND+I=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.35.1/go.mod h1:0bxIatfN0aLq4mjoLDeBpOjOke68OsFlXPDFJ7V0MYw=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
|
||||
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
@@ -468,8 +468,8 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk=
|
||||
github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc=
|
||||
github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado=
|
||||
@@ -485,8 +485,8 @@ github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+
|
||||
github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
||||
github.com/hamba/avro/v2 v2.24.0 h1:axTlaYDkcSY0dVekRSy8cdrsj5MG86WqosUQacKCids=
|
||||
github.com/hamba/avro/v2 v2.24.0/go.mod h1:7vDfy/2+kYCE8WUHoj2et59GTv0ap7ptktMXu0QHePI=
|
||||
github.com/hamba/avro/v2 v2.31.0 h1:wv3nmua7lCEIwWsb6vqsTS3pXktTxcKg5eoyNu0VhrU=
|
||||
github.com/hamba/avro/v2 v2.31.0/go.mod h1:t6lJYAGE5Mswfn17zjtyQsssRQgnqO6TXLBCHHWRqrw=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
@@ -532,8 +532,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.0 h1:T/dI+2TvmI2H8s/KH1/lXIbz1CUFk3gn5oTjr0/mBsE=
|
||||
github.com/jackc/pgx/v5 v5.9.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jgautheron/goconst v1.7.1 h1:VpdAG7Ca7yvvJk5n8dMwQhfEZJh95kl/Hl9S1OI5Jkk=
|
||||
@@ -1035,8 +1035,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
||||
@@ -1060,8 +1060,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -1082,8 +1082,8 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
@@ -1098,8 +1098,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -1124,8 +1124,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
@@ -1134,8 +1134,8 @@ golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -1147,8 +1147,8 @@ golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -377,7 +377,7 @@ func (h *Handler) AdminListWarmupPools(c *gin.Context) {
|
||||
// AdminGetWarmupHealthSummary returns an aggregate health overview of all warmup pools
|
||||
func (h *Handler) AdminGetWarmupHealthSummary(c *gin.Context) {
|
||||
if h.WarmupService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, "Warmup service not available."))
|
||||
errx.JSON(c, errx.New(errx.Internal, "warmup service not available"))
|
||||
return
|
||||
}
|
||||
summary, xerr := h.WarmupService.GetPoolHealthSummary(c.Request.Context())
|
||||
|
||||
@@ -19,12 +19,12 @@ import (
|
||||
// GET /admin/fleet/capacity
|
||||
func (h *Handler) AdminFleetCapacity(c *gin.Context) {
|
||||
if h.AdminFleetRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Fleet view is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "fleet view is not available on this instance"))
|
||||
return
|
||||
}
|
||||
rows, err := h.AdminFleetRepo.Capacity(c.Request.Context())
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": rows})
|
||||
@@ -35,7 +35,7 @@ func (h *Handler) AdminFleetCapacity(c *gin.Context) {
|
||||
// GET /admin/fleet/decisions?kind=&worker_id=&limit=
|
||||
func (h *Handler) AdminFleetDecisions(c *gin.Context) {
|
||||
if h.AdminFleetRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Fleet view is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "fleet view is not available on this instance"))
|
||||
return
|
||||
}
|
||||
var workerID *uuid.UUID
|
||||
@@ -58,7 +58,7 @@ func (h *Handler) AdminFleetDecisions(c *gin.Context) {
|
||||
}
|
||||
rows, err := h.AdminFleetRepo.Decisions(c.Request.Context(), c.Query("kind"), workerID, limit)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": rows})
|
||||
@@ -69,12 +69,12 @@ func (h *Handler) AdminFleetDecisions(c *gin.Context) {
|
||||
// GET /admin/fleet/dedicated
|
||||
func (h *Handler) AdminFleetDedicated(c *gin.Context) {
|
||||
if h.AdminFleetRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Fleet view is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "fleet view is not available on this instance"))
|
||||
return
|
||||
}
|
||||
rows, err := h.AdminFleetRepo.DedicatedAssignments(c.Request.Context())
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": rows})
|
||||
@@ -96,14 +96,14 @@ func (h *Handler) AdminFleetReleaseIsolatedEgress(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.WorkerRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Worker placement is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "worker placement is not available on this instance"))
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
assignment, err := h.WorkerRepo.GetActiveDedicatedAssignment(ctx, orgID)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
if assignment == nil {
|
||||
|
||||
@@ -81,7 +81,7 @@ func (h *Handler) AdminListOrgAPIKeys(c *gin.Context) {
|
||||
// AdminRevokeOrgAPIKey is DELETE /admin/organizations/:id/api-keys/:keyId.
|
||||
func (h *Handler) AdminRevokeOrgAPIKey(c *gin.Context) {
|
||||
if h.APIKeyService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "API keys are not available on this instance"))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "API keys are not available on this instance"))
|
||||
return
|
||||
}
|
||||
orgID, err := uuid.Parse(c.Param("id"))
|
||||
|
||||
@@ -18,12 +18,12 @@ const scheduledJobEntity models.AuditEntityType = "scheduled_job"
|
||||
// AdminListJobs lists every registered background loop.
|
||||
func (h *Handler) AdminListJobs(c *gin.Context) {
|
||||
if h.JobRuns == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Job registry is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "job registry is not available on this instance"))
|
||||
return
|
||||
}
|
||||
jobs, err := h.JobRuns.List(c.Request.Context())
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
if jobs == nil {
|
||||
@@ -35,7 +35,7 @@ func (h *Handler) AdminListJobs(c *gin.Context) {
|
||||
// AdminRunJob asks the loop that owns the job to run at its next poll.
|
||||
func (h *Handler) AdminRunJob(c *gin.Context) {
|
||||
if h.JobRuns == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Job registry is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "job registry is not available on this instance"))
|
||||
return
|
||||
}
|
||||
name := c.Param("name")
|
||||
@@ -45,7 +45,7 @@ func (h *Handler) AdminRunJob(c *gin.Context) {
|
||||
}
|
||||
found, err := h.JobRuns.RequestRun(c.Request.Context(), name)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
|
||||
@@ -25,7 +25,7 @@ type adminManagedPlanRequest struct {
|
||||
// one (an internal or partner plan), so the picker needs the full list.
|
||||
func (h *Handler) AdminListPlans(c *gin.Context) {
|
||||
if h.SubscriptionService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Plans are not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "plans are not available on this instance"))
|
||||
return
|
||||
}
|
||||
plans, xerr := h.SubscriptionService.ListPlans(c.Request.Context(), false)
|
||||
@@ -44,7 +44,7 @@ func (h *Handler) AdminGetOrgManagedPlan(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.OrganizationService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Organizations are not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "organizations are not available on this instance"))
|
||||
return
|
||||
}
|
||||
managed, xerr := h.OrganizationService.GetManagedPlan(c.Request.Context(), orgID)
|
||||
@@ -67,7 +67,7 @@ func (h *Handler) AdminGrantOrgManagedPlan(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.OrganizationService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Organizations are not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "organizations are not available on this instance"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func (h *Handler) AdminRevokeOrgManagedPlan(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.OrganizationService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Organizations are not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "organizations are not available on this instance"))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ func (h *Handler) AdminGetOrgRisk(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.OrgRiskService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Risk posture is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "risk posture is not available on this instance"))
|
||||
return
|
||||
}
|
||||
risk, xerr := h.OrgRiskService.Get(c.Request.Context(), orgID)
|
||||
@@ -54,7 +54,7 @@ func (h *Handler) AdminSetOrgRiskOverride(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.OrgRiskService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Risk posture is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "risk posture is not available on this instance"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func (h *Handler) AdminClearOrgRiskOverride(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.OrgRiskService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Risk posture is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "risk posture is not available on this instance"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ func (h *Handler) AdminClearOrgRiskSignal(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.OrgRiskService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Risk posture is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "risk posture is not available on this instance"))
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(c.Param("key"))
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// AdminSendOutreach is POST /admin/outreach.
|
||||
func (h *Handler) AdminSendOutreach(c *gin.Context) {
|
||||
if h.AdminOutreachService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, "Admin outreach service not available."))
|
||||
errx.JSON(c, errx.New(errx.Internal, "admin outreach service not available"))
|
||||
return
|
||||
}
|
||||
adminID := middleware.GetAdminUserID(c)
|
||||
@@ -56,7 +56,7 @@ func (h *Handler) AdminSendOutreach(c *gin.Context) {
|
||||
// params; returns the standard {data, pagination} envelope.
|
||||
func (h *Handler) AdminListOutreach(c *gin.Context) {
|
||||
if h.AdminOutreachService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, "Admin outreach service not available."))
|
||||
errx.JSON(c, errx.New(errx.Internal, "admin outreach service not available"))
|
||||
return
|
||||
}
|
||||
var search models.AdminOutreachSearch
|
||||
|
||||
@@ -40,7 +40,7 @@ func parseBoundedLimit(s string, def, max int) int {
|
||||
|
||||
func (h *Handler) adminSendsReady(c *gin.Context) bool {
|
||||
if h.AdminSendsRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Send operations are not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "send operations are not available on this instance"))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -55,7 +55,7 @@ func (h *Handler) AdminInFlightSends(c *gin.Context) {
|
||||
reclaimAfter := time.Duration(config.CampaignSendReclaimAfterMinutes) * time.Minute
|
||||
result, err := h.AdminSendsRepo.InFlight(c.Request.Context(), reclaimAfter, limit)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
@@ -77,7 +77,7 @@ func (h *Handler) AdminListDeadLetters(c *gin.Context) {
|
||||
}
|
||||
result, err := h.AdminSendsRepo.ListDeadLetters(c.Request.Context(), status, cursor, limit)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
@@ -89,7 +89,7 @@ func (h *Handler) AdminReplayDeadLetter(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.AdvancedService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Dead letter replay is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "dead letter replay is not available on this instance"))
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
@@ -99,7 +99,7 @@ func (h *Handler) AdminReplayDeadLetter(c *gin.Context) {
|
||||
}
|
||||
row, err := h.AdminSendsRepo.GetDeadLetter(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
if row == nil {
|
||||
@@ -130,7 +130,7 @@ func (h *Handler) AdminRecentTaskFailures(c *gin.Context) {
|
||||
limit := parseBoundedLimit(c.Query("limit"), 100, 500)
|
||||
rows, err := h.AdminSendsRepo.RecentTaskFailures(c.Request.Context(), limit)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": rows})
|
||||
@@ -143,7 +143,7 @@ func (h *Handler) AdminWebhookHealth(c *gin.Context) {
|
||||
}
|
||||
health, err := h.AdminSendsRepo.WebhookHealth(c.Request.Context(), webhookDeliveryLease)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, health)
|
||||
@@ -152,12 +152,12 @@ func (h *Handler) AdminWebhookHealth(c *gin.Context) {
|
||||
// AdminWebhookReclaim re-queues deliveries stranded in_flight past the lease.
|
||||
func (h *Handler) AdminWebhookReclaim(c *gin.Context) {
|
||||
if h.WebhookRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Webhook delivery is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "webhook delivery is not available on this instance"))
|
||||
return
|
||||
}
|
||||
n, err := h.WebhookRepo.ReclaimStuckDeliveries(c.Request.Context(), webhookDeliveryLease)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
h.audit(c, models.AuditActionUpdate, models.AuditEntityWebhook, nil, map[string]string{
|
||||
@@ -179,7 +179,7 @@ func (h *Handler) AdminListOrgWebhooks(c *gin.Context) {
|
||||
}
|
||||
rows, err := h.AdminSendsRepo.OrgWebhooks(c.Request.Context(), orgID)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": rows})
|
||||
|
||||
@@ -19,7 +19,7 @@ const (
|
||||
// AdminSearchSync is GET /admin/sync: every mailbox's sync governor state.
|
||||
func (h *Handler) AdminSearchSync(c *gin.Context) {
|
||||
if h.AdminSyncRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Sync operations are not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "sync operations are not available on this instance"))
|
||||
return
|
||||
}
|
||||
var search models.AdminSyncSearch
|
||||
@@ -45,7 +45,7 @@ func (h *Handler) AdminSearchSync(c *gin.Context) {
|
||||
// AdminSyncClearThrottle is POST /admin/sync/:id/clear-throttle.
|
||||
func (h *Handler) AdminSyncClearThrottle(c *gin.Context) {
|
||||
if h.AdminSyncRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Sync operations are not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "sync operations are not available on this instance"))
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
@@ -73,7 +73,7 @@ func (h *Handler) AdminSyncClearThrottle(c *gin.Context) {
|
||||
// AdminSyncRestartBackfill is POST /admin/sync/:id/restart-backfill.
|
||||
func (h *Handler) AdminSyncRestartBackfill(c *gin.Context) {
|
||||
if h.AdminSyncRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Sync operations are not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "sync operations are not available on this instance"))
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
|
||||
@@ -92,7 +92,7 @@ func (h *Handler) AdminCreateTester(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.UserRepo == nil || h.OrganizationService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Account creation is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "account creation is not available on this instance"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ func (h *Handler) undoHalfMadeTester(c *gin.Context, userID uuid.UUID, cause *er
|
||||
// is the set an operator needs to review and prune.
|
||||
func (h *Handler) AdminListTesters(c *gin.Context) {
|
||||
if h.UserRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Accounts are not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "accounts are not available on this instance"))
|
||||
return
|
||||
}
|
||||
list, err := h.UserRepo.ListLoginCodeExempt(c.Request.Context())
|
||||
@@ -227,7 +227,7 @@ func (h *Handler) AdminRevokeTester(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.UserRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Accounts are not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "accounts are not available on this instance"))
|
||||
return
|
||||
}
|
||||
if err := h.UserRepo.SetLoginCodeExempt(c.Request.Context(), userID, false, "", nil); err != nil {
|
||||
|
||||
@@ -27,7 +27,7 @@ func (h *Handler) adminTransferOrg(c *gin.Context) (uuid.UUID, bool) {
|
||||
}
|
||||
org, err := h.OrgRepo.GetByID(c.Request.Context(), orgID)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return uuid.Nil, false
|
||||
}
|
||||
if org == nil {
|
||||
@@ -35,7 +35,7 @@ func (h *Handler) adminTransferOrg(c *gin.Context) (uuid.UUID, bool) {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
if h.OrgTransferService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Workspace transfer is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "Workspace transfer is not available on this instance."))
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return orgID, true
|
||||
|
||||
@@ -55,10 +55,29 @@ func (h *Handler) UpdateOutreachSettings(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) GetCampaignAdvancedSettings(c *gin.Context) {
|
||||
// ownedCampaign resolves the :id campaign for the advanced-outreach routes and
|
||||
// proves it belongs to the caller's organization. The route carries only a
|
||||
// campaign id, so this is the only thing that ties the request to a tenant:
|
||||
// every handler here must go through it rather than parsing the param itself.
|
||||
func (h *Handler) ownedCampaign(c *gin.Context) (uuid.UUID, *errx.Error) {
|
||||
campaignID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.ErrUuid)
|
||||
return uuid.Nil, errx.ErrUuid
|
||||
}
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
return uuid.Nil, errx.New(errx.BadRequest, "no organization selected")
|
||||
}
|
||||
if _, xerr := h.CampaignService.Get(c.Request.Context(), orgID.String(), campaignID.String()); xerr != nil {
|
||||
return uuid.Nil, xerr
|
||||
}
|
||||
return campaignID, nil
|
||||
}
|
||||
|
||||
func (h *Handler) GetCampaignAdvancedSettings(c *gin.Context) {
|
||||
campaignID, cerr := h.ownedCampaign(c)
|
||||
if cerr != nil {
|
||||
errx.JSON(c, cerr)
|
||||
return
|
||||
}
|
||||
settings, xerr := h.AdvancedService.GetCampaignSettings(c.Request.Context(), campaignID)
|
||||
@@ -70,9 +89,9 @@ func (h *Handler) GetCampaignAdvancedSettings(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateCampaignAdvancedSettings(c *gin.Context) {
|
||||
campaignID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.ErrUuid)
|
||||
campaignID, cerr := h.ownedCampaign(c)
|
||||
if cerr != nil {
|
||||
errx.JSON(c, cerr)
|
||||
return
|
||||
}
|
||||
var req models.UpsertOutreachSettingsRequest
|
||||
@@ -94,9 +113,9 @@ func (h *Handler) UpdateCampaignAdvancedSettings(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) ListCampaignABVariants(c *gin.Context) {
|
||||
campaignID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.ErrUuid)
|
||||
campaignID, cerr := h.ownedCampaign(c)
|
||||
if cerr != nil {
|
||||
errx.JSON(c, cerr)
|
||||
return
|
||||
}
|
||||
variants, xerr := h.AdvancedService.ListABVariants(c.Request.Context(), campaignID)
|
||||
@@ -108,9 +127,9 @@ func (h *Handler) ListCampaignABVariants(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) CreateCampaignABVariant(c *gin.Context) {
|
||||
campaignID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.ErrUuid)
|
||||
campaignID, cerr := h.ownedCampaign(c)
|
||||
if cerr != nil {
|
||||
errx.JSON(c, cerr)
|
||||
return
|
||||
}
|
||||
var req models.CreateCampaignABVariantRequest
|
||||
@@ -135,9 +154,9 @@ func (h *Handler) CreateCampaignABVariant(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateCampaignABVariant(c *gin.Context) {
|
||||
campaignID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.ErrUuid)
|
||||
campaignID, cerr := h.ownedCampaign(c)
|
||||
if cerr != nil {
|
||||
errx.JSON(c, cerr)
|
||||
return
|
||||
}
|
||||
variantID, err := uuid.Parse(c.Param("variantId"))
|
||||
@@ -165,9 +184,9 @@ func (h *Handler) UpdateCampaignABVariant(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteCampaignABVariant(c *gin.Context) {
|
||||
campaignID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.ErrUuid)
|
||||
campaignID, cerr := h.ownedCampaign(c)
|
||||
if cerr != nil {
|
||||
errx.JSON(c, cerr)
|
||||
return
|
||||
}
|
||||
variantID, err := uuid.Parse(c.Param("variantId"))
|
||||
|
||||
@@ -33,7 +33,7 @@ const advisorReadMaxAge = 30 * time.Minute
|
||||
// advisorOrg resolves the caller's org, or writes the error.
|
||||
func (h *Handler) advisorOrg(c *gin.Context) (uuid.UUID, bool) {
|
||||
if h.AdvisorService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "The Advisor is not configured on this server."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the Advisor is not configured on this server"))
|
||||
return uuid.Nil, false
|
||||
}
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
@@ -140,7 +140,7 @@ func (h *Handler) RefreshAdvisor(c *gin.Context) {
|
||||
// request is safe without an idempotency key.
|
||||
func (h *Handler) ApplyAdvisorFinding(c *gin.Context) {
|
||||
if h.AdvisorService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "The Advisor is not configured on this server."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the Advisor is not configured on this server"))
|
||||
return
|
||||
}
|
||||
inv, xerr := h.jwtInvocation(c)
|
||||
@@ -169,7 +169,7 @@ func (h *Handler) ApplyAdvisorFinding(c *gin.Context) {
|
||||
// and reports what it actually called rather than only what it says it did.
|
||||
func (h *Handler) AgentFixAdvisorFinding(c *gin.Context) {
|
||||
if h.AdvisorService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "The Advisor is not configured on this server."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the Advisor is not configured on this server"))
|
||||
return
|
||||
}
|
||||
inv, xerr := h.jwtInvocation(c)
|
||||
@@ -194,7 +194,7 @@ func (h *Handler) AgentFixAdvisorFinding(c *gin.Context) {
|
||||
// UndoAdvisorFinding — POST /advisor/recommendations/:id/undo
|
||||
func (h *Handler) UndoAdvisorFinding(c *gin.Context) {
|
||||
if h.AdvisorService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "The Advisor is not configured on this server."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the Advisor is not configured on this server"))
|
||||
return
|
||||
}
|
||||
inv, xerr := h.jwtInvocation(c)
|
||||
|
||||
@@ -50,7 +50,7 @@ func (h *Handler) agentToolInvocation(c *gin.Context) (aitools.Invocation, *errx
|
||||
// array or inside a Hermes <tools> block.
|
||||
func (h *Handler) ListAgentTools(c *gin.Context) {
|
||||
if h.AITools == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "AI tools are not available"))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI tools are not available"))
|
||||
return
|
||||
}
|
||||
inv, xerr := h.agentToolInvocation(c)
|
||||
@@ -102,7 +102,7 @@ func (h *Handler) ListAgentTools(c *gin.Context) {
|
||||
// tool returned JSON (they all do today) and as a string otherwise.
|
||||
func (h *Handler) CallAgentTool(c *gin.Context) {
|
||||
if h.AITools == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "AI tools are not available"))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI tools are not available"))
|
||||
return
|
||||
}
|
||||
inv, xerr := h.agentToolInvocation(c)
|
||||
|
||||
@@ -49,7 +49,7 @@ func (h *Handler) jwtInvocation(c *gin.Context) (aitools.Invocation, *errx.Error
|
||||
// CreateAgentSession — POST /ai/sessions
|
||||
func (h *Handler) CreateAgentSession(c *gin.Context) {
|
||||
if h.AIAgentService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "The AI assistant is not configured."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
|
||||
return
|
||||
}
|
||||
inv, xerr := h.jwtInvocation(c)
|
||||
@@ -74,7 +74,7 @@ func (h *Handler) CreateAgentSession(c *gin.Context) {
|
||||
// ListAgentSessions — GET /ai/sessions (cursor paginated, newest first)
|
||||
func (h *Handler) ListAgentSessions(c *gin.Context) {
|
||||
if h.AIAgentService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "The AI assistant is not configured."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
|
||||
return
|
||||
}
|
||||
inv, xerr := h.jwtInvocation(c)
|
||||
@@ -113,7 +113,7 @@ func (h *Handler) ListAgentSessions(c *gin.Context) {
|
||||
// hydrated transcript (+ any pending approval) so a reopened tab rehydrates.
|
||||
func (h *Handler) AgentSessionMessages(c *gin.Context) {
|
||||
if h.AIAgentService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "The AI assistant is not configured."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
|
||||
return
|
||||
}
|
||||
inv, xerr := h.jwtInvocation(c)
|
||||
@@ -148,7 +148,7 @@ func (h *Handler) AgentSessionMessages(c *gin.Context) {
|
||||
// transcript. Sessions are private to the member, so no extra permission gate.
|
||||
func (h *Handler) DeleteAgentSession(c *gin.Context) {
|
||||
if h.AIAgentService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "The AI assistant is not configured."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
|
||||
return
|
||||
}
|
||||
inv, xerr := h.jwtInvocation(c)
|
||||
@@ -172,7 +172,7 @@ func (h *Handler) DeleteAgentSession(c *gin.Context) {
|
||||
// conversation history in this workspace.
|
||||
func (h *Handler) ClearAgentSessions(c *gin.Context) {
|
||||
if h.AIAgentService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "The AI assistant is not configured."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
|
||||
return
|
||||
}
|
||||
inv, xerr := h.jwtInvocation(c)
|
||||
@@ -191,7 +191,7 @@ func (h *Handler) ClearAgentSessions(c *gin.Context) {
|
||||
// AgentMessage — POST /ai/sessions/:id/messages (SSE)
|
||||
func (h *Handler) AgentMessage(c *gin.Context) {
|
||||
if h.AIAgentService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "The AI assistant is not configured."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
|
||||
return
|
||||
}
|
||||
inv, xerr := h.jwtInvocation(c)
|
||||
@@ -220,14 +220,14 @@ func (h *Handler) AgentMessage(c *gin.Context) {
|
||||
|
||||
emit := sseEmitter(c)
|
||||
if serr := h.AIAgentService.RunMessage(c.Request.Context(), inv, sessionID, req.MessageID, req.Text, req.Page, req.Resource, emit); serr != nil {
|
||||
emit(aiagent.StreamEvent{Type: "error", Code: string(codeIdentifier(serr)), Message: serr.UserMessage()})
|
||||
emit(aiagent.StreamEvent{Type: "error", Code: string(codeIdentifier(serr)), Message: serr.Message})
|
||||
}
|
||||
}
|
||||
|
||||
// AgentApprove — POST /ai/sessions/:id/approve (SSE) resumes a paused run.
|
||||
func (h *Handler) AgentApprove(c *gin.Context) {
|
||||
if h.AIAgentService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "The AI assistant is not configured."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
|
||||
return
|
||||
}
|
||||
inv, xerr := h.jwtInvocation(c)
|
||||
@@ -256,7 +256,7 @@ func (h *Handler) AgentApprove(c *gin.Context) {
|
||||
|
||||
emit := sseEmitter(c)
|
||||
if serr := h.AIAgentService.Resume(c.Request.Context(), inv, sessionID, req.Decision, emit); serr != nil {
|
||||
emit(aiagent.StreamEvent{Type: "error", Code: string(codeIdentifier(serr)), Message: serr.UserMessage()})
|
||||
emit(aiagent.StreamEvent{Type: "error", Code: string(codeIdentifier(serr)), Message: serr.Message})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ func (h *Handler) ApproveAgentDraft(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.AIDraftRepo == nil {
|
||||
errx.Handle(c, errx.NewPublic(errx.ServiceUnavailable, "The inbox agent is not configured."))
|
||||
errx.Handle(c, errx.New(errx.ServiceUnavailable, "the inbox agent is not configured"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ func (h *Handler) DiscardAgentDraft(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.AIDraftRepo == nil {
|
||||
errx.Handle(c, errx.NewPublic(errx.ServiceUnavailable, "The inbox agent is not configured."))
|
||||
errx.Handle(c, errx.New(errx.ServiceUnavailable, "the inbox agent is not configured"))
|
||||
return
|
||||
}
|
||||
ok, err := h.AIDraftRepo.SetDraftStatus(c.Request.Context(), *orgID, draftID, models.AIDraftDiscarded)
|
||||
|
||||
@@ -37,7 +37,7 @@ func (h *Handler) DraftReply(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.AIProvider == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "The AI assistant is not configured."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ func (h *Handler) DraftReply(c *gin.Context) {
|
||||
remaining = bal
|
||||
}
|
||||
}
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "The reply drafter is temporarily unavailable. Your credits were not charged."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "The reply drafter is temporarily unavailable. Your credits were not charged."))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ func (h *Handler) aiActorInvocation(c *gin.Context) (aitools.Invocation, *errx.E
|
||||
// ResearchContact — POST /contacts/:id/research (sync)
|
||||
func (h *Handler) ResearchContact(c *gin.Context) {
|
||||
if h.ResearchService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "AI research is not configured"))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI research is not configured"))
|
||||
return
|
||||
}
|
||||
inv, xerr := h.aiActorInvocation(c)
|
||||
@@ -70,7 +70,7 @@ func (h *Handler) ResearchContact(c *gin.Context) {
|
||||
// ListContactResearch — GET /contacts/:id/research
|
||||
func (h *Handler) ListContactResearch(c *gin.Context) {
|
||||
if h.ResearchService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "AI research is not configured"))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI research is not configured"))
|
||||
return
|
||||
}
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
@@ -99,7 +99,7 @@ func (h *Handler) ListContactResearch(c *gin.Context) {
|
||||
// BatchResearch — POST /contacts/research/batch
|
||||
func (h *Handler) BatchResearch(c *gin.Context) {
|
||||
if h.ResearchService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "AI research is not configured"))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI research is not configured"))
|
||||
return
|
||||
}
|
||||
inv, xerr := h.aiActorInvocation(c)
|
||||
|
||||
@@ -97,7 +97,7 @@ func (h *Handler) UploadCampaignAttachment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.Storage == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Object storage not configured."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "object storage not configured"))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +162,12 @@ func (h *Handler) GetUser(c *gin.Context) {
|
||||
// Scoped to the session's current organization, not the caller: labels
|
||||
// are workspace assets, so a teammate must see what the owner created
|
||||
// (issue #436). A session with no workspace selected gets empty lists.
|
||||
// Admin routes require a second factor, so the panel has to be able to say
|
||||
// so before it makes a call that 403s.
|
||||
if sess := middleware.GetSession(c); sess != nil {
|
||||
u.SessionMFAVerified = sess.MFAVerified
|
||||
}
|
||||
|
||||
u.Folders, u.Tags, u.Categories = []models.Group{}, []models.Group{}, []models.Group{}
|
||||
if orgID := middleware.GetOrganizationID(c); orgID != nil {
|
||||
if folders, ferr := h.FolderService.List(ctx, *orgID); ferr == nil {
|
||||
|
||||
@@ -106,7 +106,7 @@ func (h *Handler) ssoCallback(c *gin.Context, in auth.SSOCallback) {
|
||||
|
||||
handoff, err := h.AuthService.SSOCallbackComplete(c.Request.Context(), in)
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, base+"/auth/login?sso_error="+url.QueryEscape(err.UserMessage()))
|
||||
c.Redirect(http.StatusFound, base+"/auth/login?sso_error="+url.QueryEscape(err.Message))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -348,7 +348,7 @@ func readAvatarUpload(c *gin.Context) ([]byte, string, string, *errx.Error) {
|
||||
|
||||
func putPublicObject(ctx context.Context, store storage.Store, key string, body []byte, mime string) (string, *errx.Error) {
|
||||
if store == nil {
|
||||
return "", errx.NewPublic(errx.ServiceUnavailable, "Object storage not configured.")
|
||||
return "", errx.New(errx.ServiceUnavailable, "object storage not configured")
|
||||
}
|
||||
// Public-read URL with long-lived cache. The backend chooses the right
|
||||
// semantics per store (S3 sets an ACL + s3 URL; filesystem writes and
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
)
|
||||
|
||||
// billingReturnURL turns a client-supplied Stripe return URL into one that can
|
||||
// only point back at this instance's dashboard.
|
||||
//
|
||||
// Stripe redirects the customer to whatever success_url, cancel_url or
|
||||
// return_url the session was created with, so accepting the value verbatim
|
||||
// made every billing endpoint an open redirect laundered through
|
||||
// checkout.stripe.com, which is about as trustworthy a hop as a phishing link
|
||||
// can get. Every shipped client already sends a same-origin path, so pinning
|
||||
// the origin here changes nothing a real client does.
|
||||
//
|
||||
// A path is kept, so "which page did you come from" still works. Anything else
|
||||
// falls back to the dashboard root.
|
||||
func billingReturnURL(raw, fallbackPath string) string {
|
||||
base := strings.TrimRight(config.AppBaseURL(), "/")
|
||||
raw = strings.TrimSpace(raw)
|
||||
|
||||
// A deployment that never configured its own address has no origin to pin
|
||||
// to, and a relative fallback is not a URL Stripe will accept: returning one
|
||||
// would take checkout out entirely. Hand back what the caller sent, which is
|
||||
// the behaviour before this function existed.
|
||||
if base == "" {
|
||||
return raw
|
||||
}
|
||||
|
||||
fallback := base + fallbackPath
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
// A relative path is the common case and needs no parsing beyond refusing
|
||||
// the "//evil.example" form, which a browser reads as a protocol-relative
|
||||
// absolute URL.
|
||||
if strings.HasPrefix(raw, "/") && !strings.HasPrefix(raw, "//") {
|
||||
return base + raw
|
||||
}
|
||||
if base != "" && strings.HasPrefix(raw, base+"/") {
|
||||
return raw
|
||||
}
|
||||
if raw == base {
|
||||
return raw
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Access logging for the two internal endpoints that hand out something worth
|
||||
// more than a record: the data-key decrypt broker and the blob presigner.
|
||||
//
|
||||
// CASA 6.7.1 asks that access to server-side secrets be logged or monitored,
|
||||
// and these were the two that were silent. They are also the two where a
|
||||
// refusal is the clearest probe signal the instance produces: a node asks for
|
||||
// keys it owns and prefixes it uses, so a rejected key or a decrypt that fails
|
||||
// is either a misconfigured node or somebody holding the internal token and
|
||||
// looking around. Either way an operator should be able to see it.
|
||||
//
|
||||
// Deliberately not logged: the ciphertext, the plaintext key, and the signed
|
||||
// URL. What is recorded is who asked, for what shape of thing, and whether it
|
||||
// was allowed, which is what makes a pattern visible without the log itself
|
||||
// becoming the leak.
|
||||
func logBrokerAccess(c *gin.Context, operation, subject string, allowed bool, reason string) {
|
||||
ev := log.Info()
|
||||
if !allowed {
|
||||
ev = log.Warn()
|
||||
}
|
||||
ev.
|
||||
Str("event", "broker_access").
|
||||
Str("operation", operation).
|
||||
Str("subject", subject).
|
||||
Bool("allowed", allowed).
|
||||
Str("client_ip", c.ClientIP()).
|
||||
Str("request_id", c.GetHeader("X-Request-Id")).
|
||||
Str("reason", reason).
|
||||
Msg("internal broker access")
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
func (h *Handler) cliAuthReady(c *gin.Context) bool {
|
||||
if h.CLIAuthService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "CLI sign-in is not enabled on this instance"))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "CLI sign-in is not enabled on this instance"))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
func (h *Handler) cloudLinkReady(c *gin.Context) bool {
|
||||
if h.CloudLinkService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Cloud link is not enabled on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "cloud link is not enabled on this instance"))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -43,7 +43,7 @@ func (h *Handler) DraftCompose(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.AIProvider == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "The AI assistant is not configured."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the AI assistant is not configured"))
|
||||
return
|
||||
}
|
||||
if allowed, xerr := h.FeatureGateService.CanUseUnibox(c.Request.Context(), *orgID); xerr != nil {
|
||||
@@ -126,7 +126,7 @@ func (h *Handler) DraftCompose(c *gin.Context) {
|
||||
remaining = bal
|
||||
}
|
||||
}
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "The email drafter is temporarily unavailable. Your credits were not charged."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "The email drafter is temporarily unavailable. Your credits were not charged."))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ func (h *Handler) LookupContactByEmail(c *gin.Context) {
|
||||
// ListContactEmails returns one row per email we sent (or tried to
|
||||
// send) to the contact. Cursor pagination keyed on the task ID.
|
||||
func (h *Handler) ListContactEmails(c *gin.Context) {
|
||||
userID, err := middleware.GetUserUUID(c)
|
||||
if err != nil {
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.Handle(c, errx.ErrAuth)
|
||||
return
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func (h *Handler) ListContactEmails(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
res, xerr := h.ContactService.ListSentEmails(c.Request.Context(), userID, contactID, limit, beforeAt, beforeID)
|
||||
res, xerr := h.ContactService.ListSentEmails(c.Request.Context(), *orgID, contactID, limit, beforeAt, beforeID)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
|
||||
@@ -28,7 +28,7 @@ func (h *Handler) GetCreditBalance(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.CreditService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Credits are not available."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "credits are not available"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -127,7 +127,9 @@ func (h *Handler) CreateCreditCheckoutSession(c *gin.Context) {
|
||||
}
|
||||
|
||||
session, xerr := h.StripeService.CreateCreditCheckoutSession(
|
||||
c.Request.Context(), uid, *orgID, pack.Key, pack.Credits, req.SuccessURL, req.CancelURL,
|
||||
c.Request.Context(), uid, *orgID, pack.Key, pack.Credits,
|
||||
billingReturnURL(req.SuccessURL, "/app/settings/billing?credits=done"),
|
||||
billingReturnURL(req.CancelURL, "/app/settings/billing"),
|
||||
)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
@@ -149,7 +151,7 @@ func (h *Handler) ListCreditTransactions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.CreditService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Credits are not available."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "credits are not available"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -198,7 +200,7 @@ func (h *Handler) GetCreditUsage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.CreditService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Credits are not available."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "credits are not available"))
|
||||
return
|
||||
}
|
||||
days := 30
|
||||
@@ -227,7 +229,7 @@ func (h *Handler) GetCreditSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.CreditService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Credits are not available."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "credits are not available"))
|
||||
return
|
||||
}
|
||||
cfg, xerr := h.CreditService.GetSpendSettings(c.Request.Context(), *orgID)
|
||||
@@ -247,7 +249,7 @@ func (h *Handler) UpdateCreditSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.CreditService == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Credits are not available."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "credits are not available"))
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
|
||||
@@ -303,15 +303,11 @@ func (h *Handler) UpdateEmailTrackingDomain(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteEmail(c *gin.Context) {
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
||||
return
|
||||
}
|
||||
userIDStr := middleware.GetUserID(c)
|
||||
|
||||
emailAccountID := c.Param("id")
|
||||
|
||||
if err := h.EmailService.Delete(c.Request.Context(), orgID.String(), emailAccountID); err != nil {
|
||||
if err := h.EmailService.Delete(c.Request.Context(), userIDStr, emailAccountID); err != nil {
|
||||
errx.Handle(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -66,11 +66,11 @@ func (h *Handler) UploadEmailImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.Storage == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Object storage not configured."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "object storage not configured"))
|
||||
return
|
||||
}
|
||||
if h.EmailImageRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Image library not available."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "image library not available"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ func (h *Handler) DeleteEmailImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.EmailImageRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "Image library not available."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "image library not available"))
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
|
||||
@@ -128,6 +128,13 @@ func (h *Handler) renderOAuthCallback(c *gin.Context, provider string) {
|
||||
data.Status = "Connection cancelled."
|
||||
}
|
||||
|
||||
// This page is one inline script that hands the code to the opener and
|
||||
// closes. It loads nothing and submits nothing, so the policy says so;
|
||||
// 'unsafe-inline' covers the script that is the page itself.
|
||||
// Cross-Origin-Opener-Policy is relaxed here because talking to the
|
||||
// opener is the whole job, and the message is addressed to one origin.
|
||||
c.Header("Content-Security-Policy", "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
|
||||
c.Header("Cross-Origin-Opener-Policy", "unsafe-none")
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.Status(http.StatusOK)
|
||||
_ = callbackPage.Execute(c.Writer, data)
|
||||
|
||||
@@ -19,12 +19,12 @@ type emailSyncResponse struct {
|
||||
|
||||
// GetEmailSync reports a mailbox's sync progress and fair-use status.
|
||||
func (h *Handler) GetEmailSync(c *gin.Context) {
|
||||
userID, err := middleware.GetUserUUID(c)
|
||||
if err != nil {
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.JSON(c, errx.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
state, policy, xerr := h.EmailService.GetSyncState(c.Request.Context(), userID.String(), c.Param("id"))
|
||||
state, policy, xerr := h.EmailService.GetSyncState(c.Request.Context(), orgID.String(), c.Param("id"))
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/observability/errs"
|
||||
)
|
||||
|
||||
// The fleet is pull-based. A node joins with the instance token, gets the
|
||||
@@ -66,7 +67,7 @@ type fleetJoinResponse struct {
|
||||
// credentials yet — that is the whole point.
|
||||
func (h *Handler) FleetJoin(c *gin.Context) {
|
||||
if h.FleetNodes == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Fleet enrolment is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "fleet enrolment is not available on this instance"))
|
||||
return
|
||||
}
|
||||
var req fleetJoinRequest
|
||||
@@ -161,7 +162,12 @@ func (h *Handler) FleetHeartbeat(c *gin.Context) {
|
||||
case errors.Is(err, fleetnode.ErrRoleChanged):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
// The heartbeat is answered to a machine, not a person, and the
|
||||
// caller authenticates with a node credential rather than an
|
||||
// operator session. The detail goes to the log where an operator
|
||||
// reads it; the node only needs to know to retry.
|
||||
errs.CaptureException(err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "heartbeat could not be recorded"})
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -380,7 +386,7 @@ func renderNodeEnv(nodeID uuid.UUID, role models.NodeRole, region string) string
|
||||
// each is on, what it should be on, and what it is using.
|
||||
func (h *Handler) AdminFleetNodes(c *gin.Context) {
|
||||
if h.FleetNodes == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Fleet enrolment is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "fleet enrolment is not available on this instance"))
|
||||
return
|
||||
}
|
||||
role := models.NodeRole(c.Query("role"))
|
||||
@@ -390,7 +396,7 @@ func (h *Handler) AdminFleetNodes(c *gin.Context) {
|
||||
}
|
||||
nodes, err := h.FleetNodes.List(c.Request.Context(), role)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
sort.SliceStable(nodes, func(i, j int) bool { return nodes[i].Role < nodes[j].Role })
|
||||
@@ -401,12 +407,12 @@ func (h *Handler) AdminFleetNodes(c *gin.Context) {
|
||||
// once. Issuing replaces the previous one, which is also how it is revoked.
|
||||
func (h *Handler) AdminFleetIssueJoinToken(c *gin.Context) {
|
||||
if h.FleetNodes == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Fleet enrolment is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "fleet enrolment is not available on this instance"))
|
||||
return
|
||||
}
|
||||
token, err := h.FleetNodes.IssueJoinToken(c.Request.Context())
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
h.audit(c, "fleet_join_token_issued", models.AuditEntityWorker, nil, nil)
|
||||
@@ -426,7 +432,7 @@ type setTagsBody struct {
|
||||
func (h *Handler) AdminListWorkerTags(c *gin.Context) {
|
||||
tags, err := h.WorkerRepo.ListAllWorkerTags(c.Request.Context())
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": tags})
|
||||
@@ -488,7 +494,7 @@ func (h *Handler) AdminFleetReserveWorker(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.WorkerRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Worker placement is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "worker placement is not available on this instance"))
|
||||
return
|
||||
}
|
||||
var body reserveWorkerBody
|
||||
@@ -510,7 +516,7 @@ func (h *Handler) AdminFleetReserveWorker(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
w, err := h.WorkerRepo.GetWorkerDetail(ctx, id)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
if w == nil {
|
||||
@@ -551,11 +557,11 @@ func (h *Handler) AdminFleetDeleteNode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.FleetNodeRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Fleet enrolment is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "fleet enrolment is not available on this instance"))
|
||||
return
|
||||
}
|
||||
if err := h.FleetNodeRepo.Delete(c.Request.Context(), id); err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
h.audit(c, models.AuditActionDelete, models.AuditEntityWorker, &id, nil)
|
||||
@@ -582,7 +588,7 @@ func (h *Handler) AdminFleetPatchNode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.FleetNodeRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Fleet enrolment is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "fleet enrolment is not available on this instance"))
|
||||
return
|
||||
}
|
||||
var body patchNodeBody
|
||||
@@ -595,21 +601,21 @@ func (h *Handler) AdminFleetPatchNode(c *gin.Context) {
|
||||
changed := map[string]string{}
|
||||
if body.Name != nil {
|
||||
if err := h.FleetNodeRepo.SetName(ctx, id, *body.Name); err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
changed["name"] = *body.Name
|
||||
}
|
||||
if body.Notes != nil {
|
||||
if err := h.FleetNodeRepo.SetNotes(ctx, id, *body.Notes); err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
changed["notes"] = *body.Notes
|
||||
}
|
||||
if body.PinnedVersion != nil {
|
||||
if err := h.FleetNodeRepo.SetPinnedVersion(ctx, id, *body.PinnedVersion); err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
changed["pinned_version"] = *body.PinnedVersion
|
||||
@@ -622,7 +628,7 @@ func (h *Handler) AdminFleetPatchNode(c *gin.Context) {
|
||||
h.audit(c, models.AuditActionUpdate, models.AuditEntityWorker, &id, changed)
|
||||
node, err := h.FleetNodes.Get(ctx, id)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, node)
|
||||
@@ -632,12 +638,12 @@ func (h *Handler) AdminFleetPatchNode(c *gin.Context) {
|
||||
// converge on.
|
||||
func (h *Handler) AdminFleetRelease(c *gin.Context) {
|
||||
if h.FleetSettingsRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Fleet enrolment is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "fleet enrolment is not available on this instance"))
|
||||
return
|
||||
}
|
||||
state, err := h.FleetSettingsRepo.GetRelease(c.Request.Context())
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
if state == nil {
|
||||
@@ -656,7 +662,7 @@ type setReleaseBody struct {
|
||||
// rollback.
|
||||
func (h *Handler) AdminFleetSetRelease(c *gin.Context) {
|
||||
if h.FleetSettingsRepo == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "Fleet enrolment is not available on this instance."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "fleet enrolment is not available on this instance"))
|
||||
return
|
||||
}
|
||||
var body setReleaseBody
|
||||
@@ -668,7 +674,7 @@ func (h *Handler) AdminFleetSetRelease(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
state, err := h.FleetSettingsRepo.GetRelease(ctx)
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
if state == nil {
|
||||
@@ -694,7 +700,7 @@ func (h *Handler) AdminFleetSetRelease(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := h.FleetSettingsRepo.SetRelease(ctx, state); err != nil {
|
||||
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
||||
errx.JSON(c, errx.NewPublic(errx.Internal, err.Error()))
|
||||
return
|
||||
}
|
||||
h.audit(c, "fleet_release_set", models.AuditEntityWorker, nil, map[string]string{
|
||||
|
||||
@@ -88,7 +88,7 @@ func (h *Handler) GenerateWriting(c *gin.Context) {
|
||||
|
||||
// Provider must be configured.
|
||||
if h.WritingGenerator == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "AI writing assistant is not configured."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI writing assistant is not configured."))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -149,10 +149,10 @@ func (h *Handler) GenerateWriting(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if errors.Is(gerr, generation.ErrNotConfigured) {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "AI writing assistant is not configured."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI writing assistant is not configured."))
|
||||
return
|
||||
}
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "The writing assistant is temporarily unavailable. Your credit was not charged."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "The writing assistant is temporarily unavailable. Your credit was not charged."))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ func (h *Handler) GenerateAIVariable(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.AIProvider == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "AI generation is not configured."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI generation is not configured."))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ func (h *Handler) GenerateAIVariable(c *gin.Context) {
|
||||
remaining = bal
|
||||
}
|
||||
}
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "AI generation is temporarily unavailable. Your credit was not charged."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI generation is temporarily unavailable. Your credit was not charged."))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ func (h *Handler) GenerateEdit(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if h.AIProvider == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "AI writing assistant is not configured."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI writing assistant is not configured."))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -159,10 +159,10 @@ func (h *Handler) GenerateEdit(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if errors.Is(gerr, generation.ErrNotConfigured) {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "AI writing assistant is not configured."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI writing assistant is not configured."))
|
||||
return
|
||||
}
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "The writing assistant is temporarily unavailable. Your credit was not charged."))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "The writing assistant is temporarily unavailable. Your credit was not charged."))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ func (h *Handler) StartIntegrationOAuth(c *gin.Context) {
|
||||
resp, err := h.IntegrationService.OAuthStart(c.Request.Context(), orgID, userID, provider, p.Label)
|
||||
if err != nil {
|
||||
if errors.Is(err, integration.ErrOAuthNotConfigured) {
|
||||
errx.JSON(c, errx.NewPublic(errx.NotImplemented, "This provider isn't available yet — OAuth credentials are not configured on the server."))
|
||||
errx.JSON(c, errx.New(errx.NotImplemented, "This provider isn't available yet — OAuth credentials are not configured on the server."))
|
||||
return
|
||||
}
|
||||
errx.JSON(c, errx.New(errx.BadRequest, err.Error()))
|
||||
@@ -249,6 +249,12 @@ func (h *Handler) IntegrationOAuthCallback(c *gin.Context) {
|
||||
}
|
||||
// json.Marshal escapes <, >, & so the blob is safe to inline in <script>.
|
||||
blob, _ := json.Marshal(payload)
|
||||
// The message carries a live authorization code, so it is addressed to this
|
||||
// instance's dashboard origin rather than "*": a page that opens this popup
|
||||
// must not be able to read the code out of it. Falling back to "*" when the
|
||||
// origin is unconfigured would reinstate exactly that, so an unconfigured
|
||||
// origin delivers nothing instead.
|
||||
originBlob, _ := json.Marshal(callbackTargetOrigin())
|
||||
html := `<!doctype html><html><head><meta charset="utf-8"><title>Connecting…</title></head>
|
||||
<body style="font-family:system-ui;background:#f8fafc;color:#0f172a;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
|
||||
<div style="text-align:center">
|
||||
@@ -257,11 +263,19 @@ func (h *Handler) IntegrationOAuthCallback(c *gin.Context) {
|
||||
<script>
|
||||
(function(){
|
||||
var msg = ` + string(blob) + `;
|
||||
try { if (window.opener) { window.opener.postMessage(msg, "*"); } } catch (e) {}
|
||||
var origin = ` + string(originBlob) + `;
|
||||
try { if (window.opener && origin) { window.opener.postMessage(msg, origin); } } catch (e) {}
|
||||
setTimeout(function(){ window.close(); }, 300);
|
||||
})();
|
||||
</script>
|
||||
</body></html>`
|
||||
// This page is one inline script that hands the code to the opener and
|
||||
// closes. It loads nothing and submits nothing, so the policy says so;
|
||||
// 'unsafe-inline' covers the script that is the page itself.
|
||||
// Cross-Origin-Opener-Policy is relaxed here because talking to the
|
||||
// opener is the whole job, and the message is addressed to one origin.
|
||||
c.Header("Content-Security-Policy", "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
|
||||
c.Header("Cross-Origin-Opener-Policy", "unsafe-none")
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, html)
|
||||
}
|
||||
|
||||
@@ -93,11 +93,15 @@ func (h *Handler) InternalPresignBlob(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if !keyAllowedForNode(req.Key) {
|
||||
// The clearest probe signal the instance produces: a real node only
|
||||
// ever asks for prefixes it uses.
|
||||
logBrokerAccess(c, "blob.presign."+string(op), req.Key, false, "key outside the node prefixes")
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "key " + req.Key + " is outside the prefixes a node may reach",
|
||||
})
|
||||
return
|
||||
}
|
||||
logBrokerAccess(c, "blob.presign."+string(op), req.Key, true, "")
|
||||
|
||||
url, err := h.Storage.PresignedURL(c.Request.Context(), op, req.Key, req.ContentType, blobPresignTTL)
|
||||
if err != nil {
|
||||
|
||||
@@ -130,9 +130,13 @@ func (h *Handler) InternalDecryptDEK(c *gin.Context) {
|
||||
if err != nil {
|
||||
// The reason is not echoed: this answers an unauthenticated-by-org
|
||||
// caller, and KMS errors distinguish "not a key of ours" from "malformed",
|
||||
// which is exactly what a prober wants to learn.
|
||||
// which is exactly what a prober wants to learn. It is recorded, though:
|
||||
// a run of failures here is the signal that someone holding the token is
|
||||
// trying keys.
|
||||
logBrokerAccess(c, "dek.decrypt", "", false, "kms refused the ciphertext")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "could not decrypt data key"})
|
||||
return
|
||||
}
|
||||
logBrokerAccess(c, "dek.decrypt", "", true, "")
|
||||
c.JSON(http.StatusOK, dekDecryptResponse{DataKey: base64.StdEncoding.EncodeToString(key)})
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ func (h *Handler) InternalSubmitForm(c *gin.Context) {
|
||||
}
|
||||
// Message only: the errx prefix ("Bad Request (400):") is for logs,
|
||||
// not for a visitor's inline error.
|
||||
c.JSON(http.StatusBadRequest, formwire.SubmitError{Error: "form_submit_failed", Message: xerr.UserMessage()})
|
||||
c.JSON(http.StatusBadRequest, formwire.SubmitError{Error: "form_submit_failed", Message: xerr.Message})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, formwire.SubmitResult{Message: res.Message, RedirectURL: res.RedirectURL})
|
||||
|
||||
@@ -39,12 +39,17 @@ func (h *Handler) SubmitLimitIncreaseRequest(c *gin.Context) {
|
||||
|
||||
// ListOrgLimitRequests is GET /v1/organizations/:orgId/limit-requests.
|
||||
func (h *Handler) ListOrgLimitRequests(c *gin.Context) {
|
||||
session := middleware.GetSession(c)
|
||||
if session == nil {
|
||||
errx.JSON(c, errx.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
orgID, err := uuid.Parse(c.Param("orgId"))
|
||||
if err != nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "invalid organization ID"))
|
||||
return
|
||||
}
|
||||
rows, xerr := h.OrganizationService.ListLimitRequestsForOrg(c.Request.Context(), orgID)
|
||||
rows, xerr := h.OrganizationService.ListLimitRequestsForOrg(c.Request.Context(), orgID, session.UserID)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
|
||||
@@ -29,7 +29,7 @@ type jsonRPCRequest struct {
|
||||
// MCPEndpoint — POST /api/v1/mcp. Handles one JSON-RPC message.
|
||||
func (h *Handler) MCPEndpoint(c *gin.Context) {
|
||||
if h.AITools == nil {
|
||||
errx.JSON(c, errx.NewPublic(errx.ServiceUnavailable, "MCP is not available"))
|
||||
errx.JSON(c, errx.New(errx.ServiceUnavailable, "MCP is not available"))
|
||||
return
|
||||
}
|
||||
var req jsonRPCRequest
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user