Skip to content
academia.sh

Lesson 11 / 17

Business Logic Flaws

Modeling the business logic flaw as a flow of steps: in a five-step dispute-and-refund flow, five hundred and one of the six hundred and twenty-five four-step paths yield a rule-breaking result under per-step authorization, three hundred sixty-nine make a payment, and one hundred thirteen exceed the quota; a state machine brings these numbers down to zero and the paying path down to one, and when a sixth step with an undeclared transition is added, the rule-breaking path count under per-step setup rises to one thousand one hundred seventy-two while the state machine stays at zero, and as the sequence length grows the share of rule-breaking paths rises from sixty percent to ninety percent.

Published Updated

In this lesson

  • Models a business flow as a sequence of steps and generates the path set
  • Counts the number of paths where steps can be skipped and repeated
  • Compares the number of paths a state machine closes against its cost to legitimate flexibility
Contents

The previous lesson asked the authorization question over a single request and gave three numbers: how many endpoints perform object-level control, how many requests wrongly pass, how many the central gate closes. That whole measurement rested on the single-request assumption. A request being correct on its own, though, does not mean a rule has been preserved.

Part of a system’s rules live not in single requests but in sequences of requests. A refund is paid once, a discount is applied once per period, an approval comes before a payment. None of these sentences can be written inside a single endpoint; they are all about the order and repetition of steps. A business logic flaw is born where these rules are not checked: each step is authorized one by one, while the sequence itself is not.

The Rule Lives in the Order of Steps

For the measurement, a flow is reduced to a set of steps. Two properties are written for each step: its effect (payment, discount) and which prior states it is allowed to follow in the state machine. If the second field is left empty, the step cannot run in any state — this is the flow equivalent of the previous lesson’s default-deny.

Two setups are compared. Per-step authorization authorizes each step on its own and never asks about order; this is the behavior of a service that performs authorization per endpoint. The state machine tests the current state before every step and does not run a step that is not permitted.

The flow below is a model: there is no real payment, no real amount, and no real currency; the amounts are fictional units.

// flow/model.mjs — the subscriber portal's invoice dispute and refund flow (model): no real
// payment, no real amount and no real currency. The amounts are fictional units.

// IE13: every step carries two things — its effect (payment, discount) and which prior states
// it can follow in the state machine. If `allowed` is empty, the step cannot run in any state.
export const EFFECT = {
  "apply":          { allowed: ["none"],                  next: "applied" },
  "review":         { allowed: ["applied"],                next: "reviewed" },
  "approve":        { allowed: ["reviewed"],                next: "approved" },
  "refund-pay":     { allowed: ["approved"],                next: "closed",  payment: 100 },
  "apply-discount": { allowed: ["applied", "reviewed"],     next: null,      discount: 1 },
};
export const STEP = Object.keys(EFFECT);
export const QUOTA = 1;                       // discount quota: once per period

// IE14: two setups run the same sequence of steps. In the "per-step" setup, every step carries
// its own authorization and order is never asked; in the "state-machine" setup, a step that is
// not permitted is skipped.
export function run(sequence, setup, effect = EFFECT) {
  const d = { state: "none", payment: 0, discount: 0 };
  for (const step of sequence) {
    const e = effect[step];
    if (setup === "state-machine") {
      if (!e.allowed.includes(d.state)) continue;
      if (e.discount && d.discount >= QUOTA) continue;
    }
    d.payment += e.payment ?? 0;
    d.discount += e.discount ?? 0;
    if (e.next) d.state = e.next;
  }
  return d;
}

// IE15: the path set is every fixed-length sequence generated from the given step set.
export function paths(steps, length) {
  let l = [[]];
  for (let i = 0; i < length; i++) l = l.flatMap((d) => steps.map((a) => [...d, a]));
  return l;
}
// flow/measurement.mjs — how many paths exist where steps can be skipped or repeated, how many
// the state machine closes, and how many are left uncovered.
import { EFFECT, STEP, QUOTA, run, paths } from "./model.mjs";
const write = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(""));
const CODE = { b: "apply", i: "review", o: "approve", d: "refund-pay", n: "apply-discount" };

const A = [-10, 10, 13, 12, 13];
console.log("1. four-step example sequences (b=apply i=review o=approve d=refund-pay n=discount)");
write(A, "sequence", "PS payment", "PS discount", "SM payment", "SM discount");
for (const short of ["biod", "bdio", "bidd", "bddd", "bnnn"]) {
  const sequence = [...short].map((c) => CODE[c]);
  const a = run(sequence, "per-step"), m = run(sequence, "state-machine");
  write(A, [...short].join(" "), a.payment, a.discount, m.payment, m.discount);
}

