Skip to content

LFI vuln (v1) #319

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions deploy/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ services:
- TLS_ENABLED=${TLS_ENABLED:-false}
- TLS_CERTIFICATE=certs/server.crt
- TLS_KEY=certs/server.key
- FILES_LIMIT=50
depends_on:
postgresdb:
condition: service_healthy
Expand Down
1 change: 1 addition & 0 deletions deploy/helm/templates/workshop/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ data:
SERVER_PORT: {{ .Values.workshop.port | quote }}
API_GATEWAY_URL: {{ if .Values.apiGatewayServiceInstall }}"https://{{ .Values.apiGatewayService.service.name }}"{{ else }}{{ .Values.apiGatewayServiceUrl }}{{ end }}
TLS_ENABLED: {{ .Values.tlsEnabled | quote }}
FILES_LIMIT: {{ .Values.workshop.config.filesLimit }}
1 change: 1 addition & 0 deletions deploy/helm/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ workshop:
postgresDbDriver: postgres
mongoDbDriver: mongodb
secretKey: crapi
filesLimit: 50
deploymentLabels:
app: crapi-workshop
podLabels:
Expand Down
16 changes: 14 additions & 2 deletions services/web/src/components/serviceReport/serviceReport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@
import React from "react";
import {
Card,
Row,
Col,
Descriptions,
Spin,
Layout,
Expand All @@ -33,6 +31,7 @@ import {
ToolOutlined,
CommentOutlined,
CalendarOutlined,
DownloadOutlined,
} from "@ant-design/icons";
import "./styles.css";

Expand Down Expand Up @@ -65,6 +64,7 @@ interface Service {
comment: string;
created_on: string;
}[];
downloadUrl?: string;
}

interface ServiceReportProps {
Expand Down Expand Up @@ -126,6 +126,18 @@ const ServiceReport: React.FC<ServiceReportProps> = ({ service }) => {
</Text>
</div>
}
extra={[
<a
key="1"
className="download-report-button"
href={service.downloadUrl}
target="_blank"
rel="noopener noreferrer"
>
<DownloadOutlined />
Download Report
</a>,
]}
/>
</div>

Expand Down
33 changes: 33 additions & 0 deletions services/web/src/components/serviceReport/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,34 @@
gap: var(--spacing-sm);
}

/* Download Report button */
.download-report-button {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
background: linear-gradient(135deg, #8b5cf6 0%, #a855f7 100%);
color: white;
text-decoration: none;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--border-radius-lg);
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
border: none;
cursor: pointer;
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
width: 100%;
justify-content: center;
}

.download-report-button:hover, .download-report-button:focus {
background: linear-gradient(135deg, #7c3aed 0%, #9333ea 100%);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(139, 92, 246, 0.4);
color: white;
text-decoration: none;
}

/* Loading State */
.loading-container {
display: flex;
Expand Down Expand Up @@ -251,6 +279,11 @@
padding: var(--spacing-md);
}

.download-report-button {
padding: var(--spacing-md);
font-size: 13px;
}

.ant-descriptions-item-label {
font-size: 12px;
}
Expand Down
1 change: 1 addition & 0 deletions services/web/src/constants/APIConstant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const requestURLS: RequestURLSType = {
UPDATE_SERVICE_REQUEST_STATUS: "api/mechanic/service_request/<serviceId>",
GET_VEHICLE_SERVICES: "api/merchant/service_requests/<vehicleVIN>",
GET_SERVICE_REPORT: "api/mechanic/mechanic_report",
DOWNLOAD_SERVICE_REPORT: "api/mechanic/download_report",
BUY_PRODUCT: "api/shop/orders",
GET_ORDERS: "api/shop/orders/all",
GET_ORDER_BY_ID: "api/shop/orders/<orderId>",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ interface Service {
comment: string;
created_on: string;
}[];
downloadUrl?: string;
}

const mapStateToProps = (state: RootState) => ({
Expand Down
6 changes: 6 additions & 0 deletions services/web/src/sagas/vehicleSaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,12 @@ export function* getServiceReport(action: MyAction): Generator<any, void, any> {
throw responseJSON;
}

const filename = `report_${reportId}.pdf`;
responseJSON.downloadUrl =
APIService.WORKSHOP_SERVICE +
requestURLS.DOWNLOAD_SERVICE_REPORT +
"?filename=" +
filename;
yield put({ type: actionTypes.FETCHED_DATA, payload: responseJSON });
callback(responseTypes.SUCCESS, responseJSON);
} catch (e) {
Expand Down
1 change: 1 addition & 0 deletions services/workshop/crapi/mechanic/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,6 @@
r"service_request$",
mechanic_views.MechanicServiceRequestsView.as_view(),
),
re_path(r"download_report$", mechanic_views.DownloadReportView.as_view()),
re_path(r"$", mechanic_views.MechanicView.as_view()),
]
94 changes: 94 additions & 0 deletions services/workshop/crapi/mechanic/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,20 @@
"""
contains all the views related to Mechanic
"""
import os
import bcrypt
import logging
from urllib.parse import unquote
from django.template.loader import get_template
from xhtml2pdf import pisa
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.urls import reverse
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from django.db import models
from django.http import FileResponse
from crapi_site import settings
from utils.jwt import jwt_auth_required
from utils import messages
Expand All @@ -40,6 +46,7 @@
)
from rest_framework.pagination import LimitOffsetPagination

