Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions AUDIT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# Audit — custom-agent build workflow

Scope: end-to-end review of the Django generator (`rdgen/`) and the Go API
(`api/`) used by the custom-client build pipeline. Looks at the workflow
as a whole — how the form submission, the GitHub Actions runner, and the
download/cleanup endpoints fit together — not just the lines touched by
the earlier CodeQL audit.

Legend: ✅ fixed in this PR · ⚠️ flagged, not fixed (out of scope or
architectural) · ❌ confirmed bug, can't fix without breaking the protocol.

**Status:** all ✅ items below are landed on this branch as of the most
recent commit. The PR is ready for review; the ⚠️ items are listed for
the operator to consider during deployment but are not blockers.

---

## A. Critical — workflow-breaking bugs

### A1 — `appname.upper != "rustdesk".upper` is a method comparison ✅
`rdgen/rdgenerator/views.py:170`
```python
if appname.upper != "rustdesk".upper and appname != "":
decodedCustom['app-name'] = appname
```
`upper` without `()` compares **bound method objects** — they're never
equal, so the branch is always entered (when `appname != ""`). The
"don't override app-name when it's still the default" logic was dead.

Fix: call the methods (`.upper()`) and lower-case both sides for
case-insensitive compare.

### A2 — GitHub dispatch `204 No Content` parsed as JSON ✅
`rdgen/rdgenerator/views.py:349-352`
```python
if response.status_code == 204 or response.status_code == 200:
github_data = response.json() # 204 has no body → JSONDecodeError
new_github_run.github_run_id = github_data.get('workflow_run_id')
```
GitHub's `actions/workflows/.../dispatches` returns `204 No Content`.
On 204, `.json()` raises `JSONDecodeError` and the surrounding
`except Exception` returns "Connection error" 500 — **the run row is
never written and the polling page sits forever**.

Fix: when status is 204, skip the JSON parse and leave
`github_run_id = None`; only attempt JSON when there's a body.

### A3 — `gh_run.github_run_id` may be `None` ✅
`rdgen/rdgenerator/views.py:350` (consequence of A2 or any failed dispatch)
```python
api_url = f"https://api.github.com/repos/{GHUSER}/{REPONAME}/actions/runs/{gh_run.github_run_id}"
```
If `github_run_id` is `None`, the URL becomes `/runs/None`. GitHub
returns 404, and the user sees a broken "View GitHub Action Logs" link
that also contains the literal string `None`.

Fix: guard `check_for_file` against `None`/empty `github_run_id`; treat
as "still starting" instead of polling GitHub.

### A4 — Hard-coded `X-GitHub-Api-Version: '2026-03-10'` ⚠️
`rdgen/rdgenerator/views.py:340, 498`

The placeholder header doesn't match any real GitHub API version.
GitHub falls back to the default version, so it works, but the header
is misleading. Not breaking — leave as a follow-up, the upstream value
should be `2022-11-28`.

---

## B. Critical — security / auth

### B1 — Four POST endpoints have no authentication 🔀 split out to its own PR
`update_github_run`, `save_custom_client`, `cleanup_secrets`, `startgh`
are all reachable by any anonymous client. The GitHub workflows send
`Authorization: Bearer ${{ env.token }}`, but Django doesn't validate
that header, so the token is decorative. Worse, the current generator
**does not put `token` into `inputs_raw`**, so `${{ env.token }}` is
empty in the runners today.

What this enables:
- DoS on `startgh` — anyone can dispatch the GitHub workflow
repeatedly, burning the maintainer's GHBEARER quota.
- Anonymous file upload on `save_custom_client` — anyone with a UUID
(or who guesses one) can overwrite the cached binaries.
- Anonymous status spoofing on `update_github_run` — mark any UUID
"failed"/"success" without permission.
- Anonymous deletion on `cleanup_secrets` — wipe any UUID's secrets zip.

Per the "one bug — one PR" rule, this is being landed separately.
See the linked PR (workflow Bearer auth) for the fix.

### B2 — `SECRET_KEY` default is `django-insecure-…` ✅
`rdgen/rdgen/settings.py:23` falls back to the insecure literal when
`SECRET_KEY` env var is unset. CodeQL doesn't flag this but it's the
single biggest "production booby trap" in the codebase.

