Skip to content

encrypt user-uploaded payloads and decrypt on read#3361

Open
uruwhy wants to merge 7 commits into
masterfrom
handle-user-payloads
Open

encrypt user-uploaded payloads and decrypt on read#3361
uruwhy wants to merge 7 commits into
masterfrom
handle-user-payloads

Conversation

@uruwhy
Copy link
Copy Markdown
Contributor

@uruwhy uruwhy commented Apr 9, 2026

Description

Encrypt user-provided payloads on disk in data/payloads and have the file_svc decrypt them in memory when reading from disk. This avoids attack vectors that attempt to import user-provided python modules from disk.

The encryption methodology generates a random 32-byte AES key and 16-byte IV for each payload and prepends the ciphertext with an encryption flag (marking the file as an encrypted user-provided payload and distinguishing it from typical CALDERA-encrypted files), the key, and IV. The key is embedded in the ciphertext blob on disk because the purpose is not to secure the payload contents but rather to prevent the files on disk from being directly executed. Keys are generated separately from the caldera configuration to allow user-provided payloads to still be accessible if the crypt/salt values in the caldera config change for any reason.

Existing user payloads that were uploaded prior to this change will not be affected

Also adds additional payload API unit test coverage and addresses some warnings

Type of change

  • Bug fix (non-breaking change which fixes an issue)

How Has This Been Tested?

Unit tests, tested with local caldera installation (uploaded payloads via curl and UI)

# Upload
curl -s -X POST http://localhost:8888/api/v2/payloads -H "KEY: ADMIN123" -F "file=@testpythonpayload.py"

# Confirm encrypted contents
hexdump -C data/payloads/testpythonpayload.py 

# Fetch uploaded payload
curl http://localhost:8888/file/download -H "file: testpythonpayload.py" -o testdownload
diff testdownload testpythonpayload.py
Captura de pantalla 2026-04-09 a la(s) 12 25 04 p m Captura de pantalla 2026-04-09 a la(s) 12 25 27 p m

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation
  • I have added tests that prove my fix is effective or that my feature works

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines:
1 pipeline(s) were filtered out due to trigger conditions.

@sonarqubecloud
Copy link
Copy Markdown

@sonarqubecloud
Copy link
Copy Markdown

1 similar comment
@sonarqubecloud
Copy link
Copy Markdown

@sonarqubecloud
Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

1 similar comment
@sonarqubecloud
Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

Copy link
Copy Markdown
Contributor

@deacon-mp deacon-mp left a comment

Choose a reason for hiding this comment

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

Two structural problems with the encryption design that I'd want resolved before this lands:

1. The AES key is written inside the same file as the ciphertext.

The file layout is flag || key || iv || ciphertext. Anyone who can read the file recovers the key in the same read and decrypts trivially. Against the threat model that motivates encrypting payloads at rest (an attacker who reads the filesystem), this provides zero confidentiality — it's obfuscation, not encryption.

The right primitive is already in the codebase: Caldera has encryption_key in config + a Fernet encryptor (app/utility/file_decryptor.py, app/service/file_svc.py:_get_encryptor). Use that — the server already protects encryption_key and rotating it rotates all payload encryption. Per-file random keys only help if those keys live somewhere the attacker can't reach (KMS, separate keystore, etc.); kept inline they're noise.

2. Circular import.

app/service/file_svc.py now imports from app/api/v2/handlers/payload_api.py. That inverts the layering — services don't import handlers — and will break in practice depending on module load order. Move the encryption flag / constants to app/utility/ so both sides can import them without cycle.

3. (Minor) Response semantics change is out-of-scope.

delete_payloads was changed from return web.HTTPNoContent() to raise web.HTTPNoContent(). That's a different code path — raise triggers the response middleware, which #3304 (if it merges) will then treat as an exception. Worth splitting into its own PR to keep the review surface small.

Requesting changes; happy to revisit after the keying model is reworked.

@uruwhy
Copy link
Copy Markdown
Contributor Author

uruwhy commented May 19, 2026

Two structural problems with the encryption design that I'd want resolved before this lands:

1. The AES key is written inside the same file as the ciphertext.

The file layout is flag || key || iv || ciphertext. Anyone who can read the file recovers the key in the same read and decrypts trivially. Against the threat model that motivates encrypting payloads at rest (an attacker who reads the filesystem), this provides zero confidentiality — it's obfuscation, not encryption.

The right primitive is already in the codebase: Caldera has encryption_key in config + a Fernet encryptor (app/utility/file_decryptor.py, app/service/file_svc.py:_get_encryptor). Use that — the server already protects encryption_key and rotating it rotates all payload encryption. Per-file random keys only help if those keys live somewhere the attacker can't reach (KMS, separate keystore, etc.); kept inline they're noise.

2. Circular import.

app/service/file_svc.py now imports from app/api/v2/handlers/payload_api.py. That inverts the layering — services don't import handlers — and will break in practice depending on module load order. Move the encryption flag / constants to app/utility/ so both sides can import them without cycle.

3. (Minor) Response semantics change is out-of-scope.

delete_payloads was changed from return web.HTTPNoContent() to raise web.HTTPNoContent(). That's a different code path — raise triggers the response middleware, which #3304 (if it merges) will then treat as an exception. Worth splitting into its own PR to keep the review surface small.

Requesting changes; happy to revisit after the keying model is reworked.

Regarding number 1, the purpose of this PR isn't to secure the payload contents in the sense that no one else can access them. The purpose is to prevent unintended execution of files on disk (e.g. python scripts) by modifying the contents on disk, but making sure the original contents are still delivered on file reads and payload downloads. So there's no issue in storing the key along with the file. Regardless, an attacker who has access to the file system already wins no matter what since they can just modify or execute the caldera python functions to get the original contents anyway. Plus the caldera encryption key in the config isn't currently protected and is readable to anyone who has access to the config, which is no different than the case here. We do not use that key here because if that key were to change (e.g. users run caldera with a different config file or update the key manually), the user-provided payloads would no longer be accessible.

Number 2 is valid and will be addressed, either by putting the flag directly in the file svc python file or in a separate utility as suggested

Number 3: these changes were done because of Python warnings from unit tests, and the warnings themselves actually recommended the change. However, I can revert them for now and save them for a separate PR to maintain scope

@sonarqubecloud
Copy link
Copy Markdown

@sonarqubecloud
Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants