mdsmith
Esc
    v0.54.0 GitHub
    MDS072 link ready

    MDS072: external-link-check

    Probe external http and https URLs; flag any returning a transport error or 4xx/5xx response.

    This rule closes the gap with gomarklint’s external-link check (issue #47). It is off by default and opt-in, like MDS068 (link-style). Network I/O has no place on the default mdsmith check hot path. It reads the shared links: config block — the same block MDS027 and MDS068 read. So external-skip, external-timeout, and external-rate-limit sit beside site-root and style per kind.

    The rule checks inline links ([text](url)), autolinks (<https://example.com>), and images (![alt](url)). It probes over the network only on native builds. The WebAssembly engine cannot reach the network, so it treats every URL as not-probed and emits no MDS072 diagnostics. It never reports a URL as healthy on faith. A future host bridge will let a WASM host such as the Obsidian plugin supply real probe results.

    # Settings

    SettingTypeDefaultDescription
    links.external-skiplist[]Regex patterns; a matching URL is not probed
    links.external-timeoutstring"5s"Per-request timeout as a Go duration
    links.external-rate-limitint10Maximum concurrent in-flight requests; minimum 1
    links.external-allow-internalboolfalseAllow probing loopback, private, link-local, and metadata IPs
    links.external-max-probesint1000Maximum distinct URLs probed per run; 0 means unlimited

    Each external URL is probed once per run with an HTTP HEAD request. A URL whose HEAD returns 405 (Method Not Allowed) is retried with GET. Redirects are followed; a final 2xx or 3xx passes. Results are cached per URL for the run, so the same URL across many files costs one request. A non-positive external-timeout falls back to 5s; a rate limit below 1 clamps to 1.

    # SSRF guard

    When links.external-allow-internal is false (the default), the rule refuses to connect to any IP in a restricted range:

    • Loopback: 127.0.0.0/8, ::1/128 (via ip.IsLoopback())
    • Private (RFC1918): 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
    • Link-local: 169.254.0.0/16 (covers AWS/GCP/Azure metadata at 169.254.169.254), fe80::/10
    • ULA: fc00::/7
    • CGN shared-address space: 100.64.0.0/10 (covers Alibaba Cloud metadata at 100.100.100.200)

    The guard fires on both the initial connection and every redirect hop, so a redirect bounce from an allowed URL to an internal endpoint is also blocked. Redirects to restricted IP literals are caught at the HTTP layer before the TCP dial.

    A document link to a restricted address yields a "external URL unreachable" diagnostic. The guard denies the connection rather than issuing a false pass. Set links.external-allow-internal: true only when you deliberately lint an internal site from a trusted network.

    # Egress ceiling

    links.external-max-probes (default 1000) caps the total number of distinct URLs probed per run. Once the ceiling is reached, further URLs are not probed and each is reported as "external URL not probed: per-run limit reached". This bounds egress from a run over a document with a very large number of external links. Set to 0 for no ceiling.

    # Config

    Enable with defaults (5s timeout, 10 concurrent requests):

    rules:
      external-link-check: true

    Skip intranet and example hosts, tighten the timeout, cap concurrency:

    rules:
      external-link-check:
        links:
          external-skip:
            - "^https?://localhost"
            - "^https?://127\\."
          external-timeout: 10s
          external-rate-limit: 5

    Disable:

    rules:
      external-link-check: false

    # Examples

    # Good – no external URLs to probe

    # No External Links
    
    This file links to a [sibling document](good/no-external-links.md). It also
    links to an [in-page anchor](#no-external-links). Neither is an external
    URL. So the rule finds nothing to probe. It reports no diagnostics.

    The fixture suite omits a bad example on purpose. A fixture with a live broken URL would hit the network on every go test run. The HTTP behaviour lives in rule_test.go. That test drives a local httptest.NewServer. It covers the 200, 404, 405-then-GET, transport-error, and cache-hit paths.

    # Diagnostics

    ConditionMessage
    URL returns 4xx or 5xxexternal URL returned HTTP <code>: <url>
    URL unreachable (transport error)external URL unreachable: <url> (<error>)

    # Security

    This rule probes URLs from document content. When linting untrusted input, those URLs are attacker-controlled.

    SSRF risk. A hostile document can target internal hosts. These include loopback (127.0.0.1), RFC 1918 private ranges, link-local addresses, and cloud-metadata IPs (169.254.169.254). The rule also follows up to 10 redirects, so an external URL can bounce inward.

    Configure links.external-skip to block internal patterns. Keep the rule off when linting workspaces you do not control.

    # See also

    # Meta-Information

    • ID: MDS072
    • Name: external-link-check
    • Status: ready
    • Default: disabled, opt-in. Network I/O keeps it off the hot path.
    • Fixable: no
    • Implementation: source
    • Category: link
    • gomarklint: external-link