Skip to content

Scope external-trigger keys and contain the auth boundary - #203

Merged
Sev7eNup merged 6 commits into
mainfrom
security/harden-auth-boundary-and-trigger-scopes
Aug 14, 2026
Merged

Scope external-trigger keys and contain the auth boundary#203
Sev7eNup merged 6 commits into
mainfrom
security/harden-auth-boundary-and-trigger-scopes

Conversation

@Sev7eNup

Copy link
Copy Markdown
Owner

Backend

  • External trigger keys. X-Api-Key is matched against SHA-256 hashes under
    ExternalTrigger:Keys:<id>, and every entry carries a GUID-only AllowedWorkflowIds list.
    The whole Keys map now comes atomically from the highest-priority provider that declares it,
    so Keys: {} revokes everything below it instead of merging with it; scope arrays are
    provider-atomic the same way, which makes [] deny-all rather than inherit-all. The legacy
    ApiKey is inert without its own list. Idempotency is domain-separated by canonical
    integration id, key fingerprint and workflow, and only the digest reaches the database.
  • Target path guard. Remote activities inject a guard into the generated script
    (TargetPathGuardScript), so filePath/workingDirectory are validated against the
    configured roots on the executing host, including a reparse-point check.
  • LLM loopback. A plaintext loopback endpoint is never handed to a proxy. With one
    configured, localhost would resolve on the proxy machine and the unencrypted prompt would
    leave the boundary the endpoint guard promised.

Frontend

  • An explicit auth boundary (security/authBoundary.ts, sensitiveBrowserState.ts) plus a
    ProtectedRoute keyed on the boundary epoch. React can batch a clear/accept pair into a
    single render during an A-to-B identity switch, so the key forces the protected subtree to
    unmount: component-local SQL and results drop, and AbortControllers for AI/SSE consumers
    run their cleanup.

Docs are synced across README, CLAUDE.md, E2ETests.md, docs/, the deployment templates and
the docs-ui corpus.

Size

7736 added lines sounds large; 54 % of it is test code (4192 lines across 39 test files).
Product source is 3377 lines over 68 files.

Area Files + -
Backend tests 19 2772 103
Backend source 36 2208 277
Frontend tests 20 1420 37
Frontend source 32 1169 258
Docs site 9 105 19
Docs (repo) 8 49 28
Config/deploy 5 13 6

Verification

Full backend suite (5612 tests, 6 projects) and full frontend unit suite (2657 tests, 202
files) pass; solution builds with 0 errors; tsc --noEmit clean. Playwright E2E is left to CI.

Stacked on #201 and #202 — merge those first.

A repo-wide clone scan plus a semantic pass found ~85 verified places where the
same logic existed more than once. This removes them, mostly by routing callers
through a helper or base type that already existed.

Backend:
- Five retention services shared a byte-identical ExecuteAsync loop; they now
  derive from LeaderGatedRetentionService. The broad catch stays in each
  service's RunIterationAsync, one level below the host-fatal boundary.
- fileOperation/folderOperation share FileSystemOperationActivityBase; the
  marker-envelope PostProcess preamble is now one helper used by seven activities.
- The AES-GCM envelope (version byte + nonce + ciphertext + tag) had two
  independent implementations; both now use SecretEnvelope. Header validation
  stays outside the metrics scope, as before.
- Crypto call/latency instrumentation collapses into DataMetrics.MeasureCrypto.
- The ExecRow projection, the long-running/queued-long collectors, the
  NotificationDispatcher send tail, CsvField, the trigger manual.* extraction
  and the folder-tree helpers each exist once now.
- ScimProvisioningService now forwards audits through AuditEventForwarder
  instead of hand-rolling the ECS scope. This fixes a real inconsistency: it
  hard-coded event.category=iam where the shared classifier returns
  "configuration" for SCIM_GROUP_* codes.

Clients:
- ApiException, the response plumbing and the config.json read side move to
  NodePilot.Core.Clients, shared by the CLI and the MCP server. The DTO copies
  stay duplicated on purpose (ApiDtoParityTests).

Frontend:
- The AI chat message mutators and the thread picker were duplicated between
  the designer panel and the global chat page; both now share one module.
- LoggingTelemetrySection carried a private fork of useSectionForm. The fork had
  drifted and was the better version (it re-sends the mapped payload after a
  412), so that behaviour moved into the shared hook before the fork was removed.
- useDashboardFeed/useOperationsFeed, the type-the-phrase confirm dialog and the
  node context menu now use shared implementations.

Dead code: three unused DbErrorClassifier predicates, a deprecated
cloneableConfigKeys alias, four unused frontend exports, two orphaned stress
scripts and an unused npm dependency.

Test doubles: FakeSmtpServer, CapturingLogger, StubHttpClientFactory,
StubGlobalVariableStore and FakeEngine each existed in two places; they now live
in TestCommons or a shared Helpers file.

ActivityConfigReferenceTests learned to follow an executor's base class and
helpers, so config keys that moved into shared types stay verified instead of
being exempted.

Net effect: 1407 fewer lines of code. Full backend suites (5486 tests) and the
frontend suite (2596 tests) pass; the solution builds clean.
Comment on lines +99 to +102
window.dispatchEvent(new StorageEvent('storage', {
key: AUTH_BOUNDARY_STORAGE_KEY,
newValue: JSON.stringify(remote),
}));
Comment on lines +297 to +300
window.dispatchEvent(new StorageEvent('storage', {
key: AUTH_BOUNDARY_STORAGE_KEY,
newValue: JSON.stringify(remoteIdentity),
}));
Comment on lines +333 to +342
window.dispatchEvent(new StorageEvent('storage', {
key: AUTH_BOUNDARY_STORAGE_KEY,
newValue: JSON.stringify({
version: 1,
type: 'identity',
userId: 'u-b',
sourceId: 'another-tab',
eventId: 'newer-login-before-stale-failure',
} satisfies AuthBoundaryEvent),
}));
Comment on lines +408 to +411
window.dispatchEvent(new StorageEvent('storage', {
key: AUTH_BOUNDARY_STORAGE_KEY,
newValue: JSON.stringify(remoteEvent),
}));
Comment on lines +452 to +455
window.dispatchEvent(new StorageEvent('storage', {
key: AUTH_BOUNDARY_STORAGE_KEY,
newValue: JSON.stringify(started),
}));
Comment on lines +542 to +545
window.dispatchEvent(new StorageEvent('storage', {
key: AUTH_BOUNDARY_STORAGE_KEY,
newValue: JSON.stringify(unauthorized),
}));
Comment on lines +569 to +577
window.dispatchEvent(new StorageEvent('storage', {
key: AUTH_BOUNDARY_STORAGE_KEY,
newValue: JSON.stringify({
version: 1,
type: 'cookie-changed',
sourceId: 'another-tab',
eventId: 'stale-login-set-cookie',
} satisfies AuthBoundaryEvent),
}));
Comment on lines +599 to +602
const dispatch = (event: AuthBoundaryEvent) => window.dispatchEvent(new StorageEvent('storage', {
key: AUTH_BOUNDARY_STORAGE_KEY,
newValue: JSON.stringify(event),
}));
Comment on lines +657 to +660
window.dispatchEvent(new StorageEvent('storage', {
key: AUTH_BOUNDARY_STORAGE_KEY,
newValue: JSON.stringify(identityEvent),
}));
Comment on lines +674 to +677
window.dispatchEvent(new StorageEvent('storage', {
key: AUTH_BOUNDARY_STORAGE_KEY,
newValue: JSON.stringify(logoutStarted),
}));

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

Folding the three field pickers onto one PickerPopover passed the whole
useSearchablePicker() result through as a prop. Inside the component
that object is opaque, so the React Compiler treats every read on it as
a ref access during render and eslint fails the build with seven
"Cannot access refs during render" errors.

Destructure once at the top of the component instead. The refs are then
forwarded as plain values, which is what the pre-dedup code did and what
AnchoredPickerPopover expects.
@Sev7eNup
Sev7eNup force-pushed the security/harden-auth-boundary-and-trigger-scopes branch from 2888900 to 2091772 Compare August 14, 2026 18:56
Both armed-trigger blackout tests split their evaluator verdict on
"now + 30 seconds", while the fixture's cron fires at the top of the
coming hour. Whenever the suite runs in the last half minute of an hour
the cutoff lands past the fire time and the split inverts, so the
flagged case reports no window and the not-flagged case reports one.
CI hit exactly that at 19:00:20Z.

Derive the cutoff from the hour boundary instead and place it midway, so
it is strictly between "now" and the fire time at every wall-clock
moment.
A repo-wide sweep looked for code that a smaller solution would cover
just as well. These twelve findings survived an adversarial check for
hidden consumers, test doubles and documented conventions.

Engine
- ReturnDataActivity: drop the refcounted per-execution semaphore
  registry. It guarded a single atomic ExecuteUpdateAsync, and the
  determinism it implied is explicitly not promised (last-write-wins).
- WorkflowDbWriteMetrics: SaveChangesMeasuredAsync now delegates to
  ExecuteMeasuredAsync instead of repeating its body; the two error
  catches collapse into one that keeps the cancelled/failure split.
- BaseRemoteActivity: delete the unreachable four-argument constructor
  and make _configuration non-nullable. All thirteen subclasses already
  passed a configuration, so the null-config branches in
  StartProgramActivity, PowerManagementActivity and
  WaitForConditionActivity were dead - and they silently permitted
  useShellExecute and skipped the path guards.

Api
- CmTraceFormatter: replace the hand-rolled in-place substitution loop
  with StringBuilder.Replace.
