DedSec Application Security ·
A team is about to build a feature that many SaaS products build. A user uploads an invoice PDF. It is processed in the background, fields are extracted with help from a third-party service, and the user downloads a generated report. Optionally, the system calls a URL the customer configured to say the report is ready. Four processes, a queue, a bucket, a database and webhooks in both directions. Nothing exotic.
Some of the most damaging flaws this feature can have will not sit on any line of code. A worker that writes extracted data into whichever tenant the queue message names has no injection, no unsafe function and no hard-coded secret. It has a decision, made implicitly, that the queue is trusted. Threat modelling makes decisions like that explicit while changing them still means editing a diagram, not migrating a database.
This post works through that feature from diagram to recorded decisions. Sources are the Threat Modeling Manifesto, four OWASP cheat sheets (Threat Modeling, Multi-Tenant Security, File Upload, Webhook Security), Microsoft's STRIDE documentation, NIST SP 800-218 and the Amazon S3 user guide, used because its documentation is specific; the questions apply to any store that issues signed URLs.
The Threat Modeling Manifesto defines threat modelling as "analyzing representations of a system to highlight concerns about security and privacy characteristics", and frames it as four questions: What are we working on? What can go wrong? What are we going to do about it? Did we do a good enough job? The OWASP Threat Modeling Cheat Sheet builds its process on the same four. What matters is what each one leaves behind:
The Manifesto is "methodology-agnostic". A data-flow diagram with STRIDE is one combination; OWASP suggests STRIDE for a technical design and lists attack trees, misuse cases and LINDDUN, among others, for other scopes.
Microsoft's 2006 MSDN Magazine article on STRIDE, by Shawn Hernan, Scott Lambert, Tomasz Ostwald and Adam Shostack, says data-flow diagrams use four elements (data flows, data stores, processes and interactors), and adds that "for threat modeling we add one more—trust boundaries." OWASP lists the same five:
The same article gives two sanity rules worth adopting. First, no magic sources or sinks: data is not created out of thin air, and every store needs a reader as well as a writer. Second, no "psychokinesis as a data transport": data never moves without a process doing the moving. The presigned URL below breaks the second rule in an instructive way.
A flow that crosses a boundary is where data from a less-trusted party reaches something that will act on it, and every assumption the receiver makes about that data is a candidate threat. The MSDN article: "That's the very definition of a trust boundary—you don't trust what's on the other side." Flows inside a boundary are not risk-free, since the question is whether the boundary is "reliably and correctly set", but effort should follow exposure.
In practice, the most useful thing a reviewer can do with a diagram is find a missing boundary. These are the ones a network diagram does not show:
Microsoft's Threat Modeling Tool documentation defines six threat categories. The 2006 article maps each one to the property it violates:
OWASP's cheat sheet uses the same mapping, except that it lists repudiation as violating accounting. That is the same concern seen from the log rather than from the signed receipt.
STRIDE is a set of prompts, not a taxonomy. OWASP says to "use STRIDE's six categories as prompts while reviewing the system model", and the MSDN article, after filing one threat as tampering that could equally be denial of service, says: "Don't get too hung up over the terminology." A category is worth the question it makes you ask, not the bucket the answer lands in.
The 2006 article applies STRIDE by element type: tampering, information disclosure and denial of service for data flows and stores, all six for processes, spoofing and repudiation for interactors, and "unique threats that affect your trust boundaries." That is systematic but produces duplicates. The common alternative is to walk each boundary crossing and question sender, flow and receiver together, which finds threats that live in the relationship but depends on the diagram showing every crossing. For one feature and one hour, we find walking the crossings the better use of time, and the walk below does that.
External entities. The user's browser, with an authenticated session for one tenant. An extraction provider at extract.example.net, which receives the PDF and calls back with results. A customer notification endpoint configured by a tenant administrator.
Processes. The API at app.example.com, a processing worker, a webhook receiver at hooks.example.com, and a notifier.
Data stores. An object storage bucket for uploads and reports, a job queue, and a database of tenants, jobs, extracted data and audit events.
Flows. The browser gets a presigned upload URL from the API, sends the PDF straight to the bucket, and tells the API it is done. The API enqueues a job. The worker reads the message and the PDF, parses it and sends it to the provider, whose callback results are written to the database; the worker renders a report into the bucket. The browser gets a presigned download URL for it. The notifier calls the customer's endpoint.
With a presigned upload the PDF never passes through the API. The process moving the data is the storage service, and your only controls are what you sign into the URL and what the bucket policy allows.
This is the boundary that matters most here. The API authenticates the user and knows the tenant. It writes a message with a job ID, an object key and a tenant ID. Later the worker acts on it, in this design through a database connection that can write to any tenant, because it serves all of them. The worker's authorisation is whatever the message says.
The question that exposes this: who else can write to this queue, and what does the worker do with a message it did not expect? Any service with publish permission, an operator re-driving the dead-letter queue, a second producer added next year. If the worker takes the tenant and object key from the message, any of those can make it read any object and write into any tenant.
The OWASP Multi-Tenant Security Cheat Sheet is direct: "A shared queue or topic is not itself a tenant-isolation boundary." It says to bind tenant context to the message through trusted broker routing, authenticated metadata or an integrity-protected payload, and "do not let an unverified message field replace the producer's authorized scope." At the consumer, it says to re-establish tenant context, authorise the operation and target resource, and re-check membership when delayed execution could make the original decision stale. A user removed from a tenant while their job sat in the queue is that case.
Decision: the message carries only a job ID. The worker loads the job record, which only the API writes, takes tenant and object key from it, and checks the key sits under that tenant's prefix. It sets tenant context per transaction rather than using a connection that bypasses isolation, since in OWASP's words a privileged job "must explicitly authorize and constrain its tenant set". Idempotency keys and dead-letter access are scoped per tenant too.
The same question applies to every store. What enforces the tenant boundary there: a key prefix, a storage policy, a database policy, or nothing? In this design the queue has nothing, which is why this decision carries so much weight.
The worker holds read access to the whole bucket, cross-tenant write access to the database, the provider's credentials and network egress. Then it runs a PDF parser on bytes chosen by whoever uploaded them. OWASP's upload guidance lists exploiting "vulnerabilities in the file parser or processing module" and decompression bombs among the threats, and says size limits should apply after decompression. PDFs can carry compressed streams, embedded fonts, images and attached files.
At design time you cannot answer whether the parser is safe. You can answer what a compromised parser would reach, and here it is everything the worker holds. That is elevation of privilege by architecture, whatever the parser's code quality. Decision: draw a boundary around the parser. Run it in a separate sandboxed process with no credentials and no network, under CPU, memory, page-count and output-size limits, and have it return structured data the worker validates. OWASP's image-processing advice is the same: an up-to-date library with restricted formats, resource limits and sandboxing. The multi-tenant cheat sheet adds tenant-aware limits on concurrent work and queued messages, so one tenant's two-thousand-page upload does not stall everyone else.
The parser library's published vulnerabilities are a composition-analysis finding, but whether one is a contained crash or a cross-tenant breach depends on the boundary drawn here.
The OWASP Webhook Security Cheat Sheet calls a webhook endpoint "a public, unauthenticated POST handler unless you add the authentication yourself". Its controls: an HMAC signature over the raw body, verified on every delivery in constant time; one signing secret per webhook; and, because a captured delivery still carries a valid signature, an authenticated timestamp within a tolerance window plus deduplication on the event ID.
The design-level threat sits behind the signature. A valid signature proves the provider sent the delivery. It does not prove which of your tenants the delivery concerns. If the receiver writes results to whatever job ID the payload names, tenant isolation now depends on a third party echoing your identifiers back correctly. Decision: store the provider's job reference against the job record at submission. Accept results only for a reference mapping to a job currently waiting on the provider, and take the tenant from the job record. The cheat sheet's advice to fetch current state from the publisher's API when an event may be stale supports a safer variant: treat the callback as a notification and pull the result with your own credentials.
The notifier makes HTTP requests from inside your network to a URL a tenant administrator typed. This is a confused deputy. Your server holds a network position the tenant lacks, including internal services and the cloud metadata endpoint, and uses it on the tenant's behalf. OWASP calls a publisher that delivers to user-supplied URLs "an outbound request engine that attackers will try to aim at your internal network". Its mitigation is an https-only allowlist plus a deny-list of every non-public address range, "enforced on the resolved IP at connect time". The timing matters, because validating a hostname when the URL is saved is check-then-use, and DNS can answer differently when the connection is made. When the response is never shown to anyone, the request is blind, which the post on out-of-band testing covers. The notification should also say a report is ready and identify it, not carry its contents to whatever URL is configured today.
This boundary also shows the response people forget: eliminate. If an email or an in-app status covers the need, the outbound flow and every threat on it disappear.
The API checks that the user may see the report, then issues a presigned download URL. OWASP's multi-tenant guidance says to authorise access to the exact object and operation before generating a signed URL. That is necessary but not sufficient, because the check happens once, at signing. After that the URL is a bearer token until it expires. Forwarded in an email or pasted into a ticket, it works for whoever holds it.
Repudiation is the quieter threat: in Microsoft's description, a system "that lacks the ability to trace the prohibited operations". Your application log records that it issued a URL, not whether, how often or by whom the object was fetched, because the fetch never touches your code. If a tenant must be able to ask which users downloaded a report, either proxy downloads through the API (bandwidth through your service) or retain the storage service's access logs and correlate them with issued URLs (correlation work, and the logs record a network address, not your user). Writing the requirement down forces the choice; leaving it out means discovering the answer during an incident.
The OWASP cheat sheet, citing Adam Shostack, lists four responses. Mitigate reduces the likelihood, as most decisions above do. Eliminate removes the feature or component, like the notification URL. Transfer moves contractual or financial responsibility to another party, and OWASP notes that it "does not itself reduce the likelihood or harmful consequences of an event", so document who owns the remaining controls. Accept leaves the threat in place when the other options are not acceptable under business constraints.
Acceptance needs an owner, a reason and a lapse condition: "Accepted: download URLs stay valid for fifteen minutes after a user loses access. Owner: platform lead. Revisit if reports start containing payment data." That can be reviewed; "accepted: low risk" cannot. NIST SP 800-218 asks for the same in task PW.1.2: "Track and maintain the software's security requirements, risks, and design decisions". Its examples include recording the rationale for any approved exception and periodically re-evaluating those exceptions.
"Ensure tenant isolation in the worker" cannot fail. Rewrite each decision so that a test can:
Each requirement then lives where tests live: an integration test, a policy assertion against the deployed bucket, or a custom static rule.
Keep an editable diagram, as OWASP advises, plus each threat with its element, each decision with owner and reason, and links from requirements to tests. Revisit when the diagram would change, not on a calendar: a new flow across a boundary, a new external entity, a component moving across a boundary (the parser moved into the API "for speed"), or a new writer to a store. The Manifesto's principle is that threat modelling should "follow design changes in iterations that are each scoped to manageable portions of the system."
Scanners find code-level instances of known patterns: tainted data reaching a dangerous sink, a vulnerable library, a secret in history, a public bucket in infrastructure code. Some threats above leave such a trace, such as an outbound HTTP call whose URL comes from a stored value.
The worker's tenant check leaves none. The code a scanner reads is a parameterised query, using a value from a message, in a service meant to write to that database. No source the rules treat as untrusted reaches a sink the rules treat as dangerous. What is wrong is a missing comparison between two values, and whether that comparison is required depends on the design: is the queue inside the trust boundary? A taint engine can only flag it if someone has declared queue messages untrusted and written a rule for the missing check, and that is the threat model written as a rule. The download audit trail is the same. No line of code is wrong; a requirement is missing.
The model produces requirements, some of which become tests or custom rules that scanning then enforces. In the other direction, findings that keep appearing across one boundary suggest it is not where the diagram puts it. The post on exploit-chain assembly shows how individually minor findings compose into a path across exactly these boundaries, which is why repeated chains belong back in the design review, not only in the backlog.
In AppSecD's view of the lifecycle, threat modelling is the design stage: components, trust boundaries and data flows are mapped before the code exists, so the controls that matter are identified while they are still cheap to add. The later stages check the code those decisions produce. SAST runs across more than twenty languages, alongside software composition analysis with typosquat, malicious-package and provenance checks, and secret detection that reads the full git history. Policy-driven gating blocks a pull request or commit that introduces a violation. An AI layer assembles attack chains across findings and assesses their business impact. Findings carry ageing, an SLA and an owner, and closing one can require maker-checker approval. The method in this post (the four questions, the diagram, STRIDE prompts, a walk along each boundary, and recorded decisions) is general practice that works with a whiteboard. Nothing here claims that AppSecD draws data-flow diagrams, applies STRIDE or generates threat lists.