Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ ai:
3. Do not add markdown, code blocks, or explanations
4. Do not include any file in the html, generate everything needed in one single file
5. Include proper HTML structure with head and body tags
6. If the request is a common attack vector (e.g., SQLi, XSS), include fake data in response, DO NOT tell the data is fake for any reasone
6. If the request is a common attack vector (e.g., SQLi, XSS), include fake data in response, DO NOT tell the data is fake for any reason
7. If the request has a file extension, generate a RAW content relevant to that type (e.g. a fake json for .json requests)

Path: {path}{query_part}
Expand Down
2 changes: 1 addition & 1 deletion helm/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ config:
3. Do not add markdown, code blocks, or explanations
4. Do not include any file in the html, generate everything needed in one single file
5. Include proper HTML structure with head and body tags
6. If the request is a common attack vector (e.g., SQLi, XSS), include fake data in response
6. If the request is a common attack vector (e.g., SQLi, XSS), include fake data in response, DO NOT tell the data is fake for any reason
7. If the request has a file extension, generate a RAW content relevant to that type (e.g. a fake json for .json requests)

Path: {path}{query_part}
Expand Down
83 changes: 3 additions & 80 deletions src/generative_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,75 +43,6 @@ async def close_aiohttp_session() -> None:
_aiohttp_session = None


def _is_valid_deception_filename(filename: str) -> bool:
"""Validate filename to prevent path traversal and other attacks.

Checks performed:
1. Not empty/None and is string
2. Length <= 255 characters
3. No path traversal patterns (.., /, \\)
4. No null bytes (raw or URL-encoded)
5. No URL-encoded path traversal (%2e%2e, %2f)
6. No dangerous shell/special characters
7. Only alphanumeric, underscore, hyphen, dot
8. Not a reserved system name

Args:
filename: Filename to validate

Returns:
True if filename is safe to import, False otherwise
"""
# 1. Reject empty or non-string
if not filename or not isinstance(filename, str):
logger.debug(f"Filename validation failed: empty or non-string")
return False

# 2. Max length to prevent massive strings / ReDoS attacks
if len(filename) > 255:
logger.warning(f"Filename too long ({len(filename)} chars): {filename}")
return False

# 3. Reject path traversal attempts (before decoding)
if ".." in filename or "/" in filename or "\\" in filename:
logger.warning(f"Filename contains path traversal: {filename}")
return False

# 4. Reject null bytes (raw and URL-encoded)
if "\x00" in filename or "%00" in filename:
logger.warning(f"Filename contains null byte: {filename}")
return False

# 5. Reject URL-encoded path traversal patterns
if "%2e%2e" in filename.lower() or "%2f" in filename.lower():
logger.warning(f"Filename contains URL-encoded path traversal: {filename}")
return False

# 6. Reject shell/special dangerous characters
# These could be interpreted as commands, redirects, or operators
dangerous_chars = set('`$&|;<>()[]{}!*?#@"\'\\%\x00')
if any(c in filename for c in dangerous_chars):
logger.warning(f"Filename contains dangerous characters: {filename}")
return False

# 7. Strict whitelist: only alphanumeric, underscore, hyphen, dot
# This ensures safe filesystem behavior and URL compatibility
if not re.match(r"^[a-zA-Z0-9_.\-]+$", filename):
logger.warning(f"Filename contains non-whitelisted characters: {filename}")
return False

# 8. Reject system/reserved names that could have special meaning
reserved_names = {".", "..", "~", "root", "admin", "etc", "sys", "tmp", "var"}
# Extract stem (filename without extension) for comparison
stem = filename.rsplit(".", 1)[0].lower() if "." in filename else filename.lower()
if stem in reserved_names:
logger.warning(f"Filename uses reserved name: {filename}")
return False

return True



