sast-fileupload-575b6e — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited sast-fileupload-575b6e (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
You are performing a focused security assessment to find insecure file upload vulnerabilities in a codebase. This skill uses a three-phase approach with subagents: discovery (find all places where uploaded files are received and stored), batched verify (check bypass vectors in parallel batches of up to 3 upload sites each), and merge (consolidate batch reports into one results file).
Prerequisites: sast/architecture.md must exist. Run the analysis skill first if it doesn't.
Insecure file upload occurs when an application accepts files from users without properly validating or restricting what can be uploaded, allowing an attacker to upload executable or malicious files. The most critical outcome is Remote Code Execution (RCE): an attacker uploads a web shell (e.g., a .php file) and the server executes it when accessed via a direct URL.
The core pattern: a user-supplied file reaches a storage location without adequate extension validation, and the stored file is accessible or executable.
file.save(upload_path) with no validationContent-Type: image/png without verifying the actual extension or file content — trivially bypassed by setting the header manually.php is blocked but .php3, .php4, .php5, .phtml, .phar, .shtml are not.php but allowing .PHP, .Php, .pHpshell.php.jpg — code extracts the last .jpg and considers it safe, but the server (Apache) serves it as PHP../../webroot/shell.php stored via an unsanitized filename../ but not encoded variants %2e%2e%2fDo not flag these as file upload vulnerabilities:
<script> that is reflected back — that's XSS, not an upload execution issueContent-Disposition: attachment, or stored in an object storage bucket with no public execution capabilityWhen you see these patterns together, the code is likely not vulnerable:
1. Allowlist of safe extensions (most important)
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
ext = filename.rsplit('.', 1)[-1].lower()
if ext not in ALLOWED_EXTENSIONS:
abort(400)2. Magic byte / file content validation (defense in depth)
import magic
mime = magic.from_buffer(file.read(2048), mime=True)
ALLOWED_MIMES = {'image/png', 'image/jpeg', 'image/gif'}
if mime not in ALLOWED_MIMES:
abort(400)3. Filename sanitization using a trusted library
from werkzeug.utils import secure_filename
filename = secure_filename(file.filename) # strips path separators and dangerous chars4. Storing uploads outside the web root
/var/uploads/ ← not served by the web server
/var/www/html/ ← web root (do NOT store uploads here)5. Serving uploads through a controlled endpoint with Content-Disposition
@app.route('/download/<filename>')
def download(filename):
return send_from_directory(UPLOAD_FOLDER, filename,
as_attachment=True) # forces download, prevents execution6. Renaming the file to a server-generated UUID
import uuid
stored_name = str(uuid.uuid4()) + '.jpg' # extension is server-controlled, not user-controlled# VULNERABLE: no extension check, file stored in web-accessible directory
@app.route('/upload', methods=['POST'])
def upload():
f = request.files['file']
f.save(os.path.join('static/uploads', f.filename))
return 'uploaded'
# VULNERABLE: content-type only check (trivially bypassed with curl -H)
@app.route('/upload', methods=['POST'])
def upload():
f = request.files['file']
if f.content_type not in ['image/png', 'image/jpeg']:
abort(400)
f.save(os.path.join('static/uploads', f.filename))
return 'uploaded'
# VULNERABLE: blocklist — .phtml/.phar/.php5 not covered
BLOCKED = {'.php', '.sh', '.exe'}
@app.route('/upload', methods=['POST'])
def upload():
f = request.files['file']
ext = os.path.splitext(f.filename)[1].lower()
if ext in BLOCKED:
abort(400)
f.save(os.path.join('static/uploads', f.filename))
return 'uploaded'
# SECURE: allowlist + sanitized filename + outside web root
ALLOWED = {'png', 'jpg', 'jpeg', 'gif'}
UPLOAD_FOLDER = '/var/uploads' # outside web root
@app.route('/upload', methods=['POST'])
def upload():
f = request.files['file']
filename = secure_filename(f.filename)
ext = filename.rsplit('.', 1)[-1].lower()
if ext not in ALLOWED:
abort(400)
f.save(os.path.join(UPLOAD_FOLDER, filename))
return 'uploaded'# VULNERABLE: no validation on FileField
class DocumentForm(forms.ModelForm):
class Meta:
model = Document
fields = ['upload']
# VULNERABLE: manual save with no extension check
def upload(request):
f = request.FILES['file']
with open(f'media/uploads/{f.name}', 'wb+') as dest:
for chunk in f.chunks():
dest.write(chunk)
# SECURE: custom validator on FileField
def validate_file_extension(value):
ext = os.path.splitext(value.name)[1].lower()
if ext not in ['.png', '.jpg', '.jpeg', '.gif']:
raise ValidationError('Unsupported file extension.')
class DocumentForm(forms.ModelForm):
upload = forms.FileField(validators=[validate_file_extension])// VULNERABLE: no file filter, stored in public directory
const upload = multer({ dest: 'public/uploads/' });
app.post('/upload', upload.single('file'), (req, res) => {
res.send('uploaded');
});
// VULNERABLE: MIME type filter only (can be faked)
const upload = multer({
dest: 'uploads/',
fileFilter: (req, file, cb) => {
if (!file.mimetype.startsWith('image/')) return cb(null, false);
cb(null, true);
}
});
// SECURE: allowlist of extensions + storage outside web root
const ALLOWED_EXT = ['.jpg', '.jpeg', '.png', '.gif'];
const storage = multer.diskStorage({
destination: '/var/uploads', // not served by Express
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${uuidv4()}${ext}`);
}
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, ALLOWED_EXT.includes(ext));
}
});// VULNERABLE: no extension check, stored in web root
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);
// VULNERABLE: checking only content type header
if ($_FILES['file']['type'] !== 'image/jpeg') {
die('Invalid file type');
}
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);
// VULNERABLE: blocklist missing phtml/phar
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
$blocked = ['php', 'sh', 'py'];
if (in_array($ext, $blocked)) die('Blocked');
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);
// SECURE: allowlist + rename to UUID + outside web root
$allowed = ['jpg', 'jpeg', 'png', 'gif'];
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allowed)) die('Invalid extension');
$stored = '/var/uploads/' . bin2hex(random_bytes(16)) . '.' . $ext;
move_uploaded_file($_FILES['file']['tmp_name'], $stored);// VULNERABLE: no validation, stored in web-accessible path
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
Path path = Paths.get("src/main/resources/static/uploads/" + file.getOriginalFilename());
Files.write(path, file.getBytes());
return "uploaded";
}
// VULNERABLE: content type header only
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
if (!file.getContentType().startsWith("image/")) throw new BadRequestException();
Files.write(Paths.get("uploads/" + file.getOriginalFilename()), file.getBytes());
return "uploaded";
}
// SECURE: allowlist + UUID rename + path outside web root
private static final Set<String> ALLOWED = Set.of("jpg", "jpeg", "png", "gif");
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
String original = StringUtils.cleanPath(file.getOriginalFilename());
String ext = FilenameUtils.getExtension(original).toLowerCase();
if (!ALLOWED.contains(ext)) throw new BadRequestException("Invalid extension");
String stored = UUID.randomUUID() + "." + ext;
Files.write(Paths.get("/var/uploads/" + stored), file.getBytes());
return "uploaded";
}// VULNERABLE: no extension check, stored in static directory
func uploadHandler(w http.ResponseWriter, r *http.Request) {
file, header, _ := r.FormFile("file")
defer file.Close()
dst, _ := os.Create("static/uploads/" + header.Filename)
defer dst.Close()
io.Copy(dst, file)
}
// SECURE: allowlist extension + UUID rename + outside web root
var allowed = map[string]bool{"jpg": true, "jpeg": true, "png": true, "gif": true}
func uploadHandler(w http.ResponseWriter, r *http.Request) {
file, header, _ := r.FormFile("file")
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext == "" || !allowed[ext[1:]] {
http.Error(w, "invalid extension", http.StatusBadRequest)
return
}
stored := "/var/uploads/" + uuid.New().String() + ext
dst, _ := os.Create(stored)
defer dst.Close()
io.Copy(dst, file)
}# VULNERABLE: no content type or extension validation
def upload
file = params[:file]
File.open(Rails.root.join('public', 'uploads', file.original_filename), 'wb') do |f|
f.write(file.read)
end
end
# SECURE: ActiveStorage with content type allowlist (Rails 6+)
has_one_attached :avatar
validates :avatar, content_type: ['image/png', 'image/jpg', 'image/jpeg']
# Note: still validate extension too — content_type is user-supplied in some configurations
# SECURE: CarrierWave with extension and content type allowlist
class AvatarUploader < CarrierWave::Uploader::Base
def extension_allowlist
%w[jpg jpeg png gif]
end
def content_type_allowlist
/image\//
end
end// VULNERABLE: no extension check, stored in wwwroot
[HttpPost]
public async Task<IActionResult> Upload(IFormFile file) {
var path = Path.Combine("wwwroot/uploads", file.FileName);
using var stream = new FileStream(path, FileMode.Create);
await file.CopyToAsync(stream);
return Ok();
}
// SECURE: allowlist + GUID rename + outside web root
private static readonly HashSet<string> _allowed = new() { ".jpg", ".jpeg", ".png", ".gif" };
[HttpPost]
public async Task<IActionResult> Upload(IFormFile file) {
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!_allowed.Contains(ext)) return BadRequest("Invalid extension");
var stored = Path.Combine("/var/uploads", $"{Guid.NewGuid()}{ext}");
using var stream = new FileStream(stored, FileMode.Create);
await file.CopyToAsync(stream);
return Ok();
}This skill runs in three phases using subagents. Pass the contents of sast/architecture.md to all subagents as context.
Launch a subagent with the following instructions:
Goal: Find every location in the codebase where files uploaded by users are received and stored. Write results to sast/fileupload-recon.md.>
Context: You will be given the project's architecture summary. Use it to understand the framework, file storage patterns, and whether uploads go to local disk, cloud storage, or a CDN.
>
What to search for — file upload handling patterns:
>
Look for any code that receives a file from an HTTP request and writes or stores it. Do not yet evaluate whether validation is present — just find all the sites.
>
1. Python / Django: -request.FILESaccess -InMemoryUploadedFile,TemporaryUploadedFile-default_storage.save(...),FileSystemStorage().save(...)- ModelFileField/ImageFieldform submissions -shutil.copyfileobj(f, dest)or manual.write(f.read())on uploaded data
>
2. Python / Flask: -request.files.get(...)orrequest.files[...]-file.save(...)calls on aFileStorageobject -werkzeugFileStoragehandling
>
3. Node.js: -multermiddleware:upload.single(...),upload.array(...),upload.fields(...)-busboy,formidable,multipartyform parsing -express-fileupload:req.files-fs.writeFile/fs.createWriteStream/pipe()called with a request stream
>
4. PHP: -$_FILESaccess -move_uploaded_file(...)calls -copy($_FILES[...]['tmp_name'], ...)
>
5. Java / Spring: -MultipartFileparameters in controller methods:@RequestParam MultipartFile-CommonsMultipartFile,StandardMultipartFile-Part.write(...)(Servlet API) -file.transferTo(...),Files.write(path, file.getBytes())
>
6. Go: -r.FormFile(...)orr.MultipartForm.File-io.Copy(dst, file)wherefilecomes from a multipart form -os.Create(...)called with a filename derived fromheader.Filename
>
7. Ruby / Rails: -params[:file]with.read,.original_filename,.tempfile-File.open(..., 'wb')called with uploaded data -has_one_attached/has_many_attached(ActiveStorage) - CarrierWavemount_uploader, Shrineinclude Shrine::Attachment
>
8. C# / ASP.NET: -IFormFileparameters:file.CopyToAsync(...),file.OpenReadStream()-HttpPostedFileBase.SaveAs(...)-Request.Files[...]
>
Output format — write to sast/fileupload-recon.md:>
```markdown # File Upload Recon: [Project Name]
>
## Summary Found [N] file upload sites.
>
## Upload Sites
>
### 1. [Descriptive name — e.g., "Avatar upload endpoint"] - File:path/to/file.ext(lines X-Y) - Endpoint / function: [route or function name] - Framework / method: [e.g., Flask request.files / multer / move_uploaded_file] - Storage destination: [path, variable, or storage abstraction — e.g., "static/uploads/" or "S3 via boto3" or "unknown"] - Validation observed (preliminary, Phase 2 will analyze in depth): [list any extension checks, content-type checks, or "none visible"] - Code snippet: ``[the upload receive and save code]``
>
[Repeat for each site] ```
After Phase 1 completes, read sast/fileupload-recon.md. If the recon found zero upload sites (the summary reports "Found 0" or the "Upload Sites" section is empty or absent), skip Phase 2 and Phase 3 entirely. Instead, write the following content to sast/fileupload-results.md and stop:
# File Upload Analysis Results
No file upload sites found.Only proceed to Phase 2 if Phase 1 found at least one upload site.
After Phase 1 completes, read sast/fileupload-recon.md and split the upload sites into batches of up to 3 sites each. Launch one subagent per batch in parallel. Each subagent analyzes only its assigned sites and writes results to its own batch file.
Batching procedure (you, the orchestrator, do this — not a subagent):
sast/fileupload-recon.md and count the numbered site sections (### 1., ### 2., etc.).sast/fileupload-batch-N.md where N is the 1-based batch number.sast/architecture.md and select only the matching examples from the "Vulnerable vs. Secure Examples" section above. For example, if the project uses Node.js with Multer, include only the "Node.js — Multer (Express)" examples. Include these selected examples in each subagent's instructions where indicated by [TECH-STACK EXAMPLES] below.Give each batch subagent the following instructions (substitute the batch-specific values):
Goal: For each assigned file upload site below, determine whether an attacker can upload a malicious file (e.g., a PHP web shell, a JSP shell, a Python script) by manipulating the filename, extension, or Content-Type header. Write results to sast/fileupload-batch-[N].md.>
Your assigned upload sites (from the recon phase):
>
[Paste the full text of the assigned site sections here, preserving the original numbering]
>
Context: You will be given the project's architecture summary. Use it to understand the framework, storage paths, and how uploads are served.
>
Reference — what insecure file upload is and is not:
>
Focus on execution or dangerous file types reaching storage without adequate controls. Do not flag stored XSS via SVG, SSRF via uploaded XML, DoS via size limits, or IDOR on download as file-upload execution issues (other skills cover those).
>
Patterns that reduce risk — if you see a strong combination (allowlist, sanitization, non-web-root storage, UUID rename), the site is likely Not Vulnerable unless bypass still applies.
>
Vulnerable vs. Secure examples for this project's tech stack:
>
[TECH-STACK EXAMPLES]
>
For each upload site, evaluate the following bypass vectors:
>
1. No extension check: No validation of any kind on the filename or extension. Any file is accepted. Immediately flag as Vulnerable.
>
2. Content-Type / MIME header only: Validation readsContent-Typeormimetypefrom the request headers but does not inspect the actual filename extension or file bytes. Attackers can setContent-Type: image/pngwhile uploadingshell.php. Flag as Vulnerable.
>
3. Blocklist-based validation: An explicit list of forbidden extensions. Check whether the blocklist is exhaustive for the server's technology: - PHP servers: Are.php3,.php4,.php5,.php7,.phtml,.phar,.shtmlalso blocked? If any are missing, flag as Vulnerable. - Java servers: Are.jsp,.jspx,.jsw,.jsv,.jspfalso blocked? - ASP.NET servers: Are.asp,.aspx,.ashx,.asmx,.cer,.asaalso blocked? - Node.js: Is.jsexecution possible via the server config? Check if.jsfiles in the upload dir can be required/executed. - Any blocklist is inherently weaker than an allowlist — flag as Likely Vulnerable even if seemingly complete.
>
4. Case sensitivity bypass: Blocking.phpbut not.PHP,.Php,.pHp. Check whether the comparison uses.toLowerCase()/.lower()/strtolower()/ case-insensitive matching.
>
5. Double extension / multi-extension:shell.php.jpg— if the code extracts the extension using a method that takes the last segment after the last dot, this should be caught by an allowlist. However, on Apache servers withAddHandlermisconfig, the leftmost recognized extension may be used for execution. Check how the extension is extracted: - Safe:filename.rsplit('.', 1)[-1],path.extname(filename)(takes the last extension) - Risky server config: ApacheAddHandler application/x-httpd-php .php— evenshell.php.jpgmay be executed as PHP
>
6. Path traversal in filename: If the original filename is used in the storage path without sanitization,../../webroot/shell.phpcan place files in unintended directories. Check for: - Use ofsecure_filename(),basename(),path.basename(),Path.GetFileName(), orfilepath.Base()— these strip directory separators and are safe - Direct use offile.filename,header.Filename,file.getOriginalFilename(),$_FILES['name']in a path join without sanitization — flag as Vulnerable
>
7. File stored in web-executable directory: Even with a correct extension allowlist, if uploads go to a directory served by the web server (e.g.,static/uploads/,public/uploads/,wwwroot/uploads/) and the web server is configured to execute scripts, a bypass in extension validation becomes critical. Note whether the storage path is web-accessible.
>
8. No content-based validation (magic bytes): The server trusts the extension without verifying the actual file content. A file named shell.jpg with PHP code inside is still dangerous if the extension check can be bypassed and the server executes it. Note absence of magic-byte checking as a contributing weakness.>
Classification: - Vulnerable: No validation at all, or a clearly bypassable check (content-type only, missing common extensions in blocklist, missing .lower(), path traversal in filename). - Likely Vulnerable: Blocklist that appears complete but is inherently weaker than an allowlist; or an allowlist with potential edge cases (e.g., does not account for uppercase extensions). - Not Vulnerable: Strict allowlist of safe extensions (applied case-insensitively), combined with filename sanitization and/or server-generated UUID rename, files stored outside web root or behind a controlled download endpoint. - Needs Manual Review: Validation logic is in a shared helper or middleware that could not be fully read; or storage path is dynamic and could not be determined.>
Output format — write to sast/fileupload-batch-[N].md:>
```markdown # File Upload Batch [N] Results
>
## Findings
>
### [VULNERABLE] Descriptive name - File:path/to/file.ext(lines X-Y) - Endpoint / function: [route or function name] - Issue: [e.g., "No extension validation — any file type accepted" or "Content-Type header used as sole check"] - Bypass vector: [Exact technique — e.g., "Upload shell.php directly" or "Set Content-Type: image/png while uploading a .php file" or "Use .phtml extension not covered by blocklist"] - Storage path: [Where the file lands — web-accessible or not] - Impact: [e.g., "Attacker uploads PHP web shell and achieves RCE by accessing /uploads/shell.php"] - Remediation: [Specific fix — switch to allowlist, add.lower(), use secure_filename, move storage outside web root] - Dynamic Test: ``[curl or HTTP request demonstrating the bypass. Example: curl -X POST https://app.example.com/upload \ -F "[email protected];type=image/png" \ then access: https://app.example.com/static/uploads/shell.php?cmd=id]``
>
### [LIKELY VULNERABLE] Descriptive name - File:path/to/file.ext(lines X-Y) - Endpoint / function: [route or function name] - Issue: [e.g., "Blocklist-based extension check — inherently incomplete"] - Bypass vector: [Possible bypass — e.g., "Try .phtml, .phar, .php5 if server is Apache/PHP"] - Storage path: [Where the file lands] - Concern: [Why it's still a risk] - Remediation: [Replace blocklist with allowlist] - Dynamic Test: ``[payload to attempt bypass]``
>
### [NOT VULNERABLE] Descriptive name - File: path/to/file.ext (lines X-Y) - Endpoint / function: [route or function name] - Reason: [e.g., "Strict allowlist of png/jpg/gif with .lower(), UUID rename, stored outside web root"]>
### [NEEDS MANUAL REVIEW] Descriptive name - File: path/to/file.ext (lines X-Y) - Endpoint / function: [route or function name] - Uncertainty: [Why validation logic or storage path could not be determined] - Suggestion: [What to trace manually] ```After all Phase 2 batch subagents complete, read every sast/fileupload-batch-*.md file and merge them into a single sast/fileupload-results.md. You (the orchestrator) do this directly — no subagent needed.
Merge procedure:
sast/fileupload-batch-1.md, sast/fileupload-batch-2.md, ... files.sast/fileupload-results.md using this format:# File Upload Analysis Results: [Project Name]
## Executive Summary
- Upload sites analyzed: [total from recon]
- Vulnerable: [N]
- Likely Vulnerable: [N]
- Not Vulnerable: [N]
- Needs Manual Review: [N]
## Findings
[All findings from all batches, grouped by classification:
VULNERABLE first, then LIKELY VULNERABLE, then NEEDS MANUAL REVIEW, then NOT VULNERABLE.
Preserve every field from the batch results exactly as written.]sast/fileupload-results.md, delete all intermediate batch files (sast/fileupload-batch-*.md).sast/architecture.md and pass its content to all subagents as context.sast/fileupload-results.md and remove intermediates; do not re-analyze code in Phase 3..PHP bypasses a check for .php if .toLowerCase() is missing. Always check.sast/fileupload-recon.md and all sast/fileupload-batch-*.md files after the final sast/fileupload-results.md is written.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.