DEV Community

Casey Sun
Casey Sun

Posted on

Do Not Bind Agent Webhooks to a Free Host

A reviewer opened a late pull request tonight. The coding agent had added a GitHub webhook. The callback URL sat on a free host.

Signature checks lived beside the tool loop. The reviewer refused the merge that night.

Free inference is not equal to public ingress. Agents blur that line during planning work. They print a URL and call planning done.

Delivery retries and TLS remain required details. This field guide blocks inbound traffic mistakes. It targets webhooks, redirects, and MCP HTTP.

It does not ban free models for drafts.

The failure that looks like progress

The agent needed a GitHub App callback. It also needed a Stripe event target. It reused the origin used for chat.

That origin was a free experimental server host. Providers retry after timeouts and 5xx replies. A sleeping free host looks like an outage.

The provider then marks the endpoint failing. The agent fixes outages with extra retries. That loop burns tokens and burns trust.

Progress in the chat is not production truth. It does not create a durable contract.

Two retry loops, one outage

Webhook vendors retry on their own schedule. Agents retry when a tool returns 5xx. Those loops do not share a budget.

The host wakes, accepts one POST, then stalls. Duplicate deliveries arrive without idempotency keys. The agent invents a second handler for duplicates.

That handler usually shares the first secret. The blast radius grows with every fix. Stop the plan before the second handler lands.

What counts as ingress here

Treat these surfaces as ingress, not chat.

  • GitHub, GitLab, and Stripe webhook endpoints
  • OAuth redirect URIs for app installs
  • MCP streamable HTTP or SSE endpoints
  • Tool callbacks that receive untrusted POSTs
  • Browser WebSocket command channels for agents

Outbound model calls are a different risk class. Inbound HTTP is a contract with strangers. Free hosts rarely match that inbound contract.

Red flags in the agent plan

Refuse the plan when items below appear.

  1. The callback hostname equals the chat hostname.
  2. The printed URL uses http, not https.
  3. The host has no pinned certificate owner.
  4. The process may sleep after becoming idle.
  5. Signature secrets live inside the prompt context.
  6. The agent proposes ngrok, raw IP, or tickets.
  7. MCP HTTP binds 0.0.0.0 on a shared host.
  8. Retry policy is looping until a 200.
  9. The runbook lacks replay and dead-letter paths.
  10. Health checks are TCP only, never signed.

One flag can remain a review comment. Three flags should stop the merge cold. Ingress plus process sleep is always stop.

Decision table

Use this table before the agent writes YAML.

Need Free host default Better default Exit if
Draft tool schemas Allowed Local mock server Schema handles live events
GitHub webhook Not allowed Dedicated ingress and queue Provider marks endpoint failing
OAuth redirect Not allowed Stable HTTPS app origin Redirect URI changes per session
MCP stdio for one user Allowed Local process only Remote users need the tool
MCP HTTP for a team Not allowed Named service plus auth Bind address is public
Draft text on a free model Allowed Same, with redaction Prompts include webhook secrets
Signed payload verify Not on free host Worker in front of a queue Verifier shares memory with agent

The table is a gate, not a slogan. Teams should copy it into the repository.

Reproducible gate: fail CI on denied ingress

The artifact is a small Node checker. It scans env samples and agent plans. It fails webhook keys on denylisted hosts.

Label this example as a starter template. Teams must fill the denylist themselves. Do not treat host strings as vendor facts.

#!/usr/bin/env node
"use strict";

const fs = require("fs");
const path = require("path");

const INGRESS_KEY =
  /(WEBHOOK|CALLBACK|REDIRECT_URI|MCP_HTTP|INGRESS|PUBLIC_BASE)/i;

const DENY_HOSTS = new Set([
  "localhost",
  "127.0.0.1",
  "0.0.0.0",
  "example-free-host.invalid",
]);

const DENY_HINTS = [
  /free[- ]?server/i,
  /free[- ]?host/i,
  /ngrok\./i,
  /trycloudflare\./i,
];

function walk(dir, acc = []) {
  if (!fs.existsSync(dir)) return acc;
  for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
    if (ent.name === "node_modules" || ent.name === ".git") continue;
    const p = path.join(dir, ent.name);
    if (ent.isDirectory()) walk(p, acc);
    else if (/\.(env|ya?ml|md|json|toml|sample)$/i.test(ent.name)) acc.push(p);
  }
  return acc;
}

function hostOf(value) {
  try {
    return new URL(value).hostname.toLowerCase();
  } catch {
    return String(value).toLowerCase();
  }
}

