Finding (needs a design decision — not blind-fixing)
SpikerSoft.EventHandlers.Scheduler/TaskRunners/HttpCallbackTaskRunner.cs executes a scheduled task by making a server-side HTTP request to a fully payload-supplied URL, with payload-supplied method, headers, and body, and no egress restriction:
varrequest=newHttpRequestMessage(newHttpMethod(payload.Method??"GET"),payload.Url);// payload.Headers added verbatim; payload.Body as contentvarresponse=awaithttpClient.SendAsync(request,ct);
Because it runs inside the scheduler worker (on the internal overlay networks), a task can target internal-only services the public can't reach — http://mongo-router:27017, the Redis nodes, http://keycloak:8080, the Docker socket proxy, etc. — i.e. a classic SSRF pivot. The response body (first 500 chars) is also echoed back in the task result, enabling blind-ish exfil.
Severity: medium (not critical)
Creating scheduled tasks is gated [Authorize(Roles = "admin,staff")] (ScheduledTasksController), so this is not anonymous/any-user — it needs an admin or staff account. Two reasons it still matters for a millions-of-kids platform:
If "staff" includes a broad population (e.g. teachers), that's a wide trust boundary for an internal-network fetch primitive.
Defense-in-depth: a compromised staff/admin session shouldn't convert into an internal-network SSRF against Mongo/Redis/Docker.
Why this is a decision, not a unilateral patch
The runner is an intentional "call a configurable URL" feature. Locking it down means answering:
Are internal callback targets ever legitimate? (internal automation vs. external-webhook-only) → determines allowlist vs. denylist.
If denylist: block loopback, RFC1918/private ranges, link-local (169.254/16), and the overlay service hostnames — or an explicit egress allowlist of approved hosts.
Should staff be able to create HttpCallback tasks at all, or only admin? (tighten the authz on this task type specifically)
Recommendation
Add an SSRF egress guard applied in the runner (resolve the host, reject private/loopback/link-local/overlay targets unless explicitly allowlisted) + optionally restrict the HttpCallback task type to admin. Happy to implement once the allowlist/denylist policy + authz scope are decided.
Scope note (rest of the SSRF surface is clean)
Swept the codebase: other outbound HTTP is config-driven infra (health checks, Gitea/Docker/MCP/GeoIP from options — not user input), and the Twilio MakeCall/SendSms URLs are fetched by Twilio (external), not our server. Also confirmed no zip-slip (no untrusted archive extract-to-disk) and no XXE (no raw XML parsing) in the same sweep.
## Finding (needs a design decision — not blind-fixing)
`SpikerSoft.EventHandlers.Scheduler/TaskRunners/HttpCallbackTaskRunner.cs` executes a scheduled task by making a server-side HTTP request to a **fully payload-supplied URL**, with payload-supplied **method, headers, and body**, and **no egress restriction**:
```csharp
var request = new HttpRequestMessage(new HttpMethod(payload.Method ?? "GET"), payload.Url);
// payload.Headers added verbatim; payload.Body as content
var response = await httpClient.SendAsync(request, ct);
```
Because it runs inside the scheduler worker (on the internal overlay networks), a task can target internal-only services the public can't reach — `http://mongo-router:27017`, the Redis nodes, `http://keycloak:8080`, the Docker socket proxy, etc. — i.e. a classic SSRF pivot. The response body (first 500 chars) is also echoed back in the task result, enabling blind-ish exfil.
## Severity: medium (not critical)
Creating scheduled tasks is gated `[Authorize(Roles = "admin,staff")]` (`ScheduledTasksController`), so this is **not** anonymous/any-user — it needs an admin **or staff** account. Two reasons it still matters for a millions-of-kids platform:
1. If "staff" includes a broad population (e.g. teachers), that's a wide trust boundary for an internal-network fetch primitive.
2. Defense-in-depth: a compromised staff/admin session shouldn't convert into an internal-network SSRF against Mongo/Redis/Docker.
## Why this is a decision, not a unilateral patch
The runner is an **intentional** "call a configurable URL" feature. Locking it down means answering:
- **Are internal callback targets ever legitimate?** (internal automation vs. external-webhook-only) → determines allowlist vs. denylist.
- If denylist: block loopback, RFC1918/private ranges, link-local (169.254/16), and the overlay service hostnames — or an explicit egress allowlist of approved hosts.
- Should `staff` be able to create `HttpCallback` tasks at all, or only `admin`? (tighten the authz on this task type specifically)
## Recommendation
Add an SSRF egress guard applied in the runner (resolve the host, reject private/loopback/link-local/overlay targets unless explicitly allowlisted) + optionally restrict the `HttpCallback` task type to `admin`. Happy to implement once the allowlist/denylist policy + authz scope are decided.
## Scope note (rest of the SSRF surface is clean)
Swept the codebase: other outbound HTTP is config-driven infra (health checks, Gitea/Docker/MCP/GeoIP from options — not user input), and the Twilio `MakeCall`/`SendSms` URLs are fetched by Twilio (external), not our server. Also confirmed **no zip-slip** (no untrusted archive extract-to-disk) and **no XXE** (no raw XML parsing) in the same sweep.
PR #137 up — implements the recommended egress guard as a secure-by-default, with the PR as the decision point (same pattern as the CORS fail-closed change).
Enforced at connect time via SocketsHttpHandler.ConnectCallback on a named client → every connection (including redirect-triggered ones) re-enters the check, closing DNS-rebinding and redirect-to-internal bypasses.
⚠️ Behavior change: existing internal-target HttpCallback tasks fail with a clear allowlist hint until configured.
37 tests (address matrix incl. boundaries + smuggling cases; no network in tests).
Remaining decision on this ticket (not in the PR): should the HttpCallback task type be restricted to admin only, or stay admin,staff? One-line change either way — say which and I'll ship it, then this closes.
**PR #137 up** — implements the recommended egress guard as a secure-by-default, with the PR as the decision point (same pattern as the CORS fail-closed change).
- `SsrfEgressPolicy` in `SpikerSoft.Common/Http`: deny-internal-by-default (loopback, RFC1918, link-local/metadata, CGNAT, `0.0.0.0/8`, multicast, IPv6 equivalents, mapped-IPv6 unwrapped; unknown families fail closed).
- Enforced at **connect time** via `SocketsHttpHandler.ConnectCallback` on a named client → every connection (including redirect-triggered ones) re-enters the check, closing DNS-rebinding and redirect-to-internal bypasses.
- Runner requires absolute http(s) URLs (kills `file:`/`ftp:`).
- Escape hatches (default off): `Scheduler:HttpCallback:AllowedHosts` + `Scheduler:HttpCallback:AllowPrivateNetworks`.
- ⚠️ Behavior change: existing internal-target HttpCallback tasks fail with a clear allowlist hint until configured.
- 37 tests (address matrix incl. boundaries + smuggling cases; no network in tests).
**Remaining decision on this ticket** (not in the PR): should the `HttpCallback` task *type* be restricted to `admin` only, or stay `admin,staff`? One-line change either way — say which and I'll ship it, then this closes.
Egress guard merged — PR #137 (529b462). The HttpCallback SSRF pivot is closed: deny-internal-by-default SsrfEgressPolicy enforced at connect time (blocks private/loopback/link-local/metadata/CGNAT + IPv6 equivalents; DNS-rebinding and redirect-to-internal safe), http(s)-only, with AllowedHosts / AllowPrivateNetworks escape hatches (default off). 37 tests.
The one remaining item — restricting the HttpCallback task type to admin-only vs admin,staff — is an authz-scope decision with no default I can pick for you. Closing this ticket as the SSRF vulnerability is resolved; I've filed the authz-tightening question as its own follow-up so it isn't lost.
**Egress guard merged — PR #137 (`529b462`).** The HttpCallback SSRF pivot is closed: deny-internal-by-default `SsrfEgressPolicy` enforced at connect time (blocks private/loopback/link-local/metadata/CGNAT + IPv6 equivalents; DNS-rebinding and redirect-to-internal safe), http(s)-only, with `AllowedHosts` / `AllowPrivateNetworks` escape hatches (default off). 37 tests.
The one remaining item — restricting the `HttpCallback` task *type* to `admin`-only vs `admin,staff` — is an **authz-scope decision** with no default I can pick for you. Closing this ticket as the SSRF vulnerability is resolved; I've filed the authz-tightening question as its own follow-up so it isn't lost.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Finding (needs a design decision — not blind-fixing)
SpikerSoft.EventHandlers.Scheduler/TaskRunners/HttpCallbackTaskRunner.csexecutes a scheduled task by making a server-side HTTP request to a fully payload-supplied URL, with payload-supplied method, headers, and body, and no egress restriction:Because it runs inside the scheduler worker (on the internal overlay networks), a task can target internal-only services the public can't reach —
http://mongo-router:27017, the Redis nodes,http://keycloak:8080, the Docker socket proxy, etc. — i.e. a classic SSRF pivot. The response body (first 500 chars) is also echoed back in the task result, enabling blind-ish exfil.Severity: medium (not critical)
Creating scheduled tasks is gated
[Authorize(Roles = "admin,staff")](ScheduledTasksController), so this is not anonymous/any-user — it needs an admin or staff account. Two reasons it still matters for a millions-of-kids platform:Why this is a decision, not a unilateral patch
The runner is an intentional "call a configurable URL" feature. Locking it down means answering:
staffbe able to createHttpCallbacktasks at all, or onlyadmin? (tighten the authz on this task type specifically)Recommendation
Add an SSRF egress guard applied in the runner (resolve the host, reject private/loopback/link-local/overlay targets unless explicitly allowlisted) + optionally restrict the
HttpCallbacktask type toadmin. Happy to implement once the allowlist/denylist policy + authz scope are decided.Scope note (rest of the SSRF surface is clean)
Swept the codebase: other outbound HTTP is config-driven infra (health checks, Gitea/Docker/MCP/GeoIP from options — not user input), and the Twilio
MakeCall/SendSmsURLs are fetched by Twilio (external), not our server. Also confirmed no zip-slip (no untrusted archive extract-to-disk) and no XXE (no raw XML parsing) in the same sweep.PR #137 up — implements the recommended egress guard as a secure-by-default, with the PR as the decision point (same pattern as the CORS fail-closed change).
SsrfEgressPolicyinSpikerSoft.Common/Http: deny-internal-by-default (loopback, RFC1918, link-local/metadata, CGNAT,0.0.0.0/8, multicast, IPv6 equivalents, mapped-IPv6 unwrapped; unknown families fail closed).SocketsHttpHandler.ConnectCallbackon a named client → every connection (including redirect-triggered ones) re-enters the check, closing DNS-rebinding and redirect-to-internal bypasses.file:/ftp:).Scheduler:HttpCallback:AllowedHosts+Scheduler:HttpCallback:AllowPrivateNetworks.Remaining decision on this ticket (not in the PR): should the
HttpCallbacktask type be restricted toadminonly, or stayadmin,staff? One-line change either way — say which and I'll ship it, then this closes.Egress guard merged — PR #137 (
529b462). The HttpCallback SSRF pivot is closed: deny-internal-by-defaultSsrfEgressPolicyenforced at connect time (blocks private/loopback/link-local/metadata/CGNAT + IPv6 equivalents; DNS-rebinding and redirect-to-internal safe), http(s)-only, withAllowedHosts/AllowPrivateNetworksescape hatches (default off). 37 tests.The one remaining item — restricting the
HttpCallbacktask type toadmin-only vsadmin,staff— is an authz-scope decision with no default I can pick for you. Closing this ticket as the SSRF vulnerability is resolved; I've filed the authz-tightening question as its own follow-up so it isn't lost.