← Back to dashboard
networkingtoolingfreshReader view (for NotebookLM)

Networking Integration Guide

What is networking, as a working model?

The real model

Almost all of it is someone else's infrastructure, so the real skill is proving which layer is lying.

The DNS rule that resolves most tickets: dig @<authoritative-ns> gives truth, dig @1.1.1.1 gives reach — right at the NS and wrong at 1.1.1.1 means a TTL is still counting down, so wait, because lowering a TTL is not retroactive and re-editing resets nothing. The TLS rule: the most common production bug is a server that serves the leaf but omits the intermediate, which browsers rescue via AIA fetching and curl and mobile SDKs do not, so 'works in Chrome, fails in the app' is a chain problem — and NODE_TLS_REJECT_UNAUTHORIZED=0 turns it into a permanent MITM hole. For codeAmani the two live edges are the deploy path (Porkbun registrar → apex A record because CNAME is illegal at the apex → CAA record or issuance fails → Cloudflare Full (strict) so the origin hop is verified too) and the callback endpoints: M-Pesa and Stripe are inbound signature-verification problems, but any admin field that accepts a callback URL is an outbound SSRF sink where 169.254.169.254 is one fetch away. On Kenya-targeted builds, latency is the budget — HTTP/3 over QUIC removes TCP head-of-line blocking on lossy radio and survives a tower change, and every extra third-party origin costs a fresh DNS+TCP+TLS round trip, roughly a second at 300ms RTT.

Six things you reach for first

Not a protocol tour — the handful of concepts and commands that actually localise a fault.

Text
███╗   ██╗███████╗████████╗██╗    ██╗ ██████╗ ██████╗ ██╗  ██╗██╗███╗   ██╗ ██████╗
████╗  ██║██╔════╝╚══██╔══╝██║    ██║██╔═══██╗██╔══██╗██║ ██╔╝██║████╗  ██║██╔════╝
██╔██╗ ██║█████╗     ██║   ██║ █╗ ██║██║   ██║██████╔╝█████╔╝ ██║██╔██╗ ██║██║  ███╗
██║╚██╗██║██╔══╝     ██║   ██║███╗██║██║   ██║██╔══██╗██╔═██╗ ██║██║╚██╗██║██║   ██║
██║ ╚████║███████╗   ██║   ╚███╔███╔╝╚██████╔╝██║  ██║██║  ██╗██║██║ ╚████║╚██████╔╝
╚═╝  ╚═══╝╚══════╝   ╚═╝    ╚══╝╚══╝  ╚═════╝ ╚═╝  ╚═╝╚═╝  ╚═╝╚═╝╚═╝  ╚═══╝ ╚═════╝

Networking Integration Guide

Focus: The working model of TCP/IP, addressing, DNS, TLS and HTTP/2–3 that an application developer actually needs — plus the diagnostic commands that prove which layer is lying, and the defensive posture for network-layer risk (open ports, weak TLS, DNS hijacking, SSRF, MITM).

Overview

This is a topic guide, not an SDK. There is nothing to npm install; the deliverable is a mental model plus a toolbox. Reach for it when a deploy "works locally", when a domain resolves for you but not for your users, when a certificate is valid in the browser but rejected by curl, or when a webhook receiver needs to make an outbound call to a URL it did not choose.

Two models describe the same stack. The OSI seven-layer model is the vocabulary (people say "layer 7" and "layer 4"); the TCP/IP four-layer model is what actually ships. Use OSI to talk, TCP/IP to debug.

TCP/IP layerOSI layersWhat lives hereWhat breaks
Link1–2 Physical, Data LinkEthernet, Wi-Fi, MAC addresses, ARPCable/radio, MTU, local LAN only
Internet3 NetworkIPv4/IPv6, ICMP, routing, NATWrong route, firewall drop, no IPv6
Transport4 TransportTCP (ordered, reliable), UDP (datagram — carries QUIC)Port closed, RST, SYN timeout
Application5–7 Session, Presentation, ApplicationDNS, TLS, HTTP/1.1, HTTP/2, HTTP/3Bad record, expired cert, 4xx/5xx

