Skip to content

Commit 867fb27

Browse files
committed
Changed the way read-only works for custom code
1 parent be3c427 commit 867fb27

File tree

11 files changed

+77
-34
lines changed

11 files changed

+77
-34
lines changed

README.md

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ The aggregation types currently available:
4747

4848
For each widget type, date can be any column: `created_at`,`updated_at`,`custom_date`.
4949

50-
## Custom Code Widgetn
50+
## Custom Code Widgets
5151

5252
You can also use custom code widgets, allowing you to define your widget data with
5353
code, just like you would do with tinker.
@@ -63,10 +63,51 @@ $result = [
6363
];
6464
```
6565

66-
Note that for safety reasons, your code won't be allowed to perform any write operations on the database.
67-
You can only use the code to query data and transform it in-memory.
68-
Even if disabling write operations makes things sage, **remote code execution is always a
69-
very risky operation**. Be sure that your dashboard authorization is properly configured. You may want to disable custom code widgets by setting the `MODEL_STATS_CUSTOM_CODE` env variable to `false`.
66+
### Custom Code Setup
67+
🚨 Using the custom code feature against a production database is a HUGE risk 🚨
68+
69+
Any malicious user with access to the dashboard,
70+
or any mistake can cause harm to your database. Do not do that. Here's a safe way to use this feature:
71+
- Create a `read-only` database user with access to your database
72+
- Here's how to create a read-only user for a PostgreSQL database: [PostgreSQL guide](https://tableplus.com/blog/2018/04/postgresql-how-to-create-read-only-user.html)
73+
- Here's how to create a read-only user for a MySQL database: [MySQL guide](https://ubiq.co/database-blog/create-read-only-mysql-user/)
74+
- Add a readonly database connection to your `config/database.php` file
75+
```php
76+
// in database.php
77+
78+
'connections' => [
79+
80+
// ... your other connections
81+
82+
'readonly' => [
83+
'driver' => 'pgsql', // Copy the settings for the driver you use, but change the user
84+
'url' => env('DATABASE_URL'),
85+
'host' => env('DB_HOST', '127.0.0.1'),
86+
'port' => env('DB_PORT', '5432'),
87+
'database' => env('DB_DATABASE', 'forge'),
88+
'username' => env('DB_USERNAME_READONLY', 'forge'), // User is changed here
89+
'password' => env('DB_PASSWORD_READONLY', ''),
90+
'charset' => 'utf8',
91+
'prefix' => '',
92+
'prefix_indexes' => true,
93+
'schema' => 'public',
94+
'sslmode' => 'prefer',
95+
],
96+
]
97+
- In your .env set the following:
98+
```dotenv
99+
MODEL_STATS_CUSTOM_CODE=true
100+
MODEL_STATS_DB_CONNECTION=readonly
101+
DB_USERNAME_READONLY=<username>
102+
DB_PASSWORD_READONLY=<password>
103+
```
104+
Thanks to this, the package will use the readonly connection when executing your code.
105+
Note that this a protection against mistakes, but not against malicious users. One can override this
106+
connection in the custom code, so there are still some risks associate with using this feature in production.
107+
Be sure that your dashboard authorization is properly configured.
108+
109+
### Disabling Custom Code
110+
You may want to disable custom code widgets by setting the `MODEL_STATS_CUSTOM_CODE` env variable to `false`.
70111

71112
## Dashboard Authorization
72113

config/model-stats.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
*/
1818

1919
'enabled' => env('MODEL_STATS_ENABLED', true),
20-
'allow_custom_code' => env('MODEL_STATS_CUSTOM_CODE', true),
20+
'allow_custom_code' => env('MODEL_STATS_CUSTOM_CODE', false),
2121

2222
/*
2323
|--------------------------------------------------------------------------
@@ -45,6 +45,17 @@
4545
*/
4646
'table_name' => 'model_stats_dashboards',
4747

