fix(source): skip invalid endpoint hostnames - #6590
Conversation
|
Welcome @SetagGnaw! |
|
Hi @SetagGnaw. Thanks for your PR. I'm waiting for a kubernetes-sigs member to verify that this patch is reasonable to test. If it is, they should reply with Regular contributors should join the org to skip this step. Once the patch is verified, the new status will be reflected by the I understand the commands that are listed here. DetailsInstructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. |
|
/ok-to-test |
|
I can't properly review at the moment. My understanding is - the label length is valid, but this annotation could only contain valid DNS. We need to check endpoint + annotations packages, as where the validation shoild leave. |
Coverage Report for CI Build 31843984577Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage increased (+0.1%) to 81.806%Details
Uncovered ChangesNo uncovered changes found. Coverage Regressions708 previously-covered lines in 28 files lost coverage.
Coverage Stats
💛 - Coveralls |
|
Thanks for taking a look @ivankatliarchuk, no rush at all! Just to clarify the label length part: the 63 character limit involved here is the DNS per-label cap from RFC 1035 section 2.3.4, not a Kubernetes one. Kubernetes validates these names as a DNS-1123 subdomain (253 characters total, with no bound on an individual label) and the hostname annotations are free-form, so a name whose first label is 64 characters passes the API server while not being valid DNS. The validation for that already lives in the external-dns/endpoint/endpoint.go Lines 303 to 308 in 05e84d6 The gap this PR closes is on the caller side: Happy to also look at tightening validation in the |
700642b to
f2df197
Compare
ivankatliarchuk
left a comment
There was a problem hiding this comment.
I think guarding the two append sites themselves in endpoint/utils.go and engine.go with the same if ep == nil { continue } pattern fixes both gaps at once and keeps the length rule defined in exactly one place.
endpoint/utils.go — EndpointsForHostsAndTargets (around line 124-129):
endpoints := make([]*Endpoint, 0, len(sortedHosts)*len(sortedTypes))
for _, hostname := range sortedHosts {
for _, recordType := range sortedTypes {
ep := NewEndpoint(hostname, recordType, sortedTargets[recordType]...)
if ep == nil {
continue
}
endpoints = append(endpoints, ep)
}
}
return endpointsand
source/template/engine.go — endpointsFromFQDNTargetTemplate (around line 173-175):
host := strings.TrimSpace(parts[0])
target := strings.TrimSpace(parts[1])
if host == "" || target == "" {
log.Debugf("Skipping incomplete host:target pair %q from %s %s/%s: field may not yet be populated",
pair, kind, obj.GetNamespace(), obj.GetName())
continue
}
ep := endpoint.NewEndpoint(host, endpoint.SuitableType(target), target)
if ep == nil {
continue
}
eps = append(eps, ep)| return endpoint.NewTargets(targets...), nil | ||
| } | ||
|
|
||
| // appendEndpointIfValid appends ep to endpoints, skipping it when ep is nil. |
There was a problem hiding this comment.
And I'd lean toward putting one shared helper in the endpoint package itself rather than source/endpoints.go - it operates purely on endpoint.Endpoint, and then both source/ and endpoint/utils.go/engine.go could reuse the same function instead of the fix being split across two packages with two different idioms (inline check vs. named helper).
There was a problem hiding this comment.
For now, I created a helper in endpoint/utils.go and renamed it to AppendIfNotNil(). Not sure if using purely inline check would be better though. I am fine with either way!
NewEndpoint and NewEndpointWithTTL return nil for a DNS name that cannot be represented, so every caller collecting a freshly constructed endpoint has to skip it. That skip was split across two packages and two idioms: a private appendEndpointIfValid in source/endpoints.go, and inline nil checks in endpoint/utils.go and the service and fake sources. Move it to endpoint.AppendIfNotNil, beside the constructors whose contract it encodes, and route the source call sites through it. The sites that set ProviderSpecific or a resource label between constructing and collecting an endpoint could not use a helper, since they need the nil out of the way before those field accesses. They now collect first and apply the shared metadata in a second pass over the slice, the shape nodeSource already used, which leaves every append site going through the one helper. The endpoints produced are unchanged. The only difference is that the old helper's "Skipping nil endpoint" debug line is gone: the constructor already logs the offending label and name at error level, so the second line carried no information the first did not.
EndpointsForHostsAndTargets and endpointsFromFQDNTargetTemplate appended the constructor's return value without checking it, so a hostname whose label exceeds the 63 characters of RFC 1035 section 2.3.4 was collected as a nil element instead of being skipped. Nothing downstream tolerates that nil. MergeEndpoints reads ep.DNSName on every element, and the unstructured source sets labels and a ref object on every element, so the nil panicked and took the whole sync down with it, rather than dropping the one hostname and continuing. Both paths are reached from --fqdn-template and --fqdn-target-template. The annotation path already guarded against the nil in EndpointsForHostname, which is why this went unnoticed for so long: when a resource declares its own hostname the template is never executed at all, so only resources relying on a template could reach the crash. Route both through endpoint.AppendIfNotNil, and cover each with a test that fails with a nil pointer dereference when the fix is reverted.
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
|
Thanks for the fix — this closes the immediate panic in #6589. One thought on the approach, though (just my take, curious what you think): I'm a bit hesitant about leaning on Since NewEndpoint/NewEndpointWithTTL are exported and used by third-party webhook providers, I don't think we can change |
Should we change the tests as well? |
…urning pair NewEndpoint and NewEndpointWithTTL report a rejected DNS name by logging and returning nil, so every caller has to remember a nil check that nothing enforces. Changing their signatures is not an option either: both are exported and called by third-party webhook providers. Introduce NewValidatedEndpoint and NewValidatedEndpointWithTTL, which return the rejection as an error in the shape NewPTREndpoint already uses, and demote the old pair to deprecated wrappers that keep their exact contract, including the log line, byte for byte. The deprecation marker puts staticcheck's SA1019 behind the migration: a call site the follow-up misses fails the lint run instead of relying on convention. Test files are unaffected because .golangci.yml excludes staticcheck for them. NewPTREndpoint now delegates to the validated constructor, closing the gap where it could return a nil endpoint with a nil error.
Replace every non-test call of the deprecated constructors with the validated variants and delete AppendIfNotNil: with the rejection surfaced as an error at the call site, collect-then-filter-nil is no longer a shape any caller needs. Sources, the template engine, and the endpoint utils warn with the rejected hostname and skip just that record, which keeps the behavior the earlier commits on this branch introduced. Providers gain the same handling, and for them it is also a latent crash fix: a rejected name in a zone listing used to leak a nil into Records() and panic downstream. The TXT registry skips an ownership record it cannot build, and the testutils helper panics, since an unrepresentable fixture name is a bug in the test itself. The cloudflare log assertion follows the message to its new home: the skip warning now comes from the call site rather than the constructor.
|
My latest change is doing what you both were suggesting, but the change is very large. |
What does it do ?
NewEndpointandNewEndpointWithTTLreturn nil for a DNS name they cannot represent, so correctness depends on every caller remembering a nil check that nothing enforces. This replaces that contract rather than patching the call sites one at a time.NewValidatedEndpointandNewValidatedEndpointWithTTLreturn the rejection as an error, in the shapeNewPTREndpointalready used.NewPTREndpointnow delegates to them, closing a gap where it could return a nil endpoint with a nil error.Deprecated:marker puts staticcheck's SA1019 behind the migration, so a call site a follow-up misses fails the lint run instead of relying on convention. Test files are unaffected, since.golangci.ymlexcludes staticcheck for_test.go.internal/testutilspanics, since an unrepresentable fixture name is a bug in the test itself.That covers the two crash paths this PR set out to fix:
EndpointsForHostsAndTargets(endpoint/utils.go) andendpointsFromFQDNTargetTemplate(source/template/engine.go) appended the constructor's return value unchecked. Both are reached from--fqdn-templateand--fqdn-target-template.source/node.go,source/pod.go,source/compatibility.go).It also fixes a latent crash of the same shape in the providers: 23 provider packages built endpoints from zone listings without a nil check, so a rejected name in a listing leaked a nil out of
Records()and panicked downstream.Adds regression coverage for invalid names arriving from annotations, fqdn templates, fqdn-target templates, and legacy compatibility annotations. Each new source test fails with a nil pointer dereference when its fix is reverted.
Motivation
NewEndpointWithTTLreturns nil when any dot-separated label of the DNS name is longer than 63 characters (endpoint/endpoint.go). That 63 is the DNS label limit from RFC 1035 section 2.3.4, not a Kubernetes limit, and it applies per label rather than to the name as a whole (which the same section caps separately at 255).The affected paths either dereferenced that nil result or appended it for later processing, causing source reconciliation to panic in
WithLabel,AttachRefObject, orMergeEndpoints. A single invalid name therefore took down the whole sync, when it should be logged and skipped so the remaining records still reconcile.How an over-long label reaches these paths
The DNS per-label limit is stricter than what Kubernetes itself enforces on the names these sources read, so the API server can hand external-dns a name that
NewEndpointWithTTLrejects:NameIsDNSSubdomain->IsDNS1123Subdomain), which caps the total name at 253 characters and applies a regex that places no bound on individual labels. A single-label node name longer than 63 characters is therefore accepted.external-dns.alpha.kubernetes.io/internal-hostnameandexternal-dns.alpha.kubernetes.io/hostnameannotations are free-form and fully user-controlled.--fqdn-templateand--fqdn-target-templaterender object fields into a hostname, so the result is only as valid as the template and the values it interpolates.The annotation path was already guarded inside
EndpointsForHostname, which is why the template paths went unnoticed for so long: when a resource declares its own hostname the template is never executed at all, so only resources relying on a template could reach the crash.Why not a shared nil-skipping helper
An earlier revision of this branch collected the skip into one
endpoint.AppendIfNotNilhelper beside the constructors. Surfacing the rejection as an error made that helper unnecessary and it is gone from the final diff: a call site that gets an error handles it and moves on, so nothing needs to filter nils out of a slice afterwards.That also undid a restructuring the helper had forced. Sites setting
ProviderSpecificor a resource label between constructing and collecting an endpoint could not call a helper inline while the nil was still in the way, so they had collected first and applied the shared metadata in a second pass. With the error handled at the call site the nil is gone before those field accesses, and they set metadata directly again. The endpoints produced are unchanged.Fixes #6589.
Testing
make buildgo test -race ./...golangci-lint run ./endpoint/... ./source/...More