use camino::Utf8Component; use camino::Utf8Path; use camino::Utf8PathBuf; use clap::Parser as ClapParser; use controller::Controller; use git2::Commit; use git2::ObjectType; use git2::Oid; use git2::Repository; use git2::Signature; use git2::Tree; use hyper::header; use hyper::http::request::Parts; use hyper::http::response::Builder; use hyper::service::{make_service_fn, service_fn}; use hyper::Method; use hyper::StatusCode; use hyper::{Body, Server}; use origins::HttpOrigin; use percent_encoding::percent_decode_str; use serde::Deserialize; use sputnik::html_escape; use sputnik::mime; use sputnik::request::SputnikParts; use sputnik::response::SputnikBuilder; use std::convert::Infallible; use std::env; use std::marker::PhantomData; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; #[cfg(unix)] use { hyperlocal::UnixServerExt, std::fs, std::fs::Permissions, std::os::unix::prelude::PermissionsExt, }; use crate::controller::MultiUserController; use crate::controller::SoloController; use crate::error::Error; mod controller; mod diff; mod error; mod forms; mod get_routes; mod origins; mod post_routes; mod tests; pub enum Response { Raw(HyperResponse), Page(Page), } impl From for Response { fn from(page: Page) -> Self { Self::Page(page) } } impl From for Response { fn from(resp: HyperResponse) -> Self { Self::Raw(resp) } } pub(crate) type HyperResponse = hyper::Response; pub(crate) type Request = hyper::Request; #[derive(ClapParser, Debug)] #[clap(name = "gitpad")] struct Args { /// Enable mutliuser mode (requires a reverse-proxy that handles /// authentication and sets the Username header) #[clap(short)] multiuser: bool, #[clap(short, default_value = "8000")] port: u16, /// e.g. https://example.com (used to enforce Host and Origin headers) #[clap(long)] origin: Option, /// Serve via the given Unix domain socket path. #[cfg(unix)] #[clap(long)] socket: Option, } #[tokio::main] async fn main() { let args = Args::parse(); let repo_path = env::current_dir().unwrap(); let repo = Repository::open_bare(&repo_path) .expect("expected current directory to be a bare Git repository"); if args.multiuser { serve(repo_path, MultiUserController::new(&repo), args).await; } else { serve(repo_path, SoloController::new(&repo), args).await; } } struct StaticContext { repo_path: PathBuf, origin: HttpOrigin, } async fn serve( repo_path: PathBuf, controller: C, args: Args, ) { let controller = Arc::new(controller); #[cfg(unix)] if let Some(socket_path) = &args.socket { let context: &'static _ = Box::leak(Box::new(StaticContext { origin: args .origin .expect("if you use --socket, you must specify an --origin"), repo_path, })); // TODO: get rid of code duplication // we somehow need to specify the closure type or it gets too specific let service = make_service_fn(move |_| { let controller = controller.clone(); async move { Ok::<_, hyper::Error>(service_fn(move |req| { service_wrapper(context, controller.clone(), req) })) } }); let path = Path::new(&socket_path); if path.exists() { fs::remove_file(path).unwrap(); } let server = Server::bind_unix(path).unwrap(); if fs::metadata(path.parent().unwrap()) .unwrap() .permissions() .mode() & 0o001 != 0 { eprintln!("socket parent directory must not have x permission for others"); std::process::exit(1); } fs::set_permissions(path, Permissions::from_mode(0o777)) .expect("failed to set socket permissions"); println!("Listening on unix socket {}", socket_path); server.serve(service).await.expect("server error"); return; } eprint!( "[warning] Serving GitPad over a TCP socket. \ If you use a reverse-proxy for access control, \ it can be circumvented by anybody with a system account." ); #[cfg(unix)] eprint!( " Use a Unix domain socket (with --socket) to restrict \ access based on the socket parent directory permissions." ); eprintln!(); let addr = ([127, 0, 0, 1], args.port).into(); let url = format!("http://{}", addr); let context: &'static _ = Box::leak(Box::new(StaticContext { origin: args.origin.unwrap_or_else(|| url.parse().unwrap()), repo_path, })); let service = make_service_fn(move |_| { let controller = controller.clone(); async move { Ok::<_, hyper::Error>(service_fn(move |req| { service_wrapper(context, controller.clone(), req) })) } }); let server = Server::bind(&addr).serve(service); println!("Listening on {}", url); server.await.expect("server error"); } async fn service_wrapper( context: &StaticContext, controller: Arc, request: Request, ) -> Result { Ok(service(context, &*controller, request).await) } async fn service( context: &StaticContext, controller: &C, request: Request, ) -> HyperResponse { let (mut parts, body) = request.into_parts(); let mut script_csp = "'none'".into(); let mut frame_csp = "'none'"; let mut resp = build_response(context, controller, &mut parts, body) .await .map(|resp| match resp { Response::Raw(resp) => resp, Response::Page(page) => { if !page.script_src.is_empty() { script_csp = page.script_src.join(" "); } if let Some(src) = page.frame_src { frame_csp = src; } Builder::new() .content_type(mime::TEXT_HTML) .body(render_page(&page, controller, &parts).into()) .unwrap() } }) .unwrap_or_else(|err| err.into()); // we rely on CSP to thwart XSS attacks, all modern browsers support it resp.headers_mut() .entry(header::CONTENT_SECURITY_POLICY) .or_insert_with(|| { format!( "default-src 'self'; frame-src {}; script-src {}; style-src {}", frame_csp, script_csp, include_str!("static/style.css.sha"), ) .parse() .unwrap() }); // don't leak the hostname of the GitPad instance when following external links resp.headers_mut() .insert(header::REFERRER_POLICY, "same-origin".parse().unwrap()); resp } #[derive(Default)] pub struct Page { title: String, header: String, body: String, /// will be embedded as inline ", script)); } out.push_str(""); out } #[derive(Deserialize)] struct ActionParam { #[serde(default = "default_action")] action: String, } fn default_action() -> String { "view".into() } #[derive(Eq, PartialEq, Hash, Clone)] pub struct Branch(String); impl Branch { fn rev_str(&self) -> String { format!("refs/heads/{}", self.0) } } async fn build_response( StaticContext { repo_path, origin }: &StaticContext, controller: &C, parts: &mut Parts, body: Body, ) -> Result { let host = parts .headers .get("Host") .ok_or_else(|| Error::BadRequest("Host header required".into()))? .to_str() .unwrap(); if host != origin.host() { // We enforce an exact Host header to prevent DNS rebinding attacks. return Err(Error::BadRequest(format!("

