Middleware

Middleware sits before or after the route adapter in the request pipeline. Use it for cross-cutting concerns - CORS, security headers, request IDs, logging, compression, rate limiting - without coupling the concerns to any one handler.

Registration

import gebweb.middleware as mw;

gebweb.use(app, mw.securityHeaders({}));
gebweb.use(app, mw.cors({"origin": "https://example.com"}));
gebweb.use(app, mw.requestId());
gebweb.use(app, mw.requestLog({}));

gebweb.use(app, callable) registers a response-phase middleware (func(gebweb.Request, Response): Response). gebweb.after is an alias. gebweb.before(app, callable) registers a request-phase middleware (func(gebweb.Request): ?Response - return null to continue, or a Response to short-circuit). Both phases receive the rich Request; response-phase middleware build the new response with the immutable resp.withStatus/withHeader/withBody builders. Type the parameter any if you don't need the Request accessors.

For convenience the facade also re-exports the built-in factories: gebweb.cors(opts), gebweb.securityHeaders(opts), gebweb.requestId(), gebweb.requestLog(opts), gebweb.compress(opts), gebweb.rateLimit(opts), gebweb.abuseGuard(opts), gebweb.waf(opts).

Built-in middleware

CORS

gebweb.use(app, gebweb.cors({
    "origin": "https://app.example.com",
    "credentials": true,
    "methods": ["GET", "POST", "PUT", "DELETE"],
    "headers": ["Content-Type", "Authorization"],
    "maxAge": 86400,
}));

Defaults are permissive (origin: "*"); tighten as needed. A preflight OPTIONS request is handled automatically.

Security headers

gebweb.use(app, gebweb.securityHeaders({
    "extra": {"Strict-Transport-Security": "max-age=31536000"},
}));

Sets X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and Referrer-Policy: strict-origin-when-cross-origin by default. The extra option merges on top.

Request ID

gebweb.use(app, gebweb.requestId());

Adds an X-Request-ID header (an opaque random ID) to every response that doesn't already have one.

Request log

gebweb.use(app, gebweb.requestLog({}));

Emits a JSON log entry per request via log.info. Pair with the stdlib log module's sink configuration to route entries to a file, collector, or stderr.

Response compression

gebweb.use(app, gebweb.compress({
    "minBytes": 1024,
    "level": 6,
}));

Gzips response bodies when the client advertises Accept-Encoding: gzip and the body is over the minBytes threshold (default 1 KiB). Adds Content-Encoding: gzip and Vary: Accept-Encoding.

Rate limit

gebweb.before(app, gebweb.rateLimit({
    "perSecond": 100,
    "burst": 200,
    "keyFn": func(gebweb.Request req): string {
        return req.header("X-Client-Id") ?? req.clientIp();
    },
}));

Token-bucket rate-limit per client key. The default key is the engine-resolved client IP, which only honours X-Forwarded-For from peers listed in the server's trustedProxies option, so a direct client cannot spoof its key. Exhaustion returns 429 with a Retry-After header. Buckets idle long enough to refill fully are swept lazily, so limiter memory stays bounded under client churn.

Distributed rate limit (Redis)

gebweb.redisRateLimit(opts) is a token-bucket rate limiter whose bucket state lives in Redis, so every app instance drains and refills the same bucket. Use it in multi-instance deployments where the in-memory rateLimit would give each instance its own independent cap.

Requires geblang 1.28.0+ (uses redis.Client.eval).

gebweb.before(app, gebweb.redisRateLimit({
    "address":   "localhost:6379",
    "perSecond": 50,
    "burst":     100,
    "keyFn": func(any req): string {
        return (req as dict<string, any>)["ip"] as string;
    },
}));

The bucket semantics match the in-memory rateLimit: up to burst requests may be served in a burst; the bucket refills at perSecond tokens per second. Exhaustion returns 429 with a Retry-After: 1 header and a application/problem+json body. The default client key is the trusted-proxy-aware client IP (same as rateLimit).

Options:

Option Default Meaning
address required "host:port" of the Redis server.
perSecond 10 Refill rate in tokens per second.
burst perSecond * 2 Maximum tokens (bucket capacity).
keyFn client IP func(request): string - bucket key per client.
failOpen true true -> allow on Redis error; false -> 503.
message "rate limit exceeded" detail field in the 429 response.
prefix "rl:" Key prefix in Redis.
poolSize 8 Max concurrent connections.
password none Redis AUTH password.
db 0 Redis logical database index.
pool none Pre-built gebweb.redisPool(...) (shared pool).
logger none func(string): void for warn messages.

