Submit login credentials URL-encoded and accept them only in that encoding, so AppSec can redact them

This commit is contained in:
Viktor Liu
2026-09-23 13:09:03 +02:00
parent d77091cdbe
commit 469973f59d
4 changed files with 81 additions and 5 deletions
+9
View File
@@ -3,6 +3,7 @@ package auth
import (
"errors"
"math"
"mime"
"net/http"
"strconv"
"time"
@@ -25,10 +26,18 @@ func (e *credentialLimitError) Error() string {
return "too many authentication attempts"
}
// credentialFormValue returns a PIN or password field from a URL-encoded POST
// body. Any other encoding is not a credential submission: AppSec can only
// redact URL-encoded bodies, so accepting multipart here would mirror the
// credential to the engine in the clear.
func credentialFormValue(r *http.Request, field string) string {
if r.Method != http.MethodPost {
return ""
}
media, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || media != "application/x-www-form-urlencoded" {
return ""
}
return r.PostFormValue(field)
}
+65
View File
@@ -1,12 +1,14 @@
package auth
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/netip"
@@ -814,6 +816,69 @@ func TestWasCredentialSubmitted(t *testing.T) {
}
}
func TestCredentialFormValue_OnlyURLEncoded(t *testing.T) {
multipartBody := func(t *testing.T) (string, string) {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
require.NoError(t, w.WriteField(passwordFormId, "secret"))
require.NoError(t, w.Close())
return buf.String(), w.FormDataContentType()
}
tests := []struct {
name string
body func(t *testing.T) (string, string)
expected string
description string
}{
{
name: "url-encoded",
body: func(*testing.T) (string, string) {
return url.Values{passwordFormId: {"secret"}}.Encode(), "application/x-www-form-urlencoded"
},
expected: "secret",
description: "a url-encoded body carries the credential",
},
{
// What fetch sends for a URLSearchParams body.
name: "url-encoded with charset",
body: func(*testing.T) (string, string) {
return url.Values{passwordFormId: {"secret"}}.Encode(), "application/x-www-form-urlencoded;charset=UTF-8"
},
expected: "secret",
description: "the charset parameter must not hide the credential",
},
{
// AppSec cannot redact multipart, so a credential in it must not
// authenticate, or it would be mirrored to the engine in the clear.
name: "multipart",
body: multipartBody,
expected: "",
description: "a multipart body is not a credential submission",
},
{
name: "no content type",
body: func(*testing.T) (string, string) {
return url.Values{passwordFormId: {"secret"}}.Encode(), ""
},
expected: "",
description: "a body without a content type is not a credential submission",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, contentType := tt.body(t)
req := httptest.NewRequest(http.MethodPost, "http://example.com/", strings.NewReader(body))
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
assert.Equal(t, tt.expected, credentialFormValue(req, passwordFormId), tt.description)
})
}
}
func TestCheckIPRestrictions_UnparseableAddress(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
+1 -1
View File
File diff suppressed because one or more lines are too long
+6 -4
View File
@@ -52,16 +52,18 @@ function App() {
setError(null);
setSubmitting(method);
const formData = new FormData();
// URL-encoded, not FormData: the proxy only accepts credentials in this
// encoding, the one it can redact before mirroring a request to AppSec.
const body = new URLSearchParams();
if (method === "password") {
formData.append(methods.password!, value);
body.append(methods.password!, value);
} else {
formData.append(methods.pin!, value);
body.append(methods.pin!, value);
}
fetch(globalThis.location.href, {
method: "POST",
body: formData,
body,
redirect: "manual",
})
.then((res) => {