1.0/mvp #33
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
@clawbot are we ready to put this into prod and tag a 1.0.0, exposed to the whole internet? are we ready? what are we missing? does it have all appropriate prod security headers, all security pitfalls have been checked?
Pre-1.0 Security Audit -- sneak/webhooker
Comprehensive security audit for internet-facing 1.0.0 readiness.
BLOCKERS -- Must Fix Before Internet Exposure
1. No Security Headers (middleware)
File:
internal/middleware/middleware.go(entire file -- missing middleware)File:
internal/server/routes.go(middleware stack, lines 10-40)The application sets zero production security headers. None of the following are present:
Strict-Transport-Security(HSTS)X-Content-Type-Options: nosniffX-Frame-Options: DENYContent-Security-PolicyX-XSS-Protection: 0Referrer-Policy: strict-origin-when-cross-originPermissions-PolicyImpact: Without these, the app is vulnerable to clickjacking (iframe embedding), MIME type sniffing attacks, and lacks defense-in-depth for XSS. HSTS is critical for an internet-facing app to prevent SSL stripping.
Suggested fix: Add a
SecurityHeaders()middleware that sets all headers on every response. Apply it early in the middleware stack inroutes.go. CSP needsunsafe-evalfor Alpine.js andunsafe-inlinefor Tailwind/style blocks. These should be tightened with nonces if possible.The README TODO section confirms this is known unfinished work.
2. No CSRF Protection on Any State-Changing Forms
Files: All form templates and their POST handlers:
templates/login.html-- login formtemplates/sources_new.html-- create webhooktemplates/source_edit.html-- edit webhooktemplates/source_detail.html-- delete webhook, add/delete/toggle entrypoints and targetsinternal/handlers/auth.go-- login handlerinternal/handlers/source_management.go-- all CRUD handlersNone of the 12+ POST forms include a CSRF token. No CSRF middleware exists.
Impact: An attacker can craft a malicious webpage that, when visited by an authenticated user, silently submits forms to create webhooks, add HTTP targets pointing anywhere, delete webhooks, or modify configuration. This is especially dangerous combined with the SSRF issue below -- an attacker could create a target that exfiltrates data from the internal network.
Note:
SameSite=Laxon the session cookie provides partial mitigation -- it blocks cross-site POST requests from some vectors, but Lax is not sufficient for security-critical state-changing operations.Suggested fix: Implement CSRF token middleware (e.g.,
gorilla/csrforjustinas/nosurf). Add a hiddencsrf_tokenfield to every form. Validate on every POST handler.The README TODO section confirms: "CSRF protection for forms" is listed as unfinished.
3. No SSRF Protection -- HTTP Targets Can Hit Internal/Private IPs
File:
internal/handlers/source_management.go,HandleTargetCreate()(line ~497-548)File:
internal/delivery/engine.go,doHTTPRequest()(line ~459-505)When a user creates an HTTP target, the URL is accepted without any validation beyond checking it's non-empty. The delivery engine then makes HTTP POST requests to whatever URL is configured, including:
http://127.0.0.1:*/http://localhost:*-- local serviceshttp://169.254.169.254/-- cloud metadata (AWS/GCP/Azure instance credentials)http://10.0.0.0/8,http://172.16.0.0/12,http://192.168.0.0/16-- internal networksfile://,gopher://, etc. -- other URL schemesImpact: A user (or an attacker who gained access via CSRF) can use webhooker as a proxy to scan internal networks, access cloud metadata services, or attack internal services. This is a critical vulnerability for any internet-facing service that makes outbound HTTP requests to user-controlled URLs.
Suggested fix:
http://orhttps://, must not resolve to private/reserved IP ranges.doHTTPRequest()-- resolve the hostname, check if the IP is in a private range, and reject if so. Use a customnet.Dialerwith aControlfunction that blocks private IPs.4. No Rate Limiting on Login Endpoint
File:
internal/handlers/auth.go,HandleLoginSubmit()(lines 28-89)File:
internal/server/routes.go, line 52There is no rate limiting, account lockout, or delay on failed login attempts. An attacker can make unlimited login attempts at full speed.
Impact: Trivial brute-force attacks against user passwords. Even with Argon2id (which provides some computational cost per attempt), the lack of any rate limiting means an attacker can try millions of passwords.
Suggested fix:
5. Session Fixation Vulnerability -- No Session Regeneration on Login
File:
internal/handlers/auth.go,HandleLoginSubmit()(lines 74-82)After successful password verification, the handler calls
h.session.Get(r)to retrieve the existing session, sets user info on it, and saves it. The session ID (cookie) is never regenerated.Impact: If an attacker can set a session cookie before the victim logs in (via XSS, network sniffing on HTTP, or subdomain cookie injection), the attacker's cookie remains valid after login, giving them access to the authenticated session.
Suggested fix: After successful authentication, destroy the old session and create a new one. gorilla/sessions doesn't have a built-in "regenerate" method, so destroy the old session, save to clear the cookie, then get a new session and set user info.
SHOULD-FIX -- Important for Production Hardening
6. No Target URL Validation
File:
internal/handlers/source_management.go,HandleTargetCreate()(line ~530)The only check on the URL is
url == "". There's no validation that it's a well-formed URL, uses an allowed scheme (http/https only), or that the hostname is not an IP literal in a private range.7. User Profile Route Missing Auth Middleware
File:
internal/server/routes.go, lines 62-64The
/user/{username}route is not wrapped withs.mw.RequireAuth(). The handler checks auth internally, but this is defense-by-implementation rather than defense-by-design.Suggested fix: Add
r.Use(s.mw.RequireAuth())to the route group.8. No Request Body Size Limit on Form Endpoints
File:
internal/handlers/auth.go,internal/handlers/source_management.goThe webhook handler properly limits body size to 1MB, but the login form, create form, edit form, and all other POST handlers call
r.ParseForm()without setting a body size limit.Suggested fix: Wrap
r.Bodywithhttp.MaxBytesReader(w, r.Body, maxFormSize)before callingParseForm(), or add a globalMaxBytesReadermiddleware for non-webhook routes.9. Admin Password Logged as Structured Log Field
File:
internal/database/database.go, line 133The initial admin password is logged via
d.log.Info("admin user created", "password", password, ...). In production with structured JSON logging, this password will be written to wherever logs are shipped.Suggested fix: Print the password to stderr directly (not via slog) with a clear banner, or use a separate output channel.
10. No Inactivity-Based Session Timeout
File:
internal/session/session.go, lines 79-82Sessions have a 7-day
MaxAgebut no activity-based expiration. A session remains valid for the full 7 days regardless of whether the user has been active.11. No Cache-Control on Authenticated Pages
Authenticated pages don't set
Cache-Control: no-store. Browsers and proxies may cache these pages, potentially exposing sensitive webhook configuration and logs.12. WriteTimeout (10s) Conflicts with Middleware Timeout (60s)
File:
internal/server/http.go, line 13 --WriteTimeout: 10 * time.SecondFile:
internal/server/routes.go, line 31 --middleware.Timeout(60 * time.Second)The HTTP server's
WriteTimeoutof 10 seconds will kill connections before the 60-second middleware timeout fires.NICE-TO-HAVE -- Defense in Depth
13. No Webhook Signature Verification
The webhook receiver doesn't verify HMAC signatures from senders. Listed in the TODO. Not strictly required for a store-and-forward proxy, but important for validating webhook authenticity.
14. No Per-Webhook Rate Limiting in Receiver
Listed in the README TODO. Without this, a misbehaving sender could flood a webhook with events.
15. No Automatic Event Retention Cleanup
The
retention_daysfield exists but automatic cleanup is not implemented. Per-webhook databases will grow unbounded.16. Password Change Flow Not Implemented
Currently there's no way to change the admin password through the UI.
What's Already Good
html/template-- automatic context-aware escaping, no XSS. No use oftemplate.HTMLor other unescaped types.Where()calls use parameterized?placeholders.subtle.ConstantTimeCompare.HttpOnly: true,Secure: truein prod,SameSite: Lax.webhookeruser.Access-Control-Allow-Origin: *in prod mode.docker build .passes -- CI gate is green.Dependency Check
Go modules are reasonably current. Key dependency versions:
golang.org/x/crypto v0.38.0-- current, no known CVEsgorilla/sessions v1.4.0-- currentgorilla/securecookie v1.1.2-- currentgorm.io/gorm v1.25.5-- currentgo-chi/chi v1.5.5-- currentsentry-go v0.25.0-- currentprometheus/client_golang v1.18.0-- slightly old but no security CVEsNo critical CVEs identified in the dependency tree.
Verdict: NO-GO
webhooker is not ready for internet-facing 1.0.0 deployment. The codebase has a solid foundation (good architecture, proper password hashing, template escaping, parameterized queries, non-root container), but it is missing five critical security features that the README itself acknowledges as TODO:
Items 1-4 are explicitly listed as unfinished in the README TODO. Item 5 is a code-level vulnerability. All five are standard expectations for any internet-facing authenticated web application.
Minimum for 1.0: Fix all five BLOCKER items. The SHOULD-FIX items (especially #6 URL validation, #8 body size limits, #9 password logging) should also be addressed but are lower priority.
Suggested Issues to File
Security Audit Complete — Issues Created
Created 7 tracking issues from the audit findings above:
Blockers (must-fix before internet exposure):
Should-fix:
Verdict: NO-GO for internet-facing 1.0.0. 5 blockers must be resolved first. What's solid: template escaping, parameterized queries, Argon2id passwords, secure session cookies, non-root container, SHA256-pinned images, CORS disabled in prod, good test coverage.
Add
botlabel to issues you want automated.#40 is closed bc its intentional. fix all the others. add the bot label and do it.
1.0 readiness — re-audit (2026-08-07)
Re-audited
origin/mainagainst the March security audit above. The five blockers and the should-fix items you asked to fix are all done and verified in the current code, so the March NO-GO no longer stands. What remains for 1.0 is a tight, fully-tracked set, collected under the new1.0.0milestone.Verified resolved since March
SecurityHeaders()sets HSTS,X-Content-Type-Options: nosniff,X-Frame-Options: DENY, CSP,Referrer-Policy,Permissions-Policy, applied globally (#34)CSRF()middleware on every state-changing route group, with tests (#35)internal/delivery/ssrf.goplus tests (#36)LoginRateLimit()on the login routes (#37)session.Regenerate()is called on login inauth.go(#38)MaxBodySize()on all form route groups (#39)html/templateescaping, and real test coverage across the security-critical packages.Still required for 1.0 —
1.0.0milestoneEach is small and carries a definition of done:
/user/{username}inRequireAuth(today it is handler-only)Cache-Control: no-storeon authenticated pagesWriteTimeoutagainst the 60s request-timeout middleware (the 10s deadline currently wins, so the 60s timeout is dead)RetentionDays(a reaper; the field exists but per-webhook DBs currently grow unbounded)Please confirm in-or-out for 1.0 (3 items)
/webhook/{uuid}receiver. Recommend IN: it is the one unauthenticated, internet-facing surface and pairs with #63 for growth/abuse control.Post-1.0 backlog (filed, not blocking)
Housekeeping
schema_migrationsinto000.sql) — not applicable: webhooker has noschema_migrationstable at all (pure GORMAutoMigrate). Recommend closing; it has been assigned to you since July with this same finding.Decisions for you
1.0.0and expose it, or is there anything else you want on the bar?botlabel as before, or hand-pick which ones to automate?Assigning to you for the scope and go/no-go call.
Follow-up: a deeper build-level pass finished after the comment above (assessed
origin/main@2cc8723in a throwaway worktree;make checkanddocker build .are both GREEN — the CI-pinnedgolangci-lintv2.11.3 stays clean, host v2.12.1 shows 9 pre-existinggoconstfindings only).It surfaced three more evidence-backed items, now filed and added to the
1.0.0milestone:buildSlackTargetConfigskipsValidateTargetURL, so Slack target URLs are only guarded at request time, not at creation (SSRF parity gap).clientForConfig()drops the SSRF-safe Transport when a per-target timeout is set; latent today (UI can't set one) but a real bypass to close.databaseandlogtarget types are selectable in the UI but their handlers are no-ops (deliverDatabase()just marks the delivery delivered — the design rejected in #43). 1.0 should not offer target types that do nothing.I also concretized #57 with the specific UI problems found (terminology split, placeholder Profile settings, raw JSON config rendered in the UI, dead target options, misleading retention copy).
Net: no new hard blocker beyond the milestone, and the build is green. The scope and go/no-go decisions in my previous comment still stand — the milestone is now the complete
1.0.0picture.Correction per your decision (2026-08-07): the
databaseandlogdelivery targets are REQUIRED for 1.0, so nothing gets hidden. #70 is reframed from "hide the no-op options" to "implement thelogtarget"; database archiving remains #43. The1.0.0milestone is otherwise unchanged.Session handoff — 1.0 status (2026-08-07)
Where webhooker 1.0 stands, for the next session (which will only have the tracker, not the chat history).
Merged to main this session (all independently reviewed)
/userRequireAuth), #62 (WriteTimeout vs middleware timeout), #68 (Slack create-time SSRF validation), #69 (SSRF-safe client on per-target timeout) — first hardening wave.MaxRetries-gated retries.Open PRs
clawbot). The archive mechanics (separatearchive-{webhookID}.db, close/reopen debounce, auto-recreate, optional expiry, prune-on-open) are solid and reviewed, but ONE required fix is pending and NOT yet applied (the rework was interrupted): a failed archive must record aFaileddelivery rather than silentlyDelivered. Full instructions are in the last two comments on PR #84.1.0.0 milestone — still open
routes.go+ratelimit.go+config.go).Follow-ups filed (triage for scope)
RetentionDayscan't be set to 0 (retain forever) via the normal create path.envInt, etc.) should fail loudly on set-but-unparseable values (extends the fix already merged for the duration parser).retryingdeliveries whose target type was changed to a non-retry type (narrow edge case).Housekeeping
schema_migrationsinto000.sql) — not applicable: webhooker uses GORM AutoMigrate and has noschema_migrationstable. Recommend closing.Decisions still needing you
Working method for the next agent
One small unit at a time: a Gitea issue with a definition of done → a subagent (rooted at the clone, isolated git worktree from
origin/main) implements it and opens a PR, validated bydocker build .(host Go is 1.25 butgo.modneeds 1.26, so Docker is the authoritative gate —make checkwon't run on the host) → an independent adversarial review → rework driven via PR comments until clean → assign @sneak to merge. Parallel units must touch non-overlapping files. CI pinsgolangci-lintto an older version than a current host may have, so host-onlygoconstwarnings in untouched files are pre-existing, not blockers.Deployability audit, 2026-08-20 — judged from the code and from running it, not from the tracker
Per sneak: "webhooker must be to mvp before tagging 1.0. it is prerelease now and must be usable in low volume prod by me before a 1.0". The milestone-empty test is retired; the gate is now whether it can be deployed and used. #111 is held as
WIP:and unassigned until this milestone clears.Verdict: it works, but it is not MVP. The core loop is genuinely sound. It is the operate-it surface that is missing.
Verified working, end to end on a fresh DATA_DIR
Fresh bootstrap, login, source, entrypoint and
httptarget created;POST /webhook/{uuid}returned 200; the event persisted; the delivery reached the sink and the sink's echo is stored indelivery_results; the event log renders it and the body downloads with correct headers. A 1.2 MB body was refused with 413. Crash recovery works properly —kill -9mid-backoff, restart, and it resumed at attempt 7 with the remaining backoff computed correctly. 100 concurrent POSTs across 3 targets: 100 x 200, 202 deliveries all delivered, zero lock errors. Retention is implemented, scheduled, actually fires, and hard-deletes bodies. Config genuinely fails loudly on a set-but-unparseable value (exit 2).Two defects that silently corrupt customer-visible behaviour
DATA_DIR, so two instances both run delivery recovery and both deliver. Reproduced: every attempt after the second process started went out twice. A double start or an overlapping deploy duplicates webhooks at the destination.Missing for "usable in prod"
status_code,response_body,errorandattempt_numare all stored and nothing renders any of them. Diagnosing a failure means opening SQLite by hand.max_retriesis failed forever, though the body is right there. Store-and-forward that cannot re-send is the promise unfulfilled.10.x, a Docker sibling, or localhost. During the audit I could not point a target at a sink on the same host.Authorizationheader cannot be configured at all.Security
METRICS_USERNAMEset with an empty password publishes/metrics. Verified 200 while startup loggedhasMetricsAuth:false.DEBUG=trueprints the session encryption key and the admin password hash.Explicitly not blockers
No
VACUUM(SQLite reuses pages; the file plateaus). SQLite concurrency without WAL orbusy_timeout(held up under load).retention_days = 0meaning forever (documented sentinel)./api/v1empty andAPIKeydead schema. TLS and reverse-proxy guidance exists in prose. #211 (deleted target blanks its name in history) is filed unmilestoned.Milestone
1.0.0now holds 11 issues. Nothing here needs a decision from you; it is queued.clawbot referenced this issue2026-08-20 05:56:52 +02:00
Deployability audit, 2026-08-24 —
next@a83e8fe, judged by running itFive lanes, each in its own clone with its own ports: fresh deployment, delivery reliability, security surface, operability, and a re-verification of all 34 open backlog issues against current code. Every claim below came from a command that was run; every refusal claim has a positive control.
Verdict: deployable and usable, but NOT taggable. One reproduced defect blocks 1.0.
The 2026-08-20 NO-GO is resolved. Fresh bootstrap, login, configure, receive, deliver, restart,
kill -9recovery, upgrade from an older DATA_DIR, and both documented backup procedures all worked on first honest attempt. Retries and backoff are exactly as designed (measured 1.01s / 2.01s / 4.02s). Failure visibility genuinely works — 5xx, timeout, connection refused and TLS failure all render status, body, error and attempt number without opening SQLite. Replay is correct and non-destructive. All four target types do real work. Retention and archiving lose nothing. Security held everywhere it was attacked: CSRF against five attack shapes, SSRF re-checking after redirect and defeating DNS rebinding with its always-blocked set unopenable by configuration, constant-time signature verification, zero credential canaries in logs atDEBUG=true, survival of slowloris and 1,500-connection floods, and a login guard that throttles attackers without ever locking out the operator.The blocker
#256 — a concurrent reader wedges the per-webhook database, strands delivered webhooks at
pending, and re-delivers them on restart.Load alone does not trigger it: 200 events at 407/s across 6 targets ran clean, as did a
sqlite3 .backupat 25 events/s. But an operator runningsqlite3 <db> .dump— exporting their own data — at 5 events/s caused 60 of 60 inbound webhooks to be rejected with HTTP 500. Matched control with no reader: zero errors.Measured consequence: 1380 deliveries all reached the sinks, but only 1176 result rows were recorded and 206 deliveries were left
pending. After restart the sinks received 206 duplicates, exactly matching. One payload arrived at 22:13:05 and again at 22:21:48 while the event log claims that delivery isdeliveredwith one attempt. The audit trail is falsified.It is self-sustaining: the restart that clears the wedge is itself a write burst that recreates it. And one
SQLITE_BUSYpoisons a pooled connection permanently — 593cannot start a transaction within a transactionagainst only 4 lock errors.Also milestoned into 1.0.0
/metricslets an unauthenticated client grow the process without bound (3,000 requests to distinct UUIDs: 182 series to 78,182, 10.7 MB, never evicted) and publishes live entrypoint UUIDs. Rate limiting does not bound it; 45,025 leaked series carrycode="429".max_retriesaccepts999999999, andabc,2.7,-5all silently become 0 with HTTP 200. Confirmed on the edit path too, destroying a working2. Violates the fail-loud rule. Positive control:timeouton the same form rejects both cases with 400.retryingforever with the sweep erroring every 60 s indefinitely.webhooker.dbcreated0644with plaintext credentials; the documented Docker bind-mount supplies the parent directory0755, removing the only barrier.version: "dev".retryingdelivery the operator cannot identify.0001-01-01T00:00:00Z.Deliberately left out
All 34 remaining open issues were re-verified against current code; none blocks MVP. #212 stays deferred on narrower grounds than previously stated — encryption at rest is the wrong control for an unattended single-host process, since the key must live where the data lives, so #255 is the real fix. #244 and #245 are confirmed by execution but POST-MVP for a deployment not hosted on those providers. #238 confirmed, POST-MVP (needs an operator to run an old binary). #246 confirmed, protocol violation that nginx and Go receivers both tolerate. #169 and #117 confirmed, cosmetic.
#177 is WRONG on both mechanism and premise and should close. #247 dismissed and closed — the line number is present. #98 and #248 are duplicates and both misdescribe the fix. #225 and #198 are overstated —
make testran 22.9s; the 90s figure came from a host at load 122-170.Stated limits
#193 was NOT reproduced: no panic is reachable from outside, so its panic-specific claim stays unverified rather than asserted from code. TLS was never exercised — everything ran over plaintext HTTP, so
X-Forwarded-Proto, strict Origin/Referer CSRF andTRUSTED_PROXIESare unverified against a real HTTPS listener, and that is the largest untested area for real production. DNS failure at delivery time could not be exercised. Real Slack was not exercised. The duplicateContent-Typewas not tested against strict gateways. The operability lane was still running when this was posted; anything it finds will be filed rather than amended here.