Fail-open / fail-closed: a Redis error is caught and warn-logged. With failOpen: true (default) the request is allowed through; the limiter degrades gracefully when Redis is unreachable. With failOpen: false the middleware returns 503 (application/problem+json).

vs. in-memory rateLimit: rateLimit maintains per-instance in-memory buckets - each pod has its own count. redisRateLimit shares one bucket across all instances; the limit is enforced fleet-wide. The trade-off is a Redis round-trip per request (typically under 1 ms on a local network).

Abuse guard

gebweb.before(app, gebweb.abuseGuard({}));

Detects credential-scanner and exploit bots and bans the offending client IP, short-circuiting all its further requests with 403 before they reach routing. A request whose path matches a built-in list of unambiguous probe patterns - credential files (/.aws/credentials, .git-credentials, .s3cfg, .netrc), VCS exposure (/.git/), /.env, /wp-admin, /phpmyadmin, path traversal, and similar - counts against that client; once it crosses threshold (default 1, so the first probe bans) the client is blocked for banSeconds (default 3600). Rate-limiting still routes each probe; this drops the source outright, so a scanner hammering your service costs almost nothing.

gebweb.before(app, gebweb.abuseGuard({
    "banSeconds": 3600,
    "threshold": 1,
    "badPaths": ["/rest/users", "/admin/config"],
    "allowIps": ["203.0.113.10"],
    "onBan": func(string ip): void { log.warn("banned " + ip); },
}));

The identity key is the same trusted-proxy-aware client IP as rateLimit (override with keyFn); an empty/unidentifiable key is never banned, and ban records are swept once they lapse so memory stays bounded. The 403 response uses the generic about:blank Problem Details type.

Admission control

gebweb.useAdmissionControl(app, {"maxConcurrent": 200});

Bounds the number of requests executing through the dispatcher at once. While maxConcurrent requests are in flight, any further request is rejected with a 503 (and Retry-After: 1) before any handler runs, so a traffic spike sheds load instead of exhausting memory. Unlike the per-client rateLimit, this is one shared gate.

The gate wraps the whole dispatcher: it acquires a slot at the top of every request (static assets and before-middleware short-circuits included) and releases it in a finally, so an uncaught error anywhere in the request still frees the slot. maxConcurrent must be a positive integer (a non-positive value throws at wiring time). For a hard whole-server limit that also covers connections before they reach the application, use the HTTP server's maxConcurrent option.

Customise the rejection with onOverload, a (): response callable:

gebweb.useAdmissionControl(app, {
    "maxConcurrent": 200,
    "onOverload": func(): any {
        return http.jsonResponse({"error": "server busy"}, 503);
    },
});

Choose maxConcurrent from a measurement of per-request memory under load, not a guess: too low rejects healthy traffic, too high defeats the protection.

WAF (gebweb.waf)

gebweb.before(app, gebweb.waf({
    "rules": ["sqli", "xss", "rce", "traversal"],
    "mode": "block",
}));

A Web Application Firewall that inspects each inbound request for common attack signatures and policy violations before routing. Returns null to pass the request through, or a 403 application/problem+json response to short-circuit it. Register with gebweb.before.

Check order: allowIps (bypass) -> denyIps -> blockedMethods -> blockedHeaders -> maxBodyBytes -> blockUserAgents -> content signatures (query string, then body, then header values). The first match decides.

Options:

Option Default Meaning
rules all four List of rule sets to enable: "sqli", "xss", "rce", "traversal".
allowIps [] IPs/CIDRs that bypass the WAF entirely (checked first).
denyIps [] IPs/CIDRs blocked outright.
blockUserAgents [] Case-insensitive substrings matched against User-Agent.
maxBodyBytes 0 (off) Reject requests whose body exceeds this size in bytes.
allowedMethods [] (all) If non-empty, only these HTTP methods are allowed.
blockedHeaders [] Header names whose presence is rejected.
mode "block" "block" (return 403) or "log" (detect-only, never blocks).
onBlock null func(req, match) called on every match (in both modes).
ban null {threshold, banSeconds}: escalate repeat offenders to a timed IP ban.
message "forbidden" The detail field in the 403 problem response.
keyFn client IP func(req): string override for the ban/deny key.
logger null Optional logger; falls back to the framework request logger.

