WAF: Diagnosing and Triaging Synapse Geo-Restriction Blocks

WAF: Diagnosing and Triaging Synapse Geo-Restriction Blocks

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

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 Host header is a bare IP address, and the request path contains an injection payload

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 active

All 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

Script

What it does

waf-query.sh

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 date -d understands. Defaults to the last 7 days.

athena-query.sh

Runs an Athena SQL query and prints tab-separated results with a header row.

attribute-users.py

Reads the output of waf-query.sh and reports which Synapse account each blocked request belonged to.

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.tool

Profile 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 via value naming a proxy product confirms the user sits behind a corporate gateway.

  • x_forwarded_for here 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 | 1

Typically 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

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 text

Confirm 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 text

Inspect 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

Source

Holds

Retention

Limitation that will catch you out

CloudWatch log group aws-waf-logs-prod

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 warehouse.processedaccessrecord

Synapse application access records: user ID, forwarded chain, via, response status, path, client

Several years

Only requests that reached the application. Firewall-blocked requests are absent by definition. Always constrain the record_date partition.

Athena table alb_logs.repo_alb_access_logs

Load balancer access logs; a firewall block appears as actions_executed='waf' with status 403

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.

repo/v1/userProfile/{id}

Public profile: name, username, company, location

current

Self-reported. Corroborating only, never authoritative for compliance.

ipinfo.io

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

Code

Meaning

What to do

NON_NUMERIC_SUB

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.

UNDECODABLE / NOT_A_JWT

The credential is not a readable token

Fall back to step 3 and attribute by address.

NON_BEARER_AUTH

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 text

10. 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 1

athena-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))}")