juanfont/headscale · error
%s confirm form: unterminated action attribute
Error message
%s confirm form: unterminated action attribute
What it means
While auto-submitting the headscale registration confirm form, the helper found an `action="` attribute in the rendered HTML but no closing double quote after it. This is a string-scrape parser in the integration Scenario that extracts the form POST URL from the register/confirm page; it throws when the HTML is truncated or the template emits malformed/quoted attributes.
Source
Thrown at integration/scenario.go:1263
// POSTs the form using the same HTTP client (which carries the CSRF
// cookie set by the callback).
func submitConfirmForm(
hostname string,
htmlBody string,
prevResp *http.Response,
hc *http.Client,
) (string, *url.URL, error) {
// Extract form action URL.
actionIdx := strings.Index(htmlBody, `action="`)
if actionIdx == -1 {
return "", nil, fmt.Errorf("%s confirm form: no action attribute", hostname) //nolint:err113
}
actionStart := actionIdx + len(`action="`)
actionEnd := strings.Index(htmlBody[actionStart:], `"`)
if actionEnd == -1 {
return "", nil, fmt.Errorf("%s confirm form: unterminated action attribute", hostname) //nolint:err113
}
formAction := htmlBody[actionStart : actionStart+actionEnd]
// Extract hidden CSRF input value. The rendered <input> has
// attributes in name-type-value order so we grab the whole tag.
before, _, ok := strings.Cut(htmlBody, `name="headscale_register_confirm"`)
if !ok {
return "", nil, fmt.Errorf("%s confirm form: no CSRF input", hostname) //nolint:err113
}
tagStart := strings.LastIndex(before, "<input")
if tagStart == -1 {
return "", nil, fmt.Errorf("%s confirm form: no input tag for CSRF", hostname) //nolint:err113
}
tagEnd := strings.Index(htmlBody[tagStart:], ">")
if tagEnd == -1 {View on GitHub (pinned to 565fd254d0)
Solutions
- Re-fetch the confirm page with curl and inspect the raw HTML around `action=` to see what the server actually rendered.
- If the template changed, update this parser (or the template) in integration/scenario.go so the attribute format matches.
- Replace the string scrape with golang.org/x/net/html parsing so attribute quoting/escaping no longer breaks extraction.
- Verify the response is actually the confirm form (status 200, expected title) before parsing it.
Example fix
// before
actionEnd := strings.Index(htmlBody[actionStart:], `"`)
if actionEnd == -1 {
return "", nil, fmt.Errorf("%s confirm form: unterminated action attribute", hostname)
}
// after: parse the form with x/net/html instead of string indexing
doc, err := html.Parse(strings.NewReader(htmlBody))
if err != nil {
return "", nil, fmt.Errorf("%s confirm form: unparseable HTML: %w", hostname, err)
} Defensive patterns
Strategy: validation
Validate before calling
// Before scraping, cheap sanity check that the body looks like the confirm form.
if !strings.Contains(htmlBody, "<form") || !strings.Contains(htmlBody, "action=") {
return "", nil, fmt.Errorf("%s confirm form: response is not a form page", hostname)
} Prevention
- Parse confirm pages with golang.org/x/net/html instead of string indexing.
- Assert on the page identity (title or form id) before extracting fields.
- Keep the template and this parser in the same change set when editing the register page.
When it happens
Trigger: Calling the Scenario registration helper that fetches the confirm page and then runs this action-extraction code: `strings.Index(htmlBody, "action=\"")` matches, but `strings.Index(htmlBody[actionStart:], "\"")` returns -1. Happens when the response body is cut off mid-attribute, when the template uses single quotes after a double quote, or when HTML-escaping inserts a quote inside the action URL.
Common situations: The hscontrol template for the register confirm page changed attribute quoting; a reverse proxy truncates the body; the auth flow returned an error page whose first `action="` occurrence is inside JavaScript or a comment rather than the form.
Related errors
- %s confirm form: no CSRF input
- tag should be lowercase
- auth-key expired
- auth-key has already been used
- user mismatch
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/2a16c9afa66f516d.
Report an issue: GitHub.