// IE16: a path is rule-breaking if the per-step setup gives a more generous result than the
// state machine. The count below actually runs every path.
const LENGTH = 4;
const measure = (steps, effect, length = LENGTH) => {
  const path = paths(steps, length);
  const s = { path: path.length, paying: [0, 0], over: [0, 0], quota: [0, 0],
    outside: [0, 0], same: 0 };
  for (const d of path) {
    const k = [run(d, "per-step", effect), run(d, "state-machine", effect)];
    for (let i = 0; i < 2; i++) {
      if (k[i].payment > 0) s.paying[i]++;
      if (k[i].payment > 100) s.over[i]++;
      if (k[i].discount > QUOTA) s.quota[i]++;
      if (k[i].payment > k[1].payment || k[i].discount > k[1].discount) s.outside[i]++;
    }
    if (k[0].payment === k[1].payment && k[0].discount === k[1].discount) s.same++;
  }
  return s;
};
const t = measure(STEP, EFFECT);
const B = [-34, 16, 16];
console.log(`\n2. all length-${LENGTH} sequences (${STEP.length} steps, ${t.path} paths)`);
write(B, "measure", "per-step", "state-machine");
for (const [name, field] of [["paying path", "paying"], ["paying more than 100 units", "over"],
  ["exceeding the discount quota", "quota"], ["yielding a rule-breaking result", "outside"]])
  write(B, name, t[field][0], t[field][1]);
console.log(`paths where the two setups give the same result: ${t.same} / ${t.path}` +
  ` (${((t.same / t.path) * 100).toFixed(1)}%)`);

// 3. an undeclared new step: because its transition is not defined, the state machine does
// not run it
const EXTENDED = { ...EFFECT, "extra-payment": { allowed: [], next: null, payment: 100 } };
const g = measure(Object.keys(EXTENDED), EXTENDED);
const C = [-28, 7, 8, 14, 15];
console.log("\n3. when a new step without a declared transition (extra-payment) is added");
write(C, "setup", "steps", "paths", "paying", "rule-breaking");
for (const [name, s, i, n] of [["per-step", t, 0, 5], ["state-machine", t, 1, 5],
  ["per-step, new step", g, 0, 6], ["state-machine, new step", g, 1, 6]])
  write(C, name, n, s.path, s.paying[i], s.outside[i]);

// 4. as the sequence length grows, the path set and the rule-breaking path count
const D = [-9, 9, 18, 20, 18];
console.log(`\n4. as the sequence length grows (${STEP.length} steps)`);
write(D, "length", "paths", "PS rule-breaking", "PS rule-breaking %", "SM rule-breaking");
for (let u = 2; u <= 6; u++) {
  const s = measure(STEP, EFFECT, u);
  write(D, u, s.path, s.outside[0], ((s.outside[0] / s.path) * 100).toFixed(1), s.outside[1]);
}
1. four-step example sequences (b=apply i=review o=approve d=refund-pay n=discount)
sequence  PS payment  PS discount  SM payment  SM discount
b i o d          100            0         100            0
b d i o          100            0           0            0
b i d d          200            0           0            0
b d d d          300            0           0            0
b n n n            0            3           0            1

2. all length-4 sequences (5 steps, 625 paths)
measure                                   per-step   state-machine
paying path                                    369               1
paying more than 100 units                     113               0
exceeding the discount quota                   113               0
yielding a rule-breaking result                501               0
paths where the two setups give the same result: 124 / 625 (19.8%)

3. when a new step without a declared transition (extra-payment) is added
setup                         steps   paths        paying  rule-breaking
per-step                          5     625           369            501
state-machine                     5     625             1              0
per-step, new step                6    1296          1040           1172
state-machine, new step           6    1296             1              0

4. as the sequence length grows (5 steps)
length       paths  PS rule-breaking  PS rule-breaking %  SM rule-breaking
2               25                15                60.0                 0
3              125                90                72.0                 0
4              625               501                80.2                 0
5             3125              2686                86.0                 0
6            15625             14075                90.1                 0

The numbers belong to the measurement class; their inputs are IE13–IE16.

Five Sequences, Three Kinds of Flaw

The first table runs five example sequences through both setups. The first row is the expected flow: apply, review, approve, pay. Both setups pay 100 units.

The second row is step skipping: the payment comes before the approval. Under per-step authorization, 100 units are paid, because the payment step itself is authorized and whether the steps before it were completed is never asked. Under the state machine, the same sequence pays 0.

The third and fourth rows are step repetition: the same payment step passes two and three times, giving results of 200 and 300 units. In both sequences, the payment step is authorized on its own each time; what is missing is the knowledge that the step can run only once.

The fifth row is a quota overrun. The discount step is applied three times, and under per-step authorization the discount rises to 3. Under the state machine it stays at 1, because the quota is also state information: once the discount has been applied, a second application is not permitted.

What the three kinds of flaw have in common is that none of them breaks the authorization check. Every step was called with the right role, on the right object, with the right criterion. What is broken is the flow itself.

This also shows why the previous lesson’s measurement is not enough. The authorization matrix found a missing criterion in five of fourteen endpoints; here, even if every criterion is in place, 501 paths still yield a rule-breaking result. The two measurements do not substitute for one another: one checks who can touch which object, the other checks in which order they can touch it. Saying “authorization check complete” for an endpoint says nothing about that endpoint being called in the middle of a sequence.

Five Hundred and One of Six Hundred and Twenty-Five Paths

The second table counts the entire path set. The number of four-step sequences generated from five steps is 625, and every one of them is actually run through both setups.

Generating the path set at a fixed length is a deliberate choice. A real client can call whatever step it wants, as many times as it wants; the four-step window is a countable slice of that freedom. When the slice is enlarged, the result does not change, only the numbers grow — the fourth table shows this. The measurement’s validity does not rest on the window’s width, but on the same window being used in both setups.

Under per-step authorization, 369 paths make a payment; under the state machine, 1. The gap between them is 368 paths, and all of them are payment without approval. 113 paths pay more than 100 units — these are the sequences where the payment step passes two or three times. The same number comes up for the discount quota too: 113 paths exceed the quota.

The total is in the last row: 501 paths give a more generous result than the state machine — 80.2% of the path set. Only 124 paths give the same result under both setups, that is, 19.8%. This ratio says that in a flow that does not check order, the paths that “behave correctly” are a minority: the rule-breaking result is not the exception, it is the majority.

This number being so large is not surprising despite the smallness of the step set. The path count is the step count raised to the power of the length; five steps and length four give 625, six steps and length four give 1,296. Every time a new step is added to the flow, the number of sequences that need checking grows exponentially. That is why the rule cannot be written per sequence; it has to be written per state.

The fourth table measures this growth. As the sequence length goes from 2 to 6, the path count goes from 25 to 15,625, and the rule-breaking path count under the per-step setup goes from 15 to 14,075. The real information is not in the last column but in the ratio column: the share of rule-breaking paths rises from 60.0% to 90.1%. As the sequence lengthens, the share of correctly-behaving paths falls, because the correct order is unique while the number of wrong orders multiplies with length. The state-machine column is zero in all five rows — the transition table is independent of sequence length.

This also says why protecting order through testing is not enough. A test set that tests all four-step sequences contains 625 cases and rises to 3,125 at five steps; a five-row transition table, however, does not change. What keeps the rule-breaking path count at zero is not the number of tests, but where the decision sits.

What the State Machine Closes, and What It Costs

The state machine closes 501 of 625 paths and brings the rule-breaking path count to zero. The closing mechanism is not a checklist: every step’s permitted prior states are written down, and every transition not on that list is rejected. A five-row table closes 501 rule-breaking paths at once.

The third table answers the new-step question. When a sixth step with an undeclared transition — extra-payment — is added, under the per-step setup the path count rises to 1,296, the paying path count to 1,040, and the rule-breaking path count to 1,172. Under the state machine, the same step opens no path at all: the paying path count stays at 1, the rule-breaking count at 0. Because the allowed list is empty, the step never runs after any state.

The cost is here too, and it has to be written down. An undeclared step never runs at all; the new feature stays dead until it is added to the transition table. The same cost shows up in legitimate flexibility too: the discount step is only permitted in the applied and reviewed states, so a legitimate piece of work that wants to apply a discount after approval is not accepted. Satisfying that request means adding a row to the table, and that row is a deliberate decision — one that can be seen in code review, tested, and reverted.

The size of the cost is also in the table. Under the state machine, the paying path count is 1; under per-step authorization, it is 369. All 368 of those paths have been closed, and the user is left with exactly one order to follow to complete the flow. If a flow has legitimate flexibility — a document being addable before review too, a discount being applicable at two different points — that flexibility has to show up as a row in the transition table. Flexibility that is not in the table is not a product decision, it is a closed path.

One detail matters in implementation. In the model above, a step that is not permitted is silently skipped; in a real service, a rejection has to be returned instead of skipping it. Silent skipping leads the caller to believe the step ran, and that puts a consistency bug in place of the flaw that was closed. The state machine’s value is in making the decision; reporting that decision is a separate responsibility.

Summary

  • A business logic flaw is born not in a single request’s authorization, but in the order and repetition of the step sequence; each step can be authorized individually while the sequence itself is not.
  • Three kinds of flaw were measured: step skipping (payment without approval), step repetition (paying 200 and 300 units), and quota overrun (the discount applied three times).
  • Of the 625 four-step paths generated from five steps, 369 make a payment, 113 pay more than 100 units, 113 exceed the quota, and 501 (80.2%) yield a rule-breaking result.
  • The state machine brings the rule-breaking path count to zero and the paying path count to one; the closing mechanism is a five-row transition table, not a check written per sequence.
  • When a sixth step with an undeclared transition is added, the rule-breaking path count under the per-step setup rises from 501 to 1,172 while the state machine stays at 0; as the sequence length goes from 2 to 6, the share of rule-breaking paths rises from 60.0% to 90.1% while the state-machine column stays at zero.
  • A step that is not permitted is not silently skipped, it is rejected; silent skipping puts a consistency bug in place of the flaw.

Next Step

This lesson’s entire measurement rests on a single assumption: the steps run one after another. When the state machine reaches the payment step, it reads that the state is approved, makes its decision, and sets the state to closed. It is assumed that nothing else happens in the time between the read and the write. This assumption falls apart when two requests arrive at the same time: both can read the state as approved, both can pass the check, and both can pay. The next lesson counts how many times the same check passes in a concurrent-request model, and measures how many of them a lock and a single-writer queue close.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close