48+
/*
49+
|--------------------------------------------------------------------------
50+
| Database connection
51+
|--------------------------------------------------------------------------
52+
|
53+
| Database connection used to query the data.
54+
| This can be used to ensure a read-only connection, by using a custom connection with a read-only user.
55+
|
56+
*/
57+
'query_database_connection' => env('MODEL_STATS_DB_CONNECTION', env('DB_CONNECTION')),
58+
4859
/*
4960
|--------------------------------------------------------------------------
5061
| Route Prefixes

public/app.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/app.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/mix-manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"/app.js": "/app.js?id=2df710ffbd669b1cd776",
3-
"/app.css": "/app.css?id=9672ba5832bd94459e59"
2+
"/app.js": "/app.js?id=aa616d87ff4d6e21e031",
3+
"/app.css": "/app.css?id=9a7dcabdfd190adb0576"
44
}

resources/js/components/App.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export default {
3333
},
3434
3535
data: () => ({
36-
frontEndVersion: 6,
36+
frontEndVersion: 7,
3737
alert: {
3838
type: null,
3939
autoClose: 0,
@@ -49,7 +49,7 @@ export default {
4949
5050
// Check front-end version
5151
if (window.ModelStats.config.frontEndVersion > this.frontEndVersion) {
52-
this.alertError('You ModelStats front-end files are not up to date. Please run ` php artisan model-stats:publish` on your server.')
52+
this.alertError('You ModelStats front-end files are not up to date. Please run `php artisan model-stats:publish` on your server.')
5353
}
5454
},
5555

resources/js/components/common/Alert.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
<p class="mt-2 mb-0">{{ message }}</p>
55
</div>
66

7-
<div class="flex justify-end">
7+
<div class="flex items-end ml-4">
8+
<div>
89
<v-button v-if="type == 'error'" color="red" shade="light" @click="close">
910
CLOSE
1011
</v-button>
@@ -17,6 +18,7 @@
1718
<v-button v-if="type == 'confirmation'" color="gray" shade="light" @click="cancel">
1819
NO, CANCEL
1920
</v-button>
21+
</div>
2022

2123
</div>
2224

resources/js/components/widgets/components/CustomCode.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
Custom Code
3737
</p>
3838
<div class="w-full" v-if="showCode">
39-
<pre class="mt-4 p-4 flex border bg-blue-100 w-full overflow-x-scroll" v-if="showCode">{{widget.code}}</pre>
39+
<pre class="mt-4 p-4 flex border bg-blue-100 w-full overflow-x-scroll select-all" v-if="showCode">{{widget.code}}</pre>
4040
</div>
4141
</div>
4242
</div>
@@ -71,6 +71,7 @@ export default {
7171
methods: {
7272
loadData() {
7373
this.loading = true
74+
console.log('ok')
7475
axios.post(this.apiPath + "widgets/custom-code/data", {
7576
code: this.widget.code,
7677
chart_type: this.widget.chart_type,
@@ -81,7 +82,8 @@ export default {
8182
this.loading = false
8283
}).catch((error) => {
8384
this.loading = false
84-
this.alertError(error.response.data.message)
85+
console.log(error.response.data)
86+
this.alertError(error.response.data.message ?? error.response.data.output )
8587
})
8688
},
8789
deleteChart() {

src/Http/Controllers/CustomCodeController.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public function executeCustomCode(Request $request, Tinker $tinker): JsonRespons
2929
]);
3030

3131
$result = $tinker->injectDates(now()->subMonth(), now())
32-
->readonly()
32+
->setConnection()
3333
->execute($validated['code']);
3434
$codeExecuted = $tinker->lastExecSuccess();
3535

@@ -54,7 +54,7 @@ public function widgetData(Request $request, Tinker $tinker)
5454
$dateTo = Carbon::createFromFormat('Y-m-d', $request->get('date_to'));
5555

5656
$result = $tinker->injectDates($dateFrom, $dateTo)
57-
->readonly()
57+
->setConnection()
5858
->execute($validated['code']);
5959

6060
$codeExecuted = $tinker->lastExecSuccess();

src/Http/Controllers/HomeController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
class HomeController extends Controller
1616
{
17-
public const FRONT_END_VERSION = 6;
17+
public const FRONT_END_VERSION = 7;
1818

1919
public function home(): Factory|View|Application
2020
{

src/Services/Tinker.php

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
*/
2727
class Tinker
2828
{
29-
public const FAKE_WRITE_HOST = 'database_write_not_allowed_with_model_stats';
3029

3130
/** @var \Symfony\Component\Console\Output\BufferedOutput */
3231
protected BufferedOutput $output;
@@ -58,7 +57,7 @@ public function execute(string $phpCode): string
5857
$lastException = $resultVars['_e'];
5958
if (($lastException instanceof QueryException)
6059
&& Str::of($lastException->getMessage())
61-
->contains(self::FAKE_WRITE_HOST)) {
60+
->contains("SQLSTATE[42501]: Insufficient privilege")) {
6261
return "For safety reasons, you can only query data with ModelStats. Write operations are forbidden.";
6362
}
6463
}
@@ -105,20 +104,9 @@ public function lastExecSuccess(): bool
105104
/**
106105
* Prevents unwanted database modifications by enabling creating and using a readonly connection.
107106
*/
108-
public function readonly(): static
107+
public function setConnection(): static
109108
{
110-
$defaultConnection = config('database.default');
111-
$databaseConnection = Config::get('database.connections.'.$defaultConnection);
112-
$host = $databaseConnection['host'];
113-
unset($databaseConnection['host']);
114-
$databaseConnection['read'] = [
115-
'host' => $host,
116-
];
117-
$databaseConnection['write'] = [
118-
'host' => self::FAKE_WRITE_HOST,
119-
];
120-
Config::set('database.connections.readonly', $databaseConnection);
121-
DB::setDefaultConnection('readonly');
109+
DB::setDefaultConnection(config('model-stats.query_database_connection'));
122110

123111
return $this;
124112
}
@@ -140,7 +128,6 @@ protected function createShell(BufferedOutput $output): Shell
140128
{
141129
$config = new Configuration([
142130
'updateCheck' => 'never',
143-
'configFile' => config('web-tinker.config_file') !== null ? base_path().'/'.config('web-tinker.config_file') : null,
144131
]);
145132

146133
$config->setHistoryFile(defined('PHP_WINDOWS_VERSION_BUILD') ? 'null' : '/dev/null');

0 commit comments

Comments
 (0)