Business-logic flaws let users break rules the code never enforced: paying a price they chose, ordering -89 cables, reusing a coupon, skipping payment, or poisoning a reset link with a forged Host header. Recompute amounts on the server from IDs and quantities, keep workflow state on the server and allow only listed transitions, build email links from a configured origin, limit valuable flows per account, and back each rule with a database constraint and a test.
- Accept only identifiers and quantities from the client. Look up prices, discounts, shipping and currency on the server, in integer minor units.
- Validate type as well as range: a quantity is an integer from 1 to the product limit, and
-1,0,1.5,"2"andtrueare each a different bug. - Model each multi-step process as server-side state with an explicit transition table. Only the payment provider's verified webhook may mark an order paid.
- Build password reset links from a configured origin, look the account up from a single string, and send only to the address stored on the account.
- Write invariants such as 'refunds never exceed captured funds' as
CHECKandUNIQUEconstraints, and attack them with generated inputs and concurrent requests. - Treat checkout, signup bonuses, referrals, coupons and SMS sending as automation targets, and give each its own per-account and per-instrument limits (OWASP API6:2023).
| Setting | Value | Note | Source |
|---|---|---|---|
| Money | Integer minor units, or a decimal type, rounded once by a documented rule | Binary floats do not add up exactly, and rounding each line separately lets amounts drift. | — |
| Quantity | Integer from 1 to the per-product limit; any other type is rejected | Enforce the same range with a CHECK constraint on the order lines. | CWE-1284; tested on PostgreSQL 18.6 and SQLite 3.53.4 |
| Django | ALLOWED_HOSTS = ['app.example.com']; USE_X_FORWARDED_HOST = False | '*' matches anything and leaves Host validation to you. X-Forwarded-Host is ignored unless enabled. | Django 6.1 settings reference |
| ASP.NET Core | "AllowedHosts": "app.example.com" | The web template ships "*". In testing on .NET 10, a forged Host got 200 with "*" and 400 with the list. | Microsoft Learn: host filtering; tested SDK 10.0.107 |
| Rails | config.hosts << "app.example.com" | Empty outside development, so production checks no Host header until you add hosts. Blocked hosts get 403. | Rails 8.1 guide: configuring |
| Express 5 | trust proxy limited to your proxy's addresses; reset links from APP_ORIGIN | When trust proxy is on, req.host and req.hostname come from X-Forwarded-Host, and the first value wins. | Express 5 request API; tested Express 5.3.0 |
- Replay every request in each flow with edited prices, totals and currencies, and with quantities of
-1,0,0.5,2147483648,"1",trueand an array; the server must reject the request or ignore the field. - Call each step of every multi-step flow directly and out of order, including confirmation without payment, and check that the server answers 403 or 409 and changes nothing.
- Send the same coupon, refund or bonus request 20 times concurrently and check that exactly one effect reaches the database.
- Request a password reset with
Host: evil.example, withX-Forwarded-Host: evil.exampleand with a repeated email parameter; the link must use the configured origin and the mail must go only to the stored address. - Run property-based tests (Hypothesis, fast-check or jqwik) against pricing and discount code, asserting that totals never go negative and discounts never exceed the subtotal.
- Script each sensitive flow past its per-account and per-instrument limits and confirm that requests are refused and an alert fires.
- Applies to
- Express 5.x (tested 5.3.0 on Node.js 26.3)
- Django 6.1 (ALLOWED_HOSTS, USE_X_FORWARDED_HOST)
- ASP.NET Core 10.0 (tested SDK 10.0.107)
- Rails 8.1 (config.hosts)
- PostgreSQL and SQLite Constraints tested on PostgreSQL 18.6 and SQLite 3.53.4
- Hypothesis 6.x (tested 6.168.5 with pytest 9.1.1)
Server-side business rules in commerce, account and workflow features. Client-side checks and hidden fields never enforce an invariant.
- Part of
- Learning path: AppSec in practice (step 4 of 18). Workflows: Secure a feature, Test an application
Learning goals and prerequisites
What you will learn
- Recognize price, quantity, coupon, workflow and reset-poisoning flaws in request traces.
- Recompute security-relevant values on the server and model workflows as state machines.
- Express business rules as database constraints and property-based tests.
- Identify sensitive business flows and choose layered anti-automation controls.
Before you start
Developers, architects and AppSec engineers who build or review checkout, account and other multi-step flows
- Read HTTP requests and JSON bodies.
- Know basic SQL constraints and transactions.
- Understand sessions and authentication at a basic level.
A business-logic flaw is a request that is valid in every technical sense but breaks a rule of the business: a price the customer chose, a step they skipped, a coupon used twice, a reset link sent to the wrong place. Scanners rarely find these, because no payload looks malicious and the server answers 200. The rule lives in the UI, a specification or someone's head, but not in the code path the request takes.
Give each rule one enforcement point on the server, close to the data, and a test that sends the violating request. ASVS 5.0.0 collects these requirements in chapter V2: steps in order (2.3.1), documented limits (2.3.2), all-or-nothing business transactions (2.3.3), locking for limited stock (2.3.4) and anti-automation (2.4).
| Flaw | Example | Invariant to enforce |
|---|---|---|
| Price tampering (CWE-472) | price=0.01 in the cart or in the payment redirect | Amounts come from the catalog, never the client |
| Quantity abuse (CWE-1284, CWE-839) | qty of -89, 0, 1.5, "2" or true | An integer from 1 to the product limit |
| Coupon abuse (CWE-837) | One code on many orders, stacked codes, cart edited after applying | One use per account, one per order, eligibility rechecked |
| Workflow bypass (CWE-841) | POST /orders/42/confirm without paying | Only listed transitions; payment set by the provider's webhook |
| Reset poisoning (CWE-640) | Host: evil.example on /password/forgot | Links use the configured origin; mail goes to the stored address |
| Unrestricted flows (API6:2023) | Bots buy the stock or farm referral credit | Limits per account, payment instrument and flow |
The usual root cause is a server that recomputes nothing. The checkout below multiplies a price and a quantity that both came from the browser, so each call at the bottom succeeds: an edited price, a negative quantity that cancels most of the total, and a coupon above 100 percent that produces a negative total. Run as written, they returned 0.01, 9.89 and -449.5. Some payment gateways accept negative amounts because they use them for refunds.
The same bug hides in hidden form fields, in a total passed to a payment gateway's redirect, in a currency code the server never checks, and in a quantity of 1.5 that a decimal column accepts. Floats add drift of their own, so keep money in integer minor units or a decimal type and round once, by a documented rule.
# checkout.py: trusts what the browser sent
def price_cart(lines, coupon_percent=0):
# lines arrive as JSON: [{"sku": "tv-55", "price": 899.0, "qty": 1}, ...]
subtotal = sum(line["price"] * line["qty"] for line in lines)
discount = subtotal * coupon_percent / 100
return round(subtotal - discount, 2)
# Each of these is accepted:
price_cart([{"sku": "tv-55", "price": 0.01, "qty": 1}]) # edited price
price_cart([{"sku": "tv-55", "price": 899.0, "qty": 1},
{"sku": "hdmi", "price": 9.99, "qty": -89}]) # negative quantity
price_cart([{"sku": "tv-55", "price": 899.0, "qty": 1}], coupon_percent=150) # negative totalThe fixed function accepts only a SKU and a quantity per line. It looks up price and currency, requires an integer quantity from 1 to the product's limit, refuses repeated SKUs, applies the coupon only while the subtotal meets its threshold, and keeps the discount within the subtotal. type(qty) is not int also rejects True, which Python treats as 1 and a JSON body can carry. If an invariant fails anyway, the function raises instead of charging a wrong amount.
Recompute whenever the amount can change: when items are added, when a coupon is applied, when the cart changes afterwards, and when the order is placed, using the stored order rather than the cart the browser displays. Send the payment provider the amount from your order record, then compare the captured amount and currency in its webhook with that record. The business logic lab at /labs/coupon-limits/ has you repair a checkout that trusts the prices and quantities the client sends.
# pricing.py: the server derives every amount from its own data
from dataclasses import dataclass
@dataclass(frozen=True)
class Product:
sku: str
unit_price: int # integer minor units (cents), never floats
currency: str
max_qty: int = 20 # per-order purchase limit
@dataclass(frozen=True)
class Coupon:
code: str
percent_off: int # 1 to 100
min_subtotal: int # minor units
class CartError(ValueError):
pass
def price_cart(lines, catalog, coupon=None, currency="EUR"):
"""lines: [{"sku": str, "qty": int}] from the client. Nothing else is accepted."""
if not isinstance(lines, list) or not 1 <= len(lines) <= 50:
raise CartError("cart must have 1 to 50 lines")
subtotal, seen = 0, set()
for line in lines:
sku, qty = line.get("sku"), line.get("qty")
product = catalog.get(sku) if isinstance(sku, str) else None
if product is None or sku in seen:
raise CartError(f"unknown or repeated sku: {sku!r}")
if type(qty) is not int or not 1 <= qty <= product.max_qty: # rejects -1, 0, 1.5, "2", True
raise CartError(f"invalid quantity for {sku}")
if product.currency != currency:
raise CartError("currency mismatch")
seen.add(sku)
subtotal += product.unit_price * qty
discount = 0
if coupon is not None:
if not 1 <= coupon.percent_off <= 100:
raise CartError("invalid coupon")
if subtotal >= coupon.min_subtotal: # re-evaluated on every cart change
discount = subtotal * coupon.percent_off // 100
total = subtotal - discount
if not (0 <= discount <= subtotal and total >= 0):
raise CartError("pricing invariant violated") # fail closed
return {"subtotal": subtotal, "discount": discount, "total": total, "currency": currency}Promotions fail in predictable ways: a single-use code redeemed on many orders or accounts, two codes stacked to push the price below cost, a cart emptied after a minimum-spend coupon was applied, codes found by enumeration (OWASP OAT-002 Token Cracking), and refunds larger than the payment. Express each rule as a constraint, so a bug elsewhere fails loudly instead of losing money.
This schema enforces non-negative totals, quantities from 1 to 20, one redemption per coupon and account, one coupon per order, and refunds that never exceed captured funds. The conditional UPDATE refunds only while money remains, and the caller treats zero updated rows as a refusal. All of it held on PostgreSQL 18.6 and SQLite 3.53.4, and two concurrent refunds of 6,000 against a 10,000 capture applied exactly once. /domains/appsec/business-logic/transaction-races/ covers locking, isolation and idempotency keys in depth.
-- Invariants the database enforces even when application code has a bug.
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
account_id BIGINT NOT NULL,
state TEXT NOT NULL,
total BIGINT NOT NULL CHECK (total >= 0), -- minor units
captured BIGINT NOT NULL DEFAULT 0 CHECK (captured BETWEEN 0 AND total),
refunded BIGINT NOT NULL DEFAULT 0 CHECK (refunded BETWEEN 0 AND captured)
);
CREATE TABLE order_lines (
order_id BIGINT NOT NULL REFERENCES orders(id),
sku TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity BETWEEN 1 AND 20),
unit_price BIGINT NOT NULL CHECK (unit_price >= 0), -- copied from the catalog
PRIMARY KEY (order_id, sku)
);
CREATE TABLE coupon_redemptions (
coupon_id BIGINT NOT NULL,
account_id BIGINT NOT NULL,
order_id BIGINT NOT NULL REFERENCES orders(id),
UNIQUE (coupon_id, account_id), -- each account uses a coupon once
UNIQUE (order_id) -- one coupon per order: no stacking
);
-- Refund only money that was captured; the caller requires exactly one updated row.
UPDATE orders SET refunded = refunded + :amount
WHERE id = :order_id AND :amount > 0 AND refunded + :amount <= captured;Multi-step processes break when the server trusts the client to follow the steps. Typical bypasses are calling the confirmation endpoint without paying, loading the payment success page directly, replaying a step that grants something, changing the cart between payment and confirmation, and carrying the current step in a hidden field. ASVS 5.0.0 requirement 2.3.1 asks that a user's flow runs in order, without skipped steps.
Keep the state on the server and list the allowed transitions. The module below also lists which caller may request each action: the browser can check out and cancel, only the payment provider's verified webhook can mark an order paid, and only staff tools can ship or refund. Every other combination fails with 403, or with 409 when the state is wrong; the tests walk all 126 combinations of state, caller and action. Save the new state with a compare-and-set update so two requests that read the same state cannot both win.
// order-state.js: the server owns an order's state; callers can only request an action.
export const TRANSITIONS = Object.freeze({
cart: { checkout: 'awaiting_payment' },
awaiting_payment: { payment_captured: 'paid', cancel: 'cancelled' },
paid: { ship: 'shipped', refund: 'refunded' },
shipped: { deliver: 'delivered' },
});
// Who may request each action. payment_captured comes only from the verified
// payment-provider webhook, never from the browser landing on a success page.
const ALLOWED = Object.freeze({
customer: new Set(['checkout', 'cancel']),
payment_webhook: new Set(['payment_captured']),
staff: new Set(['ship', 'deliver', 'refund']),
});
export class WorkflowError extends Error {
constructor(status, message) { super(message); this.status = status; }
}
export function nextState(order, action, actor) {
if (!ALLOWED[actor.role]?.has(action)) throw new WorkflowError(403, `${actor.role} may not ${action}`);
if (actor.role === 'customer' && order.customerId !== actor.id) throw new WorkflowError(404, 'no such order');
const next = TRANSITIONS[order.state]?.[action];
if (!next) throw new WorkflowError(409, `cannot ${action} an order that is ${order.state}`);
return next;
}
// Save with compare-and-set so two requests cannot both move the same order:
// UPDATE orders SET state = :next WHERE id = :id AND state = :current -- require 1 rowA reset email is only as safe as the link inside it. If the application builds that link from the request's Host or X-Forwarded-Host header, an attacker requests a reset for the victim with a forged header, the victim receives a genuine email whose link points to the attacker's server, and one click hands over the token (CWE-640). Express 5's req.host reads X-Forwarded-Host whenever trust proxy is enabled and takes the first value, so a proxy that appends rather than replaces the header leaves the client in control. Django fixed a Host-parsing variant in 2012 (CVE-2012-4520) and now checks Host against ALLOWED_HOSTS.
The recipient matters as much as the link. In CVE-2023-7028, rated CVSS 10.0, GitLab 16.1 to 16.7 before the January 2024 patch releases delivered reset emails to unverified addresses, and GitLab told administrators to look for reset requests whose email parameter held a JSON array of several addresses. CISA added it to the Known Exploited Vulnerabilities catalog in May 2024. The handler below has both bugs: in the tests, a forged header put evil.example in the link, and a repeated email parameter sent the reset to the attacker as well as the victim.
// Vulnerable: the link's host and the recipient both come from the request.
app.set('trust proxy', true); // behind a load balancer
app.post('/password/forgot', express.urlencoded({ extended: false }), async (req, res) => {
// email=victim@example.com&email=attacker@evil.example arrives as an array, and an
// ORM that turns arrays into IN (...) still finds the victim's account.
const user = await users.findByEmail(req.body.email);
if (user) {
const token = await resetTokens.issue(user.id);
// req.host is X-Forwarded-Host when trust proxy is on; Host otherwise. Both are client input.
const link = `${req.protocol}://${req.host}/password/reset?token=${token}`;
await mailer.send({ to: req.body.email, subject: 'Reset your password', text: link });
}
res.status(202).send('If that address has an account, we sent a reset link.');
});The fixed handler takes the origin from configuration, accepts the email only as a single string, normalizes it for the lookup, and sends to the address stored on the account. It returns the same 202 response whether or not the account exists. In the tests, forged Host and X-Forwarded-Host headers no longer changed the link, and the repeated parameter sent nothing. Queue the email rather than sending it inline, so response time does not reveal which addresses have accounts. The token should be random, single-use and short-lived, as /domains/identity-crypto/authentication/account-recovery-linking/ describes.
Also reject unexpected hosts before requests reach application code, because other absolute URLs, redirects and cache keys depend on the same header. The defaults table lists the settings for Django, ASP.NET Core, Rails and Express.
// Fixed: a configured origin, one string address, and the address stored on the account.
const APP_ORIGIN = new URL(process.env.APP_ORIGIN).origin; // e.g. https://app.example.com
app.post('/password/forgot', express.urlencoded({ extended: false }), async (req, res) => {
const email = typeof req.body.email === 'string' ? req.body.email.trim().toLowerCase() : '';
const user = email && await users.findByEmail(email);
if (user) {
const token = await resetTokens.issue(user.id); // random, single-use, short-lived, stored hashed
const link = new URL('/password/reset', APP_ORIGIN); // never from Host or X-Forwarded-Host
link.searchParams.set('token', token);
await mailer.send({ to: user.email, subject: 'Reset your password', text: link.href });
}
res.status(202).send('If that address has an account, we sent a reset link.');
});Some flows hurt the business when used at machine speed, even though each request is legitimate: buying limited stock to resell it (OAT-005 Scalping), holding items in carts without paying (OAT-021 Denial of Inventory), creating accounts to farm signup or referral credit (OAT-019 Account Creation), guessing coupon and gift card codes (OAT-002), and triggering paid SMS messages, which OWASP lists as SMS pumping under OAT-003 Cost-Inflation Fraud. The OWASP API Security Top 10 calls this API6:2023, Unrestricted Access to Sensitive Business Flows.
Start from the business: list the flows whose overuse costs money or harms other customers, and set limits that fit a real customer. Then layer the controls: limits per account, payment instrument, device and address, with lower limits for new accounts; holds that expire; purchase caps on limited releases; verification before value-granting actions; and monitoring for non-human timing, such as checkout within a second of adding to the cart. CAPTCHAs and IP blocking raise the attacker's cost but are bypassed at scale, so keep the server-side limits authoritative and alert when they trip.
For every feature that moves money, grants value or changes state, write the invariants in plain words before the code: totals are never negative, a discount never exceeds the subtotal, refunds never exceed captured funds, a coupon is used once per account, and an order is paid only after the provider confirms it. Each needs an enforcement point and a test that tries to break it.
Example tests check the cases you thought of; property-based tests generate a hundred carts and coupons per test by default and check the invariants on every result. These three properties pass against the fixed pricing code. Against deliberately broken copies, with no lower bound on quantity, an unbounded coupon percentage, booleans or floats accepted as quantities, or no minimum-spend check, they failed in at least 19 of 20 fresh runs. Pair them with negative tests for every workflow step and with tests that fire the same request many times at once.
# test_pricing_properties.py: pytest + Hypothesis generate carts and coupons
import pytest
from hypothesis import given, strategies as st
from pricing import CartError, Coupon, Product, price_cart
CATALOG = {sku: Product(sku, price, "EUR") for sku, price in
[("hdmi", 999), ("mount", 4_999), ("tv-55", 89_900)]}
valid_carts = st.lists(
st.fixed_dictionaries({"sku": st.sampled_from(sorted(CATALOG)), "qty": st.integers(1, 20)}),
min_size=1, max_size=3, unique_by=lambda line: line["sku"])
bad_qty = st.one_of(st.integers(max_value=0), st.integers(min_value=21), st.floats(),
st.text(), st.booleans(), st.none())
coupons = st.none() | st.builds(Coupon, code=st.just("C"), percent_off=st.integers(-50, 200),
min_subtotal=st.integers(0, 2_000_000))
@given(valid_carts, coupons)
def test_totals_add_up_and_never_go_negative(cart, coupon):
try:
r = price_cart(cart, CATALOG, coupon)
except CartError:
assert not 1 <= coupon.percent_off <= 100 # the only acceptable reason to refuse
return
assert 0 <= r["discount"] <= r["subtotal"]
assert r["total"] == r["subtotal"] - r["discount"] >= 0
if coupon is None or r["subtotal"] < coupon.min_subtotal:
assert r["discount"] == 0 # no discount below the threshold
@given(valid_carts, bad_qty)
def test_any_bad_quantity_rejects_the_cart(cart, qty):
cart[0]["qty"] = qty
with pytest.raises(CartError):
price_cart(cart, CATALOG)
@given(valid_carts)
def test_line_order_never_changes_the_price(cart):
assert price_cart(cart[::-1], CATALOG) == price_cart(cart, CATALOG)Most logic flaws survive because a rule is enforced in one place and assumed everywhere else.
- Validating only in the browser. Client-side checks help users; ASVS 2.2.2 requires validation at a trusted service layer.
- Checking eligibility once. A coupon accepted for a 200 EUR cart stays applied after the customer removes items.
- Treating a redirect to the payment success page as proof of payment instead of the provider's signed webhook.
- Taking user, account or tenant identifiers from the request body in a business action instead of from the session.
- Relying on one global rate limit for a flow that needs per-account and per-instrument limits.
- Leaving
ALLOWED_HOSTS = ['*']or"AllowedHosts": "*"in production. - Fixing the web endpoint while the mobile API, an admin tool or a bulk import still accepts the old fields.
Guided practice
A ticketing service sells a limited number of floor seats for a concert. Review its flows, find the logic flaws, and specify the invariants and tests that would have caught them.
Supplied scenario material
POST /api/cart/items {"eventId": 7, "seatClass": "floor", "qty": 2, "unitPrice": 120.00}
POST /api/cart/coupon {"code": "FAN10"} 10% off when subtotal >= 200.00, checked only here
POST /api/orders creates an order from the cart in state "pending_payment", returns paymentUrl
GET /payment/return?orderId=55&status=success marks order 55 "paid" and issues the tickets
POST /api/orders/55/refund {"amount": 240.00} allowed for any amount up to the order total
POST /password/forgot link = "https://" + X-Forwarded-Host + "/reset?t=" + token, mailed to the submitted address
Rate limit: 600 requests per minute per IP address, at the CDN
Observed: within 90 seconds of opening, one buyer took 40% of the floor seats using 300 accounts and 12 payment cards.1. Find the tampering flaws
List what a customer can change in the cart, coupon and refund requests, and what each change gains.
Expected result and reasoning
unitPricecomes from the client, so the price is chosen by the buyer.qtyhas no stated bounds, so negative or huge values are possible. The coupon threshold is checked only when the code is applied, so seats can be removed afterwards. Each refund is checked against the order total but not against earlier refunds, so repeated refunds can exceed the payment.2. Fix the payment step
Explain why
/payment/returnis a workflow bypass and redesign it.Expected result and reasoning
Anyone can request
/payment/return?status=successfor their own pending order. Mark orders paid only from the provider's signed webhook, after matching the order ID, amount and currency, and only from thepending_paymentstate. The return page should just display the stored state, and tickets are issued on the transition topaid.3. Fix the password reset
Describe the attack on
/password/forgotand the corrected handler.Expected result and reasoning
An attacker submits the victim's address with
X-Forwarded-Host: evil.example, so the victim gets a real email that sends the token to the attacker. Build the link from a configured origin, look the account up from one string and mail the stored address only. ValidateHostat the edge and in the framework as well.4. Address the seat grab
Explain why the CDN limit did not help and propose controls for this flow.
Expected result and reasoning
A per-IP limit does not bound a buyer who spreads requests across many accounts and addresses, and each request was legitimate on its own. Add a per-customer seat cap for the event enforced in the database, limits per payment card, lower limits or verification for new accounts, short holds for unpaid carts, and monitoring for many accounts sharing a few cards. A queue or ballot suits high-demand releases.
5. Write the invariants and tests
State four invariants for this service and one test for each.
Expected result and reasoning
Order total equals catalog price times quantity minus discount and is never negative (property-based test). Refunds never exceed captured funds (CHECK constraint plus a test with concurrent refunds). Tickets exist only for paid orders (call
/payment/returndirectly and expect no change). Seats sold per customer and per event never exceed their caps (concurrent purchase test).
Paper exercise: the fixture is complete. The pricing, constraint, workflow and reset patterns in this guide run locally if you want to check your answers.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Repair the code
Business logic lab: Price an order without trusting the client
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- OWASP API Security Top 10 2023: API6 Unrestricted Access to Sensitive Business Flows
- OWASP ASVS 5.0.0, V2 Validation and Business Logic
- OWASP Business Logic Security Cheat Sheet
- OWASP WSTG 4.2: Testing for the Circumvention of Work Flows (WSTG-BUSL-06)
- OWASP WSTG 4.2: Testing for Host Header Injection, including password reset poisoning (WSTG-INPV-17)
- MITRE CWE-640: Weak Password Recovery Mechanism for Forgotten Password. CWE 4.20.
- GitLab: critical security release 16.7.2 (CVE-2023-7028). 11 January 2024.
- Django 6.1: Host header validation
- OWASP Forgot Password Cheat Sheet
- OWASP Automated Threats to Web Applications (OAT handbook)