Rule sets:

  • "sqli" - SQL injection signatures (UNION SELECT, tautologies, DDL, sleep, xp_cmdshell, etc.).
  • "xss" - Cross-site scripting signatures (<script>, javascript:, event handlers, document.cookie, etc.).
  • "rce" - Remote code execution signatures (shell commands, subshell expansion, system/exec calls, etc.).
  • "traversal" - Path traversal signatures (../, %2e%2e, /etc/passwd, /proc/self, file://, etc.).

Pass a subset to rules to enable only those sets:

gebweb.before(app, gebweb.waf({
    "rules": ["sqli", "traversal"],
    "mode": "block",
}));

IP allow/deny lists accept plain IP addresses and IPv4/IPv6 CIDR notation. allowIps is checked first - an allowlisted client skips all WAF checks. A malformed entry is logged once and never matches (a typo cannot take the app down):

gebweb.before(app, gebweb.waf({
    "allowIps": ["10.0.0.0/8", "2001:db8::/32", "192.168.1.5"],
    "denyIps":  ["203.0.113.0/24"],
    "mode": "block",
}));

onBlock hook fires on every match in both block and log modes. The match argument is a dict:

{
    "rule":   "sqli",              /* rule set name, or "ip", "method", etc. */
    "target": "query",             /* "ip", "method", "header", "body", "query", "userAgent" */
    "value":  "union select ...",  /* matched snippet */
}
gebweb.before(app, gebweb.waf({
    "mode": "block",
    "onBlock": func(any req, dict<string, any> match): void {
        log.warn("WAF block", {"rule": match["rule"], "target": match["target"]});
    },
}));

mode: "log" records every match (via onBlock and the logger) but never blocks the request. Use this to trial new rules against real traffic before enforcing:

gebweb.before(app, gebweb.waf({
    "rules": ["sqli", "xss", "rce", "traversal"],
    "mode": "log",
    "onBlock": func(any req, dict<string, any> match): void {
        log.info("WAF trial match", match);
    },
}));

Switch mode to "block" once the false-positive rate is acceptable.

Ban escalation (optional): reuse the abuseGuard-style ban store to escalate repeat offenders. A WAF-blocked request increments the client's score; at threshold it is banned for banSeconds and subsequent requests short-circuit before any inspection. The ban store is independent of any abuseGuard instance. Blocked requests use the generic about:blank Problem Details type:

gebweb.before(app, gebweb.waf({
    "ban": {"threshold": 5, "banSeconds": 3600},
}));

Composing with abuseGuard: register both middlewares with gebweb.before; they compose and are independent. abuseGuard catches well-known probe paths (credential files, admin panels, VCS dirs); waf catches content-level attacks in the query string, body, and headers. An IP allowlisted in one is not automatically allowlisted in the other.

gebweb.before(app, gebweb.abuseGuard({}));
gebweb.before(app, gebweb.waf({}));

Caveat: the content-signature rules can produce false positives on free-text or technical fields that legitimately contain SQL or shell fragments (for example, a developer tools API or a code-hosting webhook). Trial new rule sets with mode: "log" before enforcing, and disable a noisy set by removing it from rules.

Conditional GET (ETag)

gebweb.use(app, gebweb.etag());

Tags 200 GET/HEAD responses with a weak content-hash ETag and answers matching If-None-Match revalidations with an empty 304, cutting repeat-view bandwidth. Responses that already carry an ETag are left alone.

Request context: correlation ids

gebweb.useRequestContext(app);

Every request gets a correlation id - the inbound X-Request-Id header when present, a generated one otherwise. Handlers read it via req.requestId(), the response echoes it, and gebweb.requestLogger(req) returns a logger whose entries all carry the id:

@Get("/orders/{id}")
func show(gebweb.Request req, @PathParam("id") int id): dict<string, any> {
    let logger = gebweb.requestLogger(req);
    logger.info("loading order", {"order": id});
    ...
}

Propagate the id to outbound calls and job payloads explicitly: http.request(url).withHeader("X-Request-Id", req.requestId()).

Per-route overrides

Decorate a single handler method (or its enclosing controller class) with @RateLimit(perSecond, burst?), @Cors({...}), or @MaxBody(bytes) to apply policy to that route only. The decorators are recognised when the framework wires the route, so they sit alongside the routing decorators on the same method:

class AuthController {
    @Post("/login")
    @RateLimit(5, 10)
    func login(LoginDto body): dict<string, any> { /* ... */ }
}

@Cors({"allowOrigins": ["https://admin.example.com"]})
class AdminController {
    @Get("/admin/users")
    func list(): list<User> { /* ... */ }
}

@RateLimit is a token-bucket cap scoped to one route; key by remote address. @Cors applies an opts dict identical to the app-wide gebweb.cors(opts) factory, just to that route. Decorate the controller class to apply either policy to every route in it.

Pair with the app-wide registrations: the per-route decorators layer on top. For example, a global gebweb.rateLimit({"rate": 100}) caps the whole app, and @RateLimit(5, 10) further caps /login to 5 rps.

Composing

Middleware composes in registration order. The last use-registered middleware runs first when shaping the response (responses flow back through the stack in reverse). Pair before short-circuits with use shaping for end-to-end pipelines.

Custom middleware

import http;

# Response phase: receives the request and the response, returns the
# response to send (built with the immutable with* builders).
gebweb.use(app, func(gebweb.Request req, http.Response res): http.Response {
    if (res.status() >= 500) {
        io.println("server error on ${req.method()} ${req.path()}");
    }
    return res.withHeader("X-Powered-By", "Gebweb");
});

# Request phase: return null to continue, or a response to short-circuit.
gebweb.before(app, func(gebweb.Request req): ?http.Response {
    if (req.header("X-Maintenance") == "1") {
        return http.response("Down for maintenance", 503);
    }
    return null;
});

Both phases receive the rich gebweb.Request (not a dict), and the response phase receives an http.Response: read it with status(), header(name) and body(), and change it with withStatus, withHeader and withBody, which return a new response. A before middleware returns either null (continue) or a response (short-circuit). A use / after middleware always returns the (possibly modified) response.

Reference

  • gebweb.use(app, middleware): GebwebApp - response-phase.
  • gebweb.before(app, middleware): GebwebApp - request-phase short-circuit.
  • gebweb.after(app, middleware): GebwebApp - alias of use.
  • gebweb.useAdmissionControl(app, opts): GebwebApp - bounds concurrent routed requests (maxConcurrent, optional onOverload); registers the gate + release in one call.

Built-in factories (re-exported on gebweb and on gebweb.middleware):

Name Signature
cors (opts: dict): callable
securityHeaders (opts: dict): callable
requestId (): callable
requestLog (opts: dict): callable
compress (opts: dict): callable
rateLimit (opts: dict): callable - typically before (per-instance)
redisRateLimit (opts: dict): callable - before; distributed (geblang 1.28.0+)
abuseGuard (opts: dict): callable - register with before
waf (opts: dict): callable - register with before

ETag and conditional GET (1.1.0)

gebweb.useEtag(app, opts) registers a response-phase middleware that hashes every eligible 2xx body with SHA-256 and emits a weak ETag. Subsequent requests with a matching If-None-Match header return 304 with an empty body.

gebweb.useEtag(app);
gebweb.useEtag(app, {"minBytes": 256});
gebweb.useEtag(app, {"maxBytes": 65536});
gebweb.useEtag(app, {"weak": false});

The middleware skips error responses, empty bodies, and bodies outside the configured minBytes / maxBytes window (0 / 1048576 by default).

Server-Timing (1.1.0)

gebweb.useServerTiming(app) registers a before-middleware that primes a per-request timing list and a response-phase middleware that emits the Server-Timing header populated from that list.

gebweb.useServerTiming(app);

class WidgetController {
    @Get("/widget")
    func widget(dict<string, any> request): dict<string, any> {
        let row = gebweb.measureTiming(request, "db.query",
            func(): any { return widgetRepo.find("w1"); });
        gebweb.recordTiming(request, "render", 4);
        return row as dict<string, any>;
    }
}

measureTiming(request, label, fn) times the callable and appends the duration; recordTiming(request, label, ms) appends a pre-measured value. Labels are sanitised - non-token characters become underscores - so they fit the spec without surprising the browser dev-tools waterfall.