Skip to content

feat: add /tls-check endpoint for Caddy on-demand TLS#8

Merged
aspiers merged 1 commit intomainfrom
tls-check-config
Mar 2, 2026
Merged

feat: add /tls-check endpoint for Caddy on-demand TLS#8
aspiers merged 1 commit intomainfrom
tls-check-config

Conversation

@Kzoeps
Copy link
Contributor

@Kzoeps Kzoeps commented Mar 2, 2026

Summary

  • Adds a dedicated /tls-check endpoint to pds-core that Caddy's on-demand TLS ask directive calls to verify whether a domain should get a certificate provisioned
  • The endpoint returns 200 for the PDS hostname, the auth subdomain, and any valid hosted handle — rejects unknown domains with 400/404
  • Updates Caddyfile to point ask at /tls-check instead of /xrpc/_health (which always returned 200 regardless of domain, allowing certificate provisioning for arbitrary domains)
  • Adds IP-range blocks in Caddy for known malicious scanner infrastructure

Why

Previously Caddy's ask pointed at /xrpc/_health, which unconditionally returns 200. This meant Caddy would provision TLS certificates for any domain pointed at the server, which is both a security risk and wastes Let's Encrypt rate limits. The new /tls-check endpoint validates that the requested domain is actually one we host.

Changes

File Change
packages/pds-core/src/index.ts New checkHandleRoute() function + /tls-check route
Caddyfile Point ask at /tls-check, add malicious IP blocks, minor whitespace cleanup

Code

directly copied from the bleusky pds image:

docker run --rm ghcr.io/bluesky-social/pds:0.4 cat /app/index.js

"use strict";
const {
  PDS,
  envToCfg,
  envToSecrets,
  readEnv,
  httpLogger,
} = require("@atproto/pds");
const pkg = require("@atproto/pds/package.json");

const main = async () => {
  const env = readEnv();
  env.version ||= pkg.version;
  const cfg = envToCfg(env);
  const secrets = envToSecrets(env);
  const pds = await PDS.create(cfg, secrets);
  await pds.start();
  httpLogger.info("pds has started");
  pds.app.get("/tls-check", (req, res) => {
    checkHandleRoute(pds, req, res);
  });
  // Graceful shutdown (see also https://aws.amazon.com/blogs/containers/graceful-shutdowns-with-ecs/)
  process.on("SIGTERM", async () => {
    httpLogger.info("pds is stopping");
    await pds.destroy();
    httpLogger.info("pds is stopped");
  });
};

async function checkHandleRoute(
  /** @type {PDS} */ pds,
  /** @type {import('express').Request} */ req,
  /** @type {import('express').Response} */ res
) {
  try {
    const { domain } = req.query;
    if (!domain || typeof domain !== "string") {
      return res.status(400).json({
        error: "InvalidRequest",
        message: "bad or missing domain query param",
      });
    }
    if (domain === pds.ctx.cfg.service.hostname) {
      return res.json({ success: true });
    }
    const isHostedHandle = pds.ctx.cfg.identity.serviceHandleDomains.find(
      (avail) => domain.endsWith(avail)
    );
    if (!isHostedHandle) {
      return res.status(400).json({
        error: "InvalidRequest",
        message: "handles are not provided on this domain",
      });
    }
    const account = await pds.ctx.accountManager.getAccount(domain);
    if (!account) {
      return res.status(404).json({
        error: "NotFound",
        message: "handle not found for this domain",
      });
    }
    return res.json({ success: true });
  } catch (err) {
    httpLogger.error({ err }, "check handle failed");
    return res.status(500).json({
      error: "InternalServerError",
      message: "Internal Server Error",
    });
  }
}

main();

Summary by CodeRabbit

  • Security

    • Enhanced security by blocking requests from identified malicious IP ranges.
  • New Features

    • Added a new domain verification endpoint that validates domain ownership and supports hosted handles on configured domains.

@vercel
Copy link

vercel bot commented Mar 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
epds-demo Ready Ready Preview, Comment Mar 2, 2026 0:08am

Request Review

@coderabbitai
Copy link

coderabbitai bot commented Mar 2, 2026

📝 Walkthrough

Walkthrough

The pull request adds a new TLS verification endpoint (/tls-check) used by Caddy for on-demand TLS certificate provisioning. The Caddyfile is updated to point the health check to this endpoint and adds IP-based filtering to block requests from malicious scanner IP ranges. The TypeScript implementation includes domain validation logic for hosted handles, account resolution, and structured error responses.

Changes

Cohort / File(s) Summary
TLS Configuration
Caddyfile
Updated on-demand TLS health check endpoint from /xrpc/_health to /tls-check; added IP range filtering for malicious scanners (185.177.72.0/24 and 195.221.56.0/24) with abort directive to drop matching requests.
TLS Check Endpoint
packages/pds-core/src/index.ts
Introduced new /tls-check route handler (checkHandleRoute) that validates domains, allows PDS hostname and auth subdomain unconditionally, checks for hosted handles, resolves accounts, and returns appropriate success/error responses with logging.

Sequence Diagram

