---
title: 'Security Groups and Network Access Lists'
source: 'https://academia.sh/en/courses/compute-and-networking-in-the-cloud/security-groups-and-network-access-lists'
course: 'Compute and Networking in the Cloud'
language: en
updated: '2026-09-13T05:07:38+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Writes the same policy in two mechanisms and counts how many extra rows the stateless side needs for return traffic'
    - 'Runs twelve known flows and works out the caught, escaped, and mistakenly closed flows item by item'
    - 'Measures what rule order can and cannot fix, and shows flow by flow which mechanism decides when the two are used together'
---

# Security Groups and Network Access Lists

The same policy is written in two mechanisms, stateful and stateless, and run against twelve known flows: how many rules each needs, how many rows are added for return traffic, how many flows escape and how many legitimate flows get closed by mistake, what changing the rule number fixes, and which one decides when the two are used together.

The previous lesson's open item column assumed every instance listens on four ports, and how many
of those ports actually need to stay open was never asked. Subnet separation blocks an instance
from being called from outside; it does not say which call gets accepted. Two separate mechanisms
say that, and in this model both are present in the same network at once. Not every provider
offers both; some networks have only the stateful one, and the comparison below then collapses
to a single column.

The only difference between them is whether they keep state. One remembers an outgoing request and
accepts its return on its own; the other evaluates every packet on its own, remembering nothing.
Open ports and default-deny at the machine level were measured in a separate lesson; the question
here is not whether a port is open, but how many rules the same policy comes to in each mechanism,
and where the difference blows up.

- **CN25.** Twelve flows, four blocks, and the rule sets belong to the course's fictional regional
  measurement network. No real packet flows; what is measured is the model built with `node`.
- **CN26.** All flows are written relative to the application subnet, and a single zone is
  modeled. In reality every zone has its own list; it multiplies the rule count by the zone count
  without changing the direction of the result.
- **CN27.** The ephemeral port range is assumed to be 32768–60999. In reality the range varies by
  operating system; it shifts the port numbers of escaped flows without changing the mechanism of
  the escape.
- **CN28.** The state table's capacity and timeout are not modeled; the group is counted as
  recognizing the return of every outgoing request.
- **CN29.** Whether a flow is legitimate is marked by hand; there is no randomness, and every run
  produces the same classification.
- **CN30.** External-party addresses are chosen from blocks reserved for documentation.

## The Model of Two Mechanisms

The model derives both mechanisms from the same policy. The group writes only the request
direction, and the `run` function lets the return through on its own. The list is made of
numbered rows, the first match wins, and each flow's two directions are evaluated separately —
this separate evaluation is the model's entire claim.

```js
// filter.mjs — the same policy in two mechanisms; no real packet flows, the model runs
export const toInt = (ip) => ip.split('.').reduce((a, o) => a * 256 + Number(o), 0);
export const contains = (c, ip) => {
  const [a, e] = c.split('/'); const b = 2 ** (32 - Number(e));
  const start = Math.floor(toInt(a) / b) * b, n = toInt(ip);
  return n >= start && n <= start + b - 1;
};
export const SUBNET = { public: '10.42.0.0/24', application: '10.42.1.0/24', data: '10.42.2.0/24',
  management: '10.42.3.0/24', any: '0.0.0.0/0' };
const EPHEMERAL = [32768, 60999];                       // ephemeral port range (CN27)

// Stateful group: only the request direction is written, the return opens on its own
export const GROUP = {
  narrow: { inbound: [[SUBNET.public, 8080], [SUBNET.management, 22], [SUBNET.management, 9100]], outbound: [[SUBNET.data, 5432], [SUBNET.any, 443]] },
  loose: { inbound: [[SUBNET.any, 8080], [SUBNET.management, 22], [SUBNET.management, 9100]], outbound: [[SUBNET.data, 5432], [SUBNET.any, 443]] },
};
// Stateless list: numbered, first match wins; the return needs its own row
const L_INBOUND = [[100, SUBNET.public, 8080, 8080, 'allow'], [110, SUBNET.management, 22, 22, 'allow'],
  [120, SUBNET.management, 9100, 9100, 'allow'], [130, SUBNET.data, ...EPHEMERAL, 'allow'], [140, SUBNET.any, ...EPHEMERAL, 'allow']];
const L_OUTBOUND = [[100, SUBNET.data, 5432, 5432, 'allow'], [110, SUBNET.any, 443, 443, 'allow'],
  [120, SUBNET.public, ...EPHEMERAL, 'allow'], [130, SUBNET.management, ...EPHEMERAL, 'allow']];
export const LIST = {
  baseline: { inbound: L_INBOUND, outbound: L_OUTBOUND },
  'deny-145': { inbound: [...L_INBOUND, [145, SUBNET.any, ...EPHEMERAL, 'deny']], outbound: L_OUTBOUND },
  'deny-135': { inbound: [...L_INBOUND, [135, SUBNET.any, ...EPHEMERAL, 'deny']], outbound: L_OUTBOUND },
};
// [name, direction, counterpart, destination port, counterpart port, legitimate?] — by the application subnet (CN26)
export const FLOW = [
  ['public -> application', 'inbound', '10.42.0.5', 8080, 41200, true],
  ['old client', 'inbound', '10.42.0.9', 8080, 5000, true],
  ['management shell', 'inbound', '10.42.3.4', 22, 52100, true],
  ['metric scrape', 'inbound', '10.42.3.7', 9100, 44300, true],
  ['data query', 'outbound', '10.42.2.6', 5432, 39800, true],
  ['call to external service', 'outbound', '198.51.100.9', 443, 47700, true],
  ['external -> application:8080', 'inbound', '203.0.113.4', 8080, 51000, false],
  ['external -> application:22', 'inbound', '203.0.113.4', 22, 51100, false],
  ['external -> metric endpoint', 'inbound', '203.0.113.4', 9100, 51200, false],
  ['external -> ephemeral range', 'inbound', '203.0.113.4', 45000, 8443, false],
  ['spoofed return (source 443)', 'inbound', '203.0.113.4', 46000, 443, false],
  ['data -> application:22', 'inbound', '10.42.2.6', 22, 53000, false],
];

export const expand = ([, dir, ip, dp, cp]) => dir === 'inbound'
  ? { request: ['inbound', ip, dp], return: ['outbound', ip, cp] }
  : { request: ['outbound', ip, dp], return: ['inbound', ip, cp] };
export const groupAllows = (g, [d, ip, k]) => g[d].some(([c, port]) => contains(c, ip) && port === k);
export const listAllows = (l, [d, ip, k]) => {
  const s = [...l[d]].sort((a, b) => a[0] - b[0]).find(([, c, lo, hi]) => contains(c, ip) && k >= lo && k <= hi);
  return { allowed: !!s && s[4] === 'allow', no: s ? s[0] : '-' };
};
export const run = (a, { g, l } = {}) => {
  const { request, return: ret } = expand(a);
  const ok = (d) => (g ? groupAllows(g, d) : true) && (l ? listAllows(l, d).allowed : true);
  if (!ok(request)) return 'request stopped';
  if (l && !listAllows(l, ret).allowed) return 'return stopped';   // the list is stateless, it checks the return separately
  return 'passed';
};
export const classify = (a, outcome) => a[5] ? (outcome === 'passed' ? 'correct' : 'wrongly closed')
  : (outcome === 'request stopped' ? 'caught' : 'escaped');
export const count = (choice) => {
  const r = FLOW.map((a) => classify(a, run(a, choice)));
  const n = (s) => r.filter((x) => x === s).length;
  return { correct: n('correct'), wrong: n('wrongly closed'), caught: n('caught'), escaped: n('escaped') };
};
export const ruleCount = ({ g, l } = {}) =>
  (g ? g.inbound.length + g.outbound.length : 0) + (l ? l.inbound.length + l.outbound.length : 0);
```

## Same Policy, Two Mechanisms

