WAF: Diagnosing and Triaging Synapse Geo-Restriction Blocks
- 1 1. Background: why Synapse blocks by country
- 2 2. The decision you need to make
- 3 3. Setting up
- 4 4. Triage walkthrough
- 5 5. Separating real users from attack traffic
- 6 6. Reference: how the rule works
- 7 7. Reference: where the evidence lives, and its limits
- 7.1 7.1 A successful request leaves no firewall record
- 7.2 7.2 Firewall logs cover 30 days only
- 7.3 7.3 Attribute by account, never by geography
- 7.4 7.4 There is no user ID in the firewall logs
- 7.5 7.5 One principal ID is not a person
- 7.6 7.6 Unattributed credentials are normal
- 7.7 7.7 Never copy credentials out of the logs
- 7.8 7.8 Query cost and duration
- 7.9 7.9 A stale Athena database exists
- 8 8. Replying to the ticket
- 9 9. Command summary
- 10 10. Appendix: the helper scripts
- 10.1 waf-query.sh
- 10.2 athena-query.sh
- 10.3 attribute-users.py
Audience: Synapse service desk and platform engineers responding to a user who cannot reach Synapse and reports a GEO_RESTRICTION error.
Purpose: determine why a specific user was blocked, decide whether the block was correct, and record the evidence for that decision.
1. Background: why Synapse blocks by country
NIH notice NOT-OD-25-083 requires that access to NIH controlled-access data be restricted from a defined set of countries.
Synapse implements this at the network edge, before any request reaches the application: a rule in the AWS Web Application Firewall (WAF) inspects the address each request arrives from, and rejects the request outright if that address belongs to a restricted country.
Because the block happens at the edge, the user receives an HTTP 403 with this body and nothing is recorded in Synapse's own application logs:
{
"errorCode": "GEO_RESTRICTION",
"reason": "Requests originating from this region are blocked. For more information see: https://grants.nih.gov/grants/guide/notice-files/NOT-OD-25-083.html. If you think you see this error message by mistake, please submit a request at https://sagebionetworks.jira.com/servicedesk/customer/portal/9/group/16/create/84."
}That error message is the reason tickets arrive: it invites anyone who believes they were blocked in error to open a service desk request.
The one situation where a legitimate user gets blocked
Many large organisations — hospitals, pharmaceutical companies, universities — route all outbound staff traffic through a corporate proxy or secure web gateway.
When they do, the proxy commonly rewrites the X-Forwarded-For header, which is the header proxies use to record the original visitor's address, replacing the real address with an internal, private address from the organisation's own network.
Private addresses (such as those beginning 10.) are not assigned to any country.
Anyone can use them inside their own network, so no geographic lookup can succeed against one.
Historically the rule treated "no country could be determined" the same as "restricted country", so these users were blocked even though their actual traffic originated in a permitted country.
The rule now makes a narrow exception: if the forwarded address can be positively confirmed to be a private or loopback address, that check no longer blocks the request.
Everything else is unchanged — traffic genuinely arriving from a restricted country is still blocked, and so is traffic whose forwarded address is merely unreadable rather than confirmed-private.
That distinction matters more than it first appears, and §5 explains why.
Each stack carries its own copy of this exception, so when a ticket's evidence points at the proxy problem, confirm the exception is actually in force on the stack that served the user — §6.3 shows how.
2. The decision you need to make
Every geo-restriction ticket resolves to exactly one of three outcomes.
Outcome | What the evidence looks like | What you do |
|---|---|---|
1. Genuinely in a restricted country | The address the request actually arrived from belongs to a restricted country | Keep the block. Reply that access is prohibited under NOT-OD-25-083. There is no exception process. |
2. Legitimate user behind a corporate proxy | The request arrived from a permitted country, and the forwarded header holds a private internal address | Confirm the user's location (step 4), then confirm the exception is active for the stack serving them (§6.3). |
3. Automated attack traffic | The forwarded header contains unreadable junk rather than an address, the | Keep the block. Close the ticket; there is no user to reply to. |
Outcome 3 accounts for the large majority of blocks where no country could be determined.
Distinguishing it from outcome 2 is the central skill in this runbook, and §5 is devoted to it.
Confirming the user's location is mandatory for outcome 2.
Leadership approved allowing proxied users on the explicit condition that the platform team verify each one's geographic location before restoring access.
Step 4 of the walkthrough is that verification step, and it is not optional.
3. Setting up
3.1 Access
You need credentials for the Synapse production AWS account and the AWS CLI installed.
export AWS_PROFILE=<your-synapse-prod-profile>
export AWS_REGION=us-east-1
aws sts get-caller-identity # confirms your credentials are activeAll WAF resources are regional and live in us-east-1, attached to the load balancers in front of the Synapse repository service.
3.2 The three helper scripts
Three small scripts do the repetitive work of submitting a query, waiting for it to finish, and formatting the result.
Script | What it does |
|---|---|
| Runs a CloudWatch Logs Insights query against the firewall logs and returns the raw result. Arguments: the query, then optionally a start and end time accepting anything |
| Runs an Athena SQL query and prints tab-separated results with a header row. |
| Reads the output of |
The complete source for all three is in §10.
Before you start, save each one into a single directory, and run every command in this runbook from that directory.
They need no installation and no execute permission — each is invoked through bash or python3 directly.
How attribute-users.py identifies a user.
Firewall log records contain no Synapse user ID.
What they do contain, for a request made by a logged-in client, is the Authorization header holding the user's access token, whose payload carries a sub claim identifying the account.
Reading that one claim is what links a blocked request to a user, and it is the only way to do so — see §7.4 for why nothing else works.
The script reads only that claim and discards the rest of the token.
It never prints, stores, or transmits token material. Preserve that property if you modify it, and see §7.7 for why it matters.
4. Triage walkthrough
Work through these steps in order.
Each one narrows the question, and most tickets are resolved by step 3.
Step 1 — Identify the user
Tickets usually give a username or an email rather than a numeric ID. Convert it to the numeric principal ID that the rest of the process uses.
USERNAME=<username-from-ticket>
curl -s -X POST "https://repo-prod.prod.sagebase.org/repo/v1/principal/alias" \
-H 'Content-Type: application/json' \
-d "{\"alias\":\"$USERNAME\",\"type\":\"USER_NAME\"}"The response is {"principalId": <number>}. Save it:
export USER_ID=<principal-id>Then read their public profile to confirm you have the right person and to see the organisation they claim:
curl -s "https://repo-prod.prod.sagebase.org/repo/v1/userProfile/$USER_ID" | python3 -m json.toolProfile fields are self-reported, so treat company and location as corroboration only.
They never substitute for the location confirmation in step 4.
Step 2 — Find the user's blocked requests
This is the query that connects a user ID to firewall activity.
It gathers geo-restriction blocks whose forwarded address is a private internal address, then attributes each one to a Synapse account.
bash waf-query.sh 'fields @message, @timestamp
| filter terminatingRuleId like /geo-restriction/
and @message like /(?i)"name":"authorization"/
and @message like /(?i)"name":"x-forwarded-for","value":"(10\.|192\.168\.|127\.|172\.(1[6-9]|2[0-9]|3[01])\.)/
| sort @timestamp desc | limit 2000' '30 days ago' 'now' \
| python3 attribute-users.py "$USER_ID"Restricting to private forwarded addresses keeps the result small enough that a single query covers the whole retention window.
Example output:
records examined: 36
no Authorization header (anonymous / browser traffic): 0
principal 1234567: 7 blocks
window : 2025-01-10 15:02:54Z -> 2025-01-11 16:43:07Z
source : {'203.0.113.41': 5, '203.0.113.77': 2}
forwarded : {'10.20.30.40': 3, '10.20.30.55': 4}
top paths : {'GET /repo/v1/userProfile': 7}Read that as: the requests genuinely arrived from public addresses in the 203.0.113.x range, but the forwarded header carried private 10.x addresses from inside the user's corporate network.
That is the outcome 2 signature. Continue to step 4.
If your user does not appear, they are not affected by the private-address problem.
Remove the x-forwarded-for condition to see all attributable blocks, but narrow the time window to the dates on the ticket first:
bash waf-query.sh 'fields @message, @timestamp
| filter terminatingRuleId like /geo-restriction/ and @message like /(?i)"name":"authorization"/
| sort @timestamp desc | limit 2000' '<ticket-date>' '<ticket-date + 2 days>' \
| python3 attribute-users.py "$USER_ID"Narrowing matters: attributable geo blocks are dominated by traffic genuinely arriving from restricted countries, so a wide window fills the result limit with unrelated records and silently omits your user.
If your user appears here with a restricted country and no forwarded header, that is outcome 1 — keep the block.
If the user still does not appear at all, their client sends no access token, which is normal for someone browsing the web portal while logged out.
No query can attribute those requests to an account. Use step 3 to obtain their addresses, then step 4.
Step 3 — Recover the user's addresses when attribution fails
Blocked requests never reach the Synapse application, so they leave no trace in application access records.
What those records do hold is the address and proxy fingerprint of every request that succeeded — which is how you learn which addresses to look for in the firewall logs.
bash athena-query.sh "SELECT record_date,
x_forwarded_for,
via,
client,
count(*) AS requests,
sum(CASE WHEN response_status >= 400 THEN 1 ELSE 0 END) AS errors,
min(timestamp) AS first_seen,
max(timestamp) AS last_seen
FROM warehouse.processedaccessrecord
WHERE user_id = $USER_ID
AND record_date >= current_date - INTERVAL '60' DAY
GROUP BY 1,2,3,4
ORDER BY record_date DESC, requests DESC
LIMIT 100"How to read the result:
A
viavalue naming a proxy product confirms the user sits behind a corporate gateway.x_forwarded_forhere is the whole chain of addresses the application saw. The load balancer appends the address the request actually arrived from, so the last entry in the chain is the real origin address and matches what the firewall recorded. The firewall's geographic check reads only the first entry, which is why a private first entry causes a block even though a perfectly good public address appears later in the same chain.The date where successful traffic stops is when the problem began. Compare it against the ticket date and against any proxy rollout at the user's institution.
Take any origin address from this result and confirm the blocks directly:
export SOURCE_IP=<address-from-step-3>
bash waf-query.sh "fields @timestamp, httpRequest.clientIp, httpRequest.country, httpRequest.uri, terminatingRuleId, action
| filter httpRequest.clientIp = '$SOURCE_IP'
| sort @timestamp desc | limit 100" '30 days ago' 'now' \
| python3 -c 'import sys,json;d=json.load(sys.stdin);[print(" | ".join(f["value"] for f in r if f["field"]!="@ptr")) for r in d["results"]]'Step 4 — Confirm the user's location
This step is required before access is restored. Two independent checks, both quick.
4a. The firewall's own country determination.
The firewall records a country for the address the request actually arrived from — an address the user cannot influence, unlike the forwarded header. This is the authoritative signal.
bash waf-query.sh "fields @timestamp, httpRequest.clientIp, httpRequest.country
| filter httpRequest.clientIp = '$SOURCE_IP'
| stats count() as requests by httpRequest.clientIp, httpRequest.country" '30 days ago' 'now' \
| python3 -c 'import sys,json;d=json.load(sys.stdin);[print(" | ".join(f["value"] for f in r)) for r in d["results"]]'A country outside the restricted list is positive confirmation that the traffic did not originate in a restricted country.
4b. Who owns the address.
curl -s "https://ipinfo.io/$SOURCE_IP/json"Send nothing but the address to this third-party service.
You are looking for an owner consistent with the user's stated employer — a corporate network, or a cloud provider if the user works from a hosted virtual machine.
An address belonging to a consumer VPN or an anonymising service is grounds to ask further questions before restoring access.
Note that a single user may legitimately appear from several addresses, including addresses in different cities or cloud regions.
Do not treat an unexpected city as evidence that you have the wrong person; the token attribution in step 2 is far more reliable than reasoning about geography.
Step 5 — Record the determination
Add to the ticket: the principal ID, the origin addresses, the country the firewall determined for each, the address owner, the private forwarded address, and the proxy named in the Via header.
Together these show the block was a proxy side-effect rather than a restricted-country access attempt, and they leave an auditable basis for the compliance decision.
5. Separating real users from attack traffic
Do not search for blocks by "the country was not restricted".
That set is dominated by automated attacks, and filtering that way buries the handful of genuine users under thousands of irrelevant records.
Internet-wide scanners probe Synapse continuously, and a common technique is to place an injection payload in request headers — including X-Forwarded-For.
A scanner-generated header looks like this rather than like an address:
x-forwarded-for = ${${sk:-j}${z:8x9:1j:-n}${z:5sg3:-d}${1h6:-i}${z8:-:}${wklc:mv:siw9:-l}...
host = <a bare IP address, not repo-prod.prod.sagebase.org>
uri = /${${vf7c:s:-j}...That string is an obfuscated attempt at a jndi:ldap:// lookup, a well-known remote code execution probe.
The firewall cannot read an address from it, so the "cannot determine a country" path is taken and the request is blocked — which is the correct outcome.
These requests still record a harmless-looking country, because that country comes from the scanner's real address.
The reliable filter is therefore whether the forwarded address parses as a private or loopback address. That is exactly the condition the rule's exception uses:
bash waf-query.sh 'fields @timestamp, httpRequest.clientIp, httpRequest.country, httpRequest.uri
| filter terminatingRuleId like /geo-restriction/
and @message like /(?i)"name":"x-forwarded-for","value":"(10\.|192\.168\.|127\.|172\.(1[6-9]|2[0-9]|3[01])\.)/
| stats count() as blocks by httpRequest.clientIp, httpRequest.country
| sort blocks desc | limit 50' '30 days ago' 'now' \
| python3 -c 'import sys,json;d=json.load(sys.stdin);print("source | country | blocks");[print(" | ".join(f["value"] for f in r)) for r in d["results"]]'Example output:
source | country | blocks
203.0.113.10 | US | 522
203.0.113.11 | DE | 355
198.51.100.24 | US | 120
198.51.100.90 | GB | 7
192.0.2.7 | CN | 1Typically a handful of addresses account for nearly all of it, each belonging to one large organisation's proxy estate — the same employer will often appear several times, once per regional gateway.
A private forwarded address does not by itself mean a permitted country.
The last row above is a request that both carried a private forwarded address and genuinely arrived from a restricted country.
It remains blocked, because the check against the real origin address is independent and still applies.
Always read the country column per row.
Tracking the affected population over time
bash waf-query.sh 'fields @timestamp
| filter terminatingRuleId like /geo-restriction/
and @message like /(?i)"name":"x-forwarded-for","value":"(10\.|192\.168\.|127\.|172\.(1[6-9]|2[0-9]|3[01])\.)/
| stats count() as blocks by bin(1d)' '30 days ago' 'now' \
| python3 -c 'import sys,json;d=json.load(sys.stdin);print("\n".join(sorted(" | ".join(f["value"] for f in r) for r in d["results"])))'Sort client-side as shown; a sort clause has no effect after stats ... by bin(1d), which returns rows in arbitrary order.
Where the private-address exception is active, this series should sit near zero for permitted countries while the overall geo-restriction block count stays high, since scanner traffic continues to be blocked.
A sustained non-zero count for permitted countries means the exception is not in effect on at least one stack — check §6.3.
6. Reference: how the rule works
Read this section when the walkthrough gives an unexpected result, or when the rule itself changes.
6.1 The two independent checks
The rule sits at the highest priority of each regional WebACL and is defined in the Synapse-Stack-Builder repository under the web ACL template directory.
It combines two checks with OR, so either one alone will block a request.
Check 1 — the real origin address.
A geographic match against the address the request actually arrived from at the load balancer.
The client cannot forge this, so this check is what actually enforces NOT-OD-25-083.
Check 2 — the forwarded address.
A geographic match against the first entry of the X-Forwarded-For header, configured so that a header present but unreadable counts as a match, combined with an exception for addresses confirmed to be non-routable.
The restricted country codes applied by both checks are CN, HK, MO, RU, IR, KP, CU, VE.
The non-routable ranges used by the exception are the private ranges 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16, plus the loopback range 127.0.0.0/8.
Confirm both lists against the template in Synapse-Stack-Builder rather than trusting this document, since the country list changes whenever NIH updates its guidance.
6.2 What check 2 does in each situation
Forwarded header | Result |
|---|---|
First entry is a public address in a restricted country | Blocked |
First entry is a public address in a permitted country | Not blocked |
First entry is a private or loopback address | Not blocked — this is the exception |
Present, but no readable address (attack traffic) | Blocked |
Absent entirely | Check 2 does not apply |
Two behaviours here are easy to get backwards:
An absent header is not the same as an unreadable one.
Per the AWS ForwardedIPConfig reference: "If the specified header isn't present in the request, AWS WAF doesn't apply the rule to the web request at all."
The "treat as restricted" fallback fires only when the header exists but holds no readable address.
This is why ordinary API and command-line traffic, which sends no forwarded header, is unaffected by check 2 entirely.
Check 2 is not a security control.X-Forwarded-For is supplied by the client and trivially forged, so anyone determined to evade check 2 already can.
Check 1, over the unforgeable origin address, is the enforcement mechanism.
A related consequence: the geographic part of check 2 reads only the first entry in the chain, while the non-routable exception matches if any entry in the chain is non-routable.
A chain whose first entry is a restricted-country address and which also contains a private address therefore escapes check 2 — and is still caught by check 1 if it genuinely originates in a restricted country.
Given that the header is forgeable in the first place, this is an accepted trade-off.
6.3 Confirming which protections are active on a stack
Synapse production runs paired stacks and the numbering changes with every release, so never hard-code a stack name. List the live WebACLs:
aws wafv2 list-web-acls --scope REGIONAL --region us-east-1 --query 'WebACLs[].Name' --output textConfirm the non-routable exception is deployed. Each stack has its own copy, so an incomplete result means at least one stack still blocks proxied users:
aws wafv2 list-ip-sets --scope REGIONAL --region us-east-1 \
--query "IPSets[?contains(Name,'non-routable')].{Name:Name}" --output textInspect the deployed rule itself, which is the definitive answer for a stack whose behaviour is in doubt:
ACL=<web-acl-name-from-above>
ID=$(aws wafv2 list-web-acls --scope REGIONAL --region us-east-1 \
--query "WebACLs[?Name=='$ACL'].Id" --output text)
aws wafv2 get-web-acl --scope REGIONAL --region us-east-1 --name "$ACL" --id "$ID" \
--query "WebACL.Rules[?contains(Name,'geo-restriction')].Statement"7. Reference: where the evidence lives, and its limits
Source | Holds | Retention | Limitation that will catch you out |
|---|---|---|---|
CloudWatch log group | Full firewall records: origin address, country, path, all request headers, which rule blocked it | 30 days | Only blocked and counted requests are recorded. Successful requests appear nowhere, so the absence of a record proves nothing. |
Athena table | Synapse application access records: user ID, forwarded chain, | Several years | Only requests that reached the application. Firewall-blocked requests are absent by definition. Always constrain the |
Athena table | Load balancer access logs; a firewall block appears as | Several years | No request headers, so you cannot tell which rule blocked the request, nor see the forwarded address or country. Long-range volume only. |
| Public profile: name, username, company, location | current | Self-reported. Corroborating only, never authoritative for compliance. |
| Address owner, network operator, city, country | current | Third party. Send only the address. |
All production stacks write to the single aws-waf-logs-prod log group, so you never need to know which stack served a request in order to find it.
Check the available history of the access record table directly rather than assuming:
bash athena-query.sh "SELECT min(record_date) AS earliest, max(record_date) AS latest FROM warehouse.processedaccessrecord"7.1 A successful request leaves no firewall record
The firewall's logging configuration keeps only blocked and counted requests.
You therefore cannot prove from firewall logs that a request succeeded, and you cannot calculate a block rate from them.
Use the access record table for successes, or the load balancer logs when you need both in one place and can do without headers.
7.2 Firewall logs cover 30 days only
Tickets frequently arrive after that, and users often retried for weeks beforehand.
Beyond the window, the load balancer logs still show that an address was blocked by the firewall, though not which rule:
bash athena-query.sh "SELECT day, count(*) AS waf_blocks
FROM alb_logs.repo_alb_access_logs
WHERE stack='<stack-number>'
AND day >= '<yyyy/MM/dd>'
AND actions_executed='waf'
AND elb_status_code=403
AND client_ip='$SOURCE_IP'
GROUP BY day ORDER BY day"The stack partition must be supplied as an equality condition — the table cannot be scanned across stacks — so repeat the query once per live stack.
7.3 Attribute by account, never by geography
A single user can legitimately reach Synapse from several addresses in different cities or cloud regions.
Excluding an address because its city looks wrong will discard genuine evidence.
Token attribution (step 2) is the reliable method.
7.4 There is no user ID in the firewall logs
The firewall operates below the application and has no concept of a Synapse account, so no query can filter its logs by user.
The access token in the Authorization header is the only link, which is why attribute-users.py exists and why attribution works only for requests from a signed-in client.
Anonymous portal traffic cannot be attributed at all; fall back to step 3.
7.5 One principal ID is not a person
Synapse represents not-logged-in traffic with a single reserved anonymous principal, defined in the platform source as AuthorizationConstants.BOOTSTRAP_PRINCIPAL.ANONYMOUS_USER.
Blocks attributed to it are unauthenticated requests from many unrelated sources, not one individual's activity.
It usually has the highest block count in any result, which is expected and not a finding.
7.6 Unattributed credentials are normal
attribute-users.py reports a reason code instead of an account when it cannot attribute a request safely. None of these indicate a broken query.
Code | Meaning | What to do |
|---|---|---|
| An OAuth client with a registered sector identifier; the claim is an encrypted pairwise pseudonymous ID | Resolving it requires the client ID and a server-side secret, which is outside triage. Ask the user to reproduce using a personal access token. |
| The credential is not a readable token | Fall back to step 3 and attribute by address. |
| A different authentication scheme | As above. |
A large UNDECODABLE count over a wide window is expected; it is mostly scanner traffic sending junk in the Authorization header.
7.7 Never copy credentials out of the logs
The firewall logs request headers without redaction, so raw log records contain live, working access tokens.
Do not paste raw records into tickets, chat, or email.attribute-users.py exists so that you never need to handle the token itself — use it instead of reading raw records.
If a token is exposed, have the user revoke it in their Synapse account settings.
7.8 Query cost and duration
Insights queries are billed by volume scanned, and the firewall log group holds tens of gigabytes across its retention window.
The cost is small but queries over a wide window take minutes.
Start from the dates on the ticket and widen only if the result is empty.
7.9 A stale Athena database exists
An older Glue database of web ACL log tables remains in the account from a long-retired stack.
It is not connected to current logging and querying it returns nothing useful.
Current firewall logs are only in CloudWatch, as listed above.
8. Replying to the ticket
Outcome 1 — genuinely in a restricted country.
Our access logs show your requests reaching Synapse from an address that geolocates to {COUNTRY}. Access to Synapse from this location is prohibited under NIH notice NOT-OD-25-083 (https://grants.nih.gov/grants/guide/notice-files/NOT-OD-25-083.html). We are unable to grant an exception.
Outcome 2 — corporate proxy, location confirmed.
Thank you for the report. We confirmed that your requests reach us from an address operated by {ORGANISATION}, which our logs place in {COUNTRY} — not a restricted location. The block was caused by your organisation's network proxy replacing your address with an internal one that cannot be matched to any country, and our rule treating "cannot determine a country" as restricted. This is addressed in {RELEASE}. We have recorded the location determination on this ticket.
Outcome 3 — automated attack traffic.
No reply. Close the ticket.
9. Command summary
Run from the directory holding the three scripts.
export AWS_PROFILE=<synapse-prod-profile> AWS_REGION=us-east-1
export USER_ID=<principal-id>
# who is this
curl -s "https://repo-prod.prod.sagebase.org/repo/v1/userProfile/$USER_ID" | python3 -m json.tool
# their blocked requests, attributed by account
bash waf-query.sh 'fields @message, @timestamp | filter terminatingRuleId like /geo-restriction/ and @message like /(?i)"name":"authorization"/ and @message like /(?i)"name":"x-forwarded-for","value":"(10\.|192\.168\.|127\.|172\.(1[6-9]|2[0-9]|3[01])\.)/ | sort @timestamp desc | limit 2000' '30 days ago' 'now' | python3 attribute-users.py "$USER_ID"
# their addresses, from requests that succeeded
bash athena-query.sh "SELECT record_date, x_forwarded_for, via, count(*) n FROM warehouse.processedaccessrecord WHERE user_id=$USER_ID AND record_date >= current_date - INTERVAL '60' DAY GROUP BY 1,2,3 ORDER BY 1 DESC LIMIT 50"
# confirm location
export SOURCE_IP=<origin-address>
curl -s "https://ipinfo.io/$SOURCE_IP/json"
# is the private-address exception deployed
aws wafv2 list-ip-sets --scope REGIONAL --region us-east-1 --query "IPSets[?contains(Name,'non-routable')].Name" --output text10. Appendix: the helper scripts
Save each of these into one directory and run the runbook's commands from there.
No installation, no execute permission, and no changes to your PATH are needed.
waf-query.sh
Submits a CloudWatch Logs Insights query against the firewall logs, waits for it to finish, and prints the raw JSON result.
#!/usr/bin/env bash
# Runs a CloudWatch Logs Insights query against the Synapse WAF logs and prints the raw result.
#
# usage: bash waf-query.sh "<query>" [start] [end] [log-group]
# start/end accept anything `date -d` understands. Default window: last 7 days.
# log-group defaults to the shared production WAF log group.
set -euo pipefail
LOG_GROUP="${4:-aws-waf-logs-prod}"
START=$(date -u -d "${2:-7 days ago}" +%s)
END=$(date -u -d "${3:-now}" +%s)
QUERY_ID=$(aws logs start-query \
--log-group-name "$LOG_GROUP" \
--start-time "$START" --end-time "$END" \
--query-string "$1" \
--region us-east-1 --query queryId --output text)
for _ in $(seq 1 200); do
RESULT=$(aws logs get-query-results --query-id "$QUERY_ID" --region us-east-1)
STATUS=$(printf '%s' "$RESULT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["status"])')
case "$STATUS" in
Complete)
printf '%s' "$RESULT"; exit 0 ;;
Failed|Cancelled)
printf '%s' "$RESULT" >&2; exit 1 ;;
esac
sleep 3
done
echo "Timed out waiting for query $QUERY_ID" >&2
exit 1athena-query.sh
Submits an Athena SQL query, waits for it, and prints tab-separated results with a header row.
#!/usr/bin/env bash
# Runs an Athena SQL query and prints tab-separated results with a header row.
#
# usage: bash athena-query.sh "<sql>"
#
# Uses the `primary` workgroup, which already has a result output location configured.
set -euo pipefail
QUERY_ID=$(aws athena start-query-execution \
--region us-east-1 --work-group primary \
--query-string "$1" \
--query QueryExecutionId --output text)
for _ in $(seq 1 200); do
STATUS=$(aws athena get-query-execution --region us-east-1 \
--query-execution-id "$QUERY_ID" \
--query QueryExecution.Status.State --output text)
case "$STATUS" in
SUCCEEDED)
break ;;
FAILED|CANCELLED)
aws athena get-query-execution --region us-east-1 \
--query-execution-id "$QUERY_ID" \
--query QueryExecution.Status.StateChangeReason --output text >&2
exit 1 ;;
esac
sleep 2
done
aws athena get-query-results --region us-east-1 \
--query-execution-id "$QUERY_ID" --output json \
| python3 -c 'import sys,json
rows = json.load(sys.stdin)["ResultSet"]["Rows"]
for row in rows:
print("\t".join(cell.get("VarCharValue", "") for cell in row["Data"]))'attribute-users.py
Reads the JSON from waf-query.sh and reports, per Synapse account, how many requests were blocked and which addresses were involved.
#!/usr/bin/env python3
"""Attribute WAF geo-restriction blocks to Synapse principal IDs.
Reads the JSON emitted by waf-query.sh on stdin and reports, per Synapse account,
how many requests were blocked, which addresses they arrived from, and which
addresses the forwarded header carried.
usage: bash waf-query.sh '<query returning @message>' | python3 attribute-users.py [PRINCIPAL_ID]
Pass a principal ID to report only that account; omit it to report every account
found in the result.
WAF records the Authorization header without redaction, so the input to this script
contains live access tokens. This script reads only the `sub` claim identifying the
account and discards everything else. It never prints, stores, or transmits token
material -- preserve that property if you modify it.
"""
import sys
import json
import base64
import collections
from datetime import datetime, timezone
def principal_of(header_value):
"""Return the numeric principal ID from a bearer token, else a non-identifying reason code.
OAuth clients that register a sector identifier receive an encrypted, non-numeric
pairwise pseudonymous ID in `sub`; those are reported as a reason code rather than
guessed at. The token signature is never read or verified - only the `sub` claim
is needed to identify the account.
"""
if not header_value.lower().startswith("bearer "):
return "NON_BEARER_AUTH"
segments = header_value.split(None, 1)[1].split(".")
if len(segments) < 2:
return "NOT_A_JWT"
try:
payload = segments[1] + "=" * (-len(segments[1]) % 4)
subject = json.loads(base64.urlsafe_b64decode(payload)).get("sub")
except Exception:
return "UNDECODABLE"
if subject is None:
return "NO_SUB"
return str(subject) if str(subject).isdigit() else "NON_NUMERIC_SUB"
def iso(epoch_millis):
return datetime.fromtimestamp(epoch_millis / 1000, timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
wanted = sys.argv[1] if len(sys.argv) > 1 else None
rows = json.load(sys.stdin).get("results", [])
blocks = collections.Counter()
sources = collections.defaultdict(collections.Counter)
forwarded = collections.defaultdict(collections.Counter)
paths = collections.defaultdict(collections.Counter)
timestamps = collections.defaultdict(list)
unauthenticated = 0
for row in rows:
message = next((field["value"] for field in row if field["field"] == "@message"), None)
if not message:
continue
record = json.loads(message)
request = record.get("httpRequest", {})
account = None
forwarded_value = None
for header in request.get("headers", []):
name = header["name"].lower()
if name == "authorization":
account = principal_of(header["value"])
elif name == "x-forwarded-for":
forwarded_value = header["value"]
# No Authorization header means the request cannot be tied to an account at all,
# which is normal for anonymous portal browsing and for most scanner traffic.
if account is None:
unauthenticated += 1
continue
blocks[account] += 1
sources[account][request.get("clientIp")] += 1
forwarded[account][forwarded_value or "(no forwarded header)"] += 1
method = request.get("httpMethod", "?")
uri = (request.get("uri") or "?")[:60]
paths[account][f"{method} {uri}"] += 1
if record.get("timestamp"):
timestamps[account].append(record["timestamp"])
print(f"records examined: {len(rows)}")
print(f"no Authorization header (anonymous / browser traffic): {unauthenticated}")
if not blocks:
print("\nNo attributable blocks in this window. See the runbook section on 30-day retention.")
for account, count in blocks.most_common():
if wanted and account != wanted:
continue
print(f"\nprincipal {account}: {count} blocks")
if timestamps[account]:
print(f" window : {iso(min(timestamps[account]))} -> {iso(max(timestamps[account]))}")
print(f" source : {dict(sources[account])}")
print(f" forwarded : {dict(forwarded[account])}")
print(f" top paths : {dict(paths[account].most_common(5))}")