Fix: when `DEBUG=False` and `SECRET_KEY` env var is missing, raise at
startup (existing default kept only for dev).

### B3 — `ZIP_PASSWORD` default is `'insecure'` ✅
`rdgen/rdgen/settings.py:28` — the AES password protecting the
secrets zip falls back to the literal string `"insecure"` when env is
unset. Same shape as B2.

Fix: same guard — production startup must fail without the env var.

### B4 — `DATA_UPLOAD_MAX_MEMORY_SIZE = None` ✅
`rdgen/rdgen/settings.py:139` — unlimited POST body size. Combined
with B1, an unauthenticated attacker can fill disk via
`save_custom_client`.

Fix: set 200 MiB (`200 * 1024 * 1024`) — enough for the real artifacts
seen in workflows (signed APK / AppImage), small enough to bound
abuse.

### B5 — `ALLOWED_HOSTS = ['*']` ⚠️
`rdgen/rdgen/settings.py:41` — wildcard host header trust enables
host header injection in `password reset emails`, generated absolute
URLs, etc. Not changed here because the existing flow uses
`request.get_host()` for building callback URLs (line 129); narrowing
host validation needs the operator to provide the real hostnames in
env. Documented for the deployment guide.

### B6 — `download`/`get_png`/`get_zip` are unauthenticated ⚠️
GET endpoints serve any file under `exe/<uuid>/`, `png/<uuid>/`,
`temp_zips/` to anyone who knows the UUID. UUIDs leak into HTML
templates (waiting/generated pages) and into GitHub Actions logs. Not
fixed in this PR — the system relies on UUID secrecy. Adding session
auth would change the public contract significantly.

---

## C. Medium — error handling / robustness

### C1 — `json.loads(request.body)` without `try` ✅
`update_github_run` and `cleanup_secrets` blow up with a 500 + stack
trace if the body isn't valid JSON.

Fix: wrap and return 400.

### C2 — `os.listdir(temp_dir)` raises if `temp_zips/` is missing ✅
`cleanup_secrets` (called from every workflow on completion) returns
500 if the directory hasn't been created yet (e.g. first run after
deploy).

Fix: `os.makedirs(temp_dir, exist_ok=True)` before `listdir`.

### C3 — `FileNotFoundError` in download endpoints ✅
`download`, `get_png`, `get_zip` raise 500 when the file doesn't exist
(common race: user clicks Download before the worker uploads).

Fix: return 404 on missing file.

### C4 — Bare `except:` clauses in `generator_view` ⚠️
`views.py:136, 146, 156` — `except:` (no exception type) hides
`KeyboardInterrupt`, `SystemExit`, etc. Annoying but not unsafe in a
WSGI worker.

Not fixed — would change behaviour subtly (real errors visible
instead of silently using "false" placeholders). Documented.

### C5 — `defaultManual/overrideManual` parser crashes on empty / no-`=` lines ✅
`views.py:229-235`
```python
for line in defaultManual.splitlines():
k, value = line.split('=')
```
A blank line or a line without `=` raises `ValueError: not enough
values to unpack` and the whole submission 500s.

Fix: skip empty/whitespace-only lines and lines without `=`.

### C6 — `_safe_open_path` raises `PermissionError`, callers only caught `(ValueError, KeyError)` ✅
Already fixed in the previous commit on this branch.

### C7 — `GenerateToken` could return `""` on RNG failure ✅
Already fixed (panics now).

### C8 — `getPublicKey` SSRF allowlist rejected the documented CDN host ✅
Already fixed (`gosspublic.alicdn.com` + `*.aliyuncs.com`, https-only).

---

## D. Out of scope / not bugs

- `tools.go` MD5 helper — kept for the bcrypt-migration path in
`VerifyPassword`. CodeQL suppression added per-file.
- Rust `hard-coded-cryptographic-value` (37 alerts) — all in
`#[cfg(test)]` blocks. Dismissed as "used in tests".
- CodeQL workflow — per the user, GitHub's own feature, not project
code. Left alone after the v3→v4 + Go path fix.

---

## E. Summary