```js
// run1.mjs — the same policy in two mechanisms, flow by flow across twelve known flows
import { FLOW, GROUP, LIST, run, classify, count, ruleCount } from './filter.mjs';

const g = GROUP.narrow, l = LIST.baseline;
console.log(`same policy: stateful group ${ruleCount({ g })} rules, stateless list ${ruleCount({ l })} rules`);
console.log(`${'flow'.padEnd(30)} ${'dest port'.padStart(11)} ${'peer port'.padStart(11)} ${'legit'.padStart(6)}  ` +
  `${'group'.padEnd(26)} list`);
for (const a of FLOW) {
  const [sg, sl] = [run(a, { g }), run(a, { l })];
  console.log(`${a[0].padEnd(30)} ${String(a[3]).padStart(11)} ${String(a[4]).padStart(11)} ` +
    `${String(a[5] ? 'yes' : 'no').padStart(6)}  ${`${sg} (${classify(a, sg)})`.padEnd(26)} ${sl} (${classify(a, sl)})`);
}
console.log(`\n${'mechanism'.padEnd(22)} ${'rules'.padStart(6)} ${'correct'.padStart(8)} ${'wrongly closed'.padStart(15)} ` +
  `${'caught'.padStart(10)} ${'escaped'.padStart(8)}`);
for (const [name, s] of [['stateful group', { g }], ['stateless list', { l }]]) {
  const r = count(s);
  console.log(`${name.padEnd(22)} ${String(ruleCount(s)).padStart(6)} ${String(r.correct).padStart(8)} ` +
    `${String(r.wrong).padStart(15)} ${String(r.caught).padStart(10)} ${String(r.escaped).padStart(8)}`);
}
```

```
same policy: stateful group 5 rules, stateless list 9 rules
flow                             dest port   peer port  legit  group                      list
public -> application                 8080       41200    yes  passed (correct)           passed (correct)
old client                            8080        5000    yes  passed (correct)           return stopped (wrongly closed)
management shell                        22       52100    yes  passed (correct)           passed (correct)
metric scrape                         9100       44300    yes  passed (correct)           passed (correct)
data query                            5432       39800    yes  passed (correct)           passed (correct)
call to external service               443       47700    yes  passed (correct)           passed (correct)
external -> application:8080          8080       51000     no  request stopped (caught)   request stopped (caught)
external -> application:22              22       51100     no  request stopped (caught)   request stopped (caught)
external -> metric endpoint           9100       51200     no  request stopped (caught)   request stopped (caught)
external -> ephemeral range          45000        8443     no  request stopped (caught)   return stopped (escaped)
spoofed return (source 443)          46000         443     no  request stopped (caught)   passed (escaped)
data -> application:22                  22       53000     no  request stopped (caught)   request stopped (caught)

mechanism               rules  correct  wrongly closed     caught  escaped
stateful group              5        6               0          6        0
stateless list              9        5               1          4        2
```

The same policy comes to five rows and nine rows. The difference is four rows, and none of the
four write any policy: they exist only for return traffic. Two of them, on the inbound side, allow
the data tier and anywhere else into the ephemeral port range; the other two, on the outbound
side, allow the public and management blocks into the same range. The policy amounted to three
permitted calls; close to half the rows have nothing to do with the policy itself.

The two escaped flows pass through the same row: the inbound row that allows any source into the
ephemeral port range. That row had to be written, because the return of a call to an external
service comes from outside by definition, and its source is not known in advance. The spoofed
return flow sits right on this boundary: source port 443, destination port in the ephemeral range
— indistinguishable at the packet level from a legitimate return, and it passes in both
directions. The only piece of information that could tell them apart is whether a request went to
that side earlier, and the stateless mechanism does not hold that information.

The one flow closed by mistake shows the same flaw in the opposite direction. The old client
connects not from a port above eight thousand but from five thousand. The request passes, the
application receives and processes it; the return cannot exit to port five thousand because the
outbound rows only cover the ephemeral range. The server log shows a successful request, the
client shows a timeout, and both are two ends of the same event.

## Order and Combined Use