Every arrow in that diagram is a place a request can die, and each one has a distinct symptom. The rest of this guide walks them in order.

Official Documentation

ResourceURL
MDN — HTTP overviewhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Overview
MDN — Transport Layer Securityhttps://developer.mozilla.org/en-US/docs/Web/Security/Transport_Layer_Security
MDN — Connection management in HTTP/1.xhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Connection_management_in_HTTP_1.x
RFC 9293 — TCPhttps://www.rfc-editor.org/rfc/rfc9293
RFC 1035 — DNS implementation and specificationhttps://www.rfc-editor.org/rfc/rfc1035
RFC 8499 — DNS terminologyhttps://www.rfc-editor.org/rfc/rfc8499
RFC 8446 — TLS 1.3https://www.rfc-editor.org/rfc/rfc8446
RFC 9110 — HTTP semanticshttps://www.rfc-editor.org/rfc/rfc9110
RFC 9113 — HTTP/2https://www.rfc-editor.org/rfc/rfc9113
RFC 9000 — QUIC transporthttps://www.rfc-editor.org/rfc/rfc9000
RFC 9114 — HTTP/3https://www.rfc-editor.org/rfc/rfc9114
RFC 1918 — Private IPv4 address spacehttps://www.rfc-editor.org/rfc/rfc1918
RFC 4632 — CIDRhttps://www.rfc-editor.org/rfc/rfc4632
RFC 8659 — CAA recordshttps://www.rfc-editor.org/rfc/rfc8659
IANA — Service names and port numbershttps://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml
OWASP — SSRF prevention cheat sheethttps://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
OWASP — Transport layer security cheat sheethttps://cheatsheetseries.owasp.org/cheatsheets/Transport_Layer_Security_Cheat_Sheet.html
Mozilla SSL Configuration Generatorhttps://ssl-config.mozilla.org/
BIND 9 manpages (dig, nslookup)https://bind9.readthedocs.io/en/latest/manpages.html
curl manualhttps://curl.se/docs/manpage.html
Nmap — legal issueshttps://nmap.org/book/legal-issues.html

Setup — the diagnostics toolbox

Install the tools first; every section below assumes them.

Bash
# Debian / Ubuntu / WSL
sudo apt update && sudo apt install -y dnsutils curl iproute2 net-tools traceroute nmap openssl

# macOS (Homebrew) — dig/host/nslookup come from the bind formula
brew install bind curl nmap openssl@3 mtr
PowerShell
# Windows — nslookup, tracert and netstat ship with the OS.
# The PowerShell equivalents are richer and script better:
Resolve-DnsName tech-stack.codeamanilabs.org -Type A
Test-NetConnection tech-stack.codeamanilabs.org -Port 443
Get-NetTCPConnection -State Listen | Sort-Object LocalPort

Optional environment variables the examples use:

Bash
# .env.local — used by the SSRF-guard example below. Server-side only.
OUTBOUND_URL_ALLOWLIST=api.stripe.com,sandbox.safaricom.co.ke,api.safaricom.co.ke

apt install nmap is fine; running nmap is not automatically fine. See Scanning, authorized only.


IP addressing, CIDR, subnets, NAT

An IPv4 address is 32 bits (203.0.113.10); IPv6 is 128 bits (2001:db8::1). CIDR notation (RFC 4632) appends a prefix length: the first N bits are the network, the rest identify hosts inside it.

CIDRNetmaskUsable host addresses (IPv4)Typical use
/32255.255.255.2551A single host — firewall rules, allowlists
/24255.255.255.0254One small subnet / VPC subnet
/16255.255.0.065,534A VPC
/8255.0.0.016,777,21410.0.0.0/8 private space
/00.0.0.0everythingDefault route; "any source" in a security group

Two addresses in every IPv4 subnet are not usable hosts — the all-zeros network address and the all-ones broadcast address — which is why a /24 gives 254, not 256.

Ranges that must never be reachable from user input

These are the ranges an SSRF guard denies. Memorise them; they show up again in the security section.

