Drop baked-in TLS cert, run as 1001, refuse known placeholder secrets

The dev container image generated a TLS keypair at build time and
shipped it inside the image, so every pull of the same image tag was
serving the same private key. The entrypoint also reverted to USER 0
to support a dead `createusers.txt` loop and a `chmod u+s` that was a
no-op (set on a binary owned by 1001). Net result was that any RCE
in the gateway landed as root and the wire-trust posture relied on a
shared private key.

Stop generating the cert at build time: the runtime image now carries
openssl and the entrypoint mints an ephemeral self-signed cert at
first start when no cert is mounted at the configured path. Each
container instance gets its own key. Drop USER 0 entirely; the
entrypoint runs as 1001 throughout. Prune the dead createusers loop
and the `chmod u+s`.

Separately, the README and the dev compose files publish a small set
of literal placeholder values for SessionKey, SessionEncryptionKey,
and the various Token*Key fields. Operators copy-paste these into
real deployments. Refuse to start when any of those literals appear
in the corresponding config field.
This commit is contained in:
Bolke de Bruin
2026-04-30 19:09:15 +02:00
parent de31bfe8a0
commit 7d1b9af858
6 changed files with 202 additions and 43 deletions

View File

@@ -9,6 +9,17 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.
### Changed
- The dev container image no longer ships a baked-in TLS key/cert.
The runtime image carries `openssl` and the entrypoint generates an
ephemeral self-signed cert at first start if none is mounted at the
configured path; each container instance gets its own key. The
entrypoint also runs entirely as UID 1001 (no more `USER 0`).
- rdpgw refuses to start when any of `Server.SessionKey`,
`Server.SessionEncryptionKey`, `Security.PAATokenSigningKey`,
`Security.PAATokenEncryptionKey`, `Security.UserTokenSigningKey`,
`Security.UserTokenEncryptionKey`, or `Security.QueryTokenSigningKey`
matches a published placeholder value from `README.md` / the dev
compose files. See [UPGRADING.md](UPGRADING.md) for the full list.
- `rdpgw-auth` now creates its socket with mode `0660` and accepts only
connections whose peer UID is on an allow-list (default: the daemon's
own UID). Operators running rdpgw and rdpgw-auth as different users

View File

@@ -2,6 +2,50 @@
## Unreleased
### Container image no longer bakes a TLS cert and runs as UID 1001
Two changes to `dev/docker/Dockerfile` and the dev image's entrypoint:
* The build step that ran `openssl genrsa` / `openssl x509` and shipped
the resulting key/cert in the image is gone. The runtime image now
contains `openssl` and the entrypoint mints an ephemeral self-signed
cert at first start when no cert is mounted at the configured path.
Each container instance gets its own key.
* The entrypoint runs as UID 1001 throughout. The previous `USER 0`
block, the dead `createusers.txt` loop in `run.sh`, and the
`chmod u+s` on `rdpgw-auth` (which set the bit on a file owned by
1001 and was therefore a no-op anyway) have been removed.
If you mount your own cert (recommended for any non-dev deployment),
make sure `RDPGW_SERVER__CERT_FILE` and `RDPGW_SERVER__KEY_FILE` point
to the mounted paths. Otherwise the entrypoint generates a fresh
self-signed pair at the configured locations on first start.
If your deployment relies on `rdpgw-auth` running with elevated
privileges from the same image, run it from a separate container or
use Linux capabilities -- see
[docs/pam-authentication.md](docs/pam-authentication.md) for the
privilege-separation model.
### Refuse to start when known placeholder secrets are configured
These literal values appearing in `Server.SessionKey`,
`Server.SessionEncryptionKey`, `Security.PAATokenSigningKey`,
`Security.PAATokenEncryptionKey`, `Security.UserTokenSigningKey`,
`Security.UserTokenEncryptionKey`, or `Security.QueryTokenSigningKey`
will now cause rdpgw to refuse to start:
```
thisisasessionkeyreplacethisjetzt
thisisasessionkeyreplacethisjetz
thisisasessionkeyreplacethisnunu!
thisisasessionkeyreplacethisnunu
thisisasessionencryptionkey12345
```
These are the published example values from `README.md` and the dev
compose files. Replace them with unique 32-character strings.
### `rdpgw-auth` only accepts connections from the daemon's own UID by default
The auth daemon previously created its socket world-writable

View File

@@ -1,6 +1,7 @@
package config
import (
"fmt"
"log"
"os"
"strings"
@@ -13,6 +14,45 @@ import (
"github.com/knadh/koanf/v2"
)
// knownDefaultSecrets is the list of placeholder session/token-key values
// that appear in README.md and the dev compose files. Operators that
// copy-paste the examples into a real deployment would be running with
// widely-published keys, so the gateway refuses to start when one of these
// is set for any of the session-/token-binding fields.
var knownDefaultSecrets = []string{
"thisisasessionkeyreplacethisjetzt",
"thisisasessionkeyreplacethisjetz",
"thisisasessionkeyreplacethisnunu!",
"thisisasessionkeyreplacethisnunu",
"thisisasessionencryptionkey12345",
}
func checkDefaultSecrets(c *Configuration) error {
fields := []struct {
name string
value string
}{
{"server.sessionkey", c.Server.SessionKey},
{"server.sessionencryptionkey", c.Server.SessionEncryptionKey},
{"security.paatokensigningkey", c.Security.PAATokenSigningKey},
{"security.paatokenencryptionkey", c.Security.PAATokenEncryptionKey},
{"security.usertokensigningkey", c.Security.UserTokenSigningKey},
{"security.usertokenencryptionkey", c.Security.UserTokenEncryptionKey},
{"security.querytokensigningkey", c.Security.QueryTokenSigningKey},
}
for _, f := range fields {
if f.value == "" {
continue
}
for _, def := range knownDefaultSecrets {
if f.value == def {
return fmt.Errorf("%s is set to a known placeholder value (%q); replace it with a unique secret before starting", f.name, def)
}
}
}
return nil
}
const (
TlsDisable = "disable"
TlsAuto = "auto"
@@ -219,6 +259,10 @@ func Load(configFile string) Configuration {
k.UnmarshalWithConf("Client", &Conf.Client, koanfTag)
k.UnmarshalWithConf("Kerberos", &Conf.Kerberos, koanfTag)
if err := checkDefaultSecrets(&Conf); err != nil {
log.Fatalf("refusing to start: %s", err)
}
if len(Conf.Security.PAATokenEncryptionKey) != 32 {
Conf.Security.PAATokenEncryptionKey, _ = security.GenerateRandomString(32)
log.Printf("No valid `security.paatokenencryptionkey` specified (empty or not 32 characters). Setting to random")

View File

@@ -53,6 +53,85 @@ func TestAuthenticationConstants(t *testing.T) {
}
}
func TestCheckDefaultSecrets(t *testing.T) {
const placeholder = "thisisasessionkeyreplacethisjetzt"
cases := []struct {
name string
mutate func(*Configuration)
wantField string
}{
{
name: "session key",
mutate: func(c *Configuration) { c.Server.SessionKey = placeholder },
wantField: "server.sessionkey",
},
{
name: "session encryption key",
mutate: func(c *Configuration) { c.Server.SessionEncryptionKey = placeholder },
wantField: "server.sessionencryptionkey",
},
{
name: "paa signing key",
mutate: func(c *Configuration) { c.Security.PAATokenSigningKey = placeholder },
wantField: "security.paatokensigningkey",
},
{
name: "paa encryption key",
mutate: func(c *Configuration) { c.Security.PAATokenEncryptionKey = placeholder },
wantField: "security.paatokenencryptionkey",
},
{
name: "user signing key",
mutate: func(c *Configuration) { c.Security.UserTokenSigningKey = placeholder },
wantField: "security.usertokensigningkey",
},
{
name: "user encryption key",
mutate: func(c *Configuration) { c.Security.UserTokenEncryptionKey = placeholder },
wantField: "security.usertokenencryptionkey",
},
{
name: "query signing key",
mutate: func(c *Configuration) { c.Security.QueryTokenSigningKey = placeholder },
wantField: "security.querytokensigningkey",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := &Configuration{}
tc.mutate(c)
err := checkDefaultSecrets(c)
if err == nil {
t.Fatalf("checkDefaultSecrets accepted a placeholder value in %s", tc.wantField)
}
if got := err.Error(); !contains(got, tc.wantField) {
t.Errorf("error message %q should mention the field %q", got, tc.wantField)
}
})
}
}
func TestCheckDefaultSecretsAllowsRandomValues(t *testing.T) {
c := &Configuration{}
c.Server.SessionKey = "5aa3a1568fe8421cd7e127d5ace28d2d"
c.Server.SessionEncryptionKey = "d3ecd7e565e56e37e2f2e95b584d8c0c"
c.Security.PAATokenSigningKey = "0123456789abcdef0123456789abcdef"
if err := checkDefaultSecrets(c); err != nil {
t.Errorf("checkDefaultSecrets rejected non-placeholder values: %v", err)
}
}
func contains(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}
func TestHeaderConfigValidation(t *testing.T) {
cases := []struct {
name string

View File

@@ -2,21 +2,11 @@
FROM golang:1.24-alpine as builder
# Install CA certificates explicitly in builder
RUN apk --no-cache add git gcc musl-dev linux-pam-dev openssl
RUN apk --no-cache add git gcc musl-dev linux-pam-dev
# add user
RUN adduser --disabled-password --gecos "" --home /opt/rdpgw --uid 1001 rdpgw
# certificate generation
RUN mkdir -p /opt/rdpgw && cd /opt/rdpgw && \
random=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) && \
openssl genrsa -des3 -passout pass:$random -out server.pass.key 2048 && \
openssl rsa -passin pass:$random -in server.pass.key -out key.pem && \
rm server.pass.key && \
openssl req -new -sha256 -key key.pem -out server.csr \
-subj "/C=US/ST=VA/L=SomeCity/O=MyCompany/OU=MyDivision/CN=rdpgw" && \
openssl x509 -req -days 365 -in server.csr -signkey key.pem -out server.pem
# build rdpgw and set rights
ARG CACHEBUST
RUN git clone https://github.com/bolkedebruin/rdpgw.git /app && \
@@ -25,12 +15,13 @@ RUN git clone https://github.com/bolkedebruin/rdpgw.git /app && \
CGO_ENABLED=0 GOOS=linux go build -trimpath -tags '' -ldflags '' -o '/opt/rdpgw/rdpgw' ./cmd/rdpgw && \
CGO_ENABLED=1 GOOS=linux go build -trimpath -tags '' -ldflags '' -o '/opt/rdpgw/rdpgw-auth' ./cmd/auth && \
chmod +x /opt/rdpgw/rdpgw && \
chmod +x /opt/rdpgw/rdpgw-auth && \
chmod u+s /opt/rdpgw/rdpgw-auth
chmod +x /opt/rdpgw/rdpgw-auth
FROM alpine:latest
# Install CA certificates in final stage
RUN apk --no-cache add linux-pam musl tzdata ca-certificates && update-ca-certificates
# Install CA certificates and (for the dev compose) openssl so the
# entrypoint can mint an ephemeral self-signed cert at startup. No cert
# is baked into the image, so each container instance gets its own key.
RUN apk --no-cache add linux-pam musl tzdata ca-certificates openssl && update-ca-certificates
# make tempdir in case filestore is used
ADD tmp.tar /
@@ -47,6 +38,5 @@ COPY --from=builder /app/cmd/rdpgw/templates /opt/rdpgw/templates
# Copy assets directory from the app source
COPY --chown=1001 --from=builder /app/assets /opt/rdpgw/assets
USER 0
WORKDIR /opt/rdpgw
ENTRYPOINT ["/bin/sh", "/run.sh"]

View File

@@ -1,34 +1,25 @@
#!/bin/sh
set -e
USER=rdpgw
cd /opt/rdpgw
file="/root/createusers.txt"
if [ -f $file ]
then
while IFS=: read -r username password is_sudo
do
echo "Username: $username, Password: **** , Sudo: $is_sudo"
if getent passwd "$username" > /dev/null 2>&1
then
echo "User Exists"
else
adduser -s /sbin/nologin "$username"
echo "$username:$password" | chpasswd
fi
done <"$file"
# Generate an ephemeral self-signed cert at first start when one is not
# already mounted/present at the configured path. Each container instance
# gets its own key; nothing is baked into the image. This is intended for
# the dev compose stack -- production deployments should mount a real
# certificate, or set Tls=auto so rdpgw obtains one from Let's Encrypt.
CERT="${RDPGW_SERVER__CERT_FILE:-/opt/rdpgw/server.pem}"
KEY="${RDPGW_SERVER__KEY_FILE:-/opt/rdpgw/key.pem}"
if [ ! -f "${CERT}" ] && [ ! -f "${KEY}" ]; then
echo "Generating ephemeral self-signed cert at ${CERT} / ${KEY} (dev only)"
openssl req -x509 -newkey rsa:2048 -keyout "${KEY}" -out "${CERT}" \
-sha256 -days 365 -nodes \
-subj "/CN=rdpgw-ephemeral"
fi
cd /opt/rdpgw || exit 1
if [ -n "${RDPGW_SERVER__AUTHENTICATION}" ]; then
if [ "${RDPGW_SERVER__AUTHENTICATION}" = "local" ]; then
echo "Starting rdpgw-auth"
/opt/rdpgw/rdpgw-auth &
fi
if [ "${RDPGW_SERVER__AUTHENTICATION}" = "local" ]; then
echo "Starting rdpgw-auth"
/opt/rdpgw/rdpgw-auth &
fi
# drop privileges and run the application
su -c /opt/rdpgw/rdpgw "${USER}" -- "$@" &
wait
exit $?
exec /opt/rdpgw/rdpgw "$@"