```js
// run2.mjs — the deny row's rule number, and the two mechanisms evaluated together
import { FLOW, GROUP, LIST, run, classify, count, ruleCount, listAllows, expand } from './filter.mjs';

console.log(`${'list form'.padEnd(14)} ${'rules'.padStart(6)} ${'correct'.padStart(8)} ${'wrongly closed'.padStart(15)} ` +
  `${'caught'.padStart(10)} ${'escaped'.padStart(8)}  row matching the ephemeral range`);
for (const [name, l] of Object.entries(LIST)) {
  const r = count({ l }), s = listAllows(l, ['inbound', '203.0.113.4', 45000]);
  console.log(`${name.padEnd(14)} ${String(ruleCount({ l })).padStart(6)} ${String(r.correct).padStart(8)} ` +
    `${String(r.wrong).padStart(15)} ${String(r.caught).padStart(10)} ${String(r.escaped).padStart(8)}  ` +
    `row ${s.no} wins`);
}

const K = [['group (narrow)', { g: GROUP.narrow }], ['group (loose)', { g: GROUP.loose }],
  ['list (baseline)', { l: LIST.baseline }], ['narrow + list', { g: GROUP.narrow, l: LIST.baseline }],
  ['loose + list', { g: GROUP.loose, l: LIST.baseline }]];
console.log(`\n${'mechanism'.padEnd(16)} ${'rules'.padStart(6)} ${'correct'.padStart(8)} ${'wrongly closed'.padStart(15)} ` +
  `${'caught'.padStart(10)} ${'escaped'.padStart(8)}`);
for (const [name, s] of K) {
  const r = count(s);
  console.log(`${name.padEnd(16)} ${String(ruleCount(s)).padStart(6)} ${String(r.correct).padStart(8)} ` +
    `${String(r.wrong).padStart(15)} ${String(r.caught).padStart(10)} ${String(r.escaped).padStart(8)}`);
}

console.log('\nwhen the two say different things about the same flow, which one decides:');
console.log(`${'flow'.padEnd(38)} ${'group only'.padEnd(18)} ${'list only'.padEnd(18)} together`);
for (const g of [GROUP.narrow, GROUP.loose])
  for (const a of FLOW) {
    const [sg, sl, sb] = [run(a, { g }), run(a, { l: LIST.baseline }), run(a, { g, l: LIST.baseline })];
    if (sg === sl) continue;
    const tag = g === GROUP.narrow ? 'narrow' : 'loose';
    console.log(`${`${a[0]} [${tag}]`.padEnd(38)} ${sg.padEnd(18)} ${sl.padEnd(18)} ${sb} (${classify(a, sb)})`);
  }
const oldClient = FLOW[1], d = expand(oldClient);
console.log(`\nin the "${oldClient[0]}" flow, the request enters on port ${oldClient[3]} in the inbound direction, ` +
  `and the return leaves on port ${d.return[2]} in the outbound direction; the list sees these two directions as two separate rows.`);
```

```
list form       rules  correct  wrongly closed     caught  escaped  row matching the ephemeral range
baseline            9        5               1          4        2  row 140 wins
deny-145           10        5               1          4        2  row 140 wins
deny-135           10        4               2          6        0  row 135 wins

mechanism         rules  correct  wrongly closed     caught  escaped
group (narrow)        5        6               0          6        0
group (loose)         5        6               0          5        1
list (baseline)       9        5               1          4        2
narrow + list        14        5               1          6        0
loose + list         14        5               1          6        0

when the two say different things about the same flow, which one decides:
flow                                   group only         list only          together
old client [narrow]                    passed             return stopped     return stopped (wrongly closed)
external -> ephemeral range [narrow]   request stopped    return stopped     request stopped (caught)
spoofed return (source 443) [narrow]   request stopped    passed             request stopped (caught)
old client [loose]                     passed             return stopped     return stopped (wrongly closed)
external -> application:8080 [loose]   passed             request stopped    request stopped (caught)
external -> ephemeral range [loose]    request stopped    return stopped     request stopped (caught)
spoofed return (source 443) [loose]    request stopped    passed             request stopped (caught)

in the "old client" flow, the request enters on port 8080 in the inbound direction, and the return leaves on port 5000 in the outbound direction; the list sees these two directions as two separate rows.
```