RangeRFCMeaning
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16RFC 1918Private IPv4
127.0.0.0/8, ::1/128—Loopback
169.254.0.0/16, fe80::/10RFC 3927Link-local — includes 169.254.169.254, the cloud metadata endpoint
100.64.0.0/10RFC 6598Carrier-grade NAT shared space
fc00::/7RFC 4193IPv6 unique local addresses
0.0.0.0/8—"This network"; 0.0.0.0 often resolves to localhost

NAT

Network Address Translation lets many private addresses share one public address by rewriting source IP + port and keeping a translation table. Consequences that matter to application code:

  • Inbound connections do not work by default. A device behind NAT (a rider's phone on a Kenyan mobile network, almost always behind CGNAT) cannot be dialled; it must initiate. This is why webhooks push to your public HTTPS endpoint and why local webhook testing needs a tunnel (ngrok http 3000).
  • Client IP is not the client. Behind NAT and behind a CDN, req.socket.remoteAddress is the edge. Read the forwarded header your platform sets and trust it only because the platform sets it — on Vercel that is x-forwarded-for / x-real-ip, and only the value your own proxy appended is trustworthy.
  • Rate limiting by IP punishes shared exits. A whole ISP or office can share one public IP. Prefer a per-user or per-API-key key over a raw IP key wherever you have an identity.

Ports and sockets

A socket is the five-tuple {protocol, source IP, source port, destination IP, destination port}. A server binds and listens on a well-known port; a client connects from an ephemeral port. IANA divides the space into System (0–1023), User/Registered (1024–49151) and Dynamic/ephemeral (49152–65535, never assigned).

PortServiceNote
22SSHNever expose with password auth; keys only
25 / 465 / 587SMTP / SMTPS / submission587 is the modern submission port; many hosts block 25 outbound
53DNSUDP first, TCP fallback for large responses (RFC 7766)
80HTTPKeep it open only to 301 to HTTPS and to serve ACME HTTP-01
443HTTPSTCP for HTTP/1.1 + HTTP/2, UDP for HTTP/3/QUIC
853DNS-over-TLSRFC 7858
3000 / 3001Next.js devLocal only
5432PostgresSupabase / Neon — never open to 0.0.0.0/0
6379RedisHistorically unauthenticated by default; treat as internal-only

Two rules that prevent most self-inflicted outages:

  1. Bind to 127.0.0.1 for anything that does not need to be public. A service bound to 0.0.0.0 on a cloud VM is on the internet the moment the security group allows it.
  2. Open ports are inventory. You cannot defend a listener you did not know existed — see the ss recipe below.

DNS

DNS turns a name into an address (RFC 1035; terminology in RFC 8499). Four roles are involved, and confusing them is the root of most "it works for me" reports.

The authoritative nameserver is the only place a record actually changes. Everything between it and the user is a cache. "DNS propagation" is not a push — it is the world's recursive resolvers expiring their cached copy after the record's TTL elapses. That is the whole mechanism, and it dictates the operational rule below.

Record types

TypePoints atNotes
AIPv4 addressApex domains on Vercel use an A record
AAAAIPv6 addressAdd it — a growing share of mobile networks are IPv6-only with NAT64
CNAMEAnother nameCannot coexist with other records on the same name, and cannot legally sit at the zone apex. Providers work around this with CNAME flattening / ALIAS / ANAME
MXMail exchanger + priorityLower priority number wins (RFC 5321)
TXTArbitrary textDomain-ownership proofs, SPF (RFC 7208), DKIM (RFC 6376), DMARC (RFC 7489)
CAAWhich CAs may issue for this nameRFC 8659 — issue / issuewild / iodef
NSDelegation to authoritative serversChanging these at the registrar moves the whole zone
SOAZone metadataSerial, refresh, and the negative-cache TTL
SRVService host and portUsed by protocols that need port discovery
HTTPS / SVCBConnection hints before the first requestRFC 9460 — lets a client learn ALPN (h3), IP hints and port up front
PTRReverse: address → nameLives in the provider's zone, not yours

Two TXT records everyone gets wrong: SPF must be one TXT record per domain (multiple v=spf1 records is a permanent error), and DMARC lives at _dmarc.example.com, not the apex.

The TTL rule

Lower the TTL before you change anything.

Bash
# 1. Days before a cutover: drop TTL to 300s on the records you will change,
#    then wait for the OLD TTL to fully elapse.
# 2. Make the change.
# 3. Verify from multiple resolvers.
# 4. Days later, once stable, raise TTL back to 3600+.

You cannot shorten a TTL retroactively: a resolver that cached the old record at 86400s will hold it for up to a day no matter what you do afterwards. This is why a rushed DNS cutover is a multi-hour outage and a planned one is invisible.

Diagnosing DNS

Bash
# What does the authoritative server say? (bypasses every cache — the ground truth)
dig +short NS codeamanilabs.org
dig @ns1.porkbun.com tech-stack.codeamanilabs.org A

# What does the world see? Query specific public resolvers.
dig @1.1.1.1 tech-stack.codeamanilabs.org A +short
dig @8.8.8.8 tech-stack.codeamanilabs.org A +short

# Full delegation path, root → TLD → authoritative
dig +trace tech-stack.codeamanilabs.org

# Remaining TTL on the cached answer (run twice — it counts down)
dig tech-stack.codeamanilabs.org A | grep -A1 "ANSWER SECTION"

# Other record types
dig codeamanilabs.org MX +short
dig codeamanilabs.org CAA +short
dig _dmarc.codeamanilabs.org TXT +short

# nslookup — everywhere, including bare Windows
nslookup -type=A tech-stack.codeamanilabs.org 1.1.1.1
PowerShell
# Windows PowerShell equivalents
Resolve-DnsName tech-stack.codeamanilabs.org -Type A -Server 1.1.1.1
Resolve-DnsName codeamanilabs.org -Type MX
Clear-DnsClientCache   # flushes the local stub cache only, not the recursive resolver

If dig @<authoritative-ns> is right but dig @1.1.1.1 is wrong, you are waiting on TTL — do nothing. If the authoritative answer itself is wrong, fix the record. That single test separates "be patient" from "act", and it is the most useful thing in this guide.


TLS and HTTPS

TLS 1.3 (RFC 8446) authenticates the server, negotiates keys, and encrypts everything after. The handshake is one round trip before application data in the full case; a resumed session can send 0-RTT early data (with replay caveats — never put a non-idempotent request in 0-RTT).

Three fields in ClientHello do a lot of work:

  • SNI (RFC 6066) carries the hostname in cleartext so one IP can serve many certificates. It is why a CDN can host thousands of sites on one address — and why the hostname you request is visible to the network even under TLS.
  • ALPN (RFC 7301) negotiates the application protocol in the handshake itself: h2, http/1.1, or h3. No extra round trip to discover HTTP/2.
  • key_share is sent optimistically in the first flight — the reason TLS 1.3 is 1-RTT instead of TLS 1.2's 2-RTT.

Certificates: what validation actually checks

A certificate is trusted only if all of these hold. Any one failing is a hard error, and the error message rarely says which.

  1. Chain of trust — leaf → intermediate(s) → a root in the client's trust store. The single most common production TLS bug is a server that serves the leaf but omits the intermediate: browsers often paper over it with AIA fetching, curl and mobile SDKs do not. Symptom: "works in Chrome, fails in the app".
  2. Hostname match — the requested name must appear in the certificate's Subject Alternative Name list (RFC 9525). The legacy Common Name field is no longer used for matching.
  3. Validity window — notBefore ≤ now ≤ notAfter. Expiry is an outage, so renewal must be automated.
  4. Revocation / CT — modern clients also expect Certificate Transparency signatures.
Bash
# Full handshake detail: chain, SANs, protocol, cipher
openssl s_client -connect tech-stack.codeamanilabs.org:443 \
  -servername tech-stack.codeamanilabs.org -showcerts </dev/null

# Just the dates and names
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName

# Does the chain validate standalone? (no browser AIA rescue)
curl -vI https://example.com 2>&1 | grep -Ei "SSL|subject|issuer|ALPN|HTTP/"

# Prove a specific TLS version is or is not accepted
curl --tlsv1.3 --tls-max 1.3 -sI https://example.com -o /dev/null -w "%{http_version} %{ssl_verify_result}\n"

Hardening checklist

  • TLS 1.2 minimum, 1.3 preferred. SSLv3, TLS 1.0 and TLS 1.1 are deprecated — disable them. Generate config from https://ssl-config.mozilla.org/ rather than hand-writing cipher strings.
  • HSTS — send Strict-Transport-Security: max-age=63072000; includeSubDomains so the browser refuses plaintext on its own. Add preload only when you are certain every subdomain is HTTPS; it is hard to undo.
  • CAA records — publish which CAs may issue for your domain. It is one DNS record and it closes off mis-issuance by any other CA.
  • Automate renewal. Vercel, Netlify, Cloudflare and Render all issue and renew automatically; if you ever terminate TLS yourself, ACME + a monitor on notAfter is not optional.
  • Never disable verification to "fix" a TLS error. NODE_TLS_REJECT_UNAUTHORIZED=0, curl -k, and rejectUnauthorized: false convert a certificate bug into a permanent MITM vulnerability. Fix the chain instead.

HTTP/1.1, HTTP/2, HTTP/3

HTTP/1.1HTTP/2 (RFC 9113)HTTP/3 (RFC 9114)
TransportTCPTCPQUIC over UDP (RFC 9000)
FramingTextBinary framesBinary frames on QUIC streams
ConcurrencyOne request in flight per connection; browsers open ~6Multiplexed streams on one connectionMultiplexed streams, independent
Head-of-line blockingAt the request levelRemoved at HTTP level, remains at TCP level — one lost segment stalls every streamRemoved: a lost packet stalls only its own stream
Header compressionNoneHPACKQPACK
HandshakeTCP + TLS separatelyTCP + TLS separatelyTLS 1.3 folded into the QUIC handshake
Connection migrationNoNoYes — connection ID survives an IP change
DiscoverydefaultALPN h2Alt-Svc: h3=":443" header, or a DNS HTTPS record

HTTP/3 cannot be negotiated in-band on the first connection the way h2 can, because it is a different transport. A client learns about it from an Alt-Svc response header (RFC 7838) or, increasingly, from an HTTPS/SVCB DNS record (RFC 9460) that advertises alpn=h3 before the first packet — saving the initial TCP round trip entirely.

Bash
# Which protocol did you actually get?
curl -sI --http2 https://example.com -o /dev/null -w "%{http_version}\n"
curl -sI --http3 https://example.com -o /dev/null -w "%{http_version}\n"   # needs an HTTP/3-capable curl

# Is HTTP/3 advertised?
curl -sI https://example.com | grep -i alt-svc
dig example.com HTTPS +short        # SVCB/HTTPS record, if published

# Timing breakdown — where the milliseconds go
curl -s -o /dev/null -w "dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n" https://example.com

That last command is the highest-value one-liner in this document: it splits a "slow site" complaint into a DNS problem, a TCP-RTT problem, a TLS problem, or an origin problem, in one request.


CDNs and the edge

A CDN terminates TLS at a point of presence close to the user, serves cacheable responses from there, and reuses long-lived warm connections back to origin. The win is not only bytes — it is round trips. On a 3G link with a 200–400 ms RTT, moving the TLS handshake from a US origin to a nearby PoP saves more wall-clock time than any payload optimisation you can make.

Bash
# Which PoP answered, and did it cache?
curl -sI https://tech-stack.codeamanilabs.org | grep -Ei "cf-ray|cf-cache-status|x-vercel-cache|age|cache-control"
HeaderMeaning
x-vercel-cache: HIT / MISS / STALEVercel edge cache outcome
cf-cache-status: HIT / MISS / DYNAMIC / BYPASSCloudflare cache outcome
cf-ray: …-NBOCloudflare ray id; the suffix is the PoP IATA code (NBO = Nairobi)
ageSeconds the response has sat in cache

When a name is proxied through Cloudflare (orange cloud), dig returns Cloudflare's anycast address, not your origin — expected, not a misconfiguration. See cloudflare for proxy status and the origin SSL modes; use Full (strict) so the Cloudflare↔origin hop is verified too, otherwise the padlock the user sees covers only half the path.


Diagnostics: which layer is lying?

Work bottom-up. Each command clears one layer, so the first failure localises the fault.

Bash
# L3 — is the host routable at all?
ping -c 4 1.1.1.1                 # raw IP: no DNS involved
traceroute -n example.com          # hop-by-hop path; * * * means filtered ICMP, not always a fault
mtr -rw example.com                # traceroute + loss stats over time (best for flaky links)

# L4 — is the port open?
nc -vz example.com 443
# PowerShell: Test-NetConnection example.com -Port 443

# What is listening locally, and which process owns it?
ss -tlnp                           # TCP, listening, numeric, process (needs sudo for other users' procs)
ss -tunap | grep :5432             # every socket touching Postgres
netstat -tulpn                     # older systems; ss is the modern replacement

# L7 — the full story of one request
curl -v https://example.com
curl -sSL -o /dev/null -w "%{http_code} %{num_redirects} %{redirect_url}\n" http://example.com   # follow the redirect chain
SymptomMost likely layerNext command
Could not resolve hostDNSdig @1.1.1.1 <name> then dig @<authoritative-ns> <name>
Connection refusedTransport — nothing listeningss -tlnp on the host
Connection timed outFirewall / security group silently droppingtraceroute, then check the security group
certificate verify failedTLS chain or hostnameopenssl s_client -showcerts
Works in browser, fails in curl/appMissing intermediate certificateServe the full chain
Right in dig @ns, wrong for usersDNS TTL still counting downWait; do not re-edit the record
Fast locally, slow in productionRTT / cache misscurl -w timing breakdown

Scanning, authorized only

nmap is a legitimate inventory tool for systems you own or have explicit written permission to test. Unauthorized port scanning is unlawful in many jurisdictions and violates most providers' terms of service — read https://nmap.org/book/legal-issues.html before pointing it anywhere.

Bash
# Legitimate use: audit YOUR OWN host's exposed surface from outside it.
nmap -Pn -p 1-1024 <your-own-host>          # what does the internet see?
nmap --script ssl-enum-ciphers -p 443 <your-own-host>   # TLS versions/ciphers your server offers

Prefer the alternatives when they exist: ss -tlnp on the box itself is faster, more accurate and needs no permission conversation; your cloud provider's security-group listing is the authoritative answer to "what is exposed".


Network-layer risks and their mitigations

Defensive framing only — each row is a risk you close on your own infrastructure.

RiskWhy it happensMitigation
Open ports / exposed servicesService bound to 0.0.0.0; security group left at 0.0.0.0/0 from a debugging sessionBind internal services to 127.0.0.1; default-deny inbound; audit with ss -tlnp and the provider's security-group list; put admin surfaces behind a VPN or identity proxy, never behind "an unguessable port"
Weak / misconfigured TLSLegacy protocol versions left enabled; missing intermediate; expired certTLS 1.2 minimum; config from Mozilla's generator; serve the full chain; automate renewal and alert on notAfter; enable HSTS
DNS hijacking (registrar/zone takeover)Registrar account compromise, or a stale CNAME to a deprovisioned host that an attacker re-claims2FA + registrar lock on the registrar account (porkbun); publish CAA; delete DNS records when you tear down the resource they point at — dangling CNAMEs are subdomain takeover
DNS spoofing / cache poisoningPlaintext UDP:53 answers are forgeable on a hostile networkDNSSEC (RFC 4033) on your zone; DNS-over-HTTPS (RFC 8484) or DNS-over-TLS (RFC 7858) on clients you control; never make a security decision from an unauthenticated DNS answer
SSRF — server fetches an attacker-chosen URLAny endpoint that takes a URL: webhook-target config, image import, link unfurling, "test my callback" buttonsAllowlist destination hosts; resolve DNS yourself and reject private/link-local IPs before connecting; refuse redirects or re-validate every hop; block non-http(s) schemes; enforce egress rules at the network. Detail below
MITM on hostile networksPublic Wi-Fi, transparent proxies, captive portalsHTTPS everywhere + HSTS; never disable certificate verification; treat any request arriving over plain HTTP as untrusted
Credentials on the wirePostgres/Redis exposed publicly; API keys in query strings (they land in every access log)TLS on database connections (sslmode=require or stricter); keys in Authorization headers, never in URLs; rotate on exposure
Amplification / volumetric abuse of your endpointsAny unauthenticated endpoint that does real workRate limit at the edge; keep expensive routes behind auth; let the CDN absorb L3/L4 volume

SSRF: the pattern that matters for webhook and callback endpoints

The dangerous shape is outbound traffic to a URL a user supplied. From inside a cloud network, http://169.254.169.254/ and http://10.x.x.x/ are reachable and often unauthenticated, so a naïve fetch(userUrl) hands an attacker your internal network.

TypeScript
// lib/safe-fetch.ts — server-side only.
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";

const ALLOWED_HOSTS = new Set(
  (process.env.OUTBOUND_URL_ALLOWLIST ?? "").split(",").map((h) => h.trim()).filter(Boolean),
);

/** RFC 1918 / 3927 / 6598 / 4193 + loopback. Reject, do not "sanitize". */
function isPrivateAddress(ip: string): boolean {
  if (isIP(ip) === 6) {
    const v6 = ip.toLowerCase();
    return v6 === "::1" || v6.startsWith("fe80:") || v6.startsWith("fc") || v6.startsWith("fd");
  }
  const [a, b] = ip.split(".").map(Number);
  if (a === 10 || a === 127 || a === 0) return true;
  if (a === 172 && b >= 16 && b <= 31) return true;
  if (a === 192 && b === 168) return true;
  if (a === 169 && b === 254) return true;          // cloud metadata lives here
  if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
  return false;
}

export async function safeFetch(rawUrl: string, init?: RequestInit): Promise<Response> {
  const url = new URL(rawUrl);

  // 1. Scheme allowlist — blocks file:, gopher:, ftp:, data:
  if (url.protocol !== "https:") throw new Error("only https is allowed");

  // 2. Host allowlist — the strongest control. Prefer it whenever the set is known.
  if (ALLOWED_HOSTS.size > 0 && !ALLOWED_HOSTS.has(url.hostname)) {
    throw new Error(`host not allowed: ${url.hostname}`);
  }

  // 3. Resolve and reject internal addresses (all records, not just the first).
  const resolved = await lookup(url.hostname, { all: true });
  if (resolved.some((r) => isPrivateAddress(r.address))) {
    throw new Error("resolved to a private address");
  }

  // 4. Never follow redirects blindly — a 302 to 169.254.169.254 defeats steps 1-3.
  return fetch(url, { ...init, redirect: "error", signal: AbortSignal.timeout(5_000) });
}

Two honest caveats. First, a DNS rebinding attacker can return a public IP to your lookup() and a private one to the connection that follows; closing that gap requires pinning the validated IP into the connection itself (an undici Agent with a custom connect.lookup). Second, application-level checks are defence in depth — the durable control is network egress policy, so the internal address is unreachable from the request-handling process no matter what the code does. OWASP's cheat sheet is the reference: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html

Inbound webhooks are the mirror-image problem — signature verification, not URL validation. That belongs to webhooks; the broader application-security posture is in security.


codeAmani notes

The deploy path: Porkbun → Cloudflare → Vercel

This is the sequence that actually wires a codeAmani property, and the order matters.

  1. Registrar — the name lives at Porkbun; the porkbun-dns skill edits records programmatically. Registrar lock + 2FA on that account is the root of trust for the entire domain: whoever controls it controls your DNS, and therefore your certificates.
  2. DNS — either Porkbun's nameservers or Cloudflare's. Apex → A record (Vercel shows the value in the project's Domains tab; historically 76.76.21.21); subdomain → CNAME to the per-project Vercel target. CNAME cannot sit at the apex, which is exactly why the apex gets an A.
  3. TLS — issued and renewed automatically by the platform. Publish a CAA record for whichever CA the platform uses, or issuance fails; verify with dig <domain> CAA +short.
  4. Verify before declaring done — dig @<authoritative-ns> for truth, dig @1.1.1.1 for reach, curl -vI for the chain. A green dashboard and a wrong CAA record look identical until the first renewal.

If a name is proxied through Cloudflare in front of Vercel, set SSL mode to Full (strict) — anything less leaves the Cloudflare↔origin hop unverified.

Security

  • Secrets stay server-side. Nothing in this guide changes that: the SSRF allowlist, resolver config and any API tokens live in .env.local / Vercel env vars / Hazina, never in NEXT_PUBLIC_*.
  • The M-Pesa and Stripe endpoints are the two shapes at once. Inbound, they are unauthenticated public HTTPS endpoints that must verify a signature before doing work (Stripe signing secret; Daraja callback validation) and must be idempotent on retry. Outbound, any admin screen that lets someone type a callback or webhook URL is an SSRF sink — run it through safeFetch above. Daraja additionally requires an HTTPS callback, which is why local testing goes through ngrok http 3000 rather than a raw port.
  • Never widen the database to debug. Supabase and Neon connections are TLS-enforced and IP-scoped for a reason; a temporary 0.0.0.0/0 rule outlives the debugging session that created it.
  • Delete DNS records when you delete the thing they point to. A CNAME left pointing at a torn-down preview host is a subdomain takeover waiting for someone to claim the name.

Kenya-targeted projects: the bandwidth angle

On a 2G/3G link, latency and packet loss — not throughput — decide whether an app feels usable. Round trips are the budget.

  • HTTP/3 earns its keep here. QUIC removes TCP-level head-of-line blocking, so one lost packet stalls a single stream instead of every response on the connection — a large win on lossy mobile radio. Connection migration also means a rider moving between cell towers or from Wi-Fi to data keeps the same connection instead of re-handshaking. Vercel and Cloudflare serve HTTP/3 by default; confirm with curl -sI … | grep -i alt-svc.
  • Connection reuse is the cheapest optimisation available. Every new origin is a fresh DNS lookup + TCP + TLS handshake — three round trips before a byte of content. At 300 ms RTT that is ~1 s per extra domain. Serve fonts, images and scripts from your own origin rather than a third-party CDN; use preconnect for the ones you genuinely cannot move.
  • Mobile clients are behind CGNAT. They cannot receive inbound connections, and many share one public IP — so poll or use webhooks pushed to your server, and never rate-limit East African mobile traffic by raw IP.
  • IPv6 matters more here than in the US. Several African mobile networks are IPv6-only with NAT64; publish AAAA records (Vercel and Cloudflare do this for you) rather than assuming IPv4 reachability.
  • Cache aggressively at the edge. Cloudflare R2's zero egress fee plus a Nairobi PoP (cf-ray suffix NBO) means static assets never cross an ocean twice.

Troubleshooting

IssueFix
Record changed but users still see the old valueTTL has not expired. dig @<authoritative-ns> to confirm the record is correct, then wait — re-editing resets nothing
CNAME rejected at the apexNot legal in DNS. Use an A record, or the provider's ALIAS/ANAME/CNAME-flattening feature
Certificate issuance fails on a new domainCheck dig <domain> CAA +short — a CAA record that omits your platform's CA blocks issuance
curl says certificate verify failed, browser is fineServer omits the intermediate; the browser fetched it via AIA and curl did not. Serve the full chain
ERR_SSL_PROTOCOL_ERROR on a proxied domainCloudflare SSL mode vs origin mismatch — Flexible in front of an HTTPS origin loops; use Full (strict)
Webhook works in production, never fires locallyYour dev box is behind NAT and unroutable. Tunnel it: ngrok http 3000, and register the HTTPS URL
Port shows open with nc but the app 502sTransport is fine, application is not — move up a layer: curl -v and the origin's logs
traceroute shows * * * for the last hopsICMP filtered — normal for cloud hosts, not evidence of a fault. Test the port with nc -vz instead
Two v=spf1 TXT records on one domainPermanent SPF error. Merge into a single record
ss shows nothing but the service "is running"It bound to 127.0.0.1 (or inside a container's namespace) — check the bind address and, for Docker, the port publish flags