README.md
Rendering markdown...
const express = require('express');
const { EntitySchema, Type, raw } = require('@mikro-orm/core');
const { MikroORM } = require('@mikro-orm/sqlite');
const PORT = Number(process.env.PORT || 3000);
class Post {}
class Salary {}
// Type을 직접 상속한 커스텀 타입 (취약점 발생 지점)
class JsonOrRawType extends Type {
convertToDatabaseValue(value) {
if (value && typeof value === 'object' && '__raw' in value) {
return value;
}
return typeof value === 'string' ? value : String(value ?? '');
}
convertToJSValue(value) {
if (typeof value === 'string') {
try { return JSON.parse(value); } catch { return value; }
}
return value;
}
getColumnType() {
return 'text';
}
get runtimeType() {
return 'string';
}
}
const PostSchema = new EntitySchema({
class: Post,
tableName: 'posts',
properties: {
id: { type: 'number', primary: true, autoincrement: true },
author: { type: 'string' },
title: { type: 'string' },
content: { type: JsonOrRawType, trackChanges: false }, // 취약 컬럼
created_at: { type: 'string' },
},
});
const SalarySchema = new EntitySchema({
class: Salary,
tableName: 'salaries',
properties: {
id: { type: 'number', primary: true, autoincrement: true },
name: { type: 'string' },
salary: { type: 'number' },
},
});
const seedSalaries = [
{ name: 'Mia Bailey', salary: 156000 },
{ name: 'Emma Carter', salary: 168000 },
{ name: 'Ava Hughes', salary: 142000 },
{ name: 'Noah Sullivan', salary: 134000 },
{ name: 'Liam Parker', salary: 128000 },
{ name: 'Daniel Long', salary: 139000 },
{ name: 'Grace Simmons', salary: 145000 },
{ name: 'Logan Russell', salary: 172000 },
{ name: 'Alexander Hayes', salary: 161000 },
{ name: 'William Jenkins', salary: 158000 },
{ name: 'Scarlett Hamilton', salary: 183000 },
{ name: 'Sophia Ward', salary: 149000 },
{ name: 'Harper Gray', salary: 122000 },
{ name: 'Mason Powell', salary: 118000 },
{ name: 'Ethan Coleman', salary: 115000 },
{ name: 'FLAG = EQST{r4w_qu3ry_fr4gm3nt_1nj3ct10n}', salary: 299299299 },
];
let orm;
let activeQueryLogScope = null;
function escHtml(str) {
return String(str || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
async function withQueryLogging(scope, handler) {
activeQueryLogScope = scope;
try { return await handler(); }
finally { activeQueryLogScope = null; }
}
async function initOrm() {
return MikroORM.init({
entities: [PostSchema, SalarySchema],
dbName: process.env.DB_PATH || ':memory:',
debug: true,
logger: (message) => {
const prefix = activeQueryLogScope ? `[SQL][${activeQueryLogScope}]` : '[SQL]';
console.log(`${prefix} ${message}`);
},
});
}
async function seedDatabase(activeOrm) {
await activeOrm.schema.refreshDatabase();
const em = activeOrm.em.fork();
const salaries = seedSalaries.map(s => em.create(Salary, s));
em.persist(salaries);
await em.flush();
}
function buildApp(activeOrm) {
const app = express();
app.use(express.json());
// 메인 페이지 - 게시판
app.get('/', async (req, res) => {
const posts = await withQueryLogging('list-posts', async () => {
const em = activeOrm.em.fork();
return em.find(Post, {}, { orderBy: { id: 'desc' } });
});
res.type('html').send(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>사내 게시판</title>
<style>
:root { --bg:#f6f3ed; --panel:rgba(255,252,248,0.92); --ink:#1b1a17; --muted:#6b665e; --accent:#9a3f26; --border:rgba(120,102,80,0.18); --shadow:0 24px 80px rgba(53,36,12,0.08); }
* { box-sizing:border-box; }
body { margin:0; min-height:100vh; color:var(--ink); font-family:"Segoe UI","Helvetica Neue",Arial,sans-serif; background:radial-gradient(circle at top left,rgba(154,63,38,0.12),transparent 30%),radial-gradient(circle at bottom right,rgba(109,122,90,0.09),transparent 34%),linear-gradient(180deg,#fbf8f2 0%,var(--bg) 100%); }
.shell { max-width:860px; margin:0 auto; padding:48px 16px 72px; }
.card { background:var(--panel); backdrop-filter:blur(12px); border:1px solid var(--border); border-radius:24px; box-shadow:var(--shadow); }
.header { padding:24px 28px; margin-bottom:20px; display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:12px; }
.title { margin:0; font-size:1.8rem; font-weight:700; letter-spacing:0.1em; text-transform:uppercase; color:var(--muted); }
.subtitle { font-size:0.85rem; color:var(--muted); font-weight:600; letter-spacing:0.14em; text-transform:uppercase; }
a.write-btn { text-decoration:none; padding:12px 22px; border-radius:999px; color:#fffaf4; background:linear-gradient(135deg,#8f2a16,#c3562f); font-weight:700; font-size:0.95rem; }
.posts { padding:8px 16px 24px; }
.post-item { padding:20px 12px; border-bottom:1px solid var(--border); }
.post-item:last-child { border-bottom:none; }
.post-title { font-size:1.05rem; font-weight:700; margin:0 0 6px; }
.post-meta { font-size:0.85rem; color:var(--muted); margin-bottom:8px; display:flex; gap:12px; align-items:center; }
.post-content { font-size:0.95rem; color:var(--ink); white-space:pre-wrap; word-break:break-all; }
.edit-btn { text-decoration:none; font-size:0.8rem; padding:4px 10px; border-radius:999px; border:1px solid var(--border); color:var(--muted); background:rgba(255,255,255,0.8); }
.empty { padding:40px; text-align:center; color:var(--muted); }
</style>
</head>
<body>
<main class="shell">
<section class="card">
<div class="header">
<div>
<div class="title">사내 게시판</div>
<div class="subtitle">EQST Internal Board</div>
</div>
<a class="write-btn" href="/write">✏️ Write Post</a>
</div>
<div class="posts">
${posts.length === 0
? '<div class="empty">No posts yet. Be the first to write!</div>'
: posts.map(p => `
<div class="post-item">
<div class="post-title">${escHtml(p.title)}</div>
<div class="post-meta">
<span>by ${escHtml(p.author)}</span>
<span>${escHtml(p.created_at)}</span>
<a class="edit-btn" href="/edit/${p.id}">Edit</a>
</div>
<div class="post-content">${escHtml(p.content)}</div>
</div>
`).join('')}
</div>
</section>
</main>
</body>
</html>`);
});
// 글 작성 페이지
app.get('/write', (_req, res) => {
res.type('html').send(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Write Post</title>
<style>
:root { --panel:rgba(255,252,248,0.92); --muted:#6b665e; --accent:#9a3f26; --border:rgba(120,102,80,0.18); --shadow:0 24px 80px rgba(53,36,12,0.08); }
* { box-sizing:border-box; }
body { margin:0; min-height:100vh; display:grid; place-items:center; background:linear-gradient(180deg,#fbf8f2 0%,#f6f3ed 100%); font-family:"Segoe UI","Helvetica Neue",Arial,sans-serif; color:#1b1a17; }
.card { width:min(560px,calc(100vw - 32px)); padding:32px; border-radius:24px; background:var(--panel); border:1px solid var(--border); box-shadow:var(--shadow); }
h1 { margin:0 0 24px; font-size:1.4rem; }
.row { display:grid; gap:14px; }
label { font-weight:600; font-size:0.9rem; color:var(--muted); text-transform:uppercase; letter-spacing:0.08em; }
input, textarea { width:100%; padding:12px 14px; border-radius:14px; border:1px solid var(--border); font:inherit; background:rgba(255,255,255,0.9); }
textarea { min-height:160px; resize:vertical; }
button { width:100%; padding:14px; border:0; border-radius:14px; font:inherit; font-weight:700; color:#fffaf4; background:linear-gradient(135deg,#8f2a16,#c3562f); cursor:pointer; }
#status { margin-top:12px; color:var(--muted); font-size:0.95rem; }
a { color:var(--accent); }
</style>
</head>
<body>
<main class="card">
<h1>✏️ Write Post</h1>
<div class="row">
<div><label>Author</label><input id="author" type="text" placeholder="Your name" /></div>
<div><label>Title</label><input id="title" type="text" placeholder="Post title" /></div>
<div><label>Content</label><textarea id="content" placeholder="Write your post here..."></textarea></div>
<button id="submit">Post</button>
</div>
<div id="status"></div>
<div style="margin-top:14px;"><a href="/">← Back to Board</a></div>
</main>
<script>
document.getElementById('submit').addEventListener('click', async () => {
const author = document.getElementById('author').value.trim();
const title = document.getElementById('title').value.trim();
const content = document.getElementById('content').value.trim();
if (!author || !title || !content) {
document.getElementById('status').textContent = 'Please fill in all fields.';
return;
}
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author, title, content }),
});
const data = await res.json();
if (data.ok) { window.location.href = '/'; }
else { document.getElementById('status').textContent = data.error || 'Failed to post.'; }
});
</script>
</body>
</html>`);
});
// 글 수정 페이지
app.get('/edit/:id', async (req, res) => {
const post = await withQueryLogging('get-post', async () => {
const em = activeOrm.em.fork();
return em.findOne(Post, { id: Number(req.params.id) });
});
if (!post) { res.status(404).send('Not found'); return; }
res.type('html').send(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Edit Post</title>
<style>
:root { --panel:rgba(255,252,248,0.92); --muted:#6b665e; --accent:#9a3f26; --border:rgba(120,102,80,0.18); --shadow:0 24px 80px rgba(53,36,12,0.08); }
* { box-sizing:border-box; }
body { margin:0; min-height:100vh; display:grid; place-items:center; background:linear-gradient(180deg,#fbf8f2 0%,#f6f3ed 100%); font-family:"Segoe UI","Helvetica Neue",Arial,sans-serif; color:#1b1a17; }
.card { width:min(560px,calc(100vw - 32px)); padding:32px; border-radius:24px; background:var(--panel); border:1px solid var(--border); box-shadow:var(--shadow); }
h1 { margin:0 0 24px; font-size:1.4rem; }
.row { display:grid; gap:14px; }
label { font-weight:600; font-size:0.9rem; color:var(--muted); text-transform:uppercase; letter-spacing:0.08em; }
input, textarea { width:100%; padding:12px 14px; border-radius:14px; border:1px solid var(--border); font:inherit; background:rgba(255,255,255,0.9); }
textarea { min-height:160px; resize:vertical; }
button { width:100%; padding:14px; border:0; border-radius:14px; font:inherit; font-weight:700; color:#fffaf4; background:linear-gradient(135deg,#8f2a16,#c3562f); cursor:pointer; }
#status { margin-top:12px; color:var(--muted); font-size:0.95rem; }
a { color:var(--accent); }
</style>
</head>
<body>
<main class="card">
<h1>✏️ Edit Post</h1>
<div class="row">
<div><label>Title</label><input id="title" type="text" value="${escHtml(post.title)}" /></div>
<div><label>Content</label><textarea id="content">${escHtml(post.content)}</textarea></div>
<button id="submit">Save</button>
</div>
<div id="status"></div>
<div style="margin-top:14px;"><a href="/">← Back to Board</a></div>
</main>
<script>
document.getElementById('submit').addEventListener('click', async () => {
const title = document.getElementById('title').value.trim();
const content = document.getElementById('content').value.trim();
if (!title || !content) {
document.getElementById('status').textContent = 'Please fill in all fields.';
return;
}
const res = await fetch('/api/posts/${post.id}', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content }),
});
const data = await res.json();
if (data.ok) { window.location.href = '/'; }
else { document.getElementById('status').textContent = data.error || 'Failed to update.'; }
});
</script>
</body>
</html>`);
});
// 글 작성 API
app.post('/api/posts', async (req, res) => {
const { author, title, content } = req.body || {};
if (!author || !title || !content) {
res.status(400).json({ ok: false, error: 'author, title, content are required' });
return;
}
try {
await withQueryLogging('create-post', async () => {
const em = activeOrm.em.fork();
// 취약 지점: em.create + flush → ChangeSetPersister → nativeInsertMany → formatQuery → quoteValue
const post = em.create(Post, {
author,
title,
content,
created_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
});
await em.flush();
});
res.json({ ok: true });
} catch (error) {
res.status(500).json({ ok: false, error: error.message });
}
});
// 글 수정 API (취약 지점: content가 nativeUpdate SET절로 직접 전달됨)
app.put('/api/posts/:id', async (req, res) => {
const { title, content } = req.body || {};
const id = Number(req.params.id);
if (!title || !content) {
res.status(400).json({ ok: false, error: 'title, content are required' });
return;
}
try {
await withQueryLogging('update-post', async () => {
const em = activeOrm.em.fork();
await em.nativeUpdate(Post, { id }, { title, content });
});
res.json({ ok: true });
} catch (error) {
res.status(500).json({ ok: false, error: error.message });
}
});
app.get('/healthz', async (_req, res) => {
try {
await activeOrm.em.getConnection().execute('select 1');
res.json({ ok: true });
} catch (error) {
res.status(500).json({ ok: false, error: error.message });
}
});
return app;
}
async function initialize() {
if (orm) await orm.close(true);
orm = await initOrm();
await seedDatabase(orm);
return buildApp(orm);
}
async function closeOrm() {
if (orm) { await orm.close(true); orm = undefined; }
}
async function main() {
const app = await initialize();
app.listen(PORT, () => console.log(`Challenge server listening on http://127.0.0.1:${PORT}`));
}
if (require.main === module) {
main().catch(async (error) => {
console.error(error);
await closeOrm();
process.exit(1);
});
}
module.exports = { Post, PORT, buildApp, closeOrm, initialize };