A deny row was written to close the two escaped flows, and given the number 145. The rule count
rose from nine to ten, and none of the outcome columns changed. The right-hand column gives the
reason: the row that matches the ephemeral range is still number 140, because the first match wins
and 140 comes first. The review sees a deny row in the list; the run never exercises it.

Pulling the number down to 135 actually works: the escaped count drops from two to zero. The same
move takes the correct count from five to four and the wrongly-closed count from one to two. The
second thing that closes is the return of the call to the external service. The escape and that
return match the same row, because as packets they are the same thing: a packet arriving from
outside, headed to a port in the ephemeral range. Order does not tell them apart; it only chooses
which one gets sacrificed. In the stateless mechanism, no row can be written that separates these
two flows.

The table below puts the two together. The order of evaluation is this: on the inbound side the
subnet list first, then the instance's group; on the outbound side, the reverse. The narrow group
alone gives the same result as the fourteen rules together. The nine-row list closes no escape and
brings in one wrongly-closed flow. The loose group row shows the list's gain: the group let port
eight thousand eighty through from outside, and the list stops it at the subnet boundary.

The surprising result is in the last table's first row. In the old-client flow, the group says
"passed," the list says "return stopped," and together it comes out "return stopped." A correctly
written group does not rescue a wrongly written list; the reverse rescues it. The stateless layer
can only subtract — it can close what the group opened by mistake, and it can also close what the
group opened correctly, but it can never open a return path. The connection shows as "established"
in the group's state table while the packet drops at the subnet boundary, and the two records,
checked in two places, do not confirm each other.

The irreversible item is this row. A rule gets written, its number gets changed, a row gets
deleted — all of that is reversible. What cannot be reversed is the work the server side does for
a request whose return drops: the request is processed, the response never goes out, the client
retries, and the same measurement gets recorded twice. Fixing the row does not undo the duplicate
record. The item staying with the team is four pieces — which mechanism to use, rule width,
numbering, and the ephemeral-range assumption. The items handed off are also four: the state table
itself, the two mechanisms' evaluation order, the ephemeral port being chosen on the client side,
and the state table's capacity. All three of the escapes and the one wrongly-closed flow above
trace back to this handed-off foursome.

## Summary

- The same policy comes to 5 rules in the stateful mechanism and 9 in the stateless one. The 4
  rows between them write no policy; they exist only for return traffic.
- The stateless list lets 2 flows escape, and both pass through the same row: the one allowing any
  source into the ephemeral port range. The spoofed return flow passes in both directions, because
  it is indistinguishable at the packet level from a legitimate return.
- The same list wrongly closes 1 legitimate flow: the request from the client with source port
  5000 arrives, and its response cannot get out. The server log shows a successful request, the
  client shows a timeout.
- Writing the deny row as number 145 changes no number; 140 matches first. Writing it as 135 drops
  escaped from 2 to 0, but also drops correct from 5 to 4 and raises wrongly-closed from 1 to 2 —
  since the escape and the legitimate return match the same row, order only chooses which one gets
  sacrificed.
- Used together, the narrow group plus list costs 14 rules to deliver the same result the narrow
  group alone delivers with 5, and adds 1 wrongly-closed flow on top; the list's gain shows up only
  in the flow where the group was written wrong. The irreversible item is the duplicate record a
  request with a dropped return leaves on the server.

## Next Step

For two lessons now, the only externally reachable item has been the port the load balancer
listens on, and that port has passed as nothing more than a number so far. The choice behind it is
not small: the balancer can meet traffic at the transport layer or at the application layer, and
the two layers see different things while looking at the same request. The next lesson's question
counts this choice item by item: which fields each layer can read from the request, how many extra
round trips and how much extra processing reading them costs, under which arrangement the instance
behind it can still see who the other side really is — and which new items raising the layer adds
to the outward-facing surface.
