Conversation
…-orchestrator into fix/security/Q3-audit-findings-c01-05
Review — v9.5.3 Q3 security audit findingsReviewer: Claude Opus 5 (1M context), model id Disclosure: I authored the SEC-09 and SEC-10 commits and wrote this PR's description. I have weighted the review towards my own changes accordingly, and F2 below is a defect in my own code found by testing it against a real browser for the first time. Findings
No prior review rounds exist on this PR, so numbering starts at F1 and no earlier rating could be lowered. F1 — SEC-06 replay guard is bypassable (high)
So the guard is defeated by changing one integer:
Suggested fix: include only F2 — Hardened browser argument stops Chrome launching (high)
I verified this against a real Chrome 152 (downloaded via
So the argument is valid for Chrome but cannot survive PuppeteerSharp's arg handling. Every PDF export and every PDF notification attachment fails at browser launch. It fails closed rather than open, so it is a functional break rather than an exposure — but it breaks both render paths completely. Suggested fix: drop the argument. The same probe confirms request interception alone already does the job — with the CSP meta removed so only interception could act, both F3 — Eligibility guard escapes the error handling (medium)In F4 — Update check blocks re-saving an element that references the canonical ANY service (medium)The Reasoned from the metadata rather than observed at runtime; it needs a live Hasura to confirm. Worth either excluding the protocol condition from the update check (keeping it on insert, where the value is always user-authored) or extending the C# pre-check to give a clear message. F5 — The Hasura metadata is never validated (medium)This PR makes substantial F6 — Missing help and What's-new updates (low)The request module visibly changes: flow objects an administrator has hidden or retired disappear from the selection dropdowns, and a new error Verified as correct
RecommendationsFix before merge
Fix in this PR if convenient
Worth doing separately
Review process
|
…-orchestrator into fix/security/Q3-audit-findings-c01-05
|



Fixes five findings from the Q3 security audit. Each fix ships with regression tests that were verified to fail against the pre-fix code.
SEC-01 — Auditor could escalate to admin by rewriting its own identity (Critical)
auditorwas the only non-admin role allowed to update thepublic.uiusercolumns that define who an account is:uuid,uiuser_username,tenant_id,ldap_connection_idand the password flags — an outlier, since all ten other non-admin roles haduiuser_languageonly.uiuser.uuidis the DN that login and token refresh resolve against LDAP to derive roles, so rewriting it had the caller's authorization rebuilt as a different subject.uuid UNIQUEconstraint does not prevent this: the post-change lookup is by DN and finds the caller's own row, socreateIfMissing: falsenever trips. Reachable targets are an admin account that has never logged into the UI, or a role-bearing group DN.uiuserare now limited touiuser_languagefor every role exceptmiddleware-server, each permission carries a post-updatecheckequal to itsfilterso a row cannot be moved to another subject, and theauditorfilter's mis-casedX-Hasura-uuidwas normalised.middleware-serveris left byte-identical; it legitimately writes identity during login.SEC-04 — LDAP connection test carried credentials in a GET body (High)
auditorhas no password columns, the endpoint is Admin-only,TestConnectionbinds withdecryptPassword: false, and certificate plus hostname validation is enforced inValidateLdapServerCertificate.WithoutSecretsvariants.[HttpGet]with[FromBody]. A GET carrying a body is handled inconsistently by proxies and HTTP clients and risks being cached or logged. It is now[HttpPost], withMiddlewareClientswitched toMethod.Post.SEC-06 — Workflow state-change actions could be replayed (Medium)
ActionHandlerand a directPOST /api/Workflow/Actionsbypassed it entirely.request.state_change_executionrecords which transition an object's actions last ran for. The middleware claims it with a singleINSERT … ON CONFLICT … DO UPDATE … WHEREbefore executing anything:affected_rows = 1means execute,0means already consumed.ExecuteInMiddlewarethrows onSuccess != trueand an accidental double-submit should not surface as a failed promote. Replays are audit-logged.SEC-09 — Hidden/removed flow objects and internal ANY were requestable (High)
filter: {}for every workflow role, so arequestercould readflow.nwobject,flow.svcobjectandflow.timeobjectin full — including entries hidden (show_in_request_module = false), retired (removed_date) or in adenied/removedstate.request.reqelementexposedflow_nwobj_id,flow_nwgrp_id,flow_svcobj_id,flow_svcgrp_idandip_proto_idas writable withcheck: {}, so any id could be written regardless of what the catalog offered. The only eligibility test in the whole path was client-side.ip_proto_id = -1(GlobalConst.kAnyIpProtocolId) is the internal "any IP protocol" representation. The UI already excludes it from the protocol dropdown, but nothing stopped a direct write.show_in_request_module = true,removed_date IS NULLandstate IN (requested, implemented)forapprover,implementer,planner,requesterandreviewer;flow.svcobjectadditionally requiresip_proto_id >= 0.middleware-serverstays unfiltered — it runs FlowSync and the flow creation.reqelementinsert (requester,approver,modeller) and update (requester,approver) refuse any flow id failing that predicate, and any negativeip_proto_id. The needed object relationships already existed;public.report_schedule_formatalready uses relationship traversal in an insert check, so the expression shape is proven in this metadata.FlowDbCreatorObjectResolutionrefuses an id naming a hidden or retired entry and records aFlowCreationRefusalinstead of following it.FlowObjectEligibilityholds the single predicate, reused by the request-module catalogs,FlowCatalogServiceand the flow creation.IsLiveandIsRequestableare deliberately separate: the canonical ANY service passesIsLivebecause the flow creation attaches it itself for protocol-agnostic external requests, whileIsRequestablerejects it for anything user-facing.WfDbAccesschecks a task's flow ids before writing any element, so a stale editor gets localised messageE8017rather than a raw permission error.reqelementfragments carry raw ids only, so historical tickets still render from each element's own stored name and address.SEC-10 — Stored report values triggered headless-browser requests (High)
SetContentAsyncloads subresources by default, so markup smuggled into such a value made the server's own browser issue outbound requests.PdfRenderSecurity): scripting off, cache off, request interception on with everything butabout:/data:aborted, and--host-resolver-rules=MAP * ~NOTFOUNDso no host name resolves. Applied to both Puppeteer paths —ReportBaseandNotificationEmailLayoutHelper; the audit cited only the first. A test pins that the lockdown is applied beforeSetContentAsync.HtmlOutputEncoder): separate text, attribute and URL encoders.ConstructLinkencodes all four parts for their own context and refuses any target that leaves the document. The URL check strips whitespace and control characters before reading the scheme, sojava\nscript:cannot slip past a naive check.CreateTOCContentpulls decodedInnerTextout of the body and the ToC wrote it back raw; and<a name=...>was unquoted inReportRulesandReportConnectionswith an object name interpolated in, where a space breaks out into new attributes. Both fixed.Headline, the section-header and statistics sites, and every imported field of the object, service and user tables (name, uid, comment, group members) are now encoded.MemberNamesWithoutHtmlsplits first, encodes each member, then joins with<br>, so the separator stays markup and the member name does not — which also fixes the six change-report call sites that share it.Content-Security-Policymeta, the only layer that also protects the exported.htmlwhen opened in a real browser, where scripting is not disabled.Schema and upgrade impact
roles/database/files/upgrade/9.5.3.sqlcreatesrequest.state_change_execution;product_version→9.5.3. The table is tracked in Hasura formiddleware-serveronly.replace_metadata.jsonis re-applied on every install/upgrade run, andfworch-texts.sqlis indatabase_idempotent_filesand re-applied after aDELETE FROM txt, so the permission fixes and the new text keys land with any deployment.ExportTestwere updated to match.Verification
dotnet buildclean (0 warnings, 0 errors),dotnet formatclean, full unit suite green: 5845 passed, 0 failed, 17 skipped (pre-existing live-backend integration tests).9.5.3.sql, the new Hasura permissions and the renderer lockdown were exercised through unit tests and substitutes rather than against a running instance — worth one pass on a real deployment before release.🤖 Generated with Claude Code