use clap::Clap; 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 percent_encoding::percent_decode_str; use pulldown_cmark::html; use pulldown_cmark::Options; use pulldown_cmark::Parser; 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::path::Component; use std::path::Path; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; use url::Url; #[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 post_routes; mod shares; 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(Clap, 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 = Repository::open_bare(env::current_dir().unwrap()) .expect("expected current directory to be a bare Git repository"); if args.multiuser { serve(MultiUserController::new(&repo), args).await; } else { serve( SoloController(Branch( repo.find_reference("HEAD") .unwrap() .symbolic_target() .unwrap() .strip_prefix("refs/heads/") .unwrap() .to_owned(), )), args, ) .await; } } #[derive(Clone, Debug)] struct HttpOrigin { origin: String, host_idx: usize, } impl HttpOrigin { /// Returns the Host header value (e.g. `example.com` for the origin `https://example.com`). fn host(&self) -> &str { &self.origin[self.host_idx..] } } impl FromStr for HttpOrigin { type Err = &'static str; fn from_str(s: &str) -> Result { let url = Url::parse(s).map_err(|_| "invalid URL")?; if url.scheme() != "http" && url.scheme() != "https" { return Err("expected http:// or https:// scheme"); } if url.path() != "/" { return Err("path must be /".into()); } Ok(HttpOrigin { origin: url.origin().ascii_serialization(), host_idx: url.scheme().len() + "://".len(), }) } } async fn serve(controller: C, args: Args) { let controller = Arc::new(controller); #[cfg(unix)] if let Some(socket_path) = &args.socket { let origin: &'static HttpOrigin = Box::leak(Box::new( args.origin .expect("if you use --socket, you must specify an --origin"), )); // 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(origin, 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 origin: &'static HttpOrigin = Box::leak(Box::new( args.origin.unwrap_or_else(|| url.parse().unwrap()), )); let service = make_service_fn(move |_| { let controller = controller.clone(); async move { Ok::<_, hyper::Error>(service_fn(move |req| { service(&origin, controller.clone(), req) })) } }); let server = Server::bind(&addr).serve(service); println!("Listening on {}", url); server.await.expect("server error"); } async fn service( origin: &HttpOrigin, controller: Arc, request: Request, ) -> Result { let (mut parts, body) = request.into_parts(); let mut script_csp = "'none'".into(); let mut frame_csp = "'none'".into(); let mut resp = build_response(origin, &*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() }); Ok(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( origin: &HttpOrigin, 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(env::current_dir().unwrap()).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 Path::new(&*unsanitized_path).components() { match comp { Component::Normal(name) => comps.push(name), Component::ParentDir => { return Err(Error::Forbidden("path traversal is forbidden".into())) } _ => {} } } let params: ActionParam = parts.query::().unwrap(); let url_path: PathBuf = comps.iter().collect(); let ctx = Context { repo, path: url_path, branch: rev, }; 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 mut tree = ctx .repo .revparse_single(&ctx.branch.rev_str()) .map(|r| r.into_commit().unwrap().tree().unwrap()); if ctx.path.components().next().is_some() { let entr = match tree.and_then(|t| t.get_path(&ctx.path)) { Ok(entr) => entr, Err(_) => { if unsanitized_path.ends_with('/') { return Err(Error::NotFound("directory not found".into())); } if controller.may_write_path(&ctx, parts) { if params.action == "edit" { return Ok(forms::edit_text_form( &forms::EditForm::default(), None, controller, &ctx, parts, ) .into()); } else if params.action == "upload" { return Ok(forms::upload_form(false, controller, &ctx, parts).into()); } else { return Err(Error::NotFound( "file not found, but you can write it or upload it".into(), )); } } else { return Err(Error::NotFound("file not found".into())); } } }; if 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()); } return get_routes::get_blob(entr, params, controller, ctx, &parts); } tree = ctx.repo.find_tree(entr.id()); if !unsanitized_path.ends_with('/') { return Err(Error::MissingTrailingSlash(parts.uri.path().to_owned())); } } get_routes::view_tree(tree, controller, &ctx, &parts) } 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 { repo: Repository, branch: Branch, path: PathBuf, } 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, ) } fn file_name(&self) -> Option<&str> { self.path.file_name().and_then(|x| x.to_str()) } } #[derive(PartialEq)] enum RenderMode { View, Preview, } fn render_markdown(input: &str, page: &mut Page, _mode: RenderMode) { 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: &Path) -> Option { match path.extension().map(|e| e.to_str().unwrap()) { Some("md") => Some(render_markdown), Some("html") => Some(embed_html_as_iframe), _ => None, } }