bx509d: Add /get-tgts batch end-point
authorNicolas Williams <nico@twosigma.com>
Wed, 10 Aug 2022 23:08:03 +0000 (18:08 -0500)
committerNicolas Williams <nico@twosigma.com>
Mon, 3 Oct 2022 03:46:37 +0000 (22:46 -0500)
In order to support batch jobs systems that run many users' jobs and
which jobs need credentials, we add a /get-tgts end-point that is a
batched version of the /get-tgt end-point.  This end-point returns JSON.

Also, we make GETs optional, default to not-allowed in preference of
POSTs.

We also correct handling of POST (before POSTs with non-zero-length bodies
would cause the server to close the connection), and add additional CSRF
protection features, including the ability to disable all GET requests
for /get-keys and /get-config.

kdc/Makefile.am
kdc/bx509d.8
kdc/bx509d.c
tests/kdc/check-bx509.in

index c7f57251f7c8aedbef0a58603b4f7ed51756584b..48248d8248b04248a95b78f19e09b01c277e63a3 100644 (file)
@@ -44,6 +44,7 @@ bx509d_LDADD =        -ldl \
                 $(MICROHTTPD_LIBS) \
                 $(LIB_roken) \
                 $(LIB_heimbase) \
+                $(LIB_hcrypto) \
                 $(top_builddir)/lib/sl/libsl.la \
                 $(top_builddir)/lib/asn1/libasn1.la \
                 $(top_builddir)/lib/krb5/libkrb5.la \
index 512d0545ed67e23a91121f68fdfabd2aeca3f7eb..f94015568b75ce9225430fe407df3c30148a199e 100644 (file)
 .Op Fl Fl version
 .Op Fl H Ar HOSTNAME
 .Op Fl d | Fl Fl daemon
-.Op Fl Fl daemon-child
+.Op Fl Fl allow-GET
+.Op Fl Fl no-allow-GET
+.Op Fl Fl csrf-protection-type= Ns Ar CSRF-PROTECTION-TYPE
+.Op Fl Fl csrf-header= Ns Ar HEADER-NAME
+.Op Fl Fl csrf-key-file= Ns Ar FILE
 .Op Fl Fl reverse-proxied
 .Op Fl p Ar port number (default: 443)
 .Op Fl Fl cache-dir= Ns Ar DIRECTORY
 .Oc
 .Sh DESCRIPTION
 Serves RESTful (HTTPS) GETs of
-.Ar /bx509 and
-.Ar /bnegotiate ,
-end-points
-performing corresponding kx509 and, possibly, PKINIT requests
-to the KDCs of the requested realms (or just the given REALM).
+.Ar /get-cert ,
+.Ar /get-tgt ,
+.Ar /get-tgts ,
+and
+.Ar /get-negotiate-token ,
+end-points that implement various experimental Heimdal features:
+.Bl -bullet -compact -offset indent
+.It
+.Li an online CA service over HTTPS,
+.It
+.Li a kinit-over-HTTPS service, and
+.It
+.Li a Negotiate token over HTTPS service.
+.El
+.Pp
+As well, a
+.Ar /health
+service can be used for checking service status.
 .Pp
 Supported options:
 .Bl -tag -width Ds
@@ -75,6 +92,64 @@ Print version.
 .Xc
 Expected audience(s) of bearer tokens (i.e., acceptor name).
 .It Xo
+.Fl Fl allow-GET
+.Xc
+If given, then HTTP GET will be allowed for the various
+end-points other than
+.Ar /health .
+Otherwise only HEAD and POST will be allowed.
+By default GETs are allowed, but this will change soon.
+.It Xo
+.Fl Fl no-allow-GET
+.Xc
+If given then HTTP GETs will be rejected for the various
+end-points other than
+.Ar /health .
+.It Xo
+.Fl Fl csrf-protection-type= Ns Ar CSRF-PROTECTION-TYPE
+.Xc
+Possible values of
+.Ar CSRF-PROTECTION-TYPE
+are
+.Bl -bullet -compact -offset indent
+.It
+.Li GET-with-header
+.It
+.Li GET-with-token
+.It
+.Li POST-with-header
+.It
+.Li POST-with-token
+.El
+This may be given multiple times.
+The default is to require CSRF tokens for POST requests, and to
+require neither a non-simple header nor a CSRF token for GET
+requests.
+.Pp
+See
+.Sx CROSS-SITE REQUEST FORGERY PROTECTION .
+.It Xo
+.Fl Fl csrf-header= Ns Ar HEADER-NAME
+.Xc
+If given, then all requests other than to the
+.Ar /health
+service must have the given request
+.Ar HEADER-NAME
+set (the value is irrelevant).
+The
+.Ar HEADER-NAME
+must be a request header name such that a request having it makes
+it not a
+.Dq simple
+request (see the Cross-Origin Resource Sharing specification).
+Defaults to
+.Ar X-CSRF .
+.It Xo
+.Fl Fl csrf-key-file= Ns Ar FILE
+.Xc
+If given, this file must contain a 16 byte binary key for keying
+the HMAC used in CSRF token construction.
+.It Xo
 .Fl d ,
 .Fl Fl daemon
 .Xc
@@ -82,7 +157,8 @@ Detach from TTY and run in the background.
 .It Xo
 .Fl Fl reverse-proxied
 .Xc
-Serves HTTP instead of HTTPS, accepting only looped-back connections.
+Serves HTTP instead of HTTPS, accepting only looped-back
+connections.
 .It Xo
 .Fl p Ar port number (default: 443)
 .Xc
@@ -90,29 +166,106 @@ PORT
 .It Xo
 .Fl Fl cache-dir= Ns Ar DIRECTORY
 .Xc
-Directory for various caches.  If not specified then a temporary directory will
-be made.
+Directory for various caches.
+If not specified then a temporary directory will be made.
 .It Xo
 .Fl Fl cert= Ns Ar HX509-STORE
 .Xc
-Certificate file path (PEM) for HTTPS service.  May contain private key as
-well.
+Certificate file path (PEM) for HTTPS service.
+May contain private key as well.
 .It Xo
 .Fl Fl private-key= Ns Ar HX509-STORE
 .Xc
-Private key file path (PEM), if the private key is not stored along with the
-certificiate.
+Private key file path (PEM), if the private key is not stored
+along with the certificiate.
 .It Xo
 .Fl t ,
 .Fl Fl thread-per-client
 .Xc
-Uses a thread per-client instead of as many threads as there are CPUs.
+Uses a thread per-client instead of as many threads as there are
+CPUs.
 .It Xo
 .Fl v ,
 .Fl Fl verbose= Ns Ar run verbosely
 .Xc
 verbose
 .El
+.Sh HTTP APIS
+All HTTP APIs served by this program accept POSTs, with all
+request parameters given as URI query parameters and/or as
+form data in the POST request body, in either
+.Ar application/x-www-form-urlencoded
+or
+.Ar multipart/formdata .
+If request parameters are given both as URI query parameters
+and as POST forms, then they are merged into a set.
+.Pp
+If GETs are enabled, then request parameters must be supplied
+only as URI query parameters, as GET requests do not have request
+bodies.
+.Pp
+URI query parameters must be of the form
+.Ar param0=value&param1=value...
+.Pp
+Some request parameters can only have one value.
+If multiple values are given for such parameters, then either an
+error will be produced, or only the first URI query parameter
+value will be used, or the first POST form data parameter will be
+used.
+Other request parameters can have multiple values.
+See below.
+.Sh CROSS-SITE REQUEST FORGERY PROTECTION
+.Em None
+of the resources service by this service are intended to be
+executed by web pages.
+.Pp
+All the resources provided by this service are
+.Dq safe
+in the sense that they do not change server-side state besides
+logging, and in that they are idempotent, but they are
+only safe to execute
+.Em if and only if
+the requesting party is trusted to see the response.
+Since none of these resources are intended to be used from web
+pages, it is important that web pages not be able to execute them
+.Em and
+observe the responses.
+.Pp
+In a web browser context, pages from other origins will be able
+to attempt requests to this service, but should never be able to
+see the responses because browsers normally wouldn't allow that.
+Nonetheless, anti cross site request forgery (CSRF) protection
+may be desirable.
+.Pp
+This service provides the following CSRF protection features:
+.Bl -tag -width Ds -offset indent
+.It requests are rejected if they have a
+.Dq Referer
+(except the experimental /get-negotiate-token end-point)
+.It the service can be configured to require a header that would make the
+request not Dq simple
+.It GETs can be disabled (see options), thus requiring POSTs
+.It GETs can be required to have a CSRF token (see below)
+.It POSTs can be required to have a CSRF token
+.El
+.Pp
+The experimental
+.Ar /get-negotiate-token
+end-point, however, always accepts
+.Dq Referer
+requests.
+.Pp
+To obtain a CSRF token, first execute the request without the
+CSRF token, and the resulting error
+response will include a
+.Ar X-CSRF-Token
+response header.
+.Pp
+To execute a request with a CSRF token, first obtain a CSRF token
+as described above, then copy the token to the request as the
+value of the request's
+.Ar X-CSRF-Token
+header.
 .Sh ONLINE CERTIFICATION AUTHORITY HTTP API
 This service provides an HTTP-based Certification Authority (CA).
 CA credentials and configuration are specified in the
@@ -128,8 +281,8 @@ with the base-63 encoding of a DER encoding of a PKCS#10
 .Ar CertificationRequest
 (Certificate Signing Request, or CSR) in a
 .Ar csr
-required query parameter.
-In a successful query, the response body will contain a PEM
+required request parameter.
+In a successful request, the response body will contain a PEM
 encoded end entity certificate and certification chain.
 .Pp
 Or
@@ -146,9 +299,9 @@ Unauthorized requests will elicit a 403 response.
 .Pp
 Subject Alternative Names (SANs) and Extended Key Usage values
 may be requested, both in-band in the CSR as a requested
-extensions attribute, and/or via optional query parameters.
+extensions attribute, and/or via optional request parameters.
 .Pp
-Supported query parameters (separated by ampersands)
+Supported request parameters:
 .Bl -tag -width Ds -offset indent
 .It Li csr = Va base64-encoded-DER-encoded-CSR
 .It Li dNSName = Va hostname
@@ -178,20 +331,20 @@ of
 .Ar /get-negotiate-token
 with a
 .Ar target = Ar service@host
-query parameter.
+request parameter.
 .Pp
-In a successful query, the response body will contain a Negotiate
-token for the authenticated client principal to the requested
-target.
+In a successful request, the response body will contain a
+Negotiate token for the authenticated client principal to the
+requested target.
 .Pp
 Authentication is required.
 Unauthenticated requests will elicit a 401 response.
 .Pp
 Subject Alternative Names (SANs) and Extended Key Usage values
 may be requested, both in-band in the CSR as a requested
-extensions attribute, and/or via optional query parameters.
+extensions attribute, and/or via optional request parameters.
 .Pp
-Supported query parameters (separated by ampersands)
+Supported request parameters:
 .Bl -tag -width Ds -offset indent
 .It Li target = Va service@hostname
 .It Li redirect = Va URI
@@ -221,13 +374,14 @@ The protocol consists of a
 of
 .Ar /get-tgt .
 .Pp
-Supported query parameters (separated by ampersands)
+Supported request parameters:
 .Bl -tag -width Ds -offset indent
 .It Li cname = Va principal-name
 .It Li address = Va IP-address
+.It Li lifetime = Va relative-time
 .El
 .Pp
-In a successful query, the response body will contain a TGT and
+In a successful request, the response body will contain a TGT and
 its session key encoded as a "ccache" file contents.
 .Pp
 Authentication is required.
@@ -239,13 +393,14 @@ same as for
 by the authenticated client principal to get a certificate with
 a PKINIT SAN for itself or the requested principal if a
 .Va cname
-query parameter was included.
+request parameter was included.
 .Pp
 Unauthorized requests will elicit a 403 response.
 .Pp
-Requested IP addresses will be added to the issued TGT if allowed.
-The IP address of the client will be included if address-less TGTs
-are not allowed.
+Requested IP addresses will be added to the issued TGT if
+allowed.
+The IP address of the client will be included if address-less
+TGTs are not allowed.
 See the
 .Va [get-tgt]
 section of
@@ -257,6 +412,48 @@ end-point, but as configured in the
 .Va [get-tgt]
 section of
 .Xr krb5.conf 5 .
+.Sh BATCH TGT HTTP API
+Some sites may have special users that operate batch jobs systems
+and that can impersonate many others by obtaining TGTs for them,
+and which
+.Dq prestash
+credentials for those users in their credentials caches.
+To support these sytems, a
+.Ar GET
+of
+.Ar /get-tgts
+with multiple
+.Ar cname
+request parameters will return those principals' TGTs (if the
+caller is authorized).
+.Pp
+This is similar to the
+.Ar /get-tgt
+end-point, but a) multiple
+.Ar cname
+request parameter values may be given, and b) the caller's
+principal name is not used as a default for the
+.Ar cname
+request parameter.
+The
+.Ar address
+and
+.Ar lifetime
+request parameters are honored.
+.Pp
+For successful
+.Ar GETs
+the response body is a sequence of JSON texts each of which is a
+JSON object with two keys:
+.Bl -tag -width Ds -offset indent
+.It Ar ccache
+with a base64-encoded FILE-type ccache;
+.It Ar name
+the name of the principal whose credentials are in that ccache.
+.El
+.Sh NOTES
+A future release may split all these end-points into separate
+services.
 .Sh ENVIRONMENT
 .Bl -tag -width Ds
 .It Ev KRB5_CONFIG
index 064c424b7c29fa3e76997c0af737edc50871b0ca..4d1b694a914657b24f4e11e48aa24c3a8950b364 100644 (file)
 
 /*
  * This file implements a RESTful HTTPS API to an online CA, as well as an
- * HTTP/Negotiate token issuer.
+ * HTTP/Negotiate token issuer, as well as a way to get TGTs.
  *
- * Users are authenticated with bearer tokens.
+ * Users are authenticated with Negotiate and/or Bearer.
  *
- * This is essentially a RESTful online CA sharing code with the KDC's kx509
- * online CA, and also a proxy for PKINIT and GSS-API (Negotiate).
+ * This is essentially a RESTful online CA sharing some code with the KDC's
+ * kx509 online CA, and also a proxy for PKINIT and GSS-API (Negotiate).
  *
- * To get a key certified:
- *
- *  GET /bx509?csr=<base64-encoded-PKCS#10-CSR>
- *
- * To get an HTTP/Negotiate token:
- *
- *  GET /bnegotiate?target=<acceptor-principal>
- *
- * which, if authorized, produces a Negotiate token (base64-encoded, as
- * expected, with the "Negotiate " prefix, ready to be put in an Authorization:
- * header).
+ * See the manual page for HTTP API details.
  *
  * TBD:
  *  - rewrite to not use libmicrohttpd but an alternative more appropriate to
  *    Heimdal's license (though libmicrohttpd will do)
- *  - /bx509 should include the certificate chain
- *  - /bx509 should support HTTP/Negotiate
  *  - there should be an end-point for fetching an issuer's chain
- *  - maybe add /bkrb5 which returns a KRB-CRED with the user's TGT
  *
  * NOTES:
  *  - We use krb5_error_code values as much as possible.  Where we need to use
  *    (MHD_NO is an ENOMEM-cannot-even-make-a-static-503-response level event.)
  */
 
+/*
+ * Theory of operation:
+ *
+ *  - We use libmicrohttpd (MHD) for the HTTP(S) implementation.
+ *
+ *  - MHD has an online request processing model:
+ *
+ *     - all requests are handled via the `dh' and `dh_cls' closure arguments
+ *       of `MHD_start_daemon()'; ours is called `route()'
+ *
+ *     - `dh' is called N+1 times:
+ *        - once to allocate a request context
+ *        - once for every N chunks of request body
+ *        - once to process the request and produce a response
+ *
+ *     - the response cannot begin to be produced before consuming the whole
+ *       request body (for requests that have a body)
+ *       (this seems like a bug in MHD)
+ *
+ *     - the response body can be produced over multiple calls (i.e., in an
+ *       online manner)
+ *
+ *  - Our `route()' processes any POST request body form data / multipart by
+ *    treating all the key/value pairs as if they had been additional URI query
+ *    parameters.
+ *
+ *  - Then `route()' calls a handler appropriate to the URI local-part with the
+ *    request context, and the handler produces a response in one call.
+ *
+ *    I.e., we turn the online MHD request processing into not-online.  Our
+ *    handlers are presented with complete requests and must produce complete
+ *    responses in one call.
+ *
+ *  - `route()' also does any authentication and CSRF protection so that the
+ *    request handlers don't have to.
+ *
+ * This non-online request handling approach works for most everything we want
+ * to do.  However, for /get-tgts with very large numbers of principals, we
+ * might have to revisit this, using MHD_create_response_from_callback() or
+ * MHD_create_response_from_pipe() (and a thread to do the actual work of
+ * producing the body) instead of MHD_create_response_from_buffer().
+ */
+
 #define _XOPEN_SOURCE_EXTENDED  1
 #define _DEFAULT_SOURCE  1
 #define _BSD_SOURCE  1
@@ -128,20 +158,40 @@ typedef enum MHD_Result heim_mhd_result;
 
 enum k5_creds_kind { K5_CREDS_EPHEMERAL, K5_CREDS_CACHED };
 
+/*
+ * This is to keep track of memory we need to free, mainly because we had to
+ * duplicate data from the MHD POST form data processor.
+ */
+struct free_tend_list {
+    void *freeme1;
+    void *freeme2;
+    struct free_tend_list *next;
+};
+
+/* Per-request context data structure */
 typedef struct bx509_request_desc {
+    /* Common elements for Heimdal request/response services */
     HEIM_SVC_REQUEST_DESC_COMMON_ELEMENTS;
 
     struct MHD_Connection *connection;
+    struct MHD_PostProcessor *pp;
+    struct MHD_Response *response;
     krb5_times token_times;
     time_t req_life;
     hx509_request req;
+    struct free_tend_list *free_list;
     const char *for_cname;
     const char *target;
     const char *redir;
+    const char *method;
+    size_t post_data_size;
     enum k5_creds_kind cckind;
     char *pkix_store;
+    char *tgts_filename;
+    FILE *tgts;
     char *ccname;
     char *freeme1;
+    char *csrf_token;
     krb5_addresses tgt_addresses; /* For /get-tgt */
     char frombuf[128];
 } *bx509_request_desc;
@@ -214,7 +264,17 @@ get_krb5_context(krb5_context *contextp)
     return *contextp ? 0 : ENOMEM;
 }
 
+typedef enum {
+    CSRF_PROT_UNSPEC            = 0,
+    CSRF_PROT_GET_WITH_HEADER   = 1,
+    CSRF_PROT_GET_WITH_TOKEN    = 2,
+    CSRF_PROT_POST_WITH_HEADER  = 8,
+    CSRF_PROT_POST_WITH_TOKEN   = 16,
+} csrf_protection_type;
+
+static csrf_protection_type csrf_prot_type = CSRF_PROT_UNSPEC;
 static int port = -1;
+static int allow_GET_flag = -1;
 static int help_flag;
 static int daemonize;
 static int daemon_child_fd = -1;
@@ -223,11 +283,16 @@ static int version_flag;
 static int reverse_proxied_flag;
 static int thread_per_client_flag;
 struct getarg_strings audiences;
+static getarg_strings csrf_prot_type_strs;
+static const char *csrf_header = "X-CSRF";
 static const char *cert_file;
 static const char *priv_key_file;
 static const char *cache_dir;
+static const char *csrf_key_file;
 static char *impersonation_key_fn;
 
+static char csrf_key[16];
+
 static krb5_error_code resp(struct bx509_request_desc *, int,
                             enum MHD_ResponseMemoryMode, const char *,
                             const void *, size_t, const char *);
@@ -243,6 +308,7 @@ static krb5_error_code bad_404(struct bx509_request_desc *, const char *);
 static krb5_error_code bad_405(struct bx509_request_desc *, const char *);
 static krb5_error_code bad_500(struct bx509_request_desc *, krb5_error_code, const char *);
 static krb5_error_code bad_503(struct bx509_request_desc *, krb5_error_code, const char *);
+static heim_mhd_result validate_csrf_token(struct bx509_request_desc *r);
 
 static int
 validate_token(struct bx509_request_desc *r)
@@ -409,16 +475,20 @@ mk_pkix_store(char **pkix_store)
     int ret = ENOMEM;
     int fd;
 
+    if (*pkix_store) {
+        const char *fn = strchr(*pkix_store, ':');
+
+        fn = fn ? fn + 1 : *pkix_store;
+        (void) unlink(fn);
+    }
+
+    free(*pkix_store);
     *pkix_store = NULL;
     if (asprintf(&s, "PEM-FILE:%s/pkix-XXXXXX", cache_dir) == -1 ||
         s == NULL) {
         free(s);
         return ret;
     }
-    /*
-     * This way of using mkstemp() isn't safer than mktemp(), but we want to
-     * quiet the warning that we'd get if we used mktemp().
-     */
     if ((fd = mkstemp(s + sizeof("PEM-FILE:") - 1)) == -1) {
         free(s);
         return errno;
@@ -428,11 +498,6 @@ mk_pkix_store(char **pkix_store)
     return 0;
 }
 
-/*
- * XXX Shouldn't be a body, but a status message.  The body should be
- * configurable to be from a file.  MHD doesn't give us a way to set the
- * response status message though, just the body.
- */
 static krb5_error_code
 resp(struct bx509_request_desc *r,
      int http_status_code,
@@ -442,26 +507,31 @@ resp(struct bx509_request_desc *r,
      size_t bodylen,
      const char *token)
 {
-    struct MHD_Response *response;
     int mret = MHD_YES;
 
+    if (r->response)
+        return MHD_YES;
+
     (void) gettimeofday(&r->tv_end, NULL);
     if (http_status_code == MHD_HTTP_OK ||
         http_status_code == MHD_HTTP_TEMPORARY_REDIRECT)
         audit_trail(r, 0);
 
-    response = MHD_create_response_from_buffer(bodylen, rk_UNCONST(body),
-                                               rmmode);
-    if (response == NULL)
+    r->response = MHD_create_response_from_buffer(bodylen, rk_UNCONST(body),
+                                                  rmmode);
+    if (r->response == NULL)
         return -1;
-    mret = MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL,
-                                   "no-store, max-age=0");
+    if (r->csrf_token)
+        mret = MHD_add_response_header(r->response, "X-CSRF-Token", r->csrf_token);
+    if (mret == MHD_YES)
+        mret = MHD_add_response_header(r->response, MHD_HTTP_HEADER_CACHE_CONTROL,
+                                       "no-store, max-age=0");
     if (mret == MHD_YES && http_status_code == MHD_HTTP_UNAUTHORIZED) {
-        mret = MHD_add_response_header(response,
+        mret = MHD_add_response_header(r->response,
                                        MHD_HTTP_HEADER_WWW_AUTHENTICATE,
                                        "Bearer");
         if (mret == MHD_YES)
-            mret = MHD_add_response_header(response,
+            mret = MHD_add_response_header(r->response,
                                            MHD_HTTP_HEADER_WWW_AUTHENTICATE,
                                            "Negotiate");
     } else if (mret == MHD_YES && http_status_code == MHD_HTTP_TEMPORARY_REDIRECT) {
@@ -470,21 +540,21 @@ resp(struct bx509_request_desc *r,
         /* XXX Move this */
         redir = MHD_lookup_connection_value(r->connection, MHD_GET_ARGUMENT_KIND,
                                             "redirect");
-        mret = MHD_add_response_header(response, MHD_HTTP_HEADER_LOCATION,
+        mret = MHD_add_response_header(r->response, MHD_HTTP_HEADER_LOCATION,
                                        redir);
         if (mret != MHD_NO && token)
-            mret = MHD_add_response_header(response,
+            mret = MHD_add_response_header(r->response,
                                            MHD_HTTP_HEADER_AUTHORIZATION,
                                            token);
     }
     if (mret == MHD_YES && content_type) {
-        mret = MHD_add_response_header(response,
+        mret = MHD_add_response_header(r->response,
                                        MHD_HTTP_HEADER_CONTENT_TYPE,
                                        content_type);
     }
     if (mret == MHD_YES)
-        mret = MHD_queue_response(r->connection, http_status_code, response);
-    MHD_destroy_response(response);
+        mret = MHD_queue_response(r->connection, http_status_code, r->response);
+    MHD_destroy_response(r->response);
     return mret == MHD_NO ? -1 : 0;
 }
 
@@ -520,7 +590,7 @@ bad_reqv(struct bx509_request_desc *r,
             emsg = strerror(code);
     }
 
-    ret = vasprintf(&formatted, fmt, ap) == -1;
+    ret = vasprintf(&formatted, fmt, ap);
     if (code) {
         if (ret > -1 && formatted)
             ret = asprintf(&msg, "%s: %s (%d)", formatted, emsg, (int)code);
@@ -601,6 +671,13 @@ bad_405(struct bx509_request_desc *r, const char *method)
                    "Method not supported: %s", method);
 }
 
+static krb5_error_code
+bad_413(struct bx509_request_desc *r)
+{
+    return bad_req(r, E2BIG, MHD_HTTP_METHOD_NOT_ALLOWED,
+                   "POST request body too large");
+}
+
 static krb5_error_code
 bad_500(struct bx509_request_desc *r,
         krb5_error_code ret,
@@ -871,20 +948,28 @@ addr_to_string(krb5_context context,
         snprintf(str, len, "<family=%d>", addr->sa_family);
 }
 
+static void clean_req_desc(struct bx509_request_desc *);
+
 static krb5_error_code
 set_req_desc(struct MHD_Connection *connection,
+             const char *method,
              const char *url,
-             struct bx509_request_desc *r)
+             struct bx509_request_desc **rp)
 {
+    struct bx509_request_desc *r;
     const union MHD_ConnectionInfo *ci;
     const char *token;
     krb5_error_code ret;
 
-    memset(r, 0, sizeof(*r));
+    *rp = NULL;
+    if ((r = calloc(1, sizeof(*r))) == NULL)
+        return ENOMEM;
     (void) gettimeofday(&r->tv_start, NULL);
 
     ret = get_krb5_context(&r->context);
     r->connection = connection;
+    r->response = NULL;
+    r->pp = NULL;
     r->request.data = "<HTTP-REQUEST>";
     r->request.length = sizeof("<HTTP-REQUEST>");
     r->from = r->frombuf;
@@ -893,12 +978,17 @@ set_req_desc(struct MHD_Connection *connection,
     r->hcontext = r->context ? r->context->hcontext : NULL;
     r->config = NULL;
     r->logf = logfac;
+    r->csrf_token = NULL;
+    r->free_list = NULL;
+    r->method = method;
     r->reqtype = url;
     r->target = r->redir = NULL;
     r->pkix_store = NULL;
     r->for_cname = NULL;
     r->freeme1 = NULL;
     r->reason = NULL;
+    r->tgts_filename = NULL;
+    r->tgts = NULL;
     r->ccname = NULL;
     r->reply = NULL;
     r->sname = NULL;
@@ -934,6 +1024,10 @@ set_req_desc(struct MHD_Connection *connection,
 
     }
 
+    if (ret == 0)
+        *rp = r;
+    else
+        clean_req_desc(r);
     return ret;
 }
 
@@ -942,6 +1036,13 @@ clean_req_desc(struct bx509_request_desc *r)
 {
     if (!r)
         return;
+    while (r->free_list) {
+        struct free_tend_list *ftl = r->free_list;
+        r->free_list = r->free_list->next;
+        free(ftl->freeme1);
+        free(ftl->freeme2);
+        free(ftl);
+    }
     if (r->pkix_store) {
         const char *fn = strchr(r->pkix_store, ':');
 
@@ -955,6 +1056,7 @@ clean_req_desc(struct bx509_request_desc *r)
     }
     krb5_free_addresses(r->context, &r->tgt_addresses);
     hx509_request_free(&r->req);
+    heim_release(r->attributes);
     heim_release(r->reason);
     heim_release(r->kv);
     if (r->ccname && r->cckind == K5_CREDS_EPHEMERAL) {
@@ -964,11 +1066,22 @@ clean_req_desc(struct bx509_request_desc *r)
             fn += sizeof("FILE:") - 1;
         (void) unlink(fn);
     }
+    if (r->tgts)
+        (void) fclose(r->tgts);
+    if (r->tgts_filename) {
+        (void) unlink(r->tgts_filename);
+        free(r->tgts_filename);
+    }
+    /* No need to destroy r->response */
+    if (r->pp)
+        MHD_destroy_post_processor(r->pp);
+    free(r->csrf_token);
     free(r->pkix_store);
     free(r->freeme1);
     free(r->ccname);
     free(r->cname);
     free(r->sname);
+    free(r);
 }
 
 /* Implements GETs of /bx509 */
@@ -984,9 +1097,6 @@ bx509(struct bx509_request_desc *r)
     if (csr == NULL)
         return bad_400(r, EINVAL, "CSR is missing");
 
-    if ((ret = validate_token(r)))
-        return ret; /* validate_token() calls bad_req() */
-
     if (r->cname == NULL)
         return bad_403(r, EINVAL,
                        "Could not extract principal name from token");
@@ -1424,9 +1534,8 @@ k5_get_creds(struct bx509_request_desc *r, enum k5_creds_kind kind)
     if ((ret = k5_do_CA(r)))
         return ret; /* k5_do_CA() calls bad_req() */
 
-    if (ret == 0 && (ret = do_pkinit(r, kind)))
-        ret = bad_403(r, ret,
-                      "Could not acquire Kerberos credentials using PKINIT");
+    if (ret == 0)
+        ret = do_pkinit(r, kind);
     return ret;
 }
 
@@ -1682,15 +1791,11 @@ bnegotiate(struct bx509_request_desc *r)
     char *nego_tok = NULL;
 
     ret = bnegotiate_get_target(r);
-    if (ret == 0) {
-        heim_audit_addkv((heim_svc_req_desc)r, KDC_AUDIT_VIS, "target", "%s",
-                         r->target ? r->target : "<unknown>");
-        heim_audit_setkv_bool((heim_svc_req_desc)r, "redir", !!r->redir);
-        ret = validate_token(r);
-    }
-    /* bnegotiate_get_target() and validate_token() call bad_req() */
     if (ret)
-        return ret;
+        return ret; /* bnegotiate_get_target() calls bad_req() */
+    heim_audit_addkv((heim_svc_req_desc)r, KDC_AUDIT_VIS, "target", "%s",
+                     r->target ? r->target : "<unknown>");
+    heim_audit_setkv_bool((heim_svc_req_desc)r, "redir", !!r->redir);
 
     /*
      * Make sure we have Kerberos credentials for cprinc.  If we have them
@@ -1702,7 +1807,8 @@ bnegotiate(struct bx509_request_desc *r)
      */
     ret = k5_get_creds(r, K5_CREDS_CACHED);
     if (ret)
-        return ret;
+        return bad_403(r, ret,
+                      "Could not acquire Kerberos credentials using PKINIT");
 
     /* Acquire the Negotiate token and output it */
     if (ret == 0 && r->ccname != NULL)
@@ -1795,10 +1901,17 @@ get_tgt_param_cb(void *d,
 /*
  * Implements /get-tgt end-point.
  *
- * Query parameters (mutually exclusive):
+ * Query parameters:
  *
  *  - cname=<name> (client principal name, if not the same as the authenticated
- *                  name, then this will be impersonated if allowed)
+ *                  name, then this will be impersonated if allowed; may be
+ *                  given only once)
+ *
+ *  - address=<IP> (IP address to add as a ticket address; may be given
+ *                  multiple times)
+ *
+ *  - lifetime=<time> (requested lifetime for the ticket; may be given only
+ *                     once)
  */
 static krb5_error_code
 get_tgt(struct bx509_request_desc *r)
@@ -1812,12 +1925,9 @@ get_tgt(struct bx509_request_desc *r)
                                                MHD_GET_ARGUMENT_KIND, "cname");
     if (r->for_cname && r->for_cname[0] == '\0')
         r->for_cname = NULL;
-    ret = validate_token(r);
-    if (ret == 0)
-        ret = authorize_TGT_REQ(r);
-    /* validate_token() and authorize_TGT_REQ() call bad_req() */
+    ret = authorize_TGT_REQ(r);
     if (ret)
-        return ret;
+        return ret; /* authorize_TGT_REQ() calls bad_req() */
 
     r->error_code = 0;
     (void) MHD_get_connection_values(r->connection, MHD_GET_ARGUMENT_KIND,
@@ -1828,7 +1938,8 @@ get_tgt(struct bx509_request_desc *r)
     if (ret == 0)
         ret = k5_get_creds(r, K5_CREDS_EPHEMERAL);
     if (ret)
-        return ret;
+        return bad_403(r, ret,
+                      "Could not acquire Kerberos credentials using PKINIT");
 
     fn = strchr(r->ccname, ':');
     if (fn == NULL)
@@ -1843,6 +1954,262 @@ get_tgt(struct bx509_request_desc *r)
     return ret;
 }
 
+static int
+get_tgts_accumulate_ccache_write_json(struct bx509_request_desc *r,
+                                      krb5_error_code code,
+                                      const char *data,
+                                      size_t datalen)
+{
+    heim_object_t k, v;
+    heim_string_t text;
+    heim_error_t e = NULL;
+    heim_dict_t o;
+    int ret;
+
+    o = heim_dict_create(9);
+    k = heim_string_create("name");
+    v = heim_string_create(r->for_cname);
+    if (o && k && v)
+        ret = heim_dict_set_value(o, k, v);
+    else
+        ret = errno;
+
+    if (ret == 0) {
+        heim_release(v);
+        heim_release(k);
+        k = heim_string_create("error_code");
+        v = heim_number_create(code);
+        if (k && v)
+            ret = heim_dict_set_value(o, k, v);
+    }
+    if (ret == 0 && data != NULL) {
+        heim_release(v);
+        heim_release(k);
+        k = heim_string_create("ccache");
+        v = heim_data_create(data, datalen);
+        if (k && v)
+            ret = heim_dict_set_value(o, k, v);
+    }
+    if (ret == 0 && code != 0) {
+        heim_release(v);
+        heim_release(k);
+        k = heim_string_create("error");
+        v = heim_string_create(krb5_get_error_message(r->context, code));
+        if (k && v)
+            ret = heim_dict_set_value(o, k, v);
+    }
+    heim_release(v);
+    heim_release(k);
+    if (ret) {
+        heim_release(o);
+        return bad_503(r, errno, "Out of memory");
+    }
+
+    text = heim_json_copy_serialize(o,
+                                    HEIM_JSON_F_NO_DATA_DICT |
+                                    HEIM_JSON_F_ONE_LINE,
+                                    &e);
+    if (text) {
+        const char *s = heim_string_get_utf8(text);
+
+        (void) fwrite(s, strlen(s), 1, r->tgts);
+    } else {
+        const char *s = NULL;
+        v = heim_error_copy_string(e);
+        if (v)
+            s = heim_string_get_utf8(v);
+        if (s == NULL)
+            s = "<unknown encoder error>";
+        krb5_log_msg(r->context, logfac, 1, NULL, "Failed to encode JSON text with ccache or error for %s: %s",
+                     r->for_cname, s);
+        heim_release(v);
+    }
+    heim_release(text);
+    heim_release(o);
+    return MHD_YES;
+}
+
+/* Writes one ccache to a response file, as JSON */
+static int
+get_tgts_accumulate_ccache(struct bx509_request_desc *r, krb5_error_code ret)
+{
+    const char *fn;
+    size_t bodylen = 0;
+    void *body = NULL;
+    int res;
+
+    if (r->tgts == NULL) {
+        int fd = -1;
+
+        if (asprintf(&r->tgts_filename,
+                     "%s/tgts-json-XXXXXX", cache_dir) == -1 ||
+            r->tgts_filename == NULL) {
+            free(r->tgts_filename);
+            r->tgts_filename = NULL;
+
+            return bad_enomem(r, r->error_code = ENOMEM);
+        }
+        if ((fd = mkstemp(r->tgts_filename)) == -1)
+            return bad_req(r, errno, MHD_HTTP_SERVICE_UNAVAILABLE,
+                           "%s", strerror(r->error_code = errno));
+        if ((r->tgts = fdopen(fd, "w+")) == NULL) {
+            (void) close(fd);
+            return bad_req(r, errno, MHD_HTTP_SERVICE_UNAVAILABLE,
+                           "%s", strerror(r->error_code = errno));
+        }
+    }
+
+    if (ret == 0) {
+        fn = strchr(r->ccname, ':');
+        if (fn == NULL)
+            return bad_req(r, errno, MHD_HTTP_SERVICE_UNAVAILABLE,
+                           "Internal error (invalid credentials cache name)");
+        fn++;
+        if ((r->error_code = rk_undumpdata(fn, &body, &bodylen)))
+            return bad_req(r, errno, MHD_HTTP_SERVICE_UNAVAILABLE,
+                           "%s", strerror(r->error_code));
+        (void) unlink(fn);
+        free(r->ccname);
+        r->ccname = NULL;
+        if (bodylen > INT_MAX >> 4) {
+            free(body);
+            return bad_req(r, errno, MHD_HTTP_SERVICE_UNAVAILABLE,
+                           "Credentials cache too large!");
+        }
+    }
+
+    res = get_tgts_accumulate_ccache_write_json(r, ret, body, bodylen);
+    free(body);
+    return res;
+}
+
+static heim_mhd_result
+get_tgts_param_authorize_cb(void *d,
+                            enum MHD_ValueKind kind,
+                            const char *key,
+                            const char *val)
+{
+    struct bx509_request_desc *r = d;
+    krb5_error_code ret = 0;
+
+    if (strcmp(key, "cname") != 0 || val == NULL)
+        return MHD_YES;
+
+    if (r->req == NULL) {
+        ret = hx509_request_init(r->context->hx509ctx, &r->req);
+        if (ret == 0)
+            ret = hx509_request_add_eku(r->context->hx509ctx, r->req,
+                                        ASN1_OID_ID_PKEKUOID);
+        if (ret)
+            return bad_500(r, ret, "Out of resources");
+    }
+    heim_audit_addkv((heim_svc_req_desc)r, KDC_AUDIT_VIS,
+                     "requested_krb5PrincipalName", "%s", val);
+    ret = hx509_request_add_pkinit(r->context->hx509ctx, r->req,
+                                   val);
+    if (ret)
+        return bad_403(r, ret, "Not authorized to requested TGT");
+    return MHD_YES;
+}
+
+/* For each requested principal, produce a ccache */
+static heim_mhd_result
+get_tgts_param_execute_cb(void *d,
+                          enum MHD_ValueKind kind,
+                          const char *key,
+                          const char *val)
+{
+    struct bx509_request_desc *r = d;
+    heim_mhd_result res = MHD_YES;
+    krb5_error_code ret;
+
+    if (strcmp(key, "cname") == 0 && val) {
+        /* Handled upstairs */
+        r->for_cname = val;
+        ret = k5_get_creds(r, K5_CREDS_EPHEMERAL);
+        res = get_tgts_accumulate_ccache(r, ret);
+    } else {
+        /* Handled upstairs */
+    }
+    return res;
+}
+
+/*
+ * Implements /get-tgts end-point.
+ *
+ * Query parameters:
+ *
+ *  - cname=<name> (client principal name, if not the same as the authenticated
+ *                  name, then this will be impersonated if allowed; may be
+ *                  given multiple times)
+ */
+static krb5_error_code
+get_tgts(struct bx509_request_desc *r)
+{
+    krb5_error_code ret;
+    krb5_principal p = NULL;
+    size_t bodylen;
+    void *body;
+    int res = MHD_YES;
+
+    /* Prep to authorize */
+    ret = krb5_parse_name(r->context, r->cname, &p);
+    if (ret)
+        return bad_403(r, ret, "Could not parse caller principal name");
+    if (ret == 0) {
+        /* Extract q-params other than `cname' */
+        r->error_code = 0;
+        res = MHD_get_connection_values(r->connection, MHD_GET_ARGUMENT_KIND,
+                                        get_tgt_param_cb, r);
+        if (r->response || res == MHD_NO)
+            return res;
+
+        ret = r->error_code;
+    }
+    if (ret == 0) {
+        /* Authorize requested client principal names (calls bad_req()) */
+        r->error_code = 0;
+        res = MHD_get_connection_values(r->connection, MHD_GET_ARGUMENT_KIND,
+                                        get_tgts_param_authorize_cb, r);
+        if (r->response || res == MHD_NO)
+            return res;
+
+        ret = r->error_code;
+        if (ret == 0) {
+            ret = kdc_authorize_csr(r->context, "get-tgt", r->req, p);
+            if (ret) {
+                krb5_free_principal(r->context, p);
+                return bad_403(r, ret, "Permission denied");
+            }
+        }
+        hx509_request_free(&r->req);
+    }
+    if (ret == 0) {
+        /* get_tgts_param_execute_cb() calls bad_req() */
+        r->error_code = 0;
+        res = MHD_get_connection_values(r->connection, MHD_GET_ARGUMENT_KIND,
+                                        get_tgts_param_execute_cb, r);
+        if (r->response || res == MHD_NO)
+            return res;
+        ret = r->error_code;
+    }
+    krb5_free_principal(r->context, p);
+
+    /*
+     * get_tgts_param_execute_cb() will write its JSON response to the file
+     * named by r->ccname.
+     */
+    if (fflush(r->tgts) != 0)
+        return bad_503(r, ret, "Could not get TGT");
+    if ((errno = rk_undumpdata(r->tgts_filename, &body, &bodylen)))
+        return bad_503(r, ret, "Could not get TGT");
+
+    ret = resp(r, MHD_HTTP_OK, MHD_RESPMEM_MUST_COPY,
+               "application/x-krb5-ccaches-json", body, bodylen, NULL);
+    free(body);
+    return ret;
+}
+
 static krb5_error_code
 health(const char *method, struct bx509_request_desc *r)
 {
@@ -1856,7 +2223,250 @@ health(const char *method, struct bx509_request_desc *r)
 
 }
 
-/* Implements the entirety of this REST service */
+static krb5_error_code
+mac_csrf_token(struct bx509_request_desc *r, krb5_storage *sp)
+{
+    krb5_error_code ret;
+    krb5_data data;
+    char mac[EVP_MAX_MD_SIZE];
+    unsigned int maclen = sizeof(mac);
+    HMAC_CTX *ctx = NULL;
+
+    ret = krb5_storage_to_data(sp, &data);
+    if (ret == 0 && (ctx = HMAC_CTX_new()) == NULL)
+            ret = krb5_enomem(r->context);
+    /* HMAC the token body and the client principal name */
+    if (ret == 0) {
+        if (HMAC_Init_ex(ctx, csrf_key, sizeof(csrf_key),
+                         EVP_sha256(),
+                         NULL) == 0) {
+            HMAC_CTX_cleanup(ctx);
+            ret = krb5_enomem(r->context);
+        } else {
+            HMAC_Update(ctx, data.data, data.length);
+            if (r->cname)
+                HMAC_Update(ctx, r->cname, strlen(r->cname));
+            HMAC_Final(ctx, mac, &maclen);
+            HMAC_CTX_cleanup(ctx);
+            krb5_data_free(&data);
+            data.length = maclen;
+            data.data = mac;
+            if (krb5_storage_write(sp, mac, maclen) != maclen)
+                ret = krb5_enomem(r->context);
+        }
+    }
+    if (ctx)
+        HMAC_CTX_free(ctx);
+    return ret;
+}
+
+/*
+ * Make a CSRF token.  If one is also given, make one with the same body
+ * content so we can check the HMAC.
+ *
+ * Outputs the token and its age.  Do not use either if the token does not
+ * equal the given token.
+ */
+static krb5_error_code
+make_csrf_token(struct bx509_request_desc *r,
+                const char *given,
+                char **token,
+                int64_t *age)
+{
+    krb5_error_code ret = 0;
+    unsigned char given_decoded[128];
+    krb5_storage *sp = NULL;
+    krb5_data data;
+    ssize_t dlen = -1;
+    uint64_t nonce;
+    int64_t t = 0;
+
+
+    *age = 0;
+    data.data = NULL;
+    data.length = 0;
+    if (given) {
+        size_t len = strlen(given);
+
+        /* Extract issue time and nonce from token */
+        if (len >= sizeof(given_decoded))
+            ret = ERANGE;
+        if (ret == 0 && (dlen = rk_base64_decode(given, &given_decoded)) <= 0)
+            ret = errno;
+        if (ret == 0 &&
+            (sp = krb5_storage_from_mem(given_decoded, dlen)) == NULL)
+            ret = krb5_enomem(r->context);
+        if (ret == 0)
+            ret = krb5_ret_int64(sp, &t);
+        if (ret == 0)
+            ret = krb5_ret_uint64(sp, &nonce);
+        krb5_storage_free(sp);
+        sp = NULL;
+        if (ret == 0)
+            *age = time(NULL) - t;
+    } else {
+        t = time(NULL);
+        krb5_generate_random_block((void *)&nonce, sizeof(nonce));
+    }
+
+    if (ret == 0 && (sp = krb5_storage_emem()) == NULL)
+        ret = krb5_enomem(r->context);
+    if (ret == 0)
+        ret = krb5_store_int64(sp, t);
+    if (ret == 0)
+        ret = krb5_store_uint64(sp, nonce);
+    if (ret == 0)
+        ret = mac_csrf_token(r, sp);
+    if (ret == 0)
+        ret = krb5_storage_to_data(sp, &data);
+    if (ret == 0 && data.length > INT_MAX)
+        ret = ERANGE;
+    if (ret == 0 &&
+        (dlen = rk_base64_encode(data.data, data.length, token)) < 0)
+        ret = errno;
+    krb5_storage_free(sp);
+    krb5_data_free(&data);
+    return ret;
+}
+
+static heim_mhd_result
+validate_csrf_token(struct bx509_request_desc *r)
+{
+    const char *given;
+    int64_t age;
+    krb5_error_code ret;
+
+    if ((((csrf_prot_type & CSRF_PROT_GET_WITH_HEADER) &&
+          strcmp(r->method, "GET") == 0) ||
+         ((csrf_prot_type & CSRF_PROT_POST_WITH_HEADER) &&
+          strcmp(r->method, "POST") == 0)) &&
+        MHD_lookup_connection_value(r->connection, MHD_HEADER_KIND,
+                                    csrf_header) == NULL) {
+        ret = bad_req(r, EACCES, MHD_HTTP_FORBIDDEN,
+                      "Request must have header \"%s\"", csrf_header);
+        return ret == -1 ? MHD_NO : MHD_YES;
+    }
+
+    if (strcmp(r->method, "GET") == 0 &&
+        !(csrf_prot_type & CSRF_PROT_GET_WITH_TOKEN))
+        return 0;
+    if (strcmp(r->method, "POST") == 0 &&
+        !(csrf_prot_type & CSRF_PROT_POST_WITH_TOKEN))
+        return 0;
+
+    given = MHD_lookup_connection_value(r->connection, MHD_HEADER_KIND,
+                                        "X-CSRF-Token");
+    ret = make_csrf_token(r, given, &r->csrf_token, &age);
+    if (ret)
+        return bad_503(r, ret, "Could not make or validate CSRF token");
+    if (given == NULL)
+        return bad_req(r, EACCES, MHD_HTTP_FORBIDDEN,
+                       "CSRF token needed; copy the X-CSRF-Token: response "
+                       "header to your next POST");
+    if (strlen(given) != strlen(r->csrf_token) ||
+        strcmp(given, r->csrf_token) != 0)
+        return bad_403(r, EACCES, "Invalid CSRF token");
+    if (age > 300)
+        return bad_403(r, EACCES, "CSRF token expired");
+    return 0;
+}
+
+/*
+ * MHD callback to free the request context when MHD is done sending the
+ * response.
+ */
+static void
+cleanup_req(void *cls,
+            struct MHD_Connection *connection,
+            void **con_cls,
+            enum MHD_RequestTerminationCode toe)
+{
+    struct bx509_request_desc *r = *con_cls;
+
+    (void)cls;
+    (void)connection;
+    (void)toe;
+    clean_req_desc(r);
+    *con_cls = NULL;
+}
+
+/* Callback for MHD POST form data processing */
+static heim_mhd_result
+ip(void *cls,
+   enum MHD_ValueKind kind,
+   const char *key,
+   const char *content_name,
+   const char *content_type,
+   const char *transfer_encoding,
+   const char *val,
+   uint64_t off,
+   size_t size)
+{
+    struct bx509_request_desc *r = cls;
+    struct free_tend_list *ftl = calloc(1, sizeof(*ftl));
+    char *keydup = strdup(key);
+    char *valdup = strndup(val, size);
+
+    (void)content_name;         /* MIME attachment name */
+    (void)content_type;         /* Don't care -- MHD liked it */
+    (void)transfer_encoding;
+    (void)off;                  /* Offset in POST data */
+
+    /*
+     * We're going to MHD_set_connection_value(), but we need copies because
+     * the MHD POST processor quite naturally keeps none of the chunks
+     * received.
+     */
+    if (ftl == NULL || keydup == NULL || valdup == NULL) {
+        free(ftl);
+        free(keydup);
+        return MHD_NO;
+    }
+    ftl->freeme1 = keydup;
+    ftl->freeme2 = valdup;
+    ftl->next = r->free_list;
+    r->free_list = ftl;
+
+    return MHD_set_connection_value(r->connection, MHD_GET_ARGUMENT_KIND,
+                                    keydup, valdup);
+}
+
+typedef krb5_error_code (*handler)(struct bx509_request_desc *);
+
+struct route {
+    const char *local_part;
+    handler h;
+    unsigned int referer_ok:1;
+} routes[] = {
+    { "/get-cert", bx509, 0 },
+    { "/get-negotiate-token", bnegotiate, 1 },
+    { "/get-tgt", get_tgt, 0 },
+    { "/get-tgts", get_tgts, 0 },
+    /* Lousy old names to be removed eventually */
+    { "/bnegotiate", bnegotiate, 1 },
+    { "/bx509", bx509, 0 },
+};
+
+/*
+ * We should commonalize all of:
+ *
+ *  - route() and related infrastructure
+ *  - including the CSRF functions
+ *  - and Negotiate/Bearer authentication
+ *
+ * so that we end up with a simple framework that our daemons can invoke to
+ * serve simple functions that take a fully-consumed request and send a
+ * response.
+ *
+ * Then:
+ *
+ *  - split out the CA and non-CA bits into separate daemons using that common
+ *    code,
+ *  - make httpkadmind use that common code,
+ *  - abstract out all the MHD stuff.
+ */
+
+/* Routes requests */
 static heim_mhd_result
 route(void *cls,
       struct MHD_Connection *connection,
@@ -1867,46 +2477,137 @@ route(void *cls,
       size_t *upload_data_size,
       void **ctx)
 {
-    static int aptr = 0;
-    struct bx509_request_desc r;
+    struct bx509_request_desc *r = *ctx;
+    size_t i;
     int ret;
 
-    if (*ctx == NULL) {
+    if (r == NULL) {
         /*
          * This is the first call, right after headers were read.
          *
          * We must return quickly so that any 100-Continue might be sent with
-         * celerity.
+         * celerity.  We want to make sure to send any 401s early, so we check
+         * WWW-Authenticate now, not later.
          *
-         * We'll get called again to really do the processing.  If we handled
-         * POSTs then we'd also get called with upload_data != NULL between the
-         * first and last calls.  We need to keep no state between the first
-         * and last calls, but we do need to distinguish first and last call,
-         * so we use the ctx argument for this.
+         * We'll get called again to really do the processing.  If we're
+         * handling a POST then we'll also get called with upload_data != NULL,
+         * possibly multiple times.
          */
-        *ctx = &aptr;
-        return MHD_YES;
+        if ((ret = set_req_desc(connection, method, url, &r)))
+            return bad_503(r, ret, "Could not initialize request state");
+        *ctx = r;
+
+        /* All requests other than /health require authentication */
+        if (strcmp(url, "/health") == 0)
+            return MHD_YES;
+
+        /*
+         * Authenticate and do CSRF protection.
+         *
+         * If the Referer: header is set in the request, we don't want CSRF
+         * protection as only /get-negotiate-token will accept a Referer:
+         * header (see routes[] and below), so we'll call validate_csrf_token()
+         * for the other routes or reject the request for having Referer: set.
+         */
+        ret = validate_token(r);
+        if (ret == 0 &&
+            MHD_lookup_connection_value(r->connection, MHD_HEADER_KIND, "Referer") == NULL)
+            ret = validate_csrf_token(r);
+
+        /*
+         * As this is the initial call to this handler, we must return now.
+         *
+         * If authentication or CSRF protection failed then we'll already have
+         * enqueued a 401, 403, or 5xx response and then we're done.
+         *
+         * If both authentication and CSRF protection succeeded then no
+         * response has been queued up and we'll get called again to finally
+         * process the request, then this entire if block will not be executed.
+         */
+        return ret == -1 ? MHD_NO : MHD_YES;
+    }
+
+    /* Validate HTTP method */
+    if (strcmp(method, "GET") != 0 &&
+        strcmp(method, "POST") != 0 &&
+        strcmp(method, "HEAD") != 0) {
+        return bad_405(r, method) == -1 ? MHD_NO : MHD_YES;
     }
 
-    if ((ret = set_req_desc(connection, url, &r)))
-        return bad_503(&r, ret, "Could not initialize request state");
     if ((strcmp(method, "HEAD") == 0 || strcmp(method, "GET") == 0) &&
-        (strcmp(url, "/health") == 0 || strcmp(url, "/") == 0))
-        ret = health(method, &r);
-    else if (strcmp(method, "GET") != 0)
-        ret = bad_405(&r, method);
-    else if (strcmp(url, "/get-cert") == 0 ||
-             strcmp(url, "/bx509") == 0) /* old name */
-        ret = bx509(&r);
-    else if (strcmp(url, "/get-negotiate-token") == 0 ||
-             strcmp(url, "/bnegotiate") == 0) /* old name */
-        ret = bnegotiate(&r);
-    else if (strcmp(url, "/get-tgt") == 0)
-        ret = get_tgt(&r);
-    else
-        ret = bad_404(&r, url);
+        (strcmp(url, "/health") == 0 || strcmp(url, "/") == 0)) {
+        /* /health end-point -- no authentication, no CSRF, no nothing */
+        return health(method, r) == -1 ? MHD_NO : MHD_YES;
+    }
+
+    if (r->cname == NULL)
+        return bad_401(r, "Authorization token is missing");
 
-    clean_req_desc(&r);
+    if (strcmp(method, "POST") == 0 && *upload_data_size != 0) {
+        /*
+         * Consume all the POST body and set form data as MHD_GET_ARGUMENT_KIND
+         * (as if they had been URI query parameters).
+         *
+         * We have to do this before we can MHD_queue_response() as MHD will
+         * not consume the rest of the request body on its own, so it's an
+         * error to MHD_queue_response() before we've done this, and if we do
+         * then MHD just closes the connection.
+         *
+         * 4KB should be more than enough buffer space for all the keys we
+         * expect.
+         */
+        if (r->pp == NULL)
+            r->pp = MHD_create_post_processor(connection, 4096, ip, r);
+        if (r->pp == NULL) {
+            ret = bad_503(r, errno ? errno : ENOMEM,
+                          "Could not consume POST data");
+            return ret == -1 ? MHD_NO : MHD_YES;
+        }
+        if (r->post_data_size + *upload_data_size > 1UL<<17) {
+            return bad_413(r) == -1 ? MHD_NO : MHD_YES;
+        }
+        r->post_data_size += *upload_data_size;
+        if (MHD_post_process(r->pp, upload_data,
+                             *upload_data_size) == MHD_NO) {
+            ret = bad_503(r, errno ? errno : ENOMEM,
+                          "Could not consume POST data");
+            return ret == -1 ? MHD_NO : MHD_YES;
+        }
+        *upload_data_size = 0;
+        return MHD_YES;
+    }
+
+    /*
+     * Either this is a HEAD, a GET, or a POST whose request body has now been
+     * received completely and processed.
+     */
+
+    /* Allow GET? */
+    if (strcmp(method, "GET") == 0 && !allow_GET_flag) {
+        /* No */
+        return bad_405(r, method) == -1 ? MHD_NO : MHD_YES;
+    }
+
+    for (i = 0; i < sizeof(routes)/sizeof(routes[0]); i++) {
+        if (strcmp(url, routes[i].local_part) != 0)
+            continue;
+        if (!routes[i].referer_ok &&
+            MHD_lookup_connection_value(r->connection,
+                                        MHD_HEADER_KIND,
+                                        "Referer") != NULL) {
+            ret = bad_req(r, EACCES, MHD_HTTP_FORBIDDEN,
+                          "GET from browser not allowed");
+            return ret == -1 ? MHD_NO : MHD_YES;
+        }
+        if (strcmp(method, "HEAD") == 0)
+            ret = resp(r, MHD_HTTP_OK, MHD_RESPMEM_PERSISTENT, NULL, "", 0,
+                       NULL);
+        else
+            ret = routes[i].h(r);
+        return ret == -1 ? MHD_NO : MHD_YES;
+    }
+
+    ret = bad_404(r, url);
     return ret == -1 ? MHD_NO : MHD_YES;
 }
 
@@ -1914,14 +2615,21 @@ static struct getargs args[] = {
     { "help", 'h', arg_flag, &help_flag, "Print usage message", NULL },
     { "version", '\0', arg_flag, &version_flag, "Print version", NULL },
     { NULL, 'H', arg_strings, &audiences,
-        "expected token audience(s) of bx509 service", "HOSTNAME" },
+        "expected token audience(s)", "HOSTNAME" },
     { "daemon", 'd', arg_flag, &daemonize, "daemonize", "daemonize" },
     { "daemon-child", 0, arg_flag, &daemon_child_fd, NULL, NULL }, /* priv */
     { "reverse-proxied", 0, arg_flag, &reverse_proxied_flag,
         "reverse proxied", "listen on 127.0.0.1 and do not use TLS" },
-    { NULL, 'p', arg_integer, &port, "PORT", "port number (default: 443)" },
+    { "port", 'p', arg_integer, &port, "port number (default: 443)", "PORT" },
     { "cache-dir", 0, arg_string, &cache_dir,
         "cache directory", "DIRECTORY" },
+    { "allow-GET", 0, arg_negative_flag, &allow_GET_flag, NULL, NULL },
+    { "csrf-header", 0, arg_flag,
+        &csrf_header, "required request header", "HEADER-NAME" },
+    { "csrf-protection-type", 0, arg_strings, &csrf_prot_type_strs,
+        "Anti-CSRF protection type", "TYPE" },
+    { "csrf-key-file", 0, arg_string, &csrf_key_file,
+        "CSRF MAC key", "FILE" },
     { "cert", 0, arg_string, &cert_file,
         "certificate file path (PEM)", "HX509-STORE" },
     { "private-key", 0, arg_string, &priv_key_file,
@@ -1935,9 +2643,10 @@ static int
 usage(int e)
 {
     arg_printusage(args, sizeof(args) / sizeof(args[0]), "bx509",
-        "\nServes RESTful GETs of /bx509 and /bnegotiate,\n"
-        "performing corresponding kx509 and, possibly, PKINIT requests\n"
-        "to the KDCs of the requested realms (or just the given REALM).\n");
+        "\nServes RESTful GETs of /get-cert, /get-tgt, /get-tgts, and\n"
+        "/get-negotiate-toke, performing corresponding kx509 and, \n"
+        "possibly, PKINIT requests to the KDCs of the requested \n"
+        "realms (or just the given REALM).\n");
     exit(e);
 }
 
@@ -2009,6 +2718,67 @@ load_plugins(krb5_context context)
 #endif
 }
 
+static void
+get_csrf_prot_type(krb5_context context)
+{
+    char * const *strs = csrf_prot_type_strs.strings;
+    size_t n = csrf_prot_type_strs.num_strings;
+    size_t i;
+    char **freeme = NULL;
+
+    if (csrf_header == NULL)
+        csrf_header = krb5_config_get_string(context, NULL, "bx509d",
+                                             "csrf_protection_csrf_header",
+                                             NULL);
+
+    if (n == 0) {
+        char * const *p;
+
+        strs = freeme = krb5_config_get_strings(context, NULL, "bx509d",
+                                                "csrf_protection_type", NULL);
+        for (p = strs; p && p; p++)
+            n++;
+    }
+
+    for (i = 0; i < n; i++) {
+        if (strcmp(strs[i], "GET-with-header") == 0)
+            csrf_prot_type |= CSRF_PROT_GET_WITH_HEADER;
+        else if (strcmp(strs[i], "GET-with-token") == 0)
+            csrf_prot_type |= CSRF_PROT_GET_WITH_TOKEN;
+        else if (strcmp(strs[i], "POST-with-header") == 0)
+            csrf_prot_type |= CSRF_PROT_POST_WITH_HEADER;
+        else if (strcmp(strs[i], "POST-with-token") == 0)
+            csrf_prot_type |= CSRF_PROT_POST_WITH_TOKEN;
+    }
+    free(freeme);
+
+    /*
+     * For GETs we default to no CSRF protection as our GETable resources are
+     * safe and idempotent and we count on the browser not to make the
+     * responses available to cross-site requests.
+     *
+     * But, really, we don't want browsers even making these requests since, if
+     * the browsers behave correctly, then there's no point, and if they don't
+     * behave correctly then that could be catastrophic.  Of course, there's no
+     * guarantee that a browser won't have other catastrophic bugs, but still,
+     * we should probably change this default in the future:
+     *
+     *  if (!(csrf_prot_type & CSRF_PROT_GET_WITH_HEADER) &&
+     *      !(csrf_prot_type & CSRF_PROT_GET_WITH_TOKEN))
+     *      csrf_prot_type |= <whatever-the-new-default-should-be>;
+     */
+
+    /*
+     * For POSTs we default to CSRF protection with anti-CSRF tokens even
+     * though out POSTable resources are safe and idempotent when POSTed and we
+     * could count on the browser not to make the responses available to
+     * cross-site requests.
+     */
+    if (!(csrf_prot_type & CSRF_PROT_POST_WITH_HEADER) &&
+        !(csrf_prot_type & CSRF_PROT_POST_WITH_TOKEN))
+        csrf_prot_type |= CSRF_PROT_POST_WITH_TOKEN;
+}
+
 int
 main(int argc, char **argv)
 {
@@ -2039,6 +2809,39 @@ main(int argc, char **argv)
     if (port < 0)
         errx(1, "Port number must be given");
 
+    if ((errno = pthread_key_create(&k5ctx, k5_free_context)))
+        err(1, "Could not create thread-specific storage");
+
+    if ((errno = get_krb5_context(&context)))
+        err(1, "Could not init krb5 context");
+
+    bx509_openlog(context, "bx509d", &logfac);
+    load_plugins(context);
+
+    if (allow_GET_flag == -1)
+        warnx("It is safer to use --no-allow-GET");
+
+    get_csrf_prot_type(context);
+
+    krb5_generate_random_block((void *)&csrf_key, sizeof(csrf_key));
+    if (csrf_key_file == NULL)
+        csrf_key_file = krb5_config_get_string(context, NULL, "bx509d",
+                                               "csrf_key_file", NULL);
+    if (csrf_key_file) {
+        ssize_t bytes;
+        int fd;
+
+        fd = open(csrf_key_file, O_RDONLY);
+        if (fd == -1)
+            err(1, "CSRF key file missing %s", csrf_key_file);
+        bytes = read(fd, csrf_key, sizeof(csrf_key));
+        if (bytes == -1)
+            err(1, "Could not read CSRF key file %s", csrf_key_file);
+        if (bytes != sizeof(csrf_key))
+            errx(1, "CSRF key file too small (should be %lu) %s",
+                 (unsigned long)sizeof(csrf_key), csrf_key_file);
+    }
+
     if (audiences.num_strings == 0) {
         char localhost[MAXHOSTNAMELEN];
 
@@ -2062,15 +2865,6 @@ main(int argc, char **argv)
     if (argc != 0)
         usage(1);
 
-    if ((errno = pthread_key_create(&k5ctx, k5_free_context)))
-        err(1, "Could not create thread-specific storage");
-
-    if ((errno = get_krb5_context(&context)))
-        err(1, "Could not init krb5 context");
-
-    bx509_openlog(context, "bx509d", &logfac);
-    load_plugins(context);
-
     if (cache_dir == NULL) {
         char *s = NULL;
 
@@ -2191,16 +2985,23 @@ again:
         sin.sin_family = AF_INET;
         sin.sin_port = htons(port);
         current = MHD_start_daemon(flags, port,
+                                   /*
+                                    * This is a connection access callback.  We
+                                    * don't use it.
+                                    */
                                    NULL, NULL,
+                                   /* This is our request handler */
                                    route, (char *)NULL,
                                    MHD_OPTION_SOCK_ADDR, &sin,
                                    MHD_OPTION_CONNECTION_LIMIT, (unsigned int)200,
                                    MHD_OPTION_CONNECTION_TIMEOUT, (unsigned int)10,
+                                   /* This is our request cleanup handler */
+                                   MHD_OPTION_NOTIFY_COMPLETED, cleanup_req, NULL,
                                    MHD_OPTION_END);
     } else if (sock != MHD_INVALID_SOCKET) {
         /*
-         * Certificate/key rollover: reuse the listen socket returned by
-         * MHD_quiesce_daemon().
+         * Restart following a possible certificate/key rollover, reusing the
+         * listen socket returned by MHD_quiesce_daemon().
          */
         current = MHD_start_daemon(flags | MHD_USE_SSL, port,
                                    NULL, NULL,
@@ -2209,10 +3010,17 @@ again:
                                    MHD_OPTION_HTTPS_MEM_CERT, cert_pem,
                                    MHD_OPTION_CONNECTION_LIMIT, (unsigned int)200,
                                    MHD_OPTION_CONNECTION_TIMEOUT, (unsigned int)10,
+                                   MHD_OPTION_NOTIFY_COMPLETED, cleanup_req, NULL,
                                    MHD_OPTION_LISTEN_SOCKET, sock,
                                    MHD_OPTION_END);
         sock = MHD_INVALID_SOCKET;
     } else {
+        /*
+         * Initial MHD_start_daemon(), with TLS.
+         *
+         * Subsequently we'll restart reusing the listen socket this creates.
+         * See above.
+         */
         current = MHD_start_daemon(flags | MHD_USE_SSL, port,
                                    NULL, NULL,
                                    route, (char *)NULL,
@@ -2220,6 +3028,7 @@ again:
                                    MHD_OPTION_HTTPS_MEM_CERT, cert_pem,
                                    MHD_OPTION_CONNECTION_LIMIT, (unsigned int)200,
                                    MHD_OPTION_CONNECTION_TIMEOUT, (unsigned int)10,
+                                   MHD_OPTION_NOTIFY_COMPLETED, cleanup_req, NULL,
                                    MHD_OPTION_END);
     }
     if (current == NULL)
index b50239d844001509f11353d400864f0ca34cc07e..5109854fc26027cda1c07630773ec55fb19e3238 100644 (file)
@@ -42,18 +42,21 @@ testfailed="echo test failed; cat messages.log; exit 1"
 # If there is no useful db support compiled in, disable test
 ${have_db} || exit 77
 
+umask 077
+
 R=TEST.H5L.SE
 DCs="DC=test,DC=h5l,DC=se"
 
 port=@port@
 bx509port=@bx509port@
 
+server=datan.test.h5l.se
+otherserver=other.test.h5l.se
+
 kadmin="${kadmin} -l -r $R"
-bx509d="${bx509d} --reverse-proxied -p $bx509port"
+bx509d="${bx509d} --allow-GET --reverse-proxied -p $bx509port -H $server --cert=${objdir}/bx509.pem -t"
 kdc="${kdc} --addresses=localhost -P $port"
 
-server=datan.test.h5l.se
-otherserver=other.test.h5l.se
 cachefile="${objdir}/cache.krb5"
 cache="FILE:${cachefile}"
 cachefile2="${objdir}/cache2.krb5"
@@ -131,6 +134,55 @@ get_cert() {
          "$@" "$url"
 }
 
+get_with_token() {
+    if [ -n "$csr" ]; then
+        url="http://${server}:${bx509port}/${1}?csr=$csr${2}"
+    else
+        url="http://${server}:${bx509port}/${1}?${2}"
+    fi
+    shift 2
+
+    curl -fg --resolve ${server}:${bx509port}:127.0.0.1                 \
+         -H "Authorization: Negotiate $token"                           \
+         -D response-headers                                            \
+         "$@" "$url"                                                    &&
+        { echo "GET w/o CSRF token succeeded!"; exit 2; }
+    curl -g --resolve ${server}:${bx509port}:127.0.0.1                  \
+         -H "Authorization: Negotiate $token"                           \
+         -D response-headers                                            \
+         "$@" "$url"
+    grep ^X-CSRF-Token: response-headers >/dev/null ||
+        { echo "GET w/o CSRF token did not output a CSRF token!"; exit 2; }
+    curl -fg --resolve ${server}:${bx509port}:127.0.0.1                 \
+         -H "Authorization: Negotiate $token"                           \
+         -H "$(sed -e 's/\r//' response-headers | grep ^X-CSRF-Token:)" \
+         "$@" "$url"                                                    ||
+        { echo "GET w/ CSRF failed"; exit 2; }
+}
+
+get_via_POST() {
+    endpoint=$1
+    shift
+
+    curl -fg --resolve ${server}:${bx509port}:127.0.0.1                 \
+         -H "Authorization: Negotiate $token"                           \
+         -X POST -D response-headers                                    \
+         "$@" "http://${server}:${bx509port}/${endpoint}" &&
+        { echo "POST w/o CSRF token succeeded!"; exit 2; }
+    curl -g --resolve ${server}:${bx509port}:127.0.0.1                  \
+         -H "Authorization: Negotiate $token"                           \
+         -X POST -D response-headers                                    \
+         "$@" "http://${server}:${bx509port}/${endpoint}"
+    grep ^X-CSRF-Token: response-headers >/dev/null ||
+        { echo "POST w/o CSRF token did not output a CSRF token!"; exit 2; }
+    curl -fg --resolve ${server}:${bx509port}:127.0.0.1                 \
+         -H "Authorization: Negotiate $token"                           \
+         -H "$(sed -e 's/\r//' response-headers | grep ^X-CSRF-Token:)" \
+         -X POST                                                        \
+         "$@" "http://${server}:${bx509port}/${endpoint}" ||
+        { echo "POST w/ CSRF failed"; exit 2; }
+}
+
 rm -f $kt $ukt
 $ktutil -k $keytab add -r -V 1 -e aes128-cts-hmac-sha1-96               \
     -p HTTP/datan.test.h5l.se@${R} ||
@@ -292,15 +344,15 @@ ${kadmin} init \
     ${R} || exit 1
 ${kadmin} add -r --use-defaults foo@${R} || exit 1
 ${kadmin} add -r --use-defaults bar@${R} || exit 1
+${kadmin} add -r --use-defaults baz@${R} || exit 1
 ${kadmin} modify --pkinit-acl="CN=foo,DC=test,DC=h5l,DC=se" foo@${R} || exit 1
 
 
 echo "Starting bx509d"
-${bx509d} -H $server --cert=${objdir}/bx509.pem -t --daemon ||
-    { echo "bx509 failed to start"; exit 2; }
+${bx509d} --daemon || { echo "bx509 failed to start"; exit 2; }
 bx509pid=`getpid bx509d`
 
-trap "kill -9 ${bx509pid}; echo signal killing bx509d; exit 1;" EXIT
+trap 'kill -9 ${bx509pid}; echo signal killing bx509d; exit 1;' EXIT
 ec=0
 
 rm -f trivial.pem server.pem email.pem
@@ -310,12 +362,25 @@ csr_revoke
 $hxtool request-create  --subject='' --generate-key=rsa --key-bits=1024 \
                         --key=FILE:"${objdir}/k.der" "${objdir}/req" ||
     { echo "Failed to make a CSR"; exit 2; }
-csr=$($rkbase64 -- ${objdir}/req | $rkvis -h --stdin)
 
 # XXX Add autoconf check for curl?
 #     Create a barebones bx509 HTTP/1.1 client test program?
 
+echo "Fetching a trivial user certificate (no authentication, must fail)"
+# Encode the CSR in base64, then URL-encode it
+csr=$($rkbase64 -- ${objdir}/req | $rkvis -h --stdin)
+if (set -vx;
+    curl -g --resolve ${server}:${bx509port}:127.0.0.1                  \
+         -sf -o "${objdir}/trivial.pem"                                 \
+         "http://${server}:${bx509port}/bx509?csr=$csr"); then
+    $hxtool print --content "FILE:${objdir}/trivial.pem"
+    echo 'Got a certificate without authenticating!'
+    exit 1
+fi
+
 echo "Fetching a trivial user certificate"
+# Encode the CSR in base64, then URL-encode it
+csr=$($rkbase64 -- ${objdir}/req | $rkvis -h --stdin)
 token=$(KRB5CCNAME=$cache $gsstoken HTTP@$server)
 if (set -vx; get_cert '' -sf -o "${objdir}/trivial.pem"); then
     $hxtool print --content "FILE:${objdir}/trivial.pem"
@@ -336,6 +401,43 @@ else
     exit 1
 fi
 
+echo "Fetching a trivial user certificate (with POST, no auth, must fail)"
+# Encode the CSR in base64; curl will URL-encode it for us
+csr=$($rkbase64 -- ${objdir}/req)
+if (set -vx;
+    curl -fg --resolve ${server}:${bx509port}:127.0.0.1                 \
+         -X POST -D response-headers                                    \
+         -F csr="$csr" -o "${objdir}/trivial.pem"                       \
+         "http://${server}:${bx509port}/bx509" ); then
+    $hxtool print --content "FILE:${objdir}/trivial.pem"
+    echo 'Got a certificate without authenticating!'
+    exit 1
+fi
+
+echo "Fetching a trivial user certificate (with POST)"
+# Encode the CSR in base64; curl will URL-encode it for us
+csr=$($rkbase64 -- ${objdir}/req)
+token=$(KRB5CCNAME=$cache $gsstoken HTTP@$server)
+if (set -vx;
+    get_via_POST bx509 -F csr="$csr" -o "${objdir}/trivial.pem"); then
+    $hxtool print --content "FILE:${objdir}/trivial.pem"
+    if $hxtool acert --end-entity                                            \
+                    --expr="%{certificate.subject} == \"CN=foo,$DCs\""  \
+                    -P "foo@${R}" "FILE:${objdir}/trivial.pem"; then
+        echo 'Successfully obtained a trivial client certificate!'
+    else
+        echo 'FAIL: Obtained a trivial client certificate w/o expected PKINIT SAN)'
+        exit 1
+    fi
+    if $hxtool acert --expr="%{certificate.subject} == \"OU=Users,$DCs\""   \
+                     --has-private-key "FILE:${objdir}/trivial.pem"; then
+        echo 'Successfully obtained a trivial client certificate!'
+    fi
+else
+    echo 'Failed to get a certificate!'
+    exit 1
+fi
+
 echo "Checking that authorization is enforced"
 csr_revoke
 get_cert '&rfc822Name=foo@bar.example' -vvv -o "${objdir}/bad1.pem"
@@ -430,10 +532,10 @@ ${kadmin} ext_keytab -r -k $ukeytab foo@${R} || exit 1
 echo "Starting kdc";
 ${kdc} --detach --testing || { echo "kdc failed to start"; cat messages.log; exit 1; }
 kdcpid=`getpid kdc`
-trap "kill -9 ${kdcpid} ${bx509pid}; echo signal killing kdc and bx509d; exit 1;" EXIT
+trap 'kill -9 ${kdcpid} ${bx509pid}; echo signal killing kdc and bx509d; exit 1;' EXIT
 
 ${kinit} -kt $ukeytab foo@${R} || exit 1
-$klist || { echo "failed to setup kimpersonate credentials"; exit 2; }
+$klist || { echo "failed to kinit"; exit 2; }
 
 echo "Fetch TGT (not granted for other)"
 token=$(KRB5CCNAME=$cache $gsstoken HTTP@$server)
@@ -474,7 +576,7 @@ if ! (set -vx;
     exit 2
 fi
 ${kgetcred} -H HTTP/${server}@${R} ||
-    { echo "Trivial offline CA test failed (TGS)"; exit 2; }
+    { echo "Fetched TGT didn't work"; exit 2; }
 ${klist} | grep Addresses:.IPv4:8.8.8.8 ||
     { echo "Failed to get a TGT with /get-tgt end-point with addresses"; exit 2; }
 
@@ -491,7 +593,7 @@ if ! (set -vx;
     exit 2
 fi
 ${kgetcred} -H HTTP/${server}@${R} ||
-    { echo "Trivial offline CA test failed (TGS)"; exit 2; }
+    { echo "Fetched TGT didn't work"; exit 2; }
 ${klist} | grep Addresses:.IPv4:8.8.8.8 ||
     { echo "Failed to get a TGT with /get-tgt end-point with addresses"; exit 2; }
 
@@ -509,7 +611,7 @@ if ! (set -vx;
     exit 2
 fi
 ${kgetcred} -H HTTP/${server}@${R} ||
-    { echo "Trivial offline CA test failed (TGS)"; exit 2; }
+    { echo "Fetched TGT didn't work"; exit 2; }
 if which jq >/dev/null; then
     if ! ${klistjson} | jq -e '
             (reduce (.tickets[0]|(.Issued,.Expires)|
@@ -535,7 +637,7 @@ if ! (set -vx;
     exit 2
 fi
 ${kgetcred} -H HTTP/${server}@${R} ||
-    { echo "Trivial offline CA test failed (TGS)"; exit 2; }
+    { echo "Fetched TGT didn't work"; exit 2; }
 if which jq >/dev/null; then
     if ! ${klistjson} | jq -e '
             (reduce (.tickets[0]|(.Issued,.Expires)|
@@ -561,7 +663,7 @@ if ! (set -vx;
     exit 2
 fi
 ${kgetcred} -H HTTP/${server}@${R} ||
-    { echo "Trivial offline CA test failed (TGS)"; exit 2; }
+    { echo "Fetched TGT didn't work"; exit 2; }
 if which jq >/dev/null; then
     if ! ${klistjson} | jq -e '
             (reduce (.tickets[0]|(.Issued,.Expires)|
@@ -573,6 +675,153 @@ if which jq >/dev/null; then
     fi
 fi
 
+echo "Fetch TGTs (batch, authz fail)"
+${kadmin} modify --max-ticket-life=10d krbtgt/${R}@${R}
+(set -vx; csr_grant pkinit bar@${R} foo@${R})
+${kdestroy}
+token=$(KRB5CCNAME=$cache2 $gsstoken HTTP@$server)
+if (set -vx;
+    curl -o "${cachefile}.json" -Lgsf                                   \
+         --resolve ${server}:${bx509port}:127.0.0.1                     \
+         -H "Authorization: Negotiate $token"                           \
+         "http://${server}:${bx509port}/get-tgts?cname=bar@${R}&cname=baz@${R}"); then
+    echo "Got TGTs with /get-tgts end-point that should have been denied"
+    exit 2
+fi
+
+echo "Fetch TGTs (batch, authz pass)"
+${kadmin} modify --max-ticket-life=10d krbtgt/${R}@${R}
+(csr_grant pkinit bar@${R} foo@${R})
+(csr_grant pkinit baz@${R} foo@${R})
+${kdestroy}
+token=$(KRB5CCNAME=$cache2 $gsstoken HTTP@$server)
+if ! (set -vx;
+    curl -vvvo "${cachefile}.json" -Lgsf                                \
+         --resolve ${server}:${bx509port}:127.0.0.1                     \
+         -H "Authorization: Negotiate $token"                           \
+         "http://${server}:${bx509port}/get-tgts?cname=bar@${R}&cname=baz@${R}"); then
+    echo "Failed to get TGTs batch"
+    exit 2
+fi
+if which jq >/dev/null; then
+    jq -e . "${cachefile}.json" > /dev/null ||
+        { echo "/get-tgts produced non-JSON"; exit 2; }
+
+    # Check bar@$R's tickets:
+    jq -r 'select(.name|startswith("bar@")).ccache' "${cachefile}.json" |
+        $rkbase64 -d -- - > "${cachefile}"
+    ${kgetcred} -H HTTP/${server}@${R} ||
+        { echo "Fetched TGT didn't work"; exit 2; }
+    ${klistjson} | jq -e --arg p bar@$R '.principal == $p' > /dev/null ||
+        { echo "/get-tgts produced wrong TGTs"; exit 2; }
+
+    # Check baz@$R's tickets:
+    jq -r 'select(.name|startswith("baz@")).ccache' "${cachefile}.json" |
+        $rkbase64 -d -- - > "${cachefile}"
+    ${kgetcred} -H HTTP/${server}@${R} ||
+        { echo "Fetched TGT didn't work"; exit 2; }
+    ${klistjson} | jq -e --arg p baz@$R '.principal == $p' > /dev/null ||
+        { echo "/get-tgts produced wrong TGTs"; exit 2; }
+fi
+
+echo "Fetch TGTs (batch, authz pass, one non-existent principal)"
+${kadmin} modify --max-ticket-life=10d krbtgt/${R}@${R}
+(csr_grant pkinit bar@${R} foo@${R})
+(csr_grant pkinit baz@${R} foo@${R})
+(csr_grant pkinit not@${R} foo@${R})
+${kdestroy}
+token=$(KRB5CCNAME=$cache2 $gsstoken HTTP@$server)
+if ! (set -vx;
+    curl -vvvo "${cachefile}.json" -Lgsf                                \
+         --resolve ${server}:${bx509port}:127.0.0.1                     \
+         -H "Authorization: Negotiate $token"                           \
+         "http://${server}:${bx509port}/get-tgts?cname=not@${R}&cname=bar@${R}&cname=baz@${R}"); then
+    echo "Failed to get TGTs batch including non-existent principal"
+    exit 2
+fi
+if which jq >/dev/null; then
+    set -vx
+    jq -e . "${cachefile}.json" > /dev/null ||
+        { echo "/get-tgts produced non-JSON"; exit 2; }
+    jq -es '.[]|select(.name|startswith("not@"))|(.error_code//empty)' "${cachefile}.json" > /dev/null ||
+        { echo "No error was reported for not@${R}!"; exit 2; }
+
+    # Check bar@$R's tickets:
+    jq -r 'select(.name|startswith("bar@")).ccache' "${cachefile}.json" |
+        $rkbase64 -d -- - > "${cachefile}"
+    ${kgetcred} -H HTTP/${server}@${R} ||
+        { echo "Fetched TGT didn't work"; exit 2; }
+    ${klistjson} | jq -e --arg p bar@$R '.principal == $p' > /dev/null ||
+        { echo "/get-tgts produced wrong TGTs"; exit 2; }
+
+    # Check baz@$R's tickets:
+    jq -r 'select(.name|startswith("baz@")).ccache' "${cachefile}.json" |
+        $rkbase64 -d -- - > "${cachefile}"
+    ${kgetcred} -H HTTP/${server}@${R} ||
+        { echo "Fetched TGT didn't work"; exit 2; }
+    ${klistjson} | jq -e --arg p baz@$R '.principal == $p' > /dev/null ||
+        { echo "/get-tgts produced wrong TGTs"; exit 2; }
+fi
+
+echo "killing bx509d (${bx509pid})"
+sh ${leaks_kill} bx509d $bx509pid || ec=1
+
+echo "Starting bx509d (csrf-protection-type=GET-with-token, POST-with-header)"
+${bx509d} --csrf-protection-type=GET-with-token \
+          --csrf-protection-type=POST-with-header --daemon || {
+    echo "bx509 failed to start"
+    exit 2
+}
+bx509pid=`getpid bx509d`
+
+${kinit} -kt $ukeytab foo@${R} || exit 1
+$klist || { echo "failed to kinit"; exit 2; }
+
+echo "Fetching a trivial user certificate (GET with CSRF token)"
+csr=$($rkbase64 -- ${objdir}/req | $rkvis -h --stdin)
+token=$(KRB5CCNAME=$cache $gsstoken HTTP@$server)
+if (set -vx; get_with_token get-cert '' -o "${objdir}/trivial.pem"); then
+    $hxtool print --content "FILE:${objdir}/trivial.pem"
+    if $hxtool acert --end-entity                                            \
+                    --expr="%{certificate.subject} == \"CN=foo,$DCs\""  \
+                    -P "foo@${R}" "FILE:${objdir}/trivial.pem"; then
+        echo 'Successfully obtained a trivial client certificate!'
+    else
+        echo 'FAIL: Obtained a trivial client certificate w/o expected PKINIT SAN)'
+        exit 1
+    fi
+    if $hxtool acert --expr="%{certificate.subject} == \"OU=Users,$DCs\""   \
+                     --has-private-key "FILE:${objdir}/trivial.pem"; then
+        echo 'Successfully obtained a trivial client certificate!'
+    fi
+else
+    echo 'Failed to get a certificate!'
+    exit 1
+fi
+
+echo "Fetching a trivial user certificate (POST with X-CSRF header, no token)"
+# Encode the CSR in base64, then URL-encode it
+csr=$($rkbase64 -- ${objdir}/req | $rkvis -h --stdin)
+token=$(KRB5CCNAME=$cache $gsstoken HTTP@$server)
+if (set -vx; get_cert '' -H 'X-CSRF: junk' -X POST -sf -o "${objdir}/trivial.pem"); then
+    $hxtool print --content "FILE:${objdir}/trivial.pem"
+    if $hxtool acert --end-entity                                            \
+                    --expr="%{certificate.subject} == \"CN=foo,$DCs\""  \
+                    -P "foo@${R}" "FILE:${objdir}/trivial.pem"; then
+        echo 'Successfully obtained a trivial client certificate!'
+    else
+        echo 'FAIL: Obtained a trivial client certificate w/o expected PKINIT SAN)'
+        exit 1
+    fi
+    if $hxtool acert --expr="%{certificate.subject} == \"OU=Users,$DCs\""   \
+                     --has-private-key "FILE:${objdir}/trivial.pem"; then
+        echo 'Successfully obtained a trivial client certificate!'
+    fi
+else
+    echo 'Failed to get a certificate!'
+    exit 1
+fi
+
 echo "Fetch negotiate token (pre-test)"
 # Do what /bnegotiate does, roughly, prior to testing /bnegotiate
 $hxtool request-create  --subject='' --generate-key=rsa --key-bits=1024 \
@@ -596,11 +845,9 @@ grep 'REQ.*wrongaddr=true' ${objdir}/messages.log |
 
 echo "Fetching a Negotiate token"
 token=$(KRB5CCNAME=$cache $gsstoken HTTP@$server)
+csr=
 if (set -vx;
-    curl -o negotiate-token -Lgsf                                       \
-         --resolve ${server}:${bx509port}:127.0.0.1                     \
-         -H "Authorization: Negotiate $token"                           \
-         "http://${server}:${bx509port}/bnegotiate?target=HTTP%40${server}"); then
+    get_with_token get-negotiate-token "target=HTTP%40${server}" -o "${objdir}/negotiate-token"); then
     # bx509 sends us a token w/o a newline for now; we add one because
     # gss-token expects it.
     test -s negotiate-token && echo >> negotiate-token