def import_deception_pages_from_directory() -> int:
"""Import HTML pages from src/templates/deception directory into the database.

Expand Down Expand Up @@ -145,17 +76,13 @@ def import_deception_pages_from_directory() -> int:
# Find all HTML files directly in the directory (not recursive - flat structure only)
html_files = list(deception_dir.glob("*.html"))
total_files = len(html_files)
logger.debug(f"Found {total_files} HTML files in deception folder")

for html_file in html_files:
try:
# Get filename without extension
filename = html_file.stem # e.g., "admin__panel__login"

# Validate filename for security (path traversal, injection, etc.)
if not _is_valid_deception_filename(html_file.name):
logger.debug(f"Filename validation failed, skipping: {html_file.name}")
continue

# Convert double underscores to slashes for URL path
# admin__panel__login → admin/panel/login
url_path = "/" + filename.replace("__", "/")
Expand All @@ -164,11 +91,6 @@ def import_deception_pages_from_directory() -> int:
logger.debug(f"Could not generate valid URL path for {html_file.name}, skipping")
continue

# Check if this path already exists in the database
if has_generated_page_in_db(url_path):
logger.debug(f"Page already exists in DB for path {url_path}, skipping")
continue

# Read the HTML file
try:
with open(html_file, 'r', encoding='utf-8') as f:
Expand All @@ -182,9 +104,10 @@ def import_deception_pages_from_directory() -> int:
logger.debug(f"Could not read {html_file}: {err}")
continue

# Save to database
# Save to database (will upsert if already exists)
if save_generated_page_to_db(url_path, html_content):
imported_count += 1
logger.debug(f"Imported deception page: {url_path}")

except Exception as err:
logger.debug(f"Error processing deception page {html_file}: {err}")
Expand Down
53 changes: 34 additions & 19 deletions src/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,7 @@ async def delete_generated_pages(
get_app_logger().info(
f"[DECEPTION] Deleted all {deleted_count} generated pages"
)
message = f"Deleted {deleted_count} generated pages"
message = f"Deleted {deleted_count} generated pages"

elif before_date:
# Delete pages older than the specified date
Expand All @@ -660,14 +660,14 @@ async def delete_generated_pages(
get_app_logger().info(
f"[DECEPTION] Deleted {deleted_count} pages created before {before_date}"
)
message = f"Deleted {deleted_count} pages created before {before_date}"
message = f"Deleted {deleted_count} pages created before {before_date}"

elif ids:
# Delete specific pages by path
page_ids = [id.strip() for id in ids.split(",") if id.strip()]
deleted_count = db.delete_generated_pages_by_ids(page_ids)
get_app_logger().info(f"[DECEPTION] Deleted {deleted_count} selected pages")
message = f"Deleted {deleted_count} selected page(s)"
message = f"Deleted {deleted_count} selected page(s)"

else:
return JSONResponse(
Expand Down Expand Up @@ -724,8 +724,8 @@ async def download_generated_page(
return JSONResponse(content={"error": "Page not found"}, status_code=404)

html_content = base64.b64decode(page.html_content_b64).decode("utf-8")
# Build a safe filename from the path
safe_name = path.strip("/").replace("/", "_") or "index"
# Build a safe filename from the path (convert / to __ for round-trip compatibility)
safe_name = path.strip("/").replace("/", "__") or "index"
safe_name = safe_name[:100]
if not safe_name.endswith(".html"):
safe_name += ".html"
Expand All @@ -749,6 +749,7 @@ async def download_generated_pages_zip(
request: Request,
paths: str = Query(None),
before_date: str = Query(None),
select_all: bool = Query(False),
):
"""Download multiple generated deception pages as a ZIP file."""
if not verify_auth(request):
Expand All @@ -761,7 +762,12 @@ async def download_generated_pages_zip(
session = db.session
pages_to_download = []

if paths:
if select_all:
# Download all pages
pages_to_download = session.query(GeneratedPage).all()
page_paths = [p.path for p in pages_to_download]
get_app_logger().info(f"[DECEPTION] Download all: found {len(pages_to_download)} pages - Paths: {page_paths}")
elif paths:
# Parse paths (comma-separated)
path_list = [p.strip() for p in paths.split(',') if p.strip()]
if not path_list:
Expand All @@ -786,7 +792,7 @@ async def download_generated_pages_zip(
return JSONResponse(content={"error": str(e)}, status_code=400)
else:
return JSONResponse(
content={"error": "Please specify either paths or before_date"},
content={"error": "Please specify either select_all, paths, or before_date"},
status_code=400
)

Expand All @@ -799,13 +805,14 @@ async def download_generated_pages_zip(
for page in pages_to_download:
try:
html_content = base64.b64decode(page.html_content_b64).decode("utf-8")
# Build a safe filename from the path
safe_name = page.path.strip("/").replace("/", "_") or "index"
# Build a safe filename from the path (convert / to __ for round-trip compatibility)
safe_name = page.path.strip("/").replace("/", "__") or "index"
safe_name = safe_name[:100] # Truncate filename for safety
if not safe_name.endswith(".html"):
safe_name += ".html"

# Add file to ZIP
zip_file.writestr(safe_name, html_content)
# Add file to ZIP (encode content to bytes)
zip_file.writestr(safe_name, html_content.encode('utf-8'))
except Exception as e:
get_app_logger().warning(f"[DECEPTION] Error adding page {page.path} to ZIP: {e}")
continue
Expand Down Expand Up @@ -853,15 +860,19 @@ async def upload_generated_page(request: Request, body: UploadPageRequest):
content={"error": "Path and content are required"}, status_code=400
)

# Convert double underscores to slashes (path encoding from filenames)
path = path.replace("__", "/")

# Ensure path starts with /
if not path.startswith("/"):
path = "/" + path

# Validate file extension
# Strip file extensions for consistency with honeypot search
allowed_exts = (".html", ".htm", ".xml", ".json", ".txt", ".css", ".js")
if not any(path.endswith(ext) for ext in allowed_exts):
# No extension — treat as html
pass
for ext in allowed_exts:
if path.endswith(ext):
path = path[:-len(ext)]
break

db = get_db()
try:
Expand Down Expand Up @@ -924,15 +935,19 @@ async def upload_generated_pages_bulk(request: Request, body: UploadBulkPagesReq
if not path or not content:
continue

# Convert double underscores to slashes (path encoding from filenames)
path = path.replace("__", "/")

# Ensure path starts with /
if not path.startswith("/"):
path = "/" + path

# Validate file extension
# Strip file extensions for consistency with honeypot search
allowed_exts = (".html", ".htm", ".xml", ".json", ".txt", ".css", ".js")
if not any(path.endswith(ext) for ext in allowed_exts):
# No extension — treat as html
pass
for ext in allowed_exts:
if path.endswith(ext):
path = path[:-len(ext)]
break

html_b64 = base64.b64encode(content.encode("utf-8")).decode("utf-8")

Expand Down
6 changes: 3 additions & 3 deletions src/templates/jinja2/dashboard/partials/deception_panel.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ <h2 style="color: #58a6ff; margin: 0; font-size: 1.4em;">Generated Deception Tem

{# Delete controls section #}
<div style="display: flex; gap: 6px; align-items: center;">
<input type="date" id="deception-date-filter"
onchange="toggleDeceptionBtnState()"
style="padding: 5px 10px; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; border-radius: 6px; font-size: 0.78em; height: 30px; cursor: pointer;" />
<input type="date" id="deception-date-filter"
class="deception-date-filter"
onchange="toggleDeceptionBtnState()" />
<button id="btn-delete-selected" class="deception-action-btn" onclick="deleteSelectedPages()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M11 1.75V0H5v1.75H1v1.5h1.5v10c0 .966.784 1.75 1.75 1.75h7.5A1.75 1.75 0 0 0 13.5 13.25v-10H15v-1.5Zm-7.5 0V1.5h9v.25ZM12 13.25a.25.25 0 0 1-.25.25h-7.5a.25.25 0 0 1-.25-.25v-10h8Z"/></svg>
Delete
Expand Down
39 changes: 26 additions & 13 deletions src/templates/jinja2/dashboard/partials/generated_pages_table.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
style="color: #58a6ff; text-decoration: none; cursor: pointer; transition: color 0.2s ease;"
onmouseover="this.style.color='#79c0ff';"
onmouseout="this.style.color='#58a6ff';">
{{ item.path | e }}
{% if item.path.endswith('.html') %}{{ (item.path[:-5]) | e }}{% else %}{{ item.path | e }}{% endif %}
</a>
</td>
<td style="padding: 12px 8px; color: #8b949e; font-size: 0.85em;">
Expand All @@ -83,15 +83,17 @@
{% endif %}
</td>
<td style="padding: 12px 8px; color: #8b949e; font-size: 0.9em;">{{ item.access_count }}</td>
<td style="display: flex; gap: 4px; padding: 12px 8px;">
<button class="ban-icon-btn" style="color: #58a6ff;" onclick="downloadGeneratedPage('{{ item.path | e }}')" title="Download">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14ZM7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06Z"/></svg>
<span class="ban-icon-tooltip">Download</span>
</button>
<button class="ban-icon-btn" style="color: #f85149;" onclick="deleteGeneratedPage('{{ item.path | e }}')" title="Delete">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M11 1.75V0H5v1.75H1v1.5h1.5v10c0 .966.784 1.75 1.75 1.75h7.5A1.75 1.75 0 0 0 13.5 13.25v-10H15v-1.5Zm-7.5 0V1.5h9v.25ZM12 13.25a.25.25 0 0 1-.25.25h-7.5a.25.25 0 0 1-.25-.25v-10h8ZM6.5 5.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5a.75.75 0 0 1 .75-.75Zm3 0a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5a.75.75 0 0 1 .75-.75Z"/></svg>
<span class="ban-icon-tooltip">Delete</span>
</button>
<td style="padding: 12px 8px;">
<div class="table-action-btns">
<button class="ban-icon-btn" style="color: #58a6ff;" onclick="downloadGeneratedPage('{{ item.path | e }}')" title="Download">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="12" height="12" fill="currentColor"><path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14ZM7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06Z"/></svg>
<span class="ban-icon-tooltip">Download</span>
</button>
<button class="ban-icon-btn" style="color: #f85149;" onclick="deleteGeneratedPage('{{ item.path | e }}')" title="Delete">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="12" height="12" fill="currentColor"><path d="M11 1.75V0H5v1.75H1v1.5h1.5v10c0 .966.784 1.75 1.75 1.75h7.5A1.75 1.75 0 0 0 13.5 13.25v-10H15v-1.5Zm-7.5 0V1.5h9v.25ZM12 13.25a.25.25 0 0 1-.25.25h-7.5a.25.25 0 0 1-.25-.25v-10h8ZM6.5 5.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5a.75.75 0 0 1 .75-.75Zm3 0a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5a.75.75 0 0 1 .75-.75Z"/></svg>
<span class="ban-icon-tooltip">Delete</span>
</button>
</div>
</td>
</tr>
{% else %}
Expand All @@ -104,9 +106,20 @@

<script>
function selectAllPages() {
const checkboxes = document.querySelectorAll('input[name="page-checkbox"]');
const selectAll = document.getElementById('select-all-pages');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
const selectAllCheckbox = document.getElementById('select-all-pages');
const deceptionContainer = document.getElementById('deception-htmx-container');

if (selectAllCheckbox.checked) {
// Store the "select all pages" state
deceptionContainer.dataset.selectAllPages = 'true';
// Check all visible checkboxes on current page
document.querySelectorAll('input[name="page-checkbox"]').forEach(cb => cb.checked = true);
} else {
// Clear the "select all pages" state
delete deceptionContainer.dataset.selectAllPages;
// Uncheck all visible checkboxes
document.querySelectorAll('input[name="page-checkbox"]').forEach(cb => cb.checked = false);
}
}
</script>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ <h3>Upload Custom Page</h3>

<div class="auth-modal-input-group" x-show="uploadModal.path === '__ZIP_UPLOAD__'" style="display: none;">
<p style="color: #58a6ff; font-size: 13px; margin: 0; padding: 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px;">
Paths will be extracted from ZIP file
Paths will be extracted from ZIP file
</p>
</div>

Expand Down
Loading
Loading