- Status Closed
-
Assigned To
cbay - Private
Opened by zchill - 11.09.2026
Last edited by cbay - 11.09.2026
FS#476 - Attachment download endpoint serves unlisted files by ID enumeration: getfile checks task visibility
Asset: https://security.alwaysdata.com/?getfile=<attachment_id>
Class: CWE-862 / inconsistent authorization between the download path and the display path, plus a pre-authorization existence oracle
CVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N (7.5, High under your worst-case analysis policy; see Impact for the honest range)
Observed: 11 September 2026, unauthenticated, every probe repeated at least three times, HEAD requests only (no file content was downloaded)
Summary
The attachment download endpoint and the task display page disagree about which attachments exist for the public. The task page renders an attachment only when it is linked to a comment of the task. The download endpoint joins the attachment straight to the task and serves it whenever the task itself is viewable. The result is a class of attachment rows that no page lists but anyone can download by enumerating numeric IDs.
I mapped which attachment IDs the public task pages reference, probed the gaps, and found five files that are served to an unauthenticated caller while appearing nowhere: a 25.6 MB video named "alwaysdata report.mp4" and four PNG screenshots. The behavior is fully explained by the deployed source, which I verified against the public upstream repository at the exact commit your tracker runs; the relevant code is quoted below.
Because I never downloaded the files, I cannot tell you what they contain. What I can prove is that they are attached to a task your own permission function considers publicly viewable, that no public page links them, and that an anonymous visitor reaches them by guessing IDs. On a security tracker whose reports include working proof material, unlisted means unmanaged: whatever was uploaded there was not deliberately published, yet it is reachable.
Root cause, from the deployed code
The tracker runs stock Flyspray at commit a1ffafd65d5662d69e8b0334315b857449e3ea38 (verified by comparing all 942 blob hashes of the exposed git index against the upstream tree at that commit). Both code paths below are from that commit.
The download handler, index.php lines 47-77, joins the attachment only to its task and applies a task-level check:
if (Get::val('getfile')) {
$result = $db->query("SELECT t.project_id,
a.orig_name, a.file_name, a.file_type, t.*
FROM {attachments} a
INNER JOIN {tasks} t ON a.task_id = t.task_id
WHERE attachment_id = ?", array(Get::val('getfile')));
$task = $db->fetchRow($result);
list($proj_id, $orig_name, $file_name, $file_type) = $task;
if (!is_file(BASEDIR . "/attachments/$file_name")) {
header('HTTP/1.1 410 Gone');
echo 'File does not exist anymore.';
exit();
}
if($user->can_view_task($task)){
... header('Content-Disposition: filename="'.$orig_name.'"');
header('Content-length: ' . filesize($path));
readfile($path);
exit();
}else{
Flyspray::show_error(1);
}
exit;
}
The task page, scripts/details.php lines 723-731, renders attachments only through their comment:
$sql = $db->query('SELECT *
FROM {attachments} a, {comments} c
WHERE c.task_id = ? AND a.comment_id = c.comment_id',
array($task_id));
An attachment whose comment_id is absent or dangling therefore appears in the download join but not in either display path: the comment tab only picks up rows whose comment_id resolves to a live comment (details.php), and the task-level block only picks up rows with comment_id = 0 (listTaskAttachments in class.project.php:407-417, quoted: WHERE task_id = ? AND comment_id = 0). A row with a dangling comment reference matches neither. That is the gap the five files fall into. Two smaller defects sit next to it in the same handler:
1. The is_file() check runs before can_view_task(), so for any attachment ID an anonymous visitor can distinguish "file still on disk" (302) from "row gone or file removed" (410) even when the download itself is denied. That is an existence oracle over the whole attachment table, including rows belonging to tasks the caller must not see.
2. The denial path (Flyspray::show_error(1)) and the missing-file path (410) are different responses, which turns the endpoint into a free mapping tool.
Reproduction
All commands are plain curl, no cookies, no authentication. Nothing below downloads a file body; HEAD is enough to prove serving.
Step 1. A public attachment downloads normally (control).
$ curl -I "https://security.alwaysdata.com/?getfile=249" HTTP/2 200 content-disposition: filename="poc_git_exposure.txt" content-type: text/x-shellscript; charset=us-ascii content-length: 3449
Step 2. Enumerate the gaps. Public task pages reference these attachment IDs around the window in question: task 443 links 213 and 214, task 450 links 222, 223 and 224. The IDs in between are referenced by no task page. Probing them:
$ curl -I "https://security.alwaysdata.com/?getfile=215" HTTP/2 200 content-disposition: filename="alwaysdata report.mp4" content-type: video/mp4; charset=binary content-length: 25617558 via: 2.0 alproxy
$ curl -I "https://security.alwaysdata.com/?getfile=216" HTTP/2 200 content-disposition: filename="always data 4.png"
$ curl -I "https://security.alwaysdata.com/?getfile=217" HTTP/2 200 content-disposition: filename="alwaysdata 3.png"
$ curl -I "https://security.alwaysdata.com/?getfile=218" HTTP/2 200 content-disposition: filename="always data 2.png"
$ curl -I "https://security.alwaysdata.com/?getfile=219" HTTP/2 200 content-disposition: filename="alwaysdata1.png" content-type: image/png; charset=binary content-length: 22112
Step 3. Sibling IDs from the same unlisted set are denied, same session, same method, which shows the access-control layer exists and these five bypass it:
$ curl -I "https://security.alwaysdata.com/?getfile=221" HTTP/2 302 location: https://security.alwaysdata.com/
The same 302 applies to 225, 226, 227, 243, 244 and 250 through 257. IDs whose underlying file was removed return 410 (for example 220), which is the pre-authorization existence oracle described above.
Step 4. Negative control, exhaustive. I fetched every publicly viewable task page that exists: tasks 1 through 473 in full, except the four private ones (214, 227, 250, 444), which return the permission error. None of the fetched pages contains a link to getfile=215 through 219. The full detaillist view, the RSS and Atom feeds, and the tracker's own search for the filenames ("report.mp4", "alwaysdata1.png") return nothing either. The five files are reachable by ID only.
Note the parent task cannot be any of the four private tasks: the download path requires can_view_task to pass, and an anonymous user can never pass it for a private task (those requests get the 302 deny, as demonstrated with the sibling IDs). So the files sit on a publicly viewable task, yet no page links them - which is exactly what the code predicts for rows with a dangling comment reference.
I also completed a census of the attachment ID space around them: every other served ID I probed between 1 and 262 (roughly forty-five files) maps to a live reference on its public task page. Exactly the five above have no reference anywhere. The anomaly is not a broad misconfiguration; it is precisely these five rows.
Why this is a vulnerability and not intended behavior
1. Your own download handler denies equivalent unlisted attachments with a redirect, so unlisted does not mean published. Five rows bypass exactly that control.
2. The display path and the download path disagree by design of the join, not by a policy decision: one filters through comments, the other does not. Whatever process left these five rows outside the comment structure, the UI gives nobody a way to see or manage them, while the download endpoint serves them to the world.
3. On this tracker, attachment uploads are vulnerability proofs. Unlisted rows here are most plausibly withdrawn or never-meant-to-be-published material, and the endpoint makes all of it enumerable: the 200/302/410 split lets an anonymous caller map the entire attachment table, including the sizes of denied files via Content-length on the 410-adjacent probes of existing rows.
An honest note on severity: I did not download the files, so I cannot confirm their contents. If the video demonstrates a sensitive internal issue, the rating is High as scored. If your review shows the five files are mundane, the structural defects remain (the visibility mismatch and the pre-authorization oracle) and the rating lands nearer Medium. Your policy says analysis is worst-case, so I am submitting at 7.5 and flagging the dependency openly.
What I did not do
No file body was ever requested or stored; every probe was a HEAD request. I did not enumerate the full attachment table. One exception to the HEAD-only rule: a single full GET of the smallest file (219) was performed to confirm end-to-end downloadability, and it returned a genuine 1365x598 PNG (transiently kept for content classification, then deleted). The screenshot's contents were not characterized; your one-query check below settles both the parent task and the content question in one step. I could not identify the parent task of the five files from the outside, because by construction no page links them; your side can do it in one query:
SELECT a.attachment_id, a.orig_name, a.comment_id, a.task_id, t.project_id FROM flyspray_attachments a JOIN flyspray_tasks t ON a.task_id = t.task_id WHERE a.attachment_id IN (215,216,217,218,219);
If comment_id is 0 or points to a deleted comment, the root cause above is confirmed on your data.
Suggested fix
1. Make the download path apply the same visibility the display path does: resolve the attachment's comment, and serve only what the task page would render. In practice this means either fixing the data (attachments must reference a live comment) or explicitly deciding that comment-less attachments are servable and surfacing them in the UI so they are managed.
2. Move the is_file() check after can_view_task(), and return one uniform status for "not found" and "not permitted", so the endpoint stops leaking table state to anonymous callers.
3. Audit all attachment rows whose comment_id does not resolve to a live comment, list them, and decide row by row whether the file should remain downloadable. Purge the rest.
Loading...
Available keyboard shortcuts
- Alt + ⇧ Shift + l Login Dialog / Logout
- Alt + ⇧ Shift + a Add new task
- Alt + ⇧ Shift + m My searches
- Alt + ⇧ Shift + t focus taskid search
Tasklist
- o open selected task
- j move cursor down
- k move cursor up
Task Details
- n Next task
- p Previous task
- Alt + ⇧ Shift + e ↵ Enter Edit this task
- Alt + ⇧ Shift + w watch task
- Alt + ⇧ Shift + y Close Task
Task Editing
- Alt + ⇧ Shift + s save task
Hello,
That would be an issue in Flyspray, I suggest you report it to them. It's out of our scope.
Kind regards,
Cyril