| Severity | Count | Fixed | Flagged |
|----------|-------|-------|---------|
| Critical (workflow) | 4 | 3 | 1 |
| Critical (security) | 6 | 4 | 2 |
| Medium | 7 (incl. carry-over) | 6 | 1 |

After this PR the custom-agent build pipeline regains: the working
"don't override app-name with the default" branch (A1), correct
handling of GitHub's 204 dispatch response (A2), graceful behaviour
when the dispatch fails (A3), Bearer-token auth on the four

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Audit summary claims Bearer auth landed in this PR

The B1 section says workflow bearer auth was split into a follow-up PR, but the summary says "Bearer-token auth on the four runner-callable endpoints" landed here. Update the summary to avoid implying this PR fixes B1.

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

runner-callable endpoints (B1), and graceful 400/404s in place of
stack-trace 500s (C1/C2/C3/C5).
17 changes: 13 additions & 4 deletions api/http/controller/admin/custom_build.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,14 +327,14 @@ func (ct *CustomBuild) pollAndDownload(buildId uint, runId int64) {
// артефакт — flat zip с rustqs.exe (или rustdesk.exe) + dll + custom_.txt
name := filepath.Base(zf.Name)
if name == appName+".exe" || name == "rustdesk.exe" {
if n, e := extractZipFile(zf, filepath.Join(outDir, appName+".exe")); e == nil {
if n, e := extractZipFile(zf, outDir, appName+".exe"); e == nil {
b.FileSize = n
exeWritten = true
}
}
// дополнительно — custom_.txt и DLL рядом
if name == "custom_.txt" || filepath.Ext(name) == ".dll" {
_, _ = extractZipFile(zf, filepath.Join(outDir, name))
_, _ = extractZipFile(zf, outDir, name)
}
}
if !exeWritten {
Expand Down Expand Up @@ -408,8 +408,17 @@ func buildCustomTxtFromForm(raw map[string]any) string {
return base64.StdEncoding.EncodeToString(j)
}

// extractZipFile извлекает один файл из zip в dst, возвращает (записано байт, error).
func extractZipFile(zf *zip.File, dst string) (int64, error) {
// extractZipFile извлекает один файл из zip в outDir/name, возвращает (записано байт, error).
// Проверяет, что итоговый путь остаётся внутри outDir (защита от Zip Slip).
func extractZipFile(zf *zip.File, outDir, name string) (int64, error) {
absOut, err := filepath.Abs(outDir)
if err != nil {
return 0, err
}
dst := filepath.Join(absOut, filepath.Base(name))
if !strings.HasPrefix(dst+string(os.PathSeparator), absOut+string(os.PathSeparator)) {
return 0, fmt.Errorf("zip slip: path %q escapes output directory", dst)
}
rc, err := zf.Open()
if err != nil {
return 0, err
Expand Down
14 changes: 13 additions & 1 deletion api/lib/upload/oss.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import (
"io"
"io/ioutil"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)

Expand Down Expand Up @@ -173,7 +175,17 @@ func getPublicKey(r *http.Request) ([]byte, error) {
return bytePublicKey, errors.New("no x-oss-pub-key-url field in Request header ")
}
publicKeyURL, _ := base64.StdEncoding.DecodeString(publicKeyURLBase64)
// fmt.Printf("publicKeyURL={%s}\n", publicKeyURL)
// SSRF guard: accept only the public-key hosts Alibaba Cloud OSS documents
// for callback signature verification.
// See: https://www.alibabacloud.com/help/en/oss/developer-reference/callback
parsedURL, urlErr := url.Parse(string(publicKeyURL))
if urlErr != nil || parsedURL.Scheme != "https" {
return bytePublicKey, errors.New("invalid public key URL: must be https")
}
host := parsedURL.Hostname()
if host != "gosspublic.alicdn.com" && !strings.HasSuffix(host, ".aliyuncs.com") {
return bytePublicKey, errors.New("invalid public key URL: host not in OSS allowlist")
}
// get PublicKey Content from URL
responsePublicKeyURL, err := http.Get(string(publicKeyURL))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Public-key fetch can follow redirects outside the allowlist

The new validation checks only the initial URL, but http.Get follows redirects automatically. An attacker who can choose the public-key URL could point an allowed *.aliyuncs.com host to a redirect target outside the allowlist, bypassing the SSRF guard.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

if err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: OSS public-key URL allowlist rejects the documented host

Alibaba OSS callback examples use the public-key URL gosspublic.alicdn.com/aliyun/oss/pubkey.pem, which is not under .aliyuncs.com. This check will reject valid OSS callbacks and break upload notification verification.

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Expand Down
11 changes: 10 additions & 1 deletion api/service/user.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package service

import (
crand "crypto/rand"
"encoding/hex"
"errors"
"math/rand"
"strconv"
Expand Down Expand Up @@ -91,7 +93,14 @@ func (us *UserService) GenerateToken(u *model.User) string {
if len(Jwt.Key) > 0 {
return Jwt.GenerateToken(u.Id)
}
return utils.Md5(u.Username + time.Now().String())
b := make([]byte, 32)
// crypto/rand.Read fills the buffer or returns an unrecoverable OS error.
// Returning an empty token here would let the caller persist a useless
// "" token and silently break auth; panic is safer than degraded auth.
if _, err := crand.Read(b); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Random token generation can return an unusable empty token

If crypto/rand.Read fails, GenerateToken() returns "". Login() then stores and returns that empty token, but auth middleware rejects empty Authorization headers, so a successful login can produce an unusable session token. Fail closed or retry instead.

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

panic("crypto/rand failure generating user token: " + err.Error())
}
return hex.EncodeToString(b)
}

// Login
Expand Down
29 changes: 27 additions & 2 deletions rdgen/rdgen/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@
DEBUG_ENV = os.environ.get("DEBUG", "False")
DEBUG = DEBUG_ENV.lower() in ['true', '1', 't']

# Refuse to boot in production with placeholder secrets. Three of the
# defaults above (SECRET_KEY, ZIP_PASSWORD, SH_SECRET) ship insecure
# values so `manage.py` works out of the box for dev, but in production
# they let an attacker forge sessions, decrypt the secrets zip, or
# bypass workflow auth. Fail loud at startup instead.
if not DEBUG:
_insecure_settings = []
if SECRET_KEY.startswith('django-insecure-'):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Empty production secrets are still accepted

The production guard rejects the built-in placeholder strings, but an empty SECRET_KEY set through the environment still passes because "" does not start with django-insecure-. Treat an empty value as missing and fail startup when DEBUG=False.

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

_insecure_settings.append('SECRET_KEY')
if ZIP_PASSWORD == 'insecure':
_insecure_settings.append('ZIP_PASSWORD')
if SH_SECRET == 'secret':
_insecure_settings.append('SH_SECRET')
if _insecure_settings:
raise RuntimeError(
"Refusing to start with default insecure values for: "
+ ", ".join(_insecure_settings)
+ ". Set these env vars before deploying with DEBUG=False."
)

ALLOWED_HOSTS = ['*']
#CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', '').split()

Expand All @@ -57,7 +77,7 @@
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
#'django.middleware.csrf.CsrfViewMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Re-enabled CSRF breaks the generator form

generator.html posts to /generator without a {% csrf_token %} field. With CsrfViewMiddleware enabled, normal custom-client generation will be rejected with Django's CSRF 403 response.

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
Expand Down Expand Up @@ -136,4 +156,9 @@

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

DATA_UPLOAD_MAX_MEMORY_SIZE = None
# Cap POST body size at 200 MiB — fits the largest real artifacts the
# Actions runners post back (signed APK/AppImage/dmg) with headroom,
# and bounds disk/memory abuse on the unauthenticated path before
# SH_SECRET is configured. Set to a different value via env if larger
# artifacts become necessary.
DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get('DATA_UPLOAD_MAX_MEMORY_SIZE', 200 * 1024 * 1024))
1 change: 1 addition & 0 deletions rdgen/rdgenerator/templates/generator.html
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@
<body>
<h1><i class="fas fa-cogs"></i> RustDesk Custom Client Builder</h1>
<form id="myForm" action="/generator" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="save-load-section-container">
<div class="section">
<h2 id="saveLoadTitle">Save/Load Configuration <i class="fas fa-chevron-down"></i></h2>
Expand Down
Loading
Loading