Bad Request: Unknown Host header

\ Received the header
Host: {}
But expected the header
Host: {}
\

If you want to serve GitPad under a different hostname you need to specify it on startup with --origin.

", html_escape(host), html_escape(origin.host())))); } let unsanitized_path = percent_decode_str(parts.uri.path()) .decode_utf8() .map_err(|_| Error::BadRequest("failed to percent-decode path as UTF-8".into()))? .into_owned(); let repo = Repository::open_bare(repo_path).unwrap(); let (rev, unsanitized_path) = match controller.parse_url_path(&unsanitized_path, parts, &repo) { Ok(parsed) => parsed, Err(res) => return res, }; let mut comps = Vec::new(); // prevent directory traversal attacks for comp in Utf8Path::new(unsanitized_path).components() { match comp { Utf8Component::Normal(name) => comps.push(name), Utf8Component::ParentDir => { return Err(Error::Forbidden("path traversal is forbidden".into())) } _ => {} } } let params: ActionParam = parts.query::().unwrap(); let url_path: Utf8PathBuf = comps.iter().collect(); let ctx = Context { repo, path: url_path, branch: rev, __: PhantomData::default(), }; if !controller.may_read_path(&ctx, parts) { return Err(Error::Unauthorized( "you are not authorized to view this file".into(), )); } if parts.method == Method::POST { return post_routes::build_response(origin, ¶ms, controller, ctx, body, parts).await; } let tree = ctx.branch_head().ok().and_then(|c| c.tree().ok()); if ctx.path.components().next().is_none() { return get_routes::view_tree(tree, controller, &ctx, parts); } match tree.and_then(|t| t.get_path(ctx.path.as_ref()).ok()) { Some(entr) => match entr.kind().unwrap() { ObjectType::Blob => { if unsanitized_path.ends_with('/') { return Ok(Builder::new() .status(StatusCode::FOUND) .header( "location", controller.build_url_path( &ctx.branch, unsanitized_path.trim_end_matches('/'), ), ) .body("redirecting".into()) .unwrap() .into()); } get_routes::get_blob(entr, params, controller, ctx, parts) } ObjectType::Tree => { if !unsanitized_path.ends_with('/') { return Err(Error::MissingTrailingSlash(parts.uri.path().to_owned())); } get_routes::view_tree(ctx.repo.find_tree(entr.id()).ok(), controller, &ctx, parts) } _other => panic!("unexpected object type"), }, None => { if unsanitized_path.ends_with('/') { return Err(Error::NotFound("directory not found".into())); } if controller.may_write_path(&ctx, parts) { if params.action == "edit" { Ok(forms::edit_text_form( &forms::EditForm::default(), None, controller, &ctx, parts, ) .into()) } else if params.action == "upload" { Ok(forms::upload_form(false, controller, &ctx, parts).into()) } else { Err(Error::NotFound( "file not found, but you can write it or upload it".into(), )) } } else { Err(Error::NotFound("file not found".into())) } } } } fn render_link(name: &str, label: &str, active_action: &str) -> String { format!( " {}", if name == active_action { "class=active".into() } else { format!("href=?action={}", name) }, if name != label { format!(" title='{}'", name) } else { "".into() }, label ) } fn action_links( active_action: &str, controller: &C, ctx: &Context, parts: &Parts, ) -> String { let mut out = String::new(); out.push_str("ls"); out.push_str(&render_link("view", "view", active_action)); if controller.may_write_path(ctx, parts) { out.push_str(&render_link("edit", "edit", active_action)); } out.push_str(&render_link("log", "log", active_action)); out.push_str(&render_link("raw", "raw", active_action)); if controller.may_move_path(ctx, parts) { out.push_str(&render_link("move", "mv", active_action)); out.push_str(&render_link("remove", "rm", active_action)); } out } pub struct Context<'a> { repo: Repository, branch: Branch, path: Utf8PathBuf, __: PhantomData<&'a ()>, } impl Context<'_> { fn branch_head(&self) -> Result { self.repo .revparse_single(&self.branch.rev_str()) .map_err(|_| Error::NotFound("branch not found".into()))? .into_commit() .map_err(|_| Error::NotFound("branch not found".into())) } fn commit( &self, signature: &Signature, msg: &str, tree: &Tree, parent_commits: &[&Commit], ) -> Result { self.repo.commit( Some(&self.branch.rev_str()), signature, signature, msg, tree, parent_commits, ) } } #[derive(PartialEq)] enum RenderMode { View, Preview, } #[cfg(feature = "md")] fn render_markdown(input: &str, page: &mut Page, _mode: RenderMode) { use pulldown_cmark::html; use pulldown_cmark::Options; use pulldown_cmark::Parser; let parser = Parser::new_ext(input, Options::all()); page.body.push_str("
"); html::push_html(&mut page.body, parser); page.body.push_str("
"); } fn embed_html_as_iframe(input: &str, page: &mut Page, mode: RenderMode) { if mode == RenderMode::View { page.body.push_str(""); page.frame_src = Some("'self'"); } else { page.body .push_str("
Note that JavaScript does not work in the preview.
"); // sandbox=allow-scripts wouldn't work because the strict parent page CSP still applies // The sandbox attribute makes browsers treat the embedded page as a unique origin. page.body.push_str(&format!( "", html_escape(input) )); } } fn get_renderer(path: &Utf8Path) -> Option { match path.extension() { #[cfg(feature = "md")] Some("md") => Some(render_markdown), Some("html") => Some(embed_html_as_iframe), _ => None, } }