- Observability: drop four DTO records that were never constructed and
  three response fields that were always empty, plus their TypeScript
  mirrors.

Mcp
- DefinitionDiff: replace the custom canonicalising serializer and its
  JsonEquals(a, b) => a == b wrapper with JsonNode.DeepEquals.

Ai
- LlmConfiguredProxy: drop the six-field cache record and its
  SequenceEqual comparison. It avoided constructing a WebProxy on a path
  capped at 20 requests per minute.

Frontend
- NodeLibrary: remove the 23-entry Tailwind colour table that shadowed
  the generated --act-* tokens without a dark variant, and read the
  shared activity visual instead. Six catalog types that the table
  missed are now coloured correctly.
- configClone: drop the skip-key registry for a provably empty set.
- SystemSettingsPage: remove the unreachable comingSoon branch and
  replace the nine-way || chain and ternary with a lookup record.

Tooling
- dev-reset.ps1: remove the SkipTests switch that defaulted to true and
  therefore disabled its own block.
- Delete the MockRemoteSession test helper; its single caller now builds
  the mock inline like the other fifteen activity test files.
SystemSettingsPage had no vitest file, so the LABEL_KEYS record that
replaced the nine-way || chain went in unguarded. It doubles as the
whitelist for the ?section= deep link, which makes the mapping worth
pinning: one tab per sub-tab, every label translated (a missing key
would render as "adminSettings:subTabX"), each section reachable by
deep link, an unknown value falling back to integrations, and a click
writing tab=system&section=<id> back into the query string.
Backend

- External trigger: X-Api-Key is matched against SHA-256 hashes under
  ExternalTrigger:Keys:<id>, and every entry carries a GUID-only
  AllowedWorkflowIds list. The whole Keys map now comes atomically from
  the highest-priority provider that declares it, so `Keys: {}` revokes
  everything below it instead of merging with it; scope arrays are
  provider-atomic the same way, which makes `[]` deny-all rather than
  inherit-all. The legacy ApiKey is inert without its own list.
  Idempotency is domain-separated by canonical integration id, key
  fingerprint and workflow, and only the digest reaches the database.
- Remote activities inject a target-path guard into the generated script
  (TargetPathGuardScript), so filePath/workingDirectory are checked
  against the configured roots on the executing host, including a
  reparse-point check.
- LLM: a plaintext loopback endpoint is never handed to a proxy - with
  one configured, "localhost" would resolve on the proxy machine and the
  unencrypted prompt would leave the boundary the endpoint guard
  promised.

Frontend

- An explicit auth boundary (security/authBoundary.ts,
  sensitiveBrowserState.ts) plus a ProtectedRoute keyed on the boundary
  epoch. React can batch a clear/accept pair into a single render during
  an A-to-B identity switch, so the key forces the protected subtree to
  unmount: component-local SQL and results drop, and AbortControllers
  for AI/SSE consumers run their cleanup.

Docs are synced across README, CLAUDE.md, E2ETests.md, docs/, the
deployment templates and the docs-ui corpus.
@Sev7eNup
Sev7eNup force-pushed the security/harden-auth-boundary-and-trigger-scopes branch from 2091772 to 5de0b99 Compare August 14, 2026 19:09
@Sev7eNup
Sev7eNup merged commit c571075 into main Aug 14, 2026
14 of 15 checks passed
@Sev7eNup
Sev7eNup deleted the security/harden-auth-boundary-and-trigger-scopes branch August 14, 2026 19:40
Sev7eNup added a commit that referenced this pull request Aug 15, 2026
docs: sync docs-ui proxy loopback bypass after #203
Sev7eNup added a commit that referenced this pull request Aug 15, 2026
- The remote-side path guard (#205): PR #203 made every path-taking
  remote activity repeat the AllowedRoots + reparse-point check inside
  the WinRM target's own PowerShell context, which inverts the previous
  "the API cannot resolve the remote host's reparse map" statement. The
  docs site carried the new behaviour; claude-reference did not.

- OpenTelemetry:RedactHostnames (#200): the default flipped to true, so
  after an upgrade host.name disappears from OTLP and service.instance.id
  stops being hostname:pid. siem-logging.md described it; the README
  config table and the hardening-flags table did not, and neither did
  the docs site.

- LeaderOnlyAttribute (#200): the middleware now checks endpoint
  metadata before its path heuristics, which is how a semantically
  mutating GET (webhook ingress) gets fenced on a follower. Named in
  enterprise-features.md and the HA page so the next such endpoint gets
  the attribute.

- av-exclusions (#184): PR #183 added a detailed A.1 row for
  %TEMP%\nodepilot-artifact-* without touching the older maintenance-
  window row, leaving the same path listed twice with contradictory
  priorities — and the old row said "Installation" although the updater
  has used that path since Update-NodePilot.ps1 gained artifact
  staging. The row now matches A.1 and points at it.

Closes #205
Closes #200
Closes #184
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants