mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
d6e9344e03
The Azure instance-identity authentication endpoint parsed the PKCS7 envelope and verified the certificate chain, but never verified the PKCS7 signature itself. An attacker could forge a PKCS7 envelope with a legitimate, publicly obtainable Azure certificate and arbitrary vmId content to obtain any agent auth token. Add verifyPKCS7Signature(), a custom PKCS7 signature verification that handles Azure non-standard use of sha256WithRSAEncryption (OID 1.2.840.113549.1.1.11) as the DigestAlgorithm. The upstream go.mozilla.org/pkcs7 library Verify() rejects this combination. The verification checks: 1. Content digest matches the signed message-digest attribute 2. Signature over the authenticated attributes is valid Tests added: - TestValidate_TamperedContent: forges a PKCS7 with modified vmId, confirms rejection - TestValidate_UntrustedCertWithValidSignature: valid PKCS7 signature with untrusted cert chain, confirms rejection Co-authored-by: Jakub Domeracki <jakub@coder.com>
217 lines
7.6 KiB
Go
217 lines
7.6 KiB
Go
package coderd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/mitchellh/mapstructure"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/awsidentity"
|
|
"github.com/coder/coder/v2/coderd/azureidentity"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/provisionerdserver"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/agentsdk"
|
|
)
|
|
|
|
// Azure supports instance identity verification:
|
|
// https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service?tabs=linux#tabgroup_14
|
|
//
|
|
// @Summary Authenticate agent on Azure instance
|
|
// @ID authenticate-agent-on-azure-instance
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Agents
|
|
// @Param request body agentsdk.AzureInstanceIdentityToken true "Instance identity token"
|
|
// @Success 200 {object} agentsdk.AuthenticateResponse
|
|
// @Router /workspaceagents/azure-instance-identity [post]
|
|
func (api *API) postWorkspaceAuthAzureInstanceIdentity(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var req agentsdk.AzureInstanceIdentityToken
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
instanceID, err := azureidentity.Validate(r.Context(), req.Signature, api.AzureCertificates)
|
|
if err != nil {
|
|
// Log the full error for operators but return only a
|
|
// generic message to the caller. Errors from the
|
|
// certificate fetch path may contain fragments of
|
|
// internal HTTP responses, so exposing them would be
|
|
// an information disclosure risk.
|
|
api.Logger.Warn(ctx, "azure identity validation failed",
|
|
slog.Error(err),
|
|
)
|
|
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
|
|
Message: "Invalid Azure identity.",
|
|
Detail: "Signature verification failed.",
|
|
})
|
|
return
|
|
}
|
|
api.handleAuthInstanceID(rw, r, instanceID)
|
|
}
|
|
|
|
// AWS supports instance identity verification:
|
|
// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-identity-documents.html
|
|
// Using this, we can exchange a signed instance payload for an agent token.
|
|
//
|
|
// @Summary Authenticate agent on AWS instance
|
|
// @ID authenticate-agent-on-aws-instance
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Agents
|
|
// @Param request body agentsdk.AWSInstanceIdentityToken true "Instance identity token"
|
|
// @Success 200 {object} agentsdk.AuthenticateResponse
|
|
// @Router /workspaceagents/aws-instance-identity [post]
|
|
func (api *API) postWorkspaceAuthAWSInstanceIdentity(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var req agentsdk.AWSInstanceIdentityToken
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
identity, err := awsidentity.Validate(req.Signature, req.Document, api.AWSCertificates)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
|
|
Message: "Invalid AWS identity.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
api.handleAuthInstanceID(rw, r, identity.InstanceID)
|
|
}
|
|
|
|
// Google Compute Engine supports instance identity verification:
|
|
// https://cloud.google.com/compute/docs/instances/verifying-instance-identity
|
|
// Using this, we can exchange a signed instance payload for an agent token.
|
|
//
|
|
// @Summary Authenticate agent on Google Cloud instance
|
|
// @ID authenticate-agent-on-google-cloud-instance
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Agents
|
|
// @Param request body agentsdk.GoogleInstanceIdentityToken true "Instance identity token"
|
|
// @Success 200 {object} agentsdk.AuthenticateResponse
|
|
// @Router /workspaceagents/google-instance-identity [post]
|
|
func (api *API) postWorkspaceAuthGoogleInstanceIdentity(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var req agentsdk.GoogleInstanceIdentityToken
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
|
|
// We leave the audience blank. It's not important we validate who made the token.
|
|
payload, err := api.GoogleTokenValidator.Validate(ctx, req.JSONWebToken, "")
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
|
|
Message: "Invalid GCP identity.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
claims := struct {
|
|
Google struct {
|
|
ComputeEngine struct {
|
|
InstanceID string `mapstructure:"instance_id"`
|
|
} `mapstructure:"compute_engine"`
|
|
} `mapstructure:"google"`
|
|
}{}
|
|
err = mapstructure.Decode(payload.Claims, &claims)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Error decoding JWT claims.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
api.handleAuthInstanceID(rw, r, claims.Google.ComputeEngine.InstanceID)
|
|
}
|
|
|
|
func (api *API) handleAuthInstanceID(rw http.ResponseWriter, r *http.Request, instanceID string) {
|
|
ctx := r.Context()
|
|
//nolint:gocritic // needed for auth instance id
|
|
agent, err := api.Database.GetWorkspaceAgentByInstanceID(dbauthz.AsSystemRestricted(ctx), instanceID)
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
|
Message: fmt.Sprintf("Instance with id %q not found.", instanceID),
|
|
})
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching provisioner job agent.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
//nolint:gocritic // needed for auth instance id
|
|
resource, err := api.Database.GetWorkspaceResourceByID(dbauthz.AsSystemRestricted(ctx), agent.ResourceID)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching provisioner job resource.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
//nolint:gocritic // needed for auth instance id
|
|
job, err := api.Database.GetProvisionerJobByID(dbauthz.AsSystemRestricted(ctx), resource.JobID)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching provisioner job.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if job.Type != database.ProvisionerJobTypeWorkspaceBuild {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf("%q jobs cannot be authenticated.", job.Type),
|
|
})
|
|
return
|
|
}
|
|
var jobData provisionerdserver.WorkspaceProvisionJob
|
|
err = json.Unmarshal(job.Input, &jobData)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error extracting job data.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
//nolint:gocritic // needed for auth instance id
|
|
resourceHistory, err := api.Database.GetWorkspaceBuildByID(dbauthz.AsSystemRestricted(ctx), jobData.WorkspaceBuildID)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching workspace build.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
// This token should only be exchanged if the instance ID is valid
|
|
// for the latest history. If an instance ID is recycled by a cloud,
|
|
// we'd hate to leak access to a user's workspace.
|
|
//nolint:gocritic // needed for auth instance id
|
|
latestHistory, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(dbauthz.AsSystemRestricted(ctx), resourceHistory.WorkspaceID)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching the latest workspace build.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if latestHistory.ID != resourceHistory.ID {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf("Resource found for id %q, but isn't registered on the latest history.", instanceID),
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.AuthenticateResponse{
|
|
SessionToken: agent.AuthToken.String(),
|
|
})
|
|
}
|