function hits(text, file) {
  const findings = [];
  const lines = text.split(/\r?\n/);
  lines.forEach((line, i) => {
    const urls = line.match(/https?:\/\/[^\s"'`]+/gi) || [];
    for (const raw of urls) {
      const nearIngress =
        INGRESS_KEY.test(line) || /webhook|redirect_uri|mcp/i.test(line);
      if (!nearIngress) continue;
      const h = hostOf(raw);
      const denied =
        DENY_HOSTS.has(h) ||
        DENY_HINTS.some((re) => re.test(raw) || re.test(line));
      if (denied) findings.push({ file, line: i + 1, host: h });
    }
  });
  return findings;
}

const root = process.argv[2] || ".";
const all = walk(root).flatMap((f) => hits(fs.readFileSync(f, "utf8"), f));

if (all.length) {
  console.error("ingress-deny: webhook-like URL on a blocked host");
  for (const x of all) {
    console.error(`- ${x.file}:${x.line} host=${x.host}`);
  }
  process.exit(1);
}

console.log("ingress-deny: no blocked callback hosts");
Enter fullscreen mode Exit fullscreen mode

Run the checker as a required status check.

node scripts/ingress-deny.js .
Enter fullscreen mode Exit fullscreen mode

Wire the same command into GitHub Actions.

name: ingress-deny
on: [pull_request]
jobs:
  deny:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
      - run: node scripts/ingress-deny.js .
Enter fullscreen mode Exit fullscreen mode

It only blocks known-bad callback defaults here. The script does not prove paid hosts safe.

Test plan for the checker

Keep a tiny fixture tree under testdata. One fixture file must fail on purpose. Another fixture file must pass the checker.

Create testdata/fail/.env.sample with a blocked webhook.

GITHUB_WEBHOOK_URL=https://example-free-host.invalid/hooks/github
Enter fullscreen mode Exit fullscreen mode

Create testdata/pass/.env.sample with dedicated ingress.

GITHUB_WEBHOOK_URL=https://hooks.internal.example/github
Enter fullscreen mode Exit fullscreen mode

Run both cases in the same CI job.

node scripts/ingress-deny.js testdata/pass
! node scripts/ingress-deny.js testdata/fail
Enter fullscreen mode Exit fullscreen mode

The second command must exit nonzero on purpose. A checker that never fails is theater. Keep the fail fixture next to the script.

Better alternatives

Keep free model access for draft text. Move ingress off that experimental machine entirely.

  • Terminate webhooks on a dedicated HTTPS worker.
  • Verify signatures before any model sees bytes.
  • Enqueue the event after the signature check.
  • Pin OAuth redirect URIs to one app origin.
  • Prefer MCP stdio for single-user local tools.
  • Put team MCP HTTP behind an identity proxy.
  • Store replay keys outside the prompt buffer.

The agent should consume a queue later. The free model never owns the socket.

Deterministic verify, never a model tool

HMAC checks belong only in ordinary application code. They do not belong in a tool the agent may skip. A model cannot be the source of timing-safe equality.

Put WHSEC in the worker environment only. Never echo it into plan markdown files. Never ask a free model to check signatures.

A verify-then-queue worker stays small enough. Label the next snippet an unexecuted example.

import http from "node:http";
import crypto from "node:crypto";
import fs from "node:fs";

function valid(sigHeader, raw, secret) {
  const want = crypto
    .createHmac("sha256", secret)
    .update(raw)
    .digest("hex");
  const got = String(sigHeader || "").replace(/^sha256=/, "");
  const a = Buffer.from(got, "utf8");
  const b = Buffer.from(want, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

http
  .createServer(async (req, res) => {
    const chunks = [];
    for await (const c of req) chunks.push(c);
    const raw = Buffer.concat(chunks);
    if (!valid(req.headers["x-hub-signature-256"], raw, process.env.WHSEC)) {
      res.writeHead(401);
      res.end("bad sig");
      return;
    }
    fs.appendFileSync("queue.jsonl", raw.toString("utf8") + "\n");
    res.writeHead(202);
    res.end("queued");
  })
  .listen(8080);
Enter fullscreen mode Exit fullscreen mode

The worker acknowledges after the signature check. The agent reads queue.jsonl on its own schedule. Raw provider POSTs never enter the prompt.

Exit criteria

Leave the free host path when conditions trip.

  • A third party must POST to the agent.
  • Delivery retries can page a human operator.
  • OAuth redirect URIs cannot change per session.
  • Payload signatures must hold under replay attacks.
  • The listener cannot sleep without losing events.
  • More than one team consumes the MCP HTTP port.
  • Prompt logs might capture webhook signing secrets.
  • The agent proposes polling harder after 5xx.

Exit means move ingress, not abandon drafting. Free models can still summarize queued events. They should not accept the raw POST.

When free model access is still fine

Draft the webhook schema with a free model. Ask it for field names, not runtime hosts. Strip secrets before pasting any provider payloads.

That split keeps the cheap path useful. Schema help is not the same as socket ownership. Reviewers should reward that split in comments.

When MonkeyCode is the wrong default

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode offers free model access and a free server option. Agents treat that pair as one deployment. That collapse is the defect this guide blocks.

Draft prompts on free model access only. Do not publish a webhook to the free server option. Teams comparing sandboxes can run the ingress gate on a sample repo first.

The CI gate remains useful without product names.

Who should not use this gate

Skip this workflow in a few cases.

  • Fully local agents with stdio tools only
  • Batch jobs that never accept inbound HTTP
  • Platforms that already force dedicated signed ingress
  • Throwaway demos bound to a private loopback port

The gate also fails closed on localhost callbacks. Some OAuth apps need that during development. Maintain an allowlist for loopback in local jobs.

Never copy that allowlist into production CI.

Limitations

The scanner reads text, not runtime traffic. Renamed env keys can dodge the checker. Encrypted compose files remain fully invisible here.

A paid hostname can still drop TLS. Teams still need provider delivery logs daily. They still need signature tests in CI.

They still need a dead-letter queue. This guide only stops a common default. Do not claim durability because CI passed.

Pass means the denylist did not match.

Practical sequence

  1. Copy the decision table into INGRESS.md.
  2. Add scripts/ingress-deny.js as a required check.
  3. Fill DENY_HOSTS with this team's free-tier origins.
  4. Put webhook workers in a separate service folder.
  5. Redact secrets before a free model summarizes events.
  6. Re-run the checker after the agent edits YAML.

The sequence is boring on purpose here. Boring ingress survives weekend agent edits well. Invented callback URLs do not survive contact.

Teams can keep free model access for plan text. They should run the ingress gate first. No public URL should leave the repository unchecked.

Top comments (0)