Threat model¶
What JASIL defends against, how, and — just as importantly — what it leaves to you. A substrate sits underneath your domain code, so the boundary between "the library handles this" and "you handle this" has to be explicit rather than assumed.
JASIL's attack surface is narrow by design: it serves no requests, and it makes exactly two kinds of outbound call to a third party: reverse geocoding, and — only when configured — SMTP. Most of what follows is about values flowing through it from your application.
Server-side request forgery (CWE-918)¶
Where. Reverse geocoding is the capability that dials a host JASIL did not choose and then returns its response (SMTP is covered under Outbound mail). The host is operator-configured, which makes it attacker-influenced whenever configuration is.
Mitigations.
- The configured value must be a bare
host[:port]. One carrying a scheme, path, or credentials is refused, because it would otherwise be interpolated into a URL —evil.example.com/xbecomeshttps://evil.example.com/x/reverse, whose hostname check passes. - Every A/AAAA record the name resolves to must be public unicast. A single private, loopback, link-local, multicast, unspecified, or reserved answer rejects the host. Requiring all answers is what defeats a name that returns a public address on the first lookup and a private one on the next.
- Redirects are refused on every request, so a permitted host cannot
3xx-pivot onto an internal target. - The response body is read under a 1 MiB cap, streamed, so a hostile upstream cannot exhaust memory.
- Failures are logged by exception type and status code only. The
requestserror message embeds the request URL, and one supported service carries its API key in that URL's query string.
Escape hatch. NetworkSettings.ssrf_allowed_hosts exempts hostnames and
CIDRs so a self-hosted instance on a private network stays reachable. Every use
is logged. Prefer a CIDR: a hostname entry exempts every address that name
resolves to — including a cloud metadata endpoint — and taking one logs at
WARNING saying so.
Limits you should know.
- The check is time-of-check, performed once, while the platform is assembled. A name that resolves publicly at startup and privately afterwards is not caught. What is caught is the case that matters for an operator-supplied setting: a host that was already internal when configured.
socket.getaddrinfotakes no timeout, so resolution is bounded by your resolver configuration rather than by JASIL. At startup this shows up as a slow boot rather than a hung request.
Outbound mail¶
Header injection (CWE-93). Addresses are parsed, never concatenated; the
SMTP email policy rejects CR/LF in a header value; and MailMessage has no
field through which a caller could set an arbitrary header.
Transport. TLS is verified by default; smtp:// refuses a server that does
not offer STARTTLS rather than downgrading, and AUTH is sent only after TLS is
up. The one opt-out, mail.secure = False, is cleartext and is accepted only for
smtp://, only when the host resolves exclusively to private, loopback or
link-local addresses, and never with credentials. The check runs at startup and
again on every send, and the connection goes to the address that was checked
rather than to the name, so a DNS change cannot redirect cleartext mail to a
public host. Every
socket operation has a fixed timeout, and a whole send has a 90-second deadline
that a watchdog enforces by shutting the socket down — a per-read timeout alone
would let a relay that drips its replies hold a send open indefinitely. The
deadline covers the TLS handshake and every read after the connection is made;
the DNS lookup and the TCP connect (30 seconds per address) are bounded by their
own timeouts only.
Personal data and secrets in logs and job rows. The job runner persists
str(error) and logs exc_info, so MailError carries only a category and an
SMTP status code and is raised from None. Log lines record the recipient count,
Message-ID and outcome, never addresses, subjects or bodies. The password is
excluded from repr(settings), and credentials are refused in the URI so they
cannot end up in a logged string.
The SSRF address denylist is not applied to the SMTP host — a decision, not an omission. The denylist exists for geocoding because that backend fetches a response from an operator-named host and hands it to the caller. A mail relay is normally on a private network or loopback, and the SMTP backend returns nothing from the server to its caller.
Path traversal (CWE-22)¶
Where. Every storage call is addressed by a caller-supplied (area, key)
pair.
Mitigations.
- Both segments are validated before any filesystem or client access: empty,
absolute, dot-component, parent-traversing, repeated-separator,
trailing-separator, and backslash-bearing values raise
ValueError. The check is pure and shared, so both backends enforce one canonical slash-delimited grammar. Without it, local paths normalize aliases such as.anda//bwhile S3 preserves them as distinct literal keys. An area is exactly one component, so(area="a/b", key="c")cannot collapse onto(area="a", key="b/c")in an object-store key. - The local backend additionally resolves the final path and requires it to stay under the base directory, and never follows a symlink out of an area when listing. Logical objects use a reserved fixed-depth digest layout with verified identity sidecars, avoiding path-length and file/directory collisions between a key and its descendants without trusting the digest alone. Resumable parts live under a separate reserved private staging directory; session manifests and part files are validated before they are read, and symbolic-link aliases are rejected.
- URLs percent-encode the area and key, so a key holding
?,#, or%cannot alter the URL it lands in.
A conformance suite runs the same rules against both backends, so they cannot drift apart.
Pattern injection into the state store (CWE-77)¶
Where. StateProvider.delete_prefix and iter_keys take a literal key
prefix. Redis matches SCAN patterns as globs, so a prefix is not a literal to
it.
Mitigation. Glob metacharacters (\ * ? [ ]) are escaped before the prefix
becomes a pattern. Unescaped, delete_prefix("*") would empty the keyspace and
iter_keys("user:[1]") would match keys the caller never named while missing the
one it did — while the in-memory backend, which compares with startswith,
matched only the literal.
If you build a prefix from a tenant or user identifier, you do not control the characters in it. This is the mitigation that makes that safe.
Secrets in transit through the pipeline (CWE-532)¶
Where. An event's payload and metadata are stored and transmitted
verbatim — to event_outbox, processing_jobs, and event_log, serialized
onto the Redis stream, and metadata is logged in full when a best-effort
subscriber fails.
This is not mitigated, deliberately — the substrate cannot know which of your keys are sensitive. Carry identifiers, not secrets and not blobs. See Events & outbox.
JASIL does not sanitise the values it logs. It bounds the length of the
identifiers it persists (event_type, source, subscriber_id), which are
developer-authored, but a metadata dict is yours and reaches the log intact.
The same holds for failure text. A handler's exception message is stored,
truncated to 4000 characters, in processing_jobs.last_error and, one row per
attempt, in job_attempts.error, and jasil.admin.get_job returns it. Do not
put a secret in an exception message, and keep jasil.admin behind the host's own
authorization.
SQL injection (CWE-89)¶
Not a surface. Every query is SQLAlchemy Core or ORM with bound parameters.
The only raw SQL in the package is the two PostgreSQL advisory-lock statements,
which pass their key as a bound :key parameter. Table and column names are
never interpolated from input.
Resource exhaustion (CWE-400)¶
- Retention pruning deletes in bounded batches with a cap on batches per pass, so a backlog cannot hold locks on a hot table.
- Job claims and outbox relays are batch-size limited.
- Stored failure text is truncated; the joined subscriber list is clamped to its column width.
- The geocoding response body is capped (above).
- Streaming and resumable storage writes accept a host-owned
max_byteslimit. Resumable parts stream under an exact declared size rather than being buffered in memory. JASIL's built-in portability limits cap parts at 5 GiB and part numbers at 10,000. Active parts remain backend resources until completion, abort, or explicitcleanup_uploadsmaintenance. Parallel part calls may temporarily stage more than the session limit because completion is the authoritative aggregate check. S3 cleanup follows JASIL-owned manifests and cannot abort unrelated multipart operations. Configure an S3 incomplete-multipart lifecycle rule as a final safety net for a process death between native upload creation and manifest persistence.
What is not bounded: event payload and metadata size. A producer can
write an arbitrarily large payload into the outbox. JASIL also does not limit
the number of upload sessions a caller can begin; authenticate and rate-limit
that host endpoint, bound its request concurrency, retain session handles as
trusted server-side state, and run cleanup with a cutoff longer than a valid
upload.
Duplicate execution¶
Delivery is at-least-once, never exactly-once. A durable subscriber can run twice for the same event — after a lease expires, after a crash mid-batch, or on a replay.
Mitigations. (event_id, subscriber_id) uniqueness makes the fan-out
idempotent; the claim is a compare-and-set so two workers cannot take the same
row; a running worker's heartbeat renews its leases, so a slow healthy handler is
not requeued and run a second time; and a handler whose lease is lost is
signalled (jasil.jobs.lease_lost) and its result discarded by the claim's
worker-id and attempt guard. What remains is a database partition, a handler
that ignores the signal mid-side-effect, and a handler abandoned past
jobs.max_runtime_seconds.
Yours. Subscriber handlers must be idempotent. If running one twice would double-charge, double-send, or double-count, that is a correctness bug in the handler, not in the queue.
Out of scope¶
These are real risks that JASIL deliberately does not address, because it cannot:
| Risk | Why it is yours |
|---|---|
Authentication and authorization on jasil.admin |
It exposes operational data and a state-changing replay. JASIL has no notion of who is calling. |
| Serving and restricting local storage paths and URLs | They cannot expire or enforce response headers; JASIL neither runs that web server nor holds a key to sign with. Authorize every request, apply headers in the host response, and never expose a ServeFile filesystem path as a public URL. See providers & backends. |
| Namespacing keys and areas per tenant | The substrate sees opaque strings; only you know the tenancy boundary. |
| Securing the database and Redis themselves | Network policy, TLS, and credentials belong to the deployment. JASIL never creates the engine. |
| Rate limiting inbound requests | JASIL serves no requests. It provides the atomic primitives (incr, set_if_absent, record_tiered_failure) a limiter needs. |
| Vulnerabilities in an application using JASIL | Reported to that application. |
Reporting¶
See SECURITY.md for the disclosure process and what is in scope.