Diagnostic Slack
accesslimited: the right token from the wrong network
The token works. Someone pastes the same curl into their laptop terminal and it comes back ok: true, in front of witnesses. The identical command in the production container answers {"ok": false, "error": "accesslimited"}, and the deploy that shipped an hour earlier gets blamed for it.
This is the one note in this section where nothing about the app is wrong. Not the scopes, not the install, not the channel, not the code. Somebody added an IP range to an organisation policy, or an autoscaler replaced a NAT gateway, and Slack is refusing the call on the strength of where it came from.
accesslimited means "access to this method is limited on the current network". An Enterprise Grid administrator can restrict API access to a set of IP ranges, and a call whose egress address falls outside them is refused regardless of how valid the token is or how complete its scopes are.
The diagnosis needs two things a single failing call cannot give you. First, your own public egress address, which is not the address of the host and is frequently not the address anybody wrote down: the script fetches it from a plain-text echo service, without your Slack token attached. Second, a second vantage point. The same token succeeding from one network and returning accesslimited from another is conclusive; a token that fails from everywhere is a credential problem wearing an unusual error string. The script below records one vantage point at a time and combines it with the one you supply from the other network.
The problem in plain words
The shape of this failure is what makes it expensive. It is total: every method, every channel, every call from that host. It is environment-specific: staging is fine, production is not, or the reverse. And it appeared without a deploy, which means the first hour of the investigation is spent reading a diff that has nothing in it.
So the team reaches for the things that usually explain a total failure. The token gets rotated, which does nothing. The app gets reinstalled, which does nothing and costs an approval. Scopes get added, which does nothing and leaves the app holding more permission than it needs afterwards. Every one of those is a reasonable guess and every one of them is aimed at the app, and the app is not what changed. What changed is that the cluster moved to a new availability zone, or the platform team replaced a NAT gateway, or a Grid admin tightened an allowlist on Tuesday and told a distribution list that does not include you.
It gets worse when the error is not accesslimited. Slack's own documentation notes that a request coming from a disallowed address can surface as invalid_auth instead, and invalid_auth reads unambiguously as "your token is bad". A team that sees it will rotate the credential, and the rotation will not help, and the second rotation will not help either. The only thing that separates those two readings is running the same credential from somewhere else.
Why it happens
The policy lives above the app, and the app cannot read it. There is no Web API method that returns the organisation's allowlist. A read-scoped token cannot enumerate the ranges, cannot tell you when they changed, and cannot tell you who changed them. What it can do is establish the two facts that make the case: this address, this outcome.
Your egress address is not a property of your code. It belongs to the NAT gateway, the load balancer, the VPN, or the office router, and in most cloud setups it can change without anybody deploying anything. That is why the finding has to name the address observed at the moment of the failing call rather than the address in the runbook.
One vantage point cannot distinguish a network refusal from a bad token. A single failing call is compatible with both. Two calls with the same credential from two networks, with one succeeding, is compatible with exactly one. The script is built around that comparison because the comparison is the whole proof.
invalid_auth is ambiguous here and should be reported as ambiguous. A detector that maps it straight to "credential" will send a team to rotate a perfectly good token. This one returns a distinct state and says what would resolve it, which is the second vantage point rather than a new secret.
The repair belongs to two teams, and both need the same number. The Grid admin needs a CIDR to add. The platform team needs to pin egress so the CIDR keeps being true next month. A finding that says "IP allowlisting" without naming an address makes both of those conversations start from scratch.
The fix, as a flow
The only chain in this section whose last box is not a consequence but a repair that did not work. Nothing in the first three steps is a mistake and nothing was deployed, which is why the investigation starts by reading a diff with nothing in it. The fix branch has two red rows because two different error strings mean the same thing here, and the second one is the expensive one.
How to fix it
Read the body, not the status line
accesslimited arrives as HTTP 200 with ok: false, like every other Slack failure. A client that trusts the status code sees a healthy response and an empty result. The script asserts on body.ok and records body.error verbatim before it does anything else.
Observe your own egress address, without sending the token to do it
The script fetches a plain-text echo service on a bare request with no Slack credential attached, deliberately: the address is public information and the token is not, and there is no reason for one to travel with the other. Point --egress-url at your own echo endpoint if you would rather not use a third party at all.
Classify the refusal before you interpret it
refusal_kind sorts the answer into network, ambiguous, credential, scope or clear. The value of the classifier is mostly in refusing to collapse invalid_auth into the credential bucket, because that collapse is what costs afternoons.
Hold the address against the allowlist you were given
If somebody can tell you the ranges, pass them with --allow and cidr_verdict does the arithmetic. An address outside every supplied range turns a suspicion into a sentence with a number in it. A malformed range is reported as malformed rather than quietly skipped.
Run it again from a second network and feed the result back in
Run the script on the failing host and on a laptop. Take the second run's address and error and pass them as --peer-egress and --peer-error. vantage_verdict combines them, and confirmed is the only state that closes this case rather than suggesting a next step.
Print a repair addressed to the two people who can act on it
The Grid admin adds the CIDR under Organization settings, Security, IP allowlisting. The platform team pins egress to a stable NAT gateway so the entry stays true. The script prints both, with the observed address in them, so neither conversation starts by asking what the address is.
How to check it worked
Once the CIDR is allowlisted, re-run on the host that was failing. The address should be unchanged and the outcome should not be.
python3 slack_egress_allowlist.py --allow 203.0.113.0/24 --allow 198.51.100.7/32
# egress observed 203.0.113.44 via https://checkip.amazonaws.com
# auth.test clear ok: true, U0APPBOT11 in acme
# allowlist inside 203.0.113.44 is inside 203.0.113.0/24
# vantage clear this network is not being refused
The full code
One authenticated GET, one unauthenticated one, and three pure functions that carry the whole argument. refusal_kind is a five-way sort whose only interesting row is invalid_auth, which it refuses to call a credential problem. cidr_verdict is IPv4 arithmetic against the ranges you were handed. vantage_verdict is the proof: two observations of one token from two networks, reduced to whether this case is closed or still a suspicion.
"""Decide whether Slack is refusing this host by network origin.
Read only. One auth.test, and one unauthenticated GET to an echo service to
learn the caller's public egress address. Nothing is changed anywhere: an IP
allowlist lives in Enterprise Grid organisation settings and no API method can
edit it, so this reports the address, the refusal and the comparison, and
prints the entry an administrator would add.
"""
import argparse
import ipaddress
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_egress_allowlist")
API = "https://slack.com/api/"
# Errors that are unambiguously about the credential rather than the network.
# invalid_auth is deliberately not in here: Slack documents that a request from
# a disallowed address can surface as invalid_auth, so mapping it to
# "credential" is how a team ends up rotating a working token twice.
CREDENTIAL_ERRORS = {
"not_authed", "token_revoked", "token_expired",
"account_inactive", "not_allowed_token_type",
}
# invalid_auth sits in here as well as outside CREDENTIAL_ERRORS on purpose: it
# is a credential reading when both networks agree, and an allowlist reading
# when they do not.
CREDENTIAL_KINDS = {"credential", "ambiguous"}
def refusal_kind(ok, error):
"""Sort one Slack answer into the kind of refusal it is. Pure.
Returns (kind, detail). The kinds are clear, network, ambiguous,
credential, scope and other, and the one that earns its place is
ambiguous, because collapsing it into credential is the mistake this
whole note exists to prevent.
"""
if ok is True:
return ("clear", "the call succeeded, so this network is not being refused")
err = str(error or "").strip()
if not err:
return ("other", "ok was not true and no error string came back, which is "
"worth capturing verbatim before anything is concluded")
if err == "accesslimited":
return ("network", "accesslimited: the method is limited on the current "
"network, which is an organisation policy about where "
"the call came from and not about the token")
if err == "invalid_auth":
return ("ambiguous", "invalid_auth usually means a bad token, and Slack "
"documents that a request from a disallowed address "
"can surface here too. A second vantage point "
"separates them; rotating the token does not")
if err in CREDENTIAL_ERRORS:
return ("credential", "%s is about the credential itself, not the network" % err)
if err == "missing_scope":
return ("scope", "missing_scope is a grant problem and has its own note; "
"the token reached Slack and was understood")
return ("other", "%s is neither a network nor a credential refusal" % err)
def cidr_verdict(ip, allowlist):
"""Hold an observed egress address against the ranges you were given. Pure.
Returns (verdict, detail). IPv4 only and says so: Slack's allowlists are
expressed as IPv4 ranges, and a guess about an IPv6 address would be a
confident wrong answer in a report somebody is going to act on.
"""
text = str(ip or "").strip()
if not text:
return ("unknown", "no egress address was observed, so there is nothing "
"to hold against an allowlist")
if ":" in text:
return ("unsupported", "%s is IPv6 and this check does IPv4 ranges only; "
"compare it against the policy by hand" % text)
try:
addr = ipaddress.IPv4Address(text)
except ValueError:
return ("malformed", "%s is not an address this check can parse" % text)
ranges, skipped = [], []
for entry in allowlist or []:
raw = str(entry or "").strip()
if not raw:
continue
try:
ranges.append(ipaddress.IPv4Network(raw, strict=False))
except ValueError:
skipped.append(raw)
note = ""
if skipped:
note = "; %d supplied range(s) could not be parsed and were not checked: %s" % (
len(skipped), ", ".join(skipped))
if not ranges:
return ("unlisted", "%s observed, and no usable allowlist was supplied, so "
"the address is reported rather than judged%s" % (text, note))
for net in ranges:
if addr in net:
return ("inside", "%s is inside %s%s" % (text, net.with_prefixlen, note))
return ("outside", "%s is outside all %d supplied range(s): %s%s" % (
text, len(ranges), ", ".join(n.with_prefixlen for n in ranges), note))
def vantage_verdict(here, there):
"""Combine two observations of one token from two networks. Pure.
Each side is a dict with kind and egress. One failing call proves nothing;
the same credential succeeding from one address and refused from another
proves this exactly.
"""
mine = (here or {}).get("kind") or "unknown"
if not there or not (there.get("kind") or "").strip():
if mine == "network":
return ("unconfirmed", "accesslimited from %s, and no second vantage "
"point was supplied. Run this again from another "
"network to turn a strong reading into a proof"
% ((here or {}).get("egress") or "an unknown address"))
if mine == "ambiguous":
return ("unresolved", "invalid_auth from one network only. This is the "
"state that gets a good token rotated; run the "
"same token from elsewhere before touching it")
if mine == "clear":
return ("clear", "this network is not being refused")
return ("unconfirmed", "one vantage point is not enough to attribute this")
theirs = there.get("kind")
a = (here or {}).get("egress") or "an unknown address"
b = there.get("egress") or "an unknown address"
if mine in ("network", "ambiguous") and theirs == "clear":
return ("confirmed", "the same token is refused from %s and succeeds from "
"%s. That is an IP restriction and not a credential "
"problem; %s is the address to allowlist" % (a, b, a))
if mine == "network" and theirs in ("network", "ambiguous"):
return ("both-refused", "both %s and %s are refused. Either the allowlist "
"excludes both, or it was emptied; ask the Grid "
"admin for the current ranges before changing "
"anything on the app" % (a, b))
if mine in CREDENTIAL_KINDS and theirs in CREDENTIAL_KINDS:
return ("credential", "the same failure from two networks is the credential, "
"not the allowlist")
if mine == "clear" and theirs in ("network", "ambiguous"):
return ("confirmed", "this network is fine and %s is refused; %s is the "
"address to allowlist" % (b, b))
if mine == "clear" and theirs == "clear":
return ("clear", "both vantage points succeeded")
return ("mixed", "the two vantage points disagree in a way this script will not "
"guess at: %s here, %s there" % (mine, theirs))
def observe_egress(url):
"""Fetch the caller's public address from a plain-text echo service.
Sent with no Authorization header on purpose. The address is public and the
Slack token is not, and there is no reason for the two to travel together.
"""
if not url:
return ""
try:
r = requests.get(url, timeout=10)
first = (r.text or "").strip().split()
return first[0] if first else ""
except requests.RequestException as exc:
log.info("egress unknown %s did not answer (%s)", url, exc)
return ""
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
help="environment variable holding the token to test")
ap.add_argument("--egress-url", default="https://checkip.amazonaws.com",
help="plain-text echo service; pass an empty string to skip it")
ap.add_argument("--allow", action="append", default=[],
help="an allowlisted CIDR you were given; repeatable")
ap.add_argument("--peer-egress", default="",
help="the egress address this script reported on another network")
ap.add_argument("--peer-error", default="",
help="the Slack error from that other network, or ok for success")
args = ap.parse_args()
token = os.environ.get(args.token_env)
if not token:
log.error("set %s; any read scope is enough, the point is the network",
args.token_env)
return 2
egress = observe_egress(args.egress_url)
if egress:
log.info("egress observed %s via %s", egress, args.egress_url)
else:
log.info("egress unobserved no address; the finding will be "
"weaker for it")
r = requests.get(API + "auth.test", timeout=30,
headers={"Authorization": "Bearer " + token})
body = r.json()
kind, why = refusal_kind(body.get("ok") is True, body.get("error"))
(log.info if kind == "clear" else log.warning)("auth.test %-14s %s", kind, why)
if kind == "clear":
log.info("identity %s in %s", body.get("user_id"), body.get("team"))
verdict, detail = cidr_verdict(egress, args.allow)
(log.warning if verdict == "outside" else log.info)(
"allowlist %-14s %s", verdict, detail)
peer = None
if args.peer_error:
peer_kind, _ = refusal_kind(args.peer_error.strip().lower() == "ok",
"" if args.peer_error.strip().lower() == "ok"
else args.peer_error)
peer = {"kind": peer_kind, "egress": args.peer_egress}
proof, proof_detail = vantage_verdict({"kind": kind, "egress": egress}, peer)
(log.info if proof == "clear" else log.warning)(
"vantage %-14s %s", proof, proof_detail)
if kind in ("network", "ambiguous") or verdict == "outside":
log.warning(" repair: ask the Grid admin to add %s to the API allowlist "
"under Organization settings, Security, IP allowlisting",
("%s/32" % egress) if egress else "this host's egress CIDR")
log.warning(" repair: pin egress to a stable NAT gateway or static address "
"so the allowlist entry stays true after the next scale event")
log.warning(" repair: do not rotate the token or add scopes for this; "
"neither changes where the request came from")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
/**
* Decide whether Slack is refusing this host by network origin.
*
* Read only. One auth.test, and one unauthenticated GET to an echo service to
* learn the caller's public egress address. An IP allowlist lives in
* Enterprise Grid organisation settings and no API method can edit it, so this
* reports the address, the refusal and the comparison, and prints the entry an
* administrator would add.
*/
const API = 'https://slack.com/api/';
// invalid_auth is deliberately absent: Slack documents that a request from a
// disallowed address can surface there, so treating it as a credential fault
// is how a working token gets rotated twice.
const CREDENTIAL_ERRORS = new Set([
'not_authed', 'token_revoked', 'token_expired',
'account_inactive', 'not_allowed_token_type',
]);
const CREDENTIAL_KINDS = new Set(['credential', 'ambiguous']);
/**
* Sort one Slack answer into the kind of refusal it is. Pure.
* The kind that earns its place is ambiguous.
*/
export function refusalKind(ok, error) {
if (ok === true) {
return ['clear', 'the call succeeded, so this network is not being refused'];
}
const err = String(error ?? '').trim();
if (!err) {
return ['other', 'ok was not true and no error string came back, which is worth ' +
'capturing verbatim before anything is concluded'];
}
if (err === 'accesslimited') {
return ['network', 'accesslimited: the method is limited on the current network, ' +
'which is an organisation policy about where the call came from and not ' +
'about the token'];
}
if (err === 'invalid_auth') {
return ['ambiguous', 'invalid_auth usually means a bad token, and Slack documents ' +
'that a request from a disallowed address can surface here too. A second ' +
'vantage point separates them; rotating the token does not'];
}
if (CREDENTIAL_ERRORS.has(err)) {
return ['credential', `${err} is about the credential itself, not the network`];
}
if (err === 'missing_scope') {
return ['scope', 'missing_scope is a grant problem and has its own note; the ' +
'token reached Slack and was understood'];
}
return ['other', `${err} is neither a network nor a credential refusal`];
}
function toInt(ip) {
const parts = ip.split('.');
if (parts.length !== 4) return null;
let out = 0;
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return null;
const n = Number(part);
if (n > 255) return null;
out = out * 256 + n;
}
return out;
}
function parseCidr(raw) {
const [base, len] = raw.split('/');
const addr = toInt(String(base ?? '').trim());
if (addr === null) return null;
const bits = len === undefined ? 32 : Number(len);
if (!Number.isInteger(bits) || bits < 0 || bits > 32) return null;
const mask = bits === 0 ? 0 : (2 ** 32 - 2 ** (32 - bits));
return { network: addr - (addr % (2 ** (32 - bits))), mask, bits, label: `${base}/${bits}` };
}
/**
* Hold an observed egress address against the ranges you were given. Pure.
* IPv4 only and says so, because a guess about an IPv6 address would be a
* confident wrong answer in a report somebody is going to act on.
*/
export function cidrVerdict(ip, allowlist) {
const text = String(ip ?? '').trim();
if (!text) {
return ['unknown', 'no egress address was observed, so there is nothing to hold ' +
'against an allowlist'];
}
if (text.includes(':')) {
return ['unsupported', `${text} is IPv6 and this check does IPv4 ranges only; ` +
'compare it against the policy by hand'];
}
const addr = toInt(text);
if (addr === null) {
return ['malformed', `${text} is not an address this check can parse`];
}
const ranges = [];
const skipped = [];
for (const entry of allowlist ?? []) {
const raw = String(entry ?? '').trim();
if (!raw) continue;
const net = parseCidr(raw);
if (net) ranges.push(net); else skipped.push(raw);
}
const note = skipped.length
? `; ${skipped.length} supplied range(s) could not be parsed and were not ` +
`checked: ${skipped.join(', ')}`
: '';
if (!ranges.length) {
return ['unlisted', `${text} observed, and no usable allowlist was supplied, so ` +
`the address is reported rather than judged${note}`];
}
for (const net of ranges) {
const size = 2 ** (32 - net.bits);
if (addr >= net.network && addr < net.network + size) {
return ['inside', `${text} is inside ${net.label}${note}`];
}
}
return ['outside', `${text} is outside all ${ranges.length} supplied range(s): ` +
`${ranges.map((n) => n.label).join(', ')}${note}`];
}
/**
* Combine two observations of one token from two networks. Pure.
* One failing call proves nothing; the same credential succeeding from one
* address and refused from another proves this exactly.
*/
export function vantageVerdict(here, there) {
const mine = here?.kind || 'unknown';
if (!there || !String(there.kind ?? '').trim()) {
if (mine === 'network') {
return ['unconfirmed', `accesslimited from ${here?.egress || 'an unknown address'}` +
', and no second vantage point was supplied. Run this again from another ' +
'network to turn a strong reading into a proof'];
}
if (mine === 'ambiguous') {
return ['unresolved', 'invalid_auth from one network only. This is the state ' +
'that gets a good token rotated; run the same token from elsewhere before ' +
'touching it'];
}
if (mine === 'clear') return ['clear', 'this network is not being refused'];
return ['unconfirmed', 'one vantage point is not enough to attribute this'];
}
const theirs = there.kind;
const a = here?.egress || 'an unknown address';
const b = there.egress || 'an unknown address';
if ((mine === 'network' || mine === 'ambiguous') && theirs === 'clear') {
return ['confirmed', `the same token is refused from ${a} and succeeds from ${b}. ` +
`That is an IP restriction and not a credential problem; ${a} is the address ` +
'to allowlist'];
}
if (mine === 'network' && (theirs === 'network' || theirs === 'ambiguous')) {
return ['both-refused', `both ${a} and ${b} are refused. Either the allowlist ` +
'excludes both, or it was emptied; ask the Grid admin for the current ranges ' +
'before changing anything on the app'];
}
if (CREDENTIAL_KINDS.has(mine) && CREDENTIAL_KINDS.has(theirs)) {
return ['credential', 'the same failure from two networks is the credential, not ' +
'the allowlist'];
}
if (mine === 'clear' && (theirs === 'network' || theirs === 'ambiguous')) {
return ['confirmed', `this network is fine and ${b} is refused; ${b} is the ` +
'address to allowlist'];
}
if (mine === 'clear' && theirs === 'clear') {
return ['clear', 'both vantage points succeeded'];
}
return ['mixed', 'the two vantage points disagree in a way this script will not ' +
`guess at: ${mine} here, ${theirs} there`];
}
async function observeEgress(url) {
if (!url) return '';
try {
// No Authorization header on purpose: the address is public, the token is not.
const res = await fetch(url);
const text = (await res.text()).trim().split(/\s+/);
return text[0] ?? '';
} catch (err) {
console.log(`egress unknown ${url} did not answer (${err.message})`);
return '';
}
}
function arg(args, name, fallback = null) {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
function argAll(args, name) {
const out = [];
for (let i = 0; i < args.length; i += 1) {
if (args[i] === name && args[i + 1] !== undefined) out.push(args[i + 1]);
}
return out;
}
async function main() {
const args = process.argv.slice(2);
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const token = process.env[tokenEnv];
if (!token) {
console.error(`set ${tokenEnv}; any read scope is enough, the point is the network`);
process.exitCode = 2;
return;
}
const egressUrl = arg(args, '--egress-url', 'https://checkip.amazonaws.com');
const egress = await observeEgress(egressUrl);
if (egress) console.log(`egress observed ${egress} via ${egressUrl}`);
else console.log('egress unobserved no address; the finding will be weaker for it');
const res = await fetch(`${API}auth.test`, {
headers: { Authorization: `Bearer ${token}` },
});
const body = await res.json();
const [kind, why] = refusalKind(body.ok === true, body.error);
(kind === 'clear' ? console.log : console.warn)(`auth.test ${kind.padEnd(14)} ${why}`);
if (kind === 'clear') console.log(`identity ${body.user_id} in ${body.team}`);
const [verdict, detail] = cidrVerdict(egress, argAll(args, '--allow'));
(verdict === 'outside' ? console.warn : console.log)(
`allowlist ${verdict.padEnd(14)} ${detail}`);
const peerError = String(arg(args, '--peer-error', '') ?? '').trim();
let peer = null;
if (peerError) {
const ok = peerError.toLowerCase() === 'ok';
const [peerKind] = refusalKind(ok, ok ? '' : peerError);
peer = { kind: peerKind, egress: arg(args, '--peer-egress', '') };
}
const [proof, proofDetail] = vantageVerdict({ kind, egress }, peer);
(proof === 'clear' ? console.log : console.warn)(
`vantage ${proof.padEnd(14)} ${proofDetail}`);
if (kind === 'network' || kind === 'ambiguous' || verdict === 'outside') {
console.warn(` repair: ask the Grid admin to add ${egress ? `${egress}/32` : "this host's egress CIDR"} ` +
'to the API allowlist under Organization settings, Security, IP allowlisting');
console.warn(' repair: pin egress to a stable NAT gateway or static address so the ' +
'allowlist entry stays true after the next scale event');
console.warn(' repair: do not rotate the token or add scopes for this; neither ' +
'changes where the request came from');
process.exitCode = 1;
}
}
// Only run when invoked directly, so importing this module in the tests does
// not execute main() and fail the file on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Two assertions decide whether this script is worth running. The first is that invalid_auth comes back as ambiguous and never as credential, because the whole cost of this failure is the rotation that gets done instead of the second run. The second is that one vantage point is never allowed to say confirmed: a single refused call is compatible with a bad token, and the script has to keep saying so until somebody supplies the other half.
from slack_egress_allowlist import cidr_verdict, refusal_kind, vantage_verdict
def test_accesslimited_is_a_network_refusal():
kind, detail = refusal_kind(False, "accesslimited")
assert kind == "network"
assert "not about the token" in detail
def test_invalid_auth_is_ambiguous_and_never_credential():
kind, detail = refusal_kind(False, "invalid_auth")
assert kind == "ambiguous"
assert "Rotating" in detail or "rotating" in detail
def test_real_credential_errors_are_still_credential_errors():
assert refusal_kind(False, "token_revoked")[0] == "credential"
assert refusal_kind(False, "account_inactive")[0] == "credential"
assert refusal_kind(False, "not_authed")[0] == "credential"
def test_a_scope_error_is_somebody_elses_note():
assert refusal_kind(False, "missing_scope")[0] == "scope"
def test_success_and_a_silent_failure_are_different_states():
assert refusal_kind(True, None)[0] == "clear"
assert refusal_kind(False, None)[0] == "other"
def test_an_address_inside_a_range_is_named_with_the_range():
verdict, detail = cidr_verdict("203.0.113.44", ["203.0.113.0/24"])
assert verdict == "inside"
assert "203.0.113.0/24" in detail
def test_an_address_outside_every_range_lists_what_was_checked():
verdict, detail = cidr_verdict("198.51.100.7", ["203.0.113.0/24", "192.0.2.0/24"])
assert verdict == "outside"
assert "203.0.113.0/24" in detail and "192.0.2.0/24" in detail
def test_a_bare_address_is_treated_as_a_single_host_range():
assert cidr_verdict("198.51.100.7", ["198.51.100.7"])[0] == "inside"
assert cidr_verdict("198.51.100.8", ["198.51.100.7/32"])[0] == "outside"
def test_a_malformed_range_is_reported_rather_than_silently_dropped():
verdict, detail = cidr_verdict("203.0.113.44", ["not-a-range", "203.0.113.0/24"])
assert verdict == "inside"
assert "not-a-range" in detail
def test_no_usable_allowlist_reports_the_address_without_judging_it():
assert cidr_verdict("203.0.113.44", [])[0] == "unlisted"
assert cidr_verdict("203.0.113.44", ["nonsense"])[0] == "unlisted"
def test_ipv6_and_rubbish_are_kept_apart_from_each_other():
assert cidr_verdict("2001:db8::1", ["203.0.113.0/24"])[0] == "unsupported"
assert cidr_verdict("300.1.1.1", ["203.0.113.0/24"])[0] == "malformed"
assert cidr_verdict("", ["203.0.113.0/24"])[0] == "unknown"
def test_one_vantage_point_is_never_a_proof():
verdict, detail = vantage_verdict({"kind": "network", "egress": "203.0.113.44"}, None)
assert verdict == "unconfirmed"
assert "another" in detail
def test_a_lone_invalid_auth_is_reported_as_unresolved_not_as_a_bad_token():
verdict, detail = vantage_verdict({"kind": "ambiguous", "egress": "203.0.113.44"}, {})
assert verdict == "unresolved"
assert "before touching it" in detail
def test_refused_here_and_fine_there_closes_the_case():
verdict, detail = vantage_verdict(
{"kind": "network", "egress": "203.0.113.44"},
{"kind": "clear", "egress": "198.51.100.7"})
assert verdict == "confirmed"
assert "203.0.113.44 is the address to allowlist" in detail
def test_invalid_auth_here_and_success_there_is_also_the_allowlist():
assert vantage_verdict({"kind": "ambiguous", "egress": "a"},
{"kind": "clear", "egress": "b"})[0] == "confirmed"
def test_the_same_failure_from_both_networks_is_the_credential():
assert vantage_verdict({"kind": "credential"}, {"kind": "credential"})[0] == "credential"
assert vantage_verdict({"kind": "network", "egress": "a"},
{"kind": "network", "egress": "b"})[0] == "both-refused"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { cidrVerdict, refusalKind, vantageVerdict } from './slack-egress-allowlist.mjs';
test('accesslimited is a network refusal', () => {
const [kind, detail] = refusalKind(false, 'accesslimited');
assert.equal(kind, 'network');
assert.match(detail, /not about the token/);
});
test('invalid_auth is ambiguous and never credential', () => {
const [kind, detail] = refusalKind(false, 'invalid_auth');
assert.equal(kind, 'ambiguous');
assert.match(detail, /rotating the token does not/);
});
test('real credential errors are still credential errors', () => {
assert.equal(refusalKind(false, 'token_revoked')[0], 'credential');
assert.equal(refusalKind(false, 'account_inactive')[0], 'credential');
assert.equal(refusalKind(false, 'not_authed')[0], 'credential');
});
test('a scope error is somebody elses note', () => {
assert.equal(refusalKind(false, 'missing_scope')[0], 'scope');
});
test('success and a silent failure are different states', () => {
assert.equal(refusalKind(true, null)[0], 'clear');
assert.equal(refusalKind(false, null)[0], 'other');
});
test('an address inside a range is named with the range', () => {
const [verdict, detail] = cidrVerdict('203.0.113.44', ['203.0.113.0/24']);
assert.equal(verdict, 'inside');
assert.match(detail, /203\.0\.113\.0\/24/);
});
test('an address outside every range lists what was checked', () => {
const [verdict, detail] = cidrVerdict('198.51.100.7',
['203.0.113.0/24', '192.0.2.0/24']);
assert.equal(verdict, 'outside');
assert.match(detail, /203\.0\.113\.0\/24/);
assert.match(detail, /192\.0\.2\.0\/24/);
});
test('a bare address is treated as a single host range', () => {
assert.equal(cidrVerdict('198.51.100.7', ['198.51.100.7'])[0], 'inside');
assert.equal(cidrVerdict('198.51.100.8', ['198.51.100.7/32'])[0], 'outside');
});
test('a malformed range is reported rather than silently dropped', () => {
const [verdict, detail] = cidrVerdict('203.0.113.44', ['not-a-range', '203.0.113.0/24']);
assert.equal(verdict, 'inside');
assert.match(detail, /not-a-range/);
});
test('no usable allowlist reports the address without judging it', () => {
assert.equal(cidrVerdict('203.0.113.44', [])[0], 'unlisted');
assert.equal(cidrVerdict('203.0.113.44', ['nonsense'])[0], 'unlisted');
});
test('ipv6 and rubbish are kept apart from each other', () => {
assert.equal(cidrVerdict('2001:db8::1', ['203.0.113.0/24'])[0], 'unsupported');
assert.equal(cidrVerdict('300.1.1.1', ['203.0.113.0/24'])[0], 'malformed');
assert.equal(cidrVerdict('', ['203.0.113.0/24'])[0], 'unknown');
});
test('one vantage point is never a proof', () => {
const [verdict, detail] = vantageVerdict({ kind: 'network', egress: '203.0.113.44' }, null);
assert.equal(verdict, 'unconfirmed');
assert.match(detail, /another/);
});
test('a lone invalid_auth is reported as unresolved not as a bad token', () => {
const [verdict, detail] = vantageVerdict({ kind: 'ambiguous', egress: '203.0.113.44' }, {});
assert.equal(verdict, 'unresolved');
assert.match(detail, /before touching it/);
});
test('refused here and fine there closes the case', () => {
const [verdict, detail] = vantageVerdict(
{ kind: 'network', egress: '203.0.113.44' },
{ kind: 'clear', egress: '198.51.100.7' });
assert.equal(verdict, 'confirmed');
assert.match(detail, /203\.0\.113\.44 is the address to allowlist/);
});
test('invalid_auth here and success there is also the allowlist', () => {
assert.equal(vantageVerdict({ kind: 'ambiguous', egress: 'a' },
{ kind: 'clear', egress: 'b' })[0], 'confirmed');
});
test('the same failure from both networks is the credential', () => {
assert.equal(vantageVerdict({ kind: 'credential' }, { kind: 'credential' })[0],
'credential');
assert.equal(vantageVerdict({ kind: 'network', egress: 'a' },
{ kind: 'network', egress: 'b' })[0], 'both-refused');
});
FAQ
Can I read the organisation's IP allowlist through the API?
No. There is no Web API method that returns the allowed ranges, no method that reports when they last changed, and no method that tells you whether your address is in them. That asymmetry is the reason this script observes its own egress address and asks you for the ranges: the half Slack will tell you is the refusal, and the half it will not is the policy.
Why does the script fetch my IP from an outside service instead of reading the interface?
Because the address on the interface is almost never the address Slack sees. Containers, NAT gateways, load balancers and VPNs all rewrite it, and the whole question here is what the far end observed. An echo service answers that directly. Point --egress-url at an endpoint you run yourself if a third party in the path is not acceptable; the check works the same way.
It fails from production and works from my laptop. Is that always the allowlist?
It is strong evidence and it is not the only explanation. A different token in the two environments produces the same asymmetry, so check that the credential really is byte-identical before concluding. Once it is the same token, the same method and two different egress addresses with two different outcomes, there is nothing left that varies except the network.
We are not on Enterprise Grid. Can we still see accesslimited?
The IP allowlisting feature that produces it is a Grid organisation policy, so the usual answer is no. If it appears anyway, treat it as evidence that the workspace you are calling belongs to a Grid organisation you did not know about, which is worth knowing for other reasons: several of the notes in this section behave differently under Grid.
Should we allowlist a wide range to stop this recurring?
That trades one problem for a quieter one. A wide range makes the allowlist stop meaning anything, which is a security decision somebody else owns. The durable repair on your side is a stable egress address, so that a narrow entry keeps being correct: pin the NAT gateway, and treat an egress change as a Slack-affecting change in the runbook rather than as an infrastructure detail.
Related field notes
- the credential error this one impersonates
- the other refusal that is not about your code
- why the refusal arrived looking like a success
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- chat.postMessage method reference - Slack Docs
- auth.test method reference - Slack Docs
- Enterprise Grid - Slack Docs
- Rate limits - Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.