sequenceDiagram
    participant Caddy as Caddy<br/>(On-Demand TLS)
    participant App as PDS Application<br/>(/tls-check endpoint)
    participant Validation as Domain & Handle<br/>Validation Logic
    
    Caddy->>App: POST /tls-check<br/>(domain request)
    activate App
    App->>Validation: Validate domain
    activate Validation
    alt Domain is PDS hostname or auth subdomain
        Validation-->>App: Valid (allow unconditionally)
    else Check hosted handles
        Validation->>Validation: Search hosted handles<br/>on configured domains
        alt Handle found and account resolved
            Validation-->>App: Valid
        else Handle not found
            Validation-->>App: Not Found (404)
        else Account resolution failed
            Validation-->>App: Internal Server Error (500)
        end
    else Invalid domain format
        Validation-->>App: Bad Request (400)
    end
    deactivate Validation
    App-->>Caddy: Success / Error Response
    deactivate App
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A new path for certificates to dance,
Through domains we validate at a glance,
With scanners blocked and handles in place,
TLS verification finds its true space! 🔐

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding a /tls-check endpoint for Caddy on-demand TLS, which is the primary objective and most significant part of the changeset.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch tls-check-config

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Caddyfile`:
- Line 4: Replace the dynamic upstream reference in the Caddy 'ask' directive
with the Docker Compose service name: change the current ask URL that uses
{$PDS_UPSTREAM:core:3000} to use http://core:3000/tls-check so the on-demand TLS
check targets the Compose service name "core" directly.

In `@packages/pds-core/src/index.ts`:
- Around line 478-491: The domain matching in the /tls-check route uses
domain.endsWith(avail) (inside the isHostedHandle computation) which allows
sibling domains like evil-example.com to match example.com; update the check to
only accept exact matches or proper subdomain matches by testing (domain ===
avail) || domain.endsWith('.' + avail) against each entry in
pds.ctx.cfg.identity.serviceHandleDomains when computing isHostedHandle (use the
existing domain variable and isHostedHandle logic) so only identical domains or
true subdomains are authorized.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bf52747 and f36e86b.

📒 Files selected for processing (2)
  • Caddyfile
  • packages/pds-core/src/index.ts

Comment on lines +478 to +491
const isHostedHandle = pds.ctx.cfg.identity.serviceHandleDomains.find(
(avail: string) => domain.endsWith(avail),
)
if (!isHostedHandle) {
return res.status(400).json({
error: 'InvalidRequest',
message: 'handles are not provided on this domain',
})
}
const account = await pds.ctx.accountManager.getAccount(domain)
if (!account) {
return res
.status(404)
.json({ error: 'NotFound', message: 'handle not found for this domain' })
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Tighten domain boundary matching in /tls-check.

Line 479 uses domain.endsWith(avail), which can match sibling domains (for example, evil-example.com against example.com). This can incorrectly authorize domains for cert checks.

Proposed fix
-    const isHostedHandle = pds.ctx.cfg.identity.serviceHandleDomains.find(
-      (avail: string) => domain.endsWith(avail),
-    )
+    const normalizedDomain = domain.trim().toLowerCase().replace(/\.$/, '')
+    const isHostedHandle = pds.ctx.cfg.identity.serviceHandleDomains.some(
+      (avail: string) => {
+        const normalizedAvail = avail.toLowerCase()
+        return (
+          normalizedDomain === normalizedAvail ||
+          normalizedDomain.endsWith(`.${normalizedAvail}`)
+        )
+      },
+    )
@@
-    const account = await pds.ctx.accountManager.getAccount(domain)
+    const account = await pds.ctx.accountManager.getAccount(normalizedDomain)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isHostedHandle = pds.ctx.cfg.identity.serviceHandleDomains.find(
(avail: string) => domain.endsWith(avail),
)
if (!isHostedHandle) {
return res.status(400).json({
error: 'InvalidRequest',
message: 'handles are not provided on this domain',
})
}
const account = await pds.ctx.accountManager.getAccount(domain)
if (!account) {
return res
.status(404)
.json({ error: 'NotFound', message: 'handle not found for this domain' })
const normalizedDomain = domain.trim().toLowerCase().replace(/\.$/, '')
const isHostedHandle = pds.ctx.cfg.identity.serviceHandleDomains.some(
(avail: string) => {
const normalizedAvail = avail.toLowerCase()
return (
normalizedDomain === normalizedAvail ||
normalizedDomain.endsWith(`.${normalizedAvail}`)
)
},
)
if (!isHostedHandle) {
return res.status(400).json({
error: 'InvalidRequest',
message: 'handles are not provided on this domain',
})
}
const account = await pds.ctx.accountManager.getAccount(normalizedDomain)
if (!account) {
return res
.status(404)
.json({ error: 'NotFound', message: 'handle not found for this domain' })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/pds-core/src/index.ts` around lines 478 - 491, The domain matching
in the /tls-check route uses domain.endsWith(avail) (inside the isHostedHandle
computation) which allows sibling domains like evil-example.com to match
example.com; update the check to only accept exact matches or proper subdomain
matches by testing (domain === avail) || domain.endsWith('.' + avail) against
each entry in pds.ctx.cfg.identity.serviceHandleDomains when computing
isHostedHandle (use the existing domain variable and isHostedHandle logic) so
only identical domains or true subdomains are authorized.

Adds GET /tls-check to pds-core so Caddy's on-demand TLS ask URL has a
proper guard instead of the unconditional /xrpc/_health. Returns 200 for
the PDS hostname, the auth subdomain, and any hosted handle with an
existing account; non-200 otherwise so Caddy won't provision certs for
unknown domains.

Updates Caddyfile to point on_demand_tls ask at /tls-check.
Copy link
Contributor

@aspiers aspiers left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice! Good find that the pds repo already had a solution for this.

@aspiers aspiers merged commit 9351243 into main Mar 2, 2026
7 checks passed
@aspiers aspiers deleted the tls-check-config branch March 2, 2026 12:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants