<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nadia Atiq</title>
    <description>The latest articles on DEV Community by Nadia Atiq (@nadia_atiq_4378032d154159).</description>
    <link>https://dev.to/nadia_atiq_4378032d154159</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4223012%2Fa0e43768-0e1f-4997-a5e4-f65dbdd96f15.png</url>
      <title>DEV Community: Nadia Atiq</title>
      <link>https://dev.to/nadia_atiq_4378032d154159</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nadia_atiq_4378032d154159"/>
    <language>en</language>
    <item>
      <title>Letting Odoo users chart external data without opening a security hole</title>
      <dc:creator>Nadia Atiq</dc:creator>
      <pubDate>Tue, 10 Nov 2026 14:25:12 +0000</pubDate>
      <link>https://dev.to/nadia_atiq_4378032d154159/letting-odoo-users-chart-external-data-without-opening-a-security-hole-4o82</link>
      <guid>https://dev.to/nadia_atiq_4378032d154159/letting-odoo-users-chart-external-data-without-opening-a-security-hole-4o82</guid>
      <description>&lt;p&gt;Our Odoo dashboard module can now chart data that does not live in Odoo: a CSV someone exports every Monday, a JSON endpoint, a read-only view in another database. Building it took a day. Making it safe took three. Notes for anyone doing the same.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A URL field is an SSRF hole by default.&lt;/strong&gt;&lt;br&gt;
If any user can type a URL and your server fetches it, they can point it at &lt;code&gt;http://127.0.0.1:8069/web/database/manager&lt;/code&gt; or your cloud metadata IP. We resolve the hostname first and refuse private, loopback, link-local and reserved addresses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getaddrinfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ip_address&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_private&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_loopback&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_link_local&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_reserved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;UserError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Internal network addresses are not allowed.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also &lt;code&gt;allow_redirects=False&lt;/code&gt;, otherwise a public URL can just 302 you to an internal one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Who may create the source matters more than who may view it.&lt;/strong&gt;&lt;br&gt;
Regular dashboard managers can upload a file. Only Settings admins can create URL, database or Python sources. We check this in &lt;code&gt;create&lt;/code&gt; and &lt;code&gt;write&lt;/code&gt;, not only in the view, because the view is not a security layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Secrets get field-level groups.&lt;/strong&gt;&lt;br&gt;
Headers (API tokens) and the DB password have &lt;code&gt;groups='base.group_system'&lt;/code&gt;. Everyone else can use the source, nobody else can read the secret back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. External databases: read-only and a timeout.&lt;/strong&gt;&lt;br&gt;
PostgreSQL connections get &lt;code&gt;set_session(readonly=True)&lt;/code&gt; and &lt;code&gt;statement_timeout=30000&lt;/code&gt;. We still reject anything that is not a single SELECT, belt and braces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Cap everything.&lt;/strong&gt;&lt;br&gt;
50,000 rows, 30 second HTTP timeout, errors stored on the record instead of crashing the cron. One bad source should never stop the hourly refresh of the others.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. The Python transform runs in safe_eval.&lt;/strong&gt;&lt;br&gt;
Users can add a column or filter rows, but they get &lt;code&gt;rows&lt;/code&gt; and a few builtins, nothing else.&lt;/p&gt;

&lt;p&gt;None of this is exotic, but every item was missing in at least one module we reviewed for clients. If you build anything that fetches URLs from user input inside Odoo, start with point 1.&lt;/p&gt;

&lt;p&gt;The module is here if you want to see it in action: &lt;a href="https://technoversesystems.com/odoo-dashboard-studio" rel="noopener noreferrer"&gt;https://technoversesystems.com/odoo-dashboard-studio&lt;/a&gt;&lt;/p&gt;

</description>
      <category>odoo</category>
      <category>python</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Porting one Odoo module from 14 to 20: the changes that actually broke it</title>
      <dc:creator>Nadia Atiq</dc:creator>
      <pubDate>Tue, 10 Nov 2026 13:48:15 +0000</pubDate>
      <link>https://dev.to/nadia_atiq_4378032d154159/porting-one-odoo-module-from-14-to-20-the-changes-that-actually-broke-it-e7n</link>
      <guid>https://dev.to/nadia_atiq_4378032d154159/porting-one-odoo-module-from-14-to-20-the-changes-that-actually-broke-it-e7n</guid>
      <description>&lt;p&gt;We sell a dashboard module for Odoo and ship it for seven versions, 14 through 20. Same features, one codebase, a small script that rewrites the bits that differ. Most of the port is boring. A few things are not, and they cost us hours each time, so here is the list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;safe_eval changed shape in 19.&lt;/strong&gt;&lt;br&gt;
Up to 18 you call &lt;code&gt;safe_eval(code, ctx, mode='exec', nocopy=True)&lt;/code&gt; if you want the script to change your context. In 19 the signature is &lt;code&gt;safe_eval(expr, /, context, *, mode)&lt;/code&gt; and it updates the dict in place, &lt;code&gt;nocopy&lt;/code&gt; is gone. We just try the old call and fall back on TypeError:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;safe_eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'exec'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nocopy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;= 18&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nc"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;safe_eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'exec'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="c1"&gt;# 19+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Users: &lt;code&gt;groups_id&lt;/code&gt; became &lt;code&gt;group_ids&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
Any test that creates a user with groups breaks. &lt;code&gt;'group_ids' if 'group_ids' in Users._fields else 'groups_id'&lt;/code&gt; fixes it without version checks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Binary fields in 20 are not base64 anymore.&lt;/strong&gt;&lt;br&gt;
Reading &lt;code&gt;record.file&lt;/code&gt; gives you a BinaryValue with the raw bytes. Writing plain bytes in a test fails with "use BinaryValue instead of bytes". We now read with &lt;code&gt;base64.b64decode(f) if isinstance(f, (bytes, str)) else bytes(f)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recursion checks.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;_check_recursion()&lt;/code&gt; is replaced by &lt;code&gt;_has_cycle()&lt;/code&gt; from 17. Easy, but it fails silently in a constraint if you forget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The frontend is the real port.&lt;/strong&gt;&lt;br&gt;
14 to 16 still ship Chart.js 2. Options like &lt;code&gt;plugins.legend&lt;/code&gt; and &lt;code&gt;indexAxis&lt;/code&gt; simply do nothing there, so a horizontal bar quietly renders vertical. We keep two chart config builders and the port script swaps them. Also &lt;code&gt;/** @odoo-module */&lt;/code&gt; must be the first line on 15/16. Our script once wrote that header and emptied the file in the same line (open for write before reading). The module installed fine and one button was just missing. Test the UI, not only the Python.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Our routine now&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build on 19, write tests there.&lt;/li&gt;
&lt;li&gt;Run the port script per version, run the tests on 15 to 20.&lt;/li&gt;
&lt;li&gt;Open the UI on the oldest and newest version and click through.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to see where all of this ended up, the module is Dashboard Studio Pro: &lt;a href="https://technoversesystems.com/odoo-dashboard-studio" rel="noopener noreferrer"&gt;https://technoversesystems.com/odoo-dashboard-studio&lt;/a&gt; . We do Odoo work at Technoverse Systems (&lt;a href="https://technoversesystems.com" rel="noopener noreferrer"&gt;https://technoversesystems.com&lt;/a&gt;), happy to compare notes in the comments.&lt;/p&gt;

</description>
      <category>odoo</category>
      <category>python</category>
      <category>erp</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