logger = logging.getLogger()

class SignUpView(APIView):
"""
Expand Down Expand Up @@ -235,6 +242,7 @@
)
serializer = MechanicServiceRequestSerializer(service_request)
response_data = dict(serializer.data)
service_report_pdf(response_data, report_id)
return Response(response_data, status=status.HTTP_200_OK)


Expand Down Expand Up @@ -366,3 +374,89 @@
service_request = ServiceRequest.objects.get(id=service_request_id)
serializer = MechanicServiceRequestSerializer(service_request)
return Response(serializer.data, status=status.HTTP_200_OK)


class DownloadReportView(APIView):
"""
A view to download a service report.
This view contains an intentional LFI vulnerability.
"""
def get(self, request, format=None):
filename_from_user = request.query_params.get('filename')
if not filename_from_user:
return Response(
{"message": "Parameter 'filename' is required."},
status=status.HTTP_400_BAD_REQUEST
)
#Checks for directory traversal in plain as well as single URL-encoded form
#Since Django automatically decodes URL-encoded parameters once
if '..' in filename_from_user or '/' in filename_from_user:
return Response(
{"message": "Forbidden input."},
status=status.HTTP_400_BAD_REQUEST
)
filename_from_user = unquote(filename_from_user)
filename_from_user = filename_from_user.replace("../", "")

#VULNERABLE: Double URL-encoded nested path can be used for exploit
full_path = os.path.abspath(os.path.join(settings.BASE_DIR, "reports", filename_from_user))
print(f"Attempting to serve file from: {full_path}")
logger.info(f"Attempting to serve file from: {full_path}")

if os.path.exists(full_path) and os.path.isfile(full_path):
return FileResponse(open(full_path, 'rb'))
elif not os.path.exists(full_path):
return Response(
{"message": f"File not found at '{full_path}'."},
status=status.HTTP_404_NOT_FOUND
)
else:
return Response(
{"message": f"'{full_path}' is not a file."},
status=status.HTTP_403_FORBIDDEN
)


def service_report_pdf(response_data, report_id):
"""
Generates service report's PDF file from a template and saves it to the disk.
"""
reports_dir = os.path.join(settings.BASE_DIR, 'reports')
os.makedirs(reports_dir, exist_ok=True)
report_filepath = os.path.join(reports_dir, f"report_{report_id}.pdf")

template = get_template('service_report.html')
html_string = template.render({'service': response_data})
with open(report_filepath, "w+b") as pdf_file:
pisa.CreatePDF(src=html_string, dest=pdf_file)

manage_reports_directory()


def manage_reports_directory():
"""
Checks reports directory and deletes the oldest one if the
count exceeds the maximum limit.
"""
try:
reports_dir = os.path.join(settings.BASE_DIR, 'reports')
report_files = os.listdir(reports_dir)

if len(report_files) >= settings.FILES_LIMIT:
oldest_file = None
oldest_time = float('inf')
for filename in report_files:
filepath = os.path.join(reports_dir, filename)
try:
current_mtime = os.path.getmtime(filepath)
if current_mtime < oldest_time:
oldest_time = current_mtime
oldest_file = filepath
except FileNotFoundError:
continue

if oldest_file:
os.remove(oldest_file)

except (OSError, FileNotFoundError) as e:
print(f"Error during report directory management: {e}")
4 changes: 3 additions & 1 deletion services/workshop/crapi_site/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ def get_env_value(env_variable):
raise ImproperlyConfigured(error_msg)


FILES_LIMIT = int(os.environ.get("FILES_LIMIT", 50))

# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

Expand Down Expand Up @@ -108,7 +110,7 @@ def get_env_value(env_variable):
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"DIRS": [os.path.join(BASE_DIR, 'utils')],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
Expand Down
1 change: 1 addition & 0 deletions services/workshop/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ gunicorn==21.2.0
coverage==7.4.1
unittest-xml-reporting==3.2.0
black==24.4.2
xhtml2pdf==0.2.17
Loading
Loading