Remove version param, add path traversal protection

This commit is contained in:
Chris Wanstrath 2026-03-01 22:32:31 -08:00
parent 6e5d665846
commit c081785d37

View File

@ -3,11 +3,17 @@ import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { readdir, stat } from 'fs/promises'
import { readFileSync } from 'fs'
import { join, extname, basename } from 'path'
import { join, resolve, extname, basename } from 'path'
import type { Child } from 'hono/jsx'
const APPS_DIR = process.env.APPS_DIR!
const safePath = (base: string, ...segments: string[]) => {
const full = resolve(base, ...segments)
if (!full.startsWith(base + '/') && full !== base) return null
return full
}
const app = new Hype({ prettyHTML: false })
const Container = define('Container', {
@ -257,21 +263,15 @@ const fileMemoryScript = `
var params = new URLSearchParams(window.location.search);
var app = params.get('app');
var file = params.get('file');
var version = params.get('version') || 'current';
if (!app) return;
var key = 'code-app:' + app + ':' + version + ':file';
var key = 'code-app:' + app + ':file';
if (params.has('file')) {
// Explicit file param (even empty) - save it
if (file) localStorage.setItem(key, file);
else localStorage.removeItem(key);
} else {
// No file param - restore saved location
var saved = localStorage.getItem(key);
if (saved) {
var url = '/?app=' + encodeURIComponent(app);
if (version !== 'current') url += '&version=' + encodeURIComponent(version);
url += '&file=' + encodeURIComponent(saved);
window.location.replace(url);
window.location.replace('/?app=' + encodeURIComponent(app) + '&file=' + encodeURIComponent(saved));
}
}
})();
@ -327,14 +327,14 @@ app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
app.get('/raw', async c => {
const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file')
if (!appName || !filePath) {
return c.text('Missing app or file parameter', 400)
}
const fullPath = join(APPS_DIR, appName, version, filePath)
const fullPath = safePath(APPS_DIR, appName, filePath)
if (!fullPath) return c.text('Invalid path', 400)
const file = Bun.file(fullPath)
if (!await file.exists()) {
@ -346,14 +346,14 @@ app.get('/raw', async c => {
app.post('/save', async c => {
const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file')
if (!appName || !filePath) {
return c.text('Missing app or file parameter', 400)
}
const fullPath = join(APPS_DIR, appName, version, filePath)
const fullPath = safePath(APPS_DIR, appName, filePath)
if (!fullPath) return c.text('Invalid path', 400)
const content = await c.req.text()
try {
@ -385,10 +385,9 @@ async function listFiles(appPath: string, subPath: string = '') {
interface BreadcrumbProps {
appName: string
filePath: string
versionParam: string
}
function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
function PathBreadcrumb({ appName, filePath }: BreadcrumbProps) {
const parts = filePath ? filePath.split('/').filter(Boolean) : []
const crumbs: { name: string; path: string }[] = []
@ -401,7 +400,7 @@ function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
return (
<Breadcrumb>
{crumbs.length > 0 ? (
<BreadcrumbLink href={`/?app=${appName}${versionParam}&file=`}>{appName}</BreadcrumbLink>
<BreadcrumbLink href={`/?app=${appName}&file=`}>{appName}</BreadcrumbLink>
) : (
<BreadcrumbCurrent>{appName}</BreadcrumbCurrent>
)}
@ -411,7 +410,7 @@ function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
{i === crumbs.length - 1 ? (
<BreadcrumbCurrent>{crumb.name}</BreadcrumbCurrent>
) : (
<BreadcrumbLink href={`/?app=${appName}${versionParam}&file=${crumb.path}`}>{crumb.name}</BreadcrumbLink>
<BreadcrumbLink href={`/?app=${appName}&file=${crumb.path}`}>{crumb.name}</BreadcrumbLink>
)}
</>
))}
@ -479,7 +478,6 @@ function getPrismLanguage(filename: string): string {
app.get('/', async c => {
const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file') || ''
if (!appName) {
@ -490,19 +488,34 @@ app.get('/', async c => {
)
}
const appPath = join(APPS_DIR, appName, version)
const appPath = safePath(APPS_DIR, appName)
if (!appPath) {
return c.html(
<Layout title="Code Browser">
<ErrorBox>Invalid app name</ErrorBox>
</Layout>
)
}
try {
await stat(appPath)
} catch {
return c.html(
<Layout title="Code Browser">
<ErrorBox>App "{appName}" (version: {version}) not found</ErrorBox>
<ErrorBox>App "{appName}" not found</ErrorBox>
</Layout>
)
}
const fullPath = safePath(appPath, filePath)
if (!fullPath) {
return c.html(
<Layout title="Code Browser">
<ErrorBox>Invalid file path</ErrorBox>
</Layout>
)
}
const fullPath = join(appPath, filePath)
let fileStats
try {
@ -515,18 +528,16 @@ app.get('/', async c => {
)
}
const versionParam = version !== 'current' ? `&version=${version}` : ''
if (fileStats.isFile()) {
const filename = basename(fullPath)
const fileType = getFileType(filename)
const rawUrl = `/raw?app=${appName}${versionParam}&file=${filePath}`
const rawUrl = `/raw?app=${appName}&file=${filePath}`
const downloadUrl = `${rawUrl}&download=1`
if (fileType === 'image') {
return c.html(
<Layout title={`${appName}/${filePath}`}>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<MediaContainer>
<MediaHeader>
<span>{filename}</span>
@ -543,7 +554,7 @@ app.get('/', async c => {
if (fileType === 'audio') {
return c.html(
<Layout title={`${appName}/${filePath}`}>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<MediaContainer>
<MediaHeader>
<span>{filename}</span>
@ -560,7 +571,7 @@ app.get('/', async c => {
if (fileType === 'video') {
return c.html(
<Layout title={`${appName}/${filePath}`}>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<MediaContainer>
<MediaHeader>
<span>{filename}</span>
@ -615,7 +626,7 @@ saveBtn.onclick = async () => {
status.textContent = 'Saving...';
try {
const res = await fetch('/save?app=${appName}${versionParam}&file=${filePath}', {
const res = await fetch('/save?app=${appName}&file=${filePath}', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: jar.toString()
@ -641,14 +652,14 @@ document.addEventListener('keydown', (e) => {
`
return c.html(
<Layout title={`${appName}/${filePath}`} editable>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<CodeBlock>
<CodeHeader>
<span>{filename}</span>
<div style="display:flex;align-items:center;gap:8px">
<span id="save-status" style="font-size:12px;font-weight:normal;color:var(--colors-textMuted)"></span>
<EditButton id="save-btn">Save</EditButton>
<EditLink href={`/?app=${appName}${versionParam}&file=${filePath}`}>Done</EditLink>
<EditLink href={`/?app=${appName}&file=${filePath}`}>Done</EditLink>
</div>
</CodeHeader>
<pre id="editor" class={`language-${prismLang}`} contenteditable style="margin:0;padding:15px;min-height:300px;outline:none">{content}</pre>
@ -660,11 +671,11 @@ document.addEventListener('keydown', (e) => {
return c.html(
<Layout title={`${appName}/${filePath}`} highlight>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<CodeBlock>
<CodeHeader>
<span>{filename}</span>
<EditLink href={`/?app=${appName}${versionParam}&file=${filePath}&edit=1`}>Edit</EditLink>
<EditLink href={`/?app=${appName}&file=${filePath}&edit=1`}>Edit</EditLink>
</CodeHeader>
<pre><code class={`language-${language}`}>{content}</code></pre>
</CodeBlock>
@ -676,11 +687,11 @@ document.addEventListener('keydown', (e) => {
return c.html(
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`}>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<FileList>
{files.map(file => (
<FileItem>
<FileLink href={`/?app=${appName}${versionParam}&file=${file.path}`}>
<FileLink href={`/?app=${appName}&file=${file.path}`}>
{file.isDirectory ? <FolderIcon /> : <FileIconSvg />}
<span>{file.name}</span>
</FileLink>