fix(security): batch-2 hardening — XSS allowlist, event-scoping, deploy
Address the seven findings from the security & deployment review. High: - upload: make infer authoritative — reject files it can't identify (SVG/HTML/JS) and require the detected MIME to be on an ALLOWED_MEDIA allowlist; derive the stored MIME and on-disk extension from the detected type, ignoring client filename/Content-Type. Closes the stored-XSS vector via media served on-origin. - deploy: rename docker-compose.override.yml -> docker-compose.dev.yml so the default `docker compose up -d` no longer publishes Postgres 5432 to the host; the port map is now opt-in via -f. README updated. Medium: - upload: DefaultBodyLimit::disable() -> max(576 MiB) as an HTTP-level OOM backstop; handler still enforces precise per-class size limits. - docker: run backend and frontend as non-root users. Low: - social/upload: event-scope toggle_like, list_comments, add_comment, delete_comment, edit_upload, delete_upload via find_by_id_and_event / soft_delete_in_event — cross-event IDs now resolve to 404. - Caddy: site-wide HSTS / nosniff / X-Frame-Options / Referrer-Policy, plus Content-Disposition: attachment on /media/originals/*. - .env.example: replace default Postgres password with a CHANGE_ME hint. Out of scope: localStorage JWT (root cause fixed; httpOnly cookies are a larger change tracked separately). Verified: cargo build (no new warnings), cargo test (3 passed), caddy validate, docker compose config (no 5432 published by default). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,9 +6,11 @@ DOMAIN=my-event.example.com
|
|||||||
APP_PORT=3000
|
APP_PORT=3000
|
||||||
|
|
||||||
# ── Database ──────────────────────────────────────────────────────────────────
|
# ── Database ──────────────────────────────────────────────────────────────────
|
||||||
DATABASE_URL=postgres://eventsnap:secret@db:5432/eventsnap
|
# Set a strong password and keep it in sync between DATABASE_URL and
|
||||||
|
# POSTGRES_PASSWORD. Generate one with: openssl rand -hex 24
|
||||||
|
DATABASE_URL=postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/eventsnap
|
||||||
POSTGRES_USER=eventsnap
|
POSTGRES_USER=eventsnap
|
||||||
POSTGRES_PASSWORD=secret
|
POSTGRES_PASSWORD=CHANGE_ME_use_a_strong_password
|
||||||
POSTGRES_DB=eventsnap
|
POSTGRES_DB=eventsnap
|
||||||
|
|
||||||
# ── Authentication ────────────────────────────────────────────────────────────
|
# ── Authentication ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
14
Caddyfile
14
Caddyfile
@@ -1,6 +1,15 @@
|
|||||||
{$DOMAIN} {
|
{$DOMAIN} {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Site-wide security headers (defense-in-depth). HSTS is free since Caddy
|
||||||
|
# already terminates TLS. nosniff also covers all of /media/*.
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "DENY"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
# SvelteKit frontend — static assets with long-lived cache (content-hashed filenames)
|
# SvelteKit frontend — static assets with long-lived cache (content-hashed filenames)
|
||||||
@hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$
|
@hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$
|
||||||
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
|
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
|
||||||
@@ -9,9 +18,12 @@
|
|||||||
@previews path /media/previews/* /media/thumbnails/*
|
@previews path /media/previews/* /media/thumbnails/*
|
||||||
header @previews Cache-Control "public, max-age=3600"
|
header @previews Cache-Control "public, max-age=3600"
|
||||||
|
|
||||||
# Original media files (private — only host can download)
|
# Original media files (private — only host can download). Force download
|
||||||
|
# rather than inline rendering as defense-in-depth against any future
|
||||||
|
# content-type confusion (previews/thumbnails are re-encoded and stay inline).
|
||||||
@originals path /media/originals/*
|
@originals path /media/originals/*
|
||||||
header @originals Cache-Control "private, max-age=86400"
|
header @originals Cache-Control "private, max-age=86400"
|
||||||
|
header @originals Content-Disposition "attachment"
|
||||||
|
|
||||||
# API — never cache
|
# API — never cache
|
||||||
@api path /api/*
|
@api path /api/*
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ eventsnap/
|
|||||||
│ ├── svelte.config.js
|
│ ├── svelte.config.js
|
||||||
│ └── Dockerfile
|
│ └── Dockerfile
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
|
├── docker-compose.dev.yml # opt-in dev overlay (publishes Postgres on the host)
|
||||||
├── Caddyfile
|
├── Caddyfile
|
||||||
└── .env.example
|
└── .env.example
|
||||||
```
|
```
|
||||||
@@ -107,6 +108,11 @@ docker compose up -d
|
|||||||
|
|
||||||
Caddy automatically obtains a Let's Encrypt certificate on first start. The app is live at `https://DOMAIN` within ~30 seconds.
|
Caddy automatically obtains a Let's Encrypt certificate on first start. The app is live at `https://DOMAIN` within ~30 seconds.
|
||||||
|
|
||||||
|
> **Production note:** `docker compose up -d` does **not** expose the database — Postgres is reachable only on the internal Docker network. For local development where you need host access to Postgres, opt into the dev overlay explicitly:
|
||||||
|
> ```bash
|
||||||
|
> docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||||
|
> ```
|
||||||
|
|
||||||
### Generate required secrets
|
### Generate required secrets
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -20,8 +20,16 @@ FROM alpine:3.21
|
|||||||
|
|
||||||
RUN apk add --no-cache ca-certificates ffmpeg
|
RUN apk add --no-cache ca-certificates ffmpeg
|
||||||
|
|
||||||
|
# Run as a non-root user. Pre-create and chown the media mount path so the fresh
|
||||||
|
# named volume inherits the non-root ownership (Docker seeds an empty named volume
|
||||||
|
# from the image directory, preserving its uid/gid) and uploads can be written.
|
||||||
|
RUN addgroup -S app && adduser -S app -G app
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/target/release/eventsnap-backend ./
|
COPY --from=builder /app/target/release/eventsnap-backend ./
|
||||||
|
|
||||||
|
RUN mkdir -p /media && chown -R app:app /app /media
|
||||||
|
USER app
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["./eventsnap-backend"]
|
CMD ["./eventsnap-backend"]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use crate::auth::middleware::AuthUser;
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::comment::{Comment, CommentDto};
|
use crate::models::comment::{Comment, CommentDto};
|
||||||
use crate::models::hashtag::{self, Hashtag};
|
use crate::models::hashtag::{self, Hashtag};
|
||||||
|
use crate::models::upload::Upload;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub async fn toggle_like(
|
pub async fn toggle_like(
|
||||||
@@ -24,6 +25,12 @@ pub async fn toggle_like(
|
|||||||
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Event-scope: the upload must belong to the caller's event (404 otherwise),
|
||||||
|
// matching the host handlers' find_by_id_and_event pattern.
|
||||||
|
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||||
|
|
||||||
// Try to insert; if conflict, delete (toggle)
|
// Try to insert; if conflict, delete (toggle)
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2)
|
"INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2)
|
||||||
@@ -64,10 +71,15 @@ const COMMENT_PAGE_SIZE: i64 = 50;
|
|||||||
|
|
||||||
pub async fn list_comments(
|
pub async fn list_comments(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path(upload_id): Path<Uuid>,
|
Path(upload_id): Path<Uuid>,
|
||||||
Query(q): Query<ListCommentsQuery>,
|
Query(q): Query<ListCommentsQuery>,
|
||||||
) -> Result<Json<Vec<CommentDto>>, AppError> {
|
) -> Result<Json<Vec<CommentDto>>, AppError> {
|
||||||
|
// Event-scope: only list comments for an upload in the caller's event.
|
||||||
|
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||||
|
|
||||||
let comments =
|
let comments =
|
||||||
Comment::list_for_upload(&state.pool, upload_id, q.before, COMMENT_PAGE_SIZE).await?;
|
Comment::list_for_upload(&state.pool, upload_id, q.before, COMMENT_PAGE_SIZE).await?;
|
||||||
Ok(Json(comments))
|
Ok(Json(comments))
|
||||||
@@ -91,6 +103,11 @@ pub async fn add_comment(
|
|||||||
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Event-scope: only comment on an upload that belongs to the caller's event.
|
||||||
|
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||||
|
|
||||||
let text = body.body.trim();
|
let text = body.body.trim();
|
||||||
let text_chars = text.chars().count();
|
let text_chars = text.chars().count();
|
||||||
if text_chars == 0 || text_chars > 500 {
|
if text_chars == 0 || text_chars > 500 {
|
||||||
@@ -145,6 +162,11 @@ pub async fn delete_comment(
|
|||||||
return Err(AppError::Forbidden("Nur eigene Kommentare löschen.".into()));
|
return Err(AppError::Forbidden("Nur eigene Kommentare löschen.".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Comment::soft_delete(&state.pool, comment_id).await?;
|
// Event-scope: soft_delete_in_event only matches comments whose upload is in
|
||||||
|
// the caller's event, so a cross-event comment_id resolves to a 404 here.
|
||||||
|
let deleted = Comment::soft_delete_in_event(&state.pool, comment_id, auth.event_id).await?;
|
||||||
|
if !deleted {
|
||||||
|
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
|
||||||
|
}
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,23 @@ use crate::state::AppState;
|
|||||||
|
|
||||||
const MAX_CAPTION_LENGTH: usize = 2000;
|
const MAX_CAPTION_LENGTH: usize = 2000;
|
||||||
|
|
||||||
|
/// Allowlist of accepted media types, keyed by the MIME that `infer` derives from
|
||||||
|
/// the file's magic bytes. The detected MIME (not the client-declared one) is what
|
||||||
|
/// we trust, store, and hand to the compression pipeline — so a text-based payload
|
||||||
|
/// (SVG/HTML/JS) can never be stored or served on-origin. Each entry maps to the
|
||||||
|
/// server-controlled file extension we persist the original under.
|
||||||
|
const ALLOWED_MEDIA: &[(&str, &str)] = &[
|
||||||
|
("image/jpeg", "jpg"),
|
||||||
|
("image/png", "png"),
|
||||||
|
("image/webp", "webp"),
|
||||||
|
("image/gif", "gif"),
|
||||||
|
("image/heic", "heic"),
|
||||||
|
("image/heif", "heif"),
|
||||||
|
("video/mp4", "mp4"),
|
||||||
|
("video/quicktime", "mov"),
|
||||||
|
("video/webm", "webm"),
|
||||||
|
];
|
||||||
|
|
||||||
pub async fn upload(
|
pub async fn upload(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
@@ -62,8 +79,6 @@ pub async fn upload(
|
|||||||
let max_video_mb: i64 = config::get_i64(&state.pool, "max_video_size_mb", 500).await;
|
let max_video_mb: i64 = config::get_i64(&state.pool, "max_video_size_mb", 500).await;
|
||||||
|
|
||||||
let mut file_data: Option<Vec<u8>> = None;
|
let mut file_data: Option<Vec<u8>> = None;
|
||||||
let mut file_name: Option<String> = None;
|
|
||||||
let mut content_type: Option<String> = None;
|
|
||||||
let mut caption: Option<String> = None;
|
let mut caption: Option<String> = None;
|
||||||
let mut hashtags_csv: Option<String> = None;
|
let mut hashtags_csv: Option<String> = None;
|
||||||
|
|
||||||
@@ -71,8 +86,9 @@ pub async fn upload(
|
|||||||
let name = field.name().unwrap_or_default().to_string();
|
let name = field.name().unwrap_or_default().to_string();
|
||||||
match name.as_str() {
|
match name.as_str() {
|
||||||
"file" => {
|
"file" => {
|
||||||
file_name = field.file_name().map(|s| s.to_string());
|
// Note: the client-declared filename and Content-Type are intentionally
|
||||||
content_type = field.content_type().map(|s| s.to_string());
|
// ignored — the stored MIME and extension are derived from the file's
|
||||||
|
// magic bytes below, so a mislabelled payload can't influence them.
|
||||||
file_data = Some(
|
file_data = Some(
|
||||||
field.bytes().await
|
field.bytes().await
|
||||||
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?
|
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?
|
||||||
@@ -96,7 +112,6 @@ pub async fn upload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let data = file_data.ok_or_else(|| AppError::BadRequest("Keine Datei hochgeladen.".into()))?;
|
let data = file_data.ok_or_else(|| AppError::BadRequest("Keine Datei hochgeladen.".into()))?;
|
||||||
let mime = content_type.unwrap_or_else(|| "application/octet-stream".to_string());
|
|
||||||
let size = data.len() as i64;
|
let size = data.len() as i64;
|
||||||
|
|
||||||
// Validate caption length. Counted in chars (code points) to match the
|
// Validate caption length. Counted in chars (code points) to match the
|
||||||
@@ -111,22 +126,23 @@ pub async fn upload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file MIME type using magic bytes
|
// Determine the file type from its magic bytes and require it to be on the
|
||||||
let detected_mime = infer::get(&data);
|
// allowlist. `infer` returns None for text-based payloads (SVG/HTML/JS), so
|
||||||
if let Some(detected) = detected_mime {
|
// those are rejected outright — closing the stored-XSS vector. Both the MIME
|
||||||
let detected_type = detected.mime_type();
|
// we persist and the on-disk extension come from the detected type, never from
|
||||||
// Ensure detected type is compatible with declared MIME type
|
// client-supplied values.
|
||||||
let declared_category = mime.split('/').next().unwrap_or("");
|
let kind = infer::get(&data)
|
||||||
let detected_category = detected_type.split('/').next().unwrap_or("");
|
.ok_or_else(|| AppError::BadRequest("Dateityp nicht erkannt oder nicht unterstützt.".into()))?;
|
||||||
|
let (mime, ext) = ALLOWED_MEDIA
|
||||||
// Only reject if categories don't match (e.g., image vs video)
|
.iter()
|
||||||
if declared_category != "application" && declared_category != detected_category {
|
.find(|(allowed, _)| *allowed == kind.mime_type())
|
||||||
return Err(AppError::BadRequest(format!(
|
.map(|(m, e)| ((*m).to_string(), *e))
|
||||||
"Dateiinhalt entspricht nicht dem deklarierten Typ. Erwartet: {}, erkannt: {}",
|
.ok_or_else(|| {
|
||||||
mime, detected_type
|
AppError::BadRequest(format!(
|
||||||
)));
|
"Dateityp wird nicht unterstützt: {}.",
|
||||||
}
|
kind.mime_type()
|
||||||
}
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
// Validate file size
|
// Validate file size
|
||||||
let max_bytes = if mime.starts_with("video/") {
|
let max_bytes = if mime.starts_with("video/") {
|
||||||
@@ -159,12 +175,6 @@ pub async fn upload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine file extension
|
|
||||||
let ext = file_name
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|n| n.rsplit('.').next())
|
|
||||||
.unwrap_or(if mime.starts_with("video/") { "mp4" } else { "jpg" });
|
|
||||||
|
|
||||||
let upload_id = Uuid::new_v4();
|
let upload_id = Uuid::new_v4();
|
||||||
let event_slug = &state.config.event_slug;
|
let event_slug = &state.config.event_slug;
|
||||||
let relative_path = format!("originals/{event_slug}/{upload_id}.{ext}");
|
let relative_path = format!("originals/{event_slug}/{upload_id}.{ext}");
|
||||||
@@ -257,7 +267,7 @@ pub async fn edit_upload(
|
|||||||
Path(upload_id): Path<Uuid>,
|
Path(upload_id): Path<Uuid>,
|
||||||
Json(body): Json<EditUploadRequest>,
|
Json(body): Json<EditUploadRequest>,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
let upload = Upload::find_by_id(&state.pool, upload_id)
|
let upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||||
|
|
||||||
@@ -285,7 +295,7 @@ pub async fn delete_upload(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path(upload_id): Path<Uuid>,
|
Path(upload_id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
let upload = Upload::find_by_id(&state.pool, upload_id)
|
let upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||||
|
|
||||||
@@ -293,7 +303,7 @@ pub async fn delete_upload(
|
|||||||
return Err(AppError::Forbidden("Nur eigene Uploads löschen.".into()));
|
return Err(AppError::Forbidden("Nur eigene Uploads löschen.".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Upload::soft_delete(&state.pool, upload_id).await?;
|
Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).await?;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ mod state;
|
|||||||
use config::AppConfig;
|
use config::AppConfig;
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
|
|
||||||
|
/// Hard HTTP body cap for the upload endpoint (576 MiB). Backstop against
|
||||||
|
/// memory-exhaustion; precise per-class size limits are enforced in the handler.
|
||||||
|
const MAX_UPLOAD_BYTES: usize = 576 * 1024 * 1024;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
@@ -57,9 +61,13 @@ async fn main() -> Result<()> {
|
|||||||
.route("/api/v1/recover", post(auth::handlers::recover))
|
.route("/api/v1/recover", post(auth::handlers::recover))
|
||||||
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
|
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
|
||||||
.route("/api/v1/session", delete(auth::handlers::logout))
|
.route("/api/v1/session", delete(auth::handlers::logout))
|
||||||
// Upload — body limit disabled; size validation is done inside the handler
|
// Upload — HTTP-level body cap as an OOM backstop. The handler still enforces
|
||||||
|
// the precise per-class limits from DB config (max_image/video_size_mb); this
|
||||||
|
// layer just stops a multi-GB body from being buffered into memory before that
|
||||||
|
// check runs. Sized generously above the default 500 MB video limit + multipart
|
||||||
|
// overhead — if an admin raises max_video_size_mb above this, bump MAX_UPLOAD_BYTES.
|
||||||
.route("/api/v1/upload", post(handlers::upload::upload)
|
.route("/api/v1/upload", post(handlers::upload::upload)
|
||||||
.route_layer(DefaultBodyLimit::disable()))
|
.route_layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)))
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/upload/{id}",
|
"/api/v1/upload/{id}",
|
||||||
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
|
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
|
||||||
|
|||||||
8
docker-compose.dev.yml
Normal file
8
docker-compose.dev.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Dev-only overlay. NOT loaded automatically (unlike docker-compose.override.yml).
|
||||||
|
# Opt in explicitly for local development when you need host access to Postgres:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||||
|
# Never use this overlay in production — it publishes the database port on the host.
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
services:
|
|
||||||
db:
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
@@ -16,6 +16,10 @@ COPY --from=builder /app/build ./build
|
|||||||
COPY --from=builder /app/package.json ./
|
COPY --from=builder /app/package.json ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
# Run as the image's built-in non-root `node` user.
|
||||||
|
RUN chown -R node:node /app
|
||||||
|
USER node
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
ENV PORT=3001 HOST=0.0.0.0
|
ENV PORT=3001 HOST=0.0.0.0
|
||||||
CMD ["node", "build"]
|
CMD ["node", "build"]
|
||||||
|
|||||||
Reference in New Issue
Block a user