This site, Dottiq, was built with ChatGPT.
I'm not a programmer. My process was to describe what I wanted — "I need this feature," "I want it to look like this" — and let the AI handle the implementation. Vibe coding, as they call it.
Something functional got made. I liked the design. I could write posts, publish tools. For a while, that felt like enough.
Then one day, a thought crept in.
"Is this site actually secure?"
Working doesn't mean safe. And just because AI wrote the code doesn't mean the code is secure. So what would happen if I asked a different AI to review it?
What followed turned into more of a horror story than I expected.
Eight Problems Found
I handed Claude a copy of the site's source files and asked it to review the security. The response came back:
"I found eight problems."
Eight.
For a moment I felt strangely detached — "huh, eight, interesting" — before remembering it was my own site.
Here's what was found. Engineers might wince. Fellow vibe coders might feel a chill of recognition.
① Password Stored in Plain Text
The password for the CMS admin panel was written directly into the config file as a plain string.
// Before
define('CMS_PASSWORD', 'mypassword123');
if ($_POST['password'] === CMS_PASSWORD) {
// Login successful
}
What could go wrong: If someone gained access to the server, they could read the config file and immediately have the admin password. Same outcome if the code was accidentally pushed to a public GitHub repository — a scenario that has ended badly for plenty of developers.
// After
define('CMS_PASSWORD_HASH', '$2y$10$9blx829VBVr/...(hash)');
if (password_verify($_POST['password'], CMS_PASSWORD_HASH)) {
// Login successful
}
Passwords are now hashed with password_hash() and verified with password_verify(). A hash can't be reversed back to the original password, so seeing the file doesn't reveal what the password actually is.
② Image Upload with No Authentication
The blog editor uses TinyMCE, which includes an image upload feature. The file that handles those uploads — upload_image.php — had no login check.
// Before (no auth check)
if ($_FILES['file']['error'] === UPLOAD_ERR_OK) {
$filename = $_FILES['file']['name'];
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $filename);
echo json_encode(['location' => 'uploads/' . $filename]);
}
What could go wrong: Anyone who knew the URL could POST a file directly to the server — no login required. The dangerous part isn't just images. If someone uploaded a .php file and then accessed it through a browser, they could execute arbitrary code on the server. This is called Remote Code Execution, and it's one of the most serious classes of web vulnerabilities.
// After
if (!isset($_SESSION['logged_in']) || !$_SESSION['logged_in']) {
http_response_code(403);
exit(json_encode(['error' => 'Authentication required']));
}
// Upload logic follows
One check. That's all it takes.
③ Delete Actions Triggered by GET Requests
The article list in the CMS had a delete button. Its implementation looked like this:
// Delete link (before)
<a href="delete.php?id=<?= $article['id'] ?>">Delete</a>
// delete.php (before)
$id = $_GET['id'];
// Delete the article
Visiting a URL with ?id=xxx would delete the article. No confirmation, no verification.
What could go wrong: This is a textbook CSRF (Cross-Site Request Forgery) vulnerability. Imagine someone created a page containing:
<img src="https://dottiq.com/cms/blog/delete.php?id=1" style="display:none">
If I visited that page while logged into the CMS, the article would be deleted the moment the page loaded. The "attacker" didn't have to do anything — just get me to open a page. The browser did the rest.
// Delete form (after)
<form method="post" action="delete.php" onsubmit="return confirm('Delete this article?')">
<?= csrf_input() ?>
<input type="hidden" name="id" value="<?= $article['id'] ?>">
<button type="submit">Delete</button>
</form>
// delete.php (after)
csrf_verify(); // Returns 403 if token doesn't match
$id = $_POST['id'];
// Delete logic
A CSRF token is a random string shared only between the server and the legitimate form. Requests from external sites won't have it, so they get blocked.
④ Reorder Endpoint with No Authentication
Tools can be reordered by drag and drop in the CMS. The reorder.php endpoint handling that was wide open — no login check at all.
// Before (no auth, debug settings still active)
ini_set('display_errors', 1);
error_reporting(E_ALL);
$data = json_decode(file_get_contents('php://input'), true);
// Reorder logic runs directly
What could go wrong: Anyone could send a request to this endpoint and shuffle the tool order. The impact is minor, but unauthenticated API endpoints can serve as footholds for larger attacks.
The other issue: debug settings left running in production.
ini_set('display_errors', 1);
error_reporting(E_ALL);
With this active, PHP errors print directly to the browser. Error messages often include file paths and internal structure — useful information for an attacker. Convenient during development, dangerous on a live server.
⑤ Mail Header Injection
The contact form's mail-sending logic had a header injection vulnerability.
// Before
$headers = "From: {$email}"; // User input used directly in From header
mb_send_mail($to, $subject, $body, $headers);
What could go wrong: When user input goes directly into a mail header, a malicious value can inject additional headers. For example, entering this in the email field:
attacker@evil.com\r\nBcc: victim1@example.com,victim2@example.com
The \r\n (newline) gets interpreted as a header separator. Suddenly the form becomes a spam cannon with a massive Bcc list — sending from my server's IP address, which then gets added to spam blacklists.
// After
$headers = "From: noreply@dottiq.com\r\n"; // Fixed address, not user input
$headers .= "Reply-To: " . mb_encode_mimeheader($name) . " <{$email}>\r\n";
// Strip newlines from all user input
$email = preg_replace('/[\r\n]/', '', $email);
$name = preg_replace('/[\r\n]/', '', $name);
⑥ Duplicate session_start() Calls
Multiple files were calling session_start() independently, including files that also loaded config.php, which called it too.
// A.php
session_start();
require_once 'config.php'; // config.php also calls session_start()
What could go wrong: The duplicate calls themselves just trigger PHP warnings. But combined with the display_errors issue, those warnings could leak internal information. More broadly, when session initialization is scattered across multiple files, defending against session fixation attacks becomes much harder.
// config.php
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// No other files call session_start()
⑦ Language Detection via HTTP_REFERER
The contact form was determining whether to send a Japanese or English auto-reply using this logic:
// Before
$is_en = strpos($_SERVER['HTTP_REFERER'] ?? '', '/en/') !== false;
HTTP_REFERER is the header browsers send to indicate the previously visited page.
What could go wrong: REFERER isn't reliable. Privacy settings, browser configurations, and HTTPS-to-HTTP transitions can all cause it to be absent. Result: someone submitting the English contact form receives an auto-reply in Japanese. Not a security hole exactly, but a broken user experience.
<!-- After: pass language explicitly via hidden field -->
<input type="hidden" name="lang" value="en">
// send_mail.php
$is_en = ($_POST['lang'] ?? '') === 'en';
⑧ Incorrect File Path
When an article's slug (the URL identifier) changes, the old URL should redirect to the new one. The logic writing that redirect data had the file path wrong in one of the files.
// Before (path was off by one directory)
$redirectsFile = '../cms/blog/redirects.json';
// After
$redirectsFile = __DIR__ . '/redirects.json';
Changing an article title silently broke the redirect — visitors following old links would land on a 404 page. More of a quality issue than a security hole, but it was caught in the review all the same.
Summary: What's Actually at Stake
| Issue | Worst Case | Severity |
|---|---|---|
| Plain text password | Admin panel taken over | 🔴 High |
| Unauthenticated file upload | Arbitrary code executed on server | 🔴 High |
| CSRF vulnerability | Articles deleted by visiting a page | 🟠 Medium–High |
| Unauthenticated API endpoint | Data modified externally | 🟡 Medium |
| Mail header injection | Server used as spam cannon | 🟠 Medium–High |
| Duplicate session_start() | Session management instability | 🟡 Low–Medium |
| REFERER-based language detection | Wrong language auto-reply | 🟢 Low |
| Incorrect file path | Broken redirects | 🟢 Low |
Serious issues sitting next to trivial ones. And this site had been live the whole time. It was always in this state — I just didn't know.
While I Was at It: JSON to SQLite
After the security fixes, Claude suggested revisiting how data was being stored.
Dottiq had been managing all its data — articles, tools, links, news — as JSON files.
cms/blog/articles.json
cms/links/links.json
tools/tools.json
cms/news/news.json
For a small site, JSON files work. They did work. But concurrent writes create consistency risks, and searching or filtering gets inefficient as content grows.
SQLite is a database that lives as a single file on the server. No separate database server needed — PHP can use it directly. It's well-suited for personal sites and small projects.
After migration, all data was consolidated into one database file.
// Before: reading from JSON
$articles = json_decode(file_get_contents('articles.json'), true);
// After: querying SQLite
$db = get_db();
$stmt = $db->prepare("SELECT * FROM articles WHERE published = 1 ORDER BY created_at DESC");
$stmt->execute();
$articles = $stmt->fetchAll();
The code got cleaner. Search and category filtering can now be written as SQL queries, making future features much easier to add.
What I Learned from Having AI Review AI's Code
AI is good at making things that work. Say "I need a way to save articles" and it will write code that saves articles. That code will probably run.
But "does it work" and "is it safe" are different questions. Vibe coding in particular — adding features one conversation at a time — tends to optimize for solving the immediate task rather than thinking about the whole system. That's how you end up with something that works but is full of holes.
What I found interesting: the AI that reviewed the code was different from the AI that wrote it. Different model, different training data, different tendencies. Even within the broad category of "AI," one can catch things the other misses. It's not unlike how self-review is notoriously unreliable for humans too.
I also discovered that a handoff document written by one AI contained inaccuracies about what had actually been done — commits that hadn't happened, files marked as updated that hadn't been touched. Trusting AI-generated documentation without verification is its own risk.
A Note to Fellow Vibe Coders
I'm writing this because I want people building things with AI to hear: get your site reviewed.
Security problems are invisible until something goes wrong. And by the time something goes wrong, it's often too late. I don't know whether Dottiq was ever actively targeted, but it was certainly targetable.
Asking AI for a security review isn't complicated. "Please review these PHP files and identify any security issues" — then attach the files. It's free. It takes ten minutes.
To any engineers reading: yes, this is all pretty basic stuff. But this is genuinely what happens when non-programmers build things with AI. I hope it was at least entertaining.
We're in an era where anyone can build almost anything. That's remarkable. It just means "it works" can't be the last word — "it's safe" has to come next.