Skip to content

Automated access block

When clients keep sending suspicious requests (the same rules as SuspiciousRequestBlockMiddleware), Django returns 404 as today but also enqueues the event. A shared Azure Function on the stack rate-counts in Table Storage and, above a threshold, adds an App Service IP Deny access restriction. Further requests from that IP are rejected at the App Service edge (403) without reaching Gunicorn.

No Redis is required. Django never reads or writes block state — only fire-and-forget queue messages.

How it works

sequenceDiagram
    participant Client
    participant AppService as AppService_edge
    participant Django as Django_Gunicorn
    participant Queue as StorageQueue
    participant Fn as AccessBlockFunction
    participant Table as TableStorage
    participant ARM as Azure_ARM

    Client->>AppService: suspicious request
    AppService->>Django: forward
    Django->>Queue: enqueue (fire-and-forget)
    Django-->>Client: 404
    Queue->>Fn: trigger
    Fn->>Table: increment counter
    Note over Fn,Table: threshold reached
    Fn->>Table: block history + active block
    Fn->>ARM: PUT ipSecurityRestrictions Deny
    Client->>AppService: next request
    AppService-->>Client: 403 Forbidden

Pulumi

Shared access-block infrastructure (Function, queue, tables) is provisioned automatically when the first site has access block enabled (default on). Further sites reuse the same resources.

  • Storage queue access-block-requests
  • Table Storage tables SuspiciousRequestCounters, AccessBlockActive, AccessBlockHistory
  • Function App fn-access-block-{stack} on the existing App Service plan

Stack exports (after the first enabled site): access_block_function_name, access_block_queue_url (secret).

Access block is on by default for each Django website. Pulumi sets ACCESS_BLOCK_ENABLED=true and the queue SAS URL on that Web App. Pass access_block=False to disable enqueueing on a specific site.

The queue SAS uses fixed start/expiry timestamps so ACCESS_BLOCK_QUEUE_URL is stable across deploys (a changing app setting would recycle the site). The signature still changes if the storage account key rotates.

deployment.add_django_website(
    "mysite",
    # ...
    access_block=False,  # opt out
)

Escalating block duration

Managed by the Function (not Django):

Block # Duration
1 24 hours
2 48 hours
3 4 days
… doubles each time
cap 1 year

Repeat history is stored in AccessBlockHistory after a Deny rule expires, so the next block for the same client IP is longer.

Limits and safety

  • 500 auto-managed Deny rules per Web App (accessblock-* prefix). Azure’s hard limit is 512; ~12 slots are reserved for manual Portal rules.
  • When the 500 cap is hit, the oldest active block is removed (FIFO). Block history is kept for escalation.
  • The Function only modifies rules whose name starts with accessblock-. Manual access restrictions are never touched.
  • Idempotent cleanup: if an accessblock-* rule was already removed in the Portal, the purge timer and FIFO eviction skip the missing rule and still clear Table Storage state (no crash).
  • Unmatched traffic stays allowed: each block write sets ipSecurityRestrictionsDefaultAction to Allow. Without that, App Service’s implicit deny all (once any restriction exists) would take the whole site offline. This feature is a denylist and is incompatible with allowlist-style access restrictions (default Deny + specific Allow rules).
  • Shared NAT risk: only suspicious-path and empty-User-Agent hits count. Tune ACCESS_BLOCK_THRESHOLD and ACCESS_BLOCK_WINDOW_SECONDS on the Function if needed.

Function app settings

Setting Default
ACCESS_BLOCK_THRESHOLD 15
ACCESS_BLOCK_WINDOW_SECONDS 300
ACCESS_BLOCK_BASE_TTL_HOURS 24
ACCESS_BLOCK_MAX_TTL_HOURS 8760
ACCESS_BLOCK_MAX_ACTIVE_RULES 500

Django app settings

Setting Purpose
ACCESS_BLOCK_ENABLED Per-site master switch (Pulumi sets true by default; false when access_block=False)
ACCESS_BLOCK_QUEUE_URL SAS URL for enqueue (set by Pulumi; stable across deploys)
ACCESS_BLOCK_RESOURCE_GROUP Resource group name for the Web App
ACCESS_BLOCK_SUBSCRIPTION_ID Azure subscription ID

Middleware: SuspiciousRequestAccessBlockMiddleware (registered by patch_django_settings_for_azure when enabled and queue URL is set).

Manual smoke test

  1. Deploy a site (access block is enabled by default).
  2. From a test IP, request suspicious paths (e.g. /.env) until threshold (default 15 in 5 minutes).
  3. In Azure Portal → Web App → Networking → Access restrictions, confirm an accessblock-* Deny rule.
  4. Further requests from that IP should return 403 at the edge.
  5. After TTL expiry (or timer purge every 20 minutes), the rule